* chore(#2795): reviewer manifest body + registry harvest, validation, forward-compat Phase 2 of epic #2782 under ADR-2782. Delivers D1, D2, D3, D7, D8 and the four Phase-1 vocabulary amendments (A1-A4). - VALID_ROLES gains "reviewer"; the reviewer body is admissible on role:runtime (a host that is also a reviewer keeps one manifest) and on the new role:reviewer (a lane that is not an install target). A reviewer body on role:feature is an error: declaring one is an assertion of lane-ness. - validateReviewerBody + validateLaneProbe + validateLaneInvoke: nine closed enums, a transport discriminator selecting mutually-exclusive invoke sub-shapes, bounded probes (D7), and outputArg required-iff outputChannel is file-arg and forbidden otherwise. - Absent-safe (D4.1): only `undefined` is absent. null/{}/[]/false/0 are malformed assertions and error. 39 of 39 shipped capabilities depend on this. - collectReviewerWarnings: an unknown field inside the body warns, never errors, so a forward-built manifest degrades visibly instead of failing the build. - D8 uniqueness (slug / flags / reviewsSection) lives in validateCrossCapability, so it is enforced at build time over first-party AND at load time over the merged first-party union overlay set, with first-party-wins falling out of the loader's existing ordering rather than a new provenance check. - Config harvest widened past the role==="feature" branch in both the generator and the ownership loop. The often-cited cause of the stranded reviewer config keys -- the runtime body forbidding feature-only fields -- is not the mechanism: `config` is not in FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME. The cause is two harvest sites that never read it. Verified inert: no shipped capability declares config on a non-feature role, and the generated registry is unchanged. Three ADR corrections are folded in (Phase 1 set the precedent of amending in-phase): the misattributed config-stranding cause, D3's inverted profile-membership claim, and the specified capability folder names for lm_studio / llama_cpp, which would have failed the id kebab-case invariant. Closes #2795 * chore(#2795): collapse nine enum checks into one validateEnumField helper Standards-axis review findings, both applied: - Duplicated Code: the enum-membership + enumerate-the-members error shape repeated near-verbatim at nine call sites. Routing them through one helper makes "the error names its valid members" structural rather than a convention repeated nine times, where it would drift. That property is load-bearing until Phase 6 ships the prose reference, because these errors are currently the only documentation of the vocabulary. - Speculative Generality: the isReservedName() pre-check on every enum field was inert. A VALID_* set never contains __proto__/constructor/prototype, so membership alone already rejects them, and "must be one of: ..." is more actionable than "is a reserved name". The literal guards remain where they do real work -- the key-derived write sites in the registry generator and the claim() accumulator. The reserved-name test now asserts all three reserved names are rejected via enum membership, rather than one name via a branch that no longer exists. * fix(#2795): align lane slug grammar with Phase 1 and wire the load-time diagnostic channel Spec-axis review findings, both applied. (1) The slug grammar had diverged from Phase 1's core descriptor. Phase 1 exports LANE_SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/ (leading digit permitted); the manifest validator required a leading LETTER. A slug the core descriptor accepts -- a model-named lane such as 4o-mini -- would have been rejected by the manifest validator, which is exactly the translation layer ADR-2782 exists to delete. It was inert only because all eleven shipped slugs begin with a letter, so nothing else would have caught it until a third party shipped such a lane. The grammar cannot be reduced to one definition: Phase 1's module compiles to gitignored build output, and capability-validator.cjs is a committed plain .cjs that must load on a fresh worktree before build:lib has ever run. That makes this the repo's DEFECT.GENERATIVE-FIX class, so the duplication now carries a parity assertion -- laneSlugGrammarMatchesPhase1Descriptor -- which compares both the source grammar and the accept/reject verdict for a shared input set, and fails if the two ever drift again. (2) collectReviewerWarnings had exactly one caller: the build-time generator, which only ever sees first-party in-repo manifests. The real third-party overlay loader never called it and ValidatorModule did not declare it, so ADR-2782 D4.3 -- an unknown field inside a reviewer body is ignored WITH A WARNING -- surfaced nowhere at runtime, which is precisely the case D4.3 exists for. loadRegistry now collects those diagnostics on the accept path, behind a typeof-guard (an older built validator without the function still loads) and a try/catch (ADR-1244 D2's never-crash contract outranks a diagnostic). They land in a NEW OverlayMeta.diagnostics field rather than OverlayMeta.warnings, because warnings records capabilities that were SKIPPED and a consumer treating every entry as inactive would mislabel a working lane. Covered end-to-end by overlayLaneWithUnknownFieldIsAcceptedAndDiagnosed, which drives a real global-scope overlay through loadRegistry and asserts the lane is accepted, produces no skip warning, and yields a diagnostic naming the field. * fix(#2795): make the reviewer validators honour their documented totality contract Isolated adversarial review finding (MAJOR), reproduced by execution. validateReviewerBody documents itself as "TOTAL: returns an array of error strings for ANY input and never throws", and the overlay loader contracts every validator to RETURN errors -- #1461 OVL-1 records a validator that THREW and would have crashed every consumer of loadRegistry. The contract was false at ten sites: JSON.stringify throws on a BigInt and on a circular structure, and every enum/scalar rejection path interpolated the rejected value into its own rejection message. Reading the value could throw too, before any message was built, via a throwing getter or a Proxy get/ownKeys trap. Not reachable through a capability.json today -- every ingestion path is a plain JSON.parse of file text, which cannot express any of those shapes. Fixed anyway: the contract is stated on an EXPORTED function, and a caller must not have to re-derive today's reachability analysis to know whether it holds. Two layers, because serialization safety alone is insufficient: - describeValue() renders any value without throwing, so messages stay useful (a BigInt now reads "got: 10n" rather than degrading to a generic fallback). - A structural try/catch around validateReviewerBody and collectReviewerWarnings makes the guarantee absolute rather than argued, covering read-time throws that fire before any message exists. The same review found the property test guarding this contract was FALSE CONFIDENCE, which is the more important half. fc.anything() at default constraints emits no BigInt, no circular reference, no getter and no Proxy -- 20,000 sampled draws produced zero of each -- so the test was named for a contract its generator could not reach. Even withBigInt is insufficient under whole-value fuzzing, because the defect needs an exotic value in a specifically NAMED field and random key names never land on one. The property is now field-targeted across all twelve reviewer fields, and a companion test enumerates the shapes fast-check cannot generate at all (BigInt, circular, throwing getter, symbol, function, null-prototype) across scalar positions, array-element positions, and read-time traps. Verified red-before-green: with the fix reverted both property tests fail; with it restored all 119 pass. * chore(#2795): backfill changeset pr number to 2823
This commit is contained in:
@@ -610,3 +610,55 @@ discriminator selecting the invoke sub-shape; `probe.kind` and `handler` are unt
|
||||
absent-safe invariant (D4), the disclosure class (D5), and the config-ownership table (D9) are
|
||||
unaffected. Phase 2 (#2795) implements the manifest validator against the vocabulary **as amended
|
||||
here**, which is the point of amending rather than leaving it for Phase 2 to rediscover.
|
||||
|
||||
### 2026-07-29 — three factual corrections from Phase 2 (#2795)
|
||||
|
||||
Implementing the validator required reading the code each claim rests on. Three statements above
|
||||
did not survive that reading. None changes a decision; each would have misdirected a later phase,
|
||||
which is precisely why they are corrected here rather than worked around in code.
|
||||
|
||||
**1. The cause of the stranded config keys was misattributed (Context (b), Scope of changes, D9).**
|
||||
|
||||
The ADR attributes reviewer config keys living centrally to the runtime body forbidding feature-only
|
||||
fields. That is not the mechanism. `FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME` is
|
||||
`['skills','agents','steps','contributions','gates','hooks','activationKey']` — **`config` is not in
|
||||
it**, and a `role: "runtime"` capability carrying a `config` slice passes validation today. The real
|
||||
cause is two *harvest* sites that never read it:
|
||||
|
||||
- `gen-capability-registry.cjs` nested its config-harvest loop inside the `role === 'feature'`
|
||||
branch, so a non-feature capability's `config` was silently dropped from `configKeys`/`configSchema`.
|
||||
- `validateCrossCapability` opened its config-key ownership loop with `if (cap.role !== 'feature') …
|
||||
continue`, so a non-feature capability was exempt from both single-ownership **and** the
|
||||
central-schema collision check.
|
||||
|
||||
Phase 2 fixes both by filtering on the *presence of a `config` slice* rather than on the role.
|
||||
This matters for Phase 4 (#2797), which would otherwise have been designed against a constraint that
|
||||
does not exist — and it means the exclusivity invariant was, until now, unenforced for every
|
||||
non-feature capability rather than merely unused.
|
||||
|
||||
**2. D3's profile-membership claim is inverted.**
|
||||
|
||||
D3 states that a `role: "reviewer"` capability "receives profile membership from
|
||||
`deriveProfileMembership` (`gen-capability-registry.cjs:201-213`) like any other" and that the
|
||||
membership is inert. It receives **no** membership: that function skips any capability without a
|
||||
non-empty `skills` array, and a lane-only capability has none. The *intended outcome* — a reviewer
|
||||
capability installs nothing — holds exactly as D3 wanted, and `tier` remains required as the source
|
||||
of truth for the `requires`-closure. Only the stated mechanism was wrong, and a Phase 5a author
|
||||
following D3 would have gone looking for membership that is not there.
|
||||
|
||||
**3. The specified capability folder names for two lanes would fail the build.**
|
||||
|
||||
The Scope-of-changes section and #2798 both name `capabilities/lm_studio/` and
|
||||
`capabilities/llama_cpp/`. Both would be rejected: `id` must equal the folder name **and** match
|
||||
`KEBAB_RE` (`/^[a-z][a-z0-9-]*$/`), which does not admit `_`. Three namespaces are in play for one
|
||||
lane and they are deliberately not the same string:
|
||||
|
||||
| | value | casing | fixed by |
|
||||
|---|---|---|---|
|
||||
| capability `id` / folder | `lm-studio` | kebab | the `id` conformance invariant |
|
||||
| `reviewer.slug` | `lm_studio` | snake | the shipped roster and `review.lm_studio_host`, which D9 leaves unchanged |
|
||||
| `reviewer.flags` | `--lm-studio` | kebab | the shipped flag |
|
||||
|
||||
Phase 2 therefore validates `reviewer.slug` against its own pattern (`/^[a-z][a-z0-9_-]*$/`) rather
|
||||
than reusing `KEBAB_RE`, which would have rejected two shipped lanes. Phase 5a must create
|
||||
`capabilities/lm-studio/` and `capabilities/llama-cpp/`, each declaring the snake-case slug.
|
||||
|
||||
Reference in New Issue
Block a user