canon-kit — spec discipline for agent-authored components
One canonical spec per component, deltas as short-lived amendment files, and a star topology across the prose surfaces: every fact has exactly one owning surface, and every other surface cites it — never restates it. The problem the kit solves: when coding agents author the specs, design rationale gets re-derived under build pressure unless it is captured up front, and a parallel copy of any gated fact is an un-gateable second source that drifts silently. The remedy is a lifecycle (amendments authored up front, merged and deleted at build) and gates over the copy-shaped failure modes.
The kit carries the lifecycle, the checklists, and its gates; a consumer’s surface names, banned headings, and scan languages are config, with this repo’s layout as the defaults. Requires gate-sdk (the gates follow its four contracts and resolve through its registry); the queue lifecycle gate reads the tag syntax queue-kit defines.
The spec model
Every component has exactly one canonical spec file (default SPEC.md) —
complete, current, the single source of truth. A spec has three jobs, and
“copying the code into prose” is not one of them:
- Owns semantics — invariants, ordering, error behavior; the prose no other artifact carries.
- Names contracts — every interface is named, and each name is tied to the implementation by a gate; a named-but-ungated contract is the rot vector.
- Never copies a structural definition — reference the code, do not
copy it. Anything readable from the code (exact types, field layouts,
config values) is implementation, not spec content; an ungated verbatim
copy drifts silently the moment the code changes
(
check-spec-embedded-source).
A SPEC amendment (default glob SPEC-*.md, in the owning component’s
directory) is the delta artifact for a designed-but-unimplemented change:
it describes only what is added or changed, is named after the feature
(SPEC-sqlite.md, never SPEC-PHASE3-SQLITE.md), and is a transition
artifact — merged into the canonical spec and deleted when the work
completes; an amendment never outlives its implementation. The one
sanctioned copy exemption: an amendment may embed a wire-contract delta
(e.g. a fenced proto block) until merge, because it is the design home for
a contract that does not exist yet; the canonical spec cites the contract
file, never re-embeds it. A ruling with no owning component (governance,
workflow, gates) is a root-level amendment: same lifecycle, lives at
the repo root, merges into the consumer’s rulebook instead of a component
spec.
The amendment lifecycle
Spec-writing is an authoring-stage activity, not a build activity — build sessions never author specs. The feature/debt litmus runs at filing in the scoping stage’s triage; the amendment is authored in whichever stage the roster designates for it (scope by default, or a dedicated authoring stage where the roster splits one out). A feature task is therefore in exactly one of two states, marked with the queue tags whose syntax queue-kit defines:
- Design-pending — no amendment yet; the entry sits in a design-pending
section tagged
[design-pending], excluded from selection. That set is the deferred section plus, where the consumer configures one, queue-kit’s optional icebox tier (CANON_KIT_ICEBOX_SECTION; the tier is queue-kit/SPEC.md §The icebox tier). A dormant entry is unbuilt design, so the state and its tag reach it unchanged. Every entry in the set carries the tag — all such work is design-pending by definition. The token names the state, not a promised artifact — a deferred debt entry promotes with the tag deleted rather than converted, and the tag has to be true of that entry too. - Spec-ready — the amendment exists; the entry sits in the feature
section tagged
[spec: <ref>], eligible for selection.
The bidirectional rule: every feature entry in the active queue carries
a [spec:] ref that resolves to a file on disk, and every amendment on
disk has a queue entry pointing at it. Writing the amendment is promoting
the deferred entry — without the pairing, design rationale and ruled-out
alternatives get re-derived under build pressure. Technical debt needs no
amendment (it fixes behavior to an existing spec); a debt task that needs a
design ruling is design work — it goes to the deferred section
[design-pending] until scope rules on it. Enforced by
check-amendment-queue.
The delta-ID grammar. An amendment’s ## What changes is a sequence of
deltas, and both a delta and a citation to one have a pinned form. Pinned
because the next section requires each update target to name the delta that
owns it, and a name no surface specifies is unmechanizable: a gate cannot match
a grammar the template never states.
A delta is a ### heading under ## What changes of the form
### (<N>) <title>, where <N> is a positive decimal integer written without a
leading zero and <title> is non-empty. Delta numbers begin at one and are
sequential and unique within the amendment — a gap or a repeat means a delta
was split or dropped without its citations moving, which is the drift this
grammar exists to make visible.
A citation is the token delta <N> or deltas <N> (case-insensitive),
optionally continuing into further integers separated by commas and/or the word
and; a trailing possessive (delta 3's) is the same citation. The literal
all deltas cites every delta the amendment defines — the form a
whole-amendment update target already uses in practice (a generated mirror is
stale the moment any delta lands), admitted rather than forced into an
enumeration that would itself drift as deltas are added.
A heading rather than a bold lead-in token, because a heading gives a delta an
anchor a reader can link to and a scanner can find in one pass, and it survives
reordering under a diff more legibly than a token buried in a paragraph. The
work-class tag an amendment may carry ({mechanical} / {design-bearing})
is deliberately outside this grammar: its owner is the roster’s
authoring-stage template and its reader is the iteration lead at batch-cut, so
carrying it inside the delta token would tie two independent conventions to one
string and stop either changing without the other. Stated as a non-target so a
later reader does not read the silence as an omission. Enforced by
check-amendment-update-target.
An amendment file is outside the governed manifest: CANON_KIT_MANIFEST_FILES
globs */SPEC.md, which SPEC-<name>.md does not match, so the prose and knob
gates never scan one. Their obligations attach at the merge rather than at the
amendment — an amendment may carry a citation or a knob mention that would red
once integrated, and §Merging an amendment step 2 is where that debt comes due.
The tag is a checksum on the promotion move, not a second source of the
state its section already carries. Its redundancy is the mechanism: a
promotion crosses a section boundary and swaps the tag, so
forbidden-in-active catches a move that dropped the swap and
[spec:]-in-design-pending catches a swap that dropped the move. That is
also the test a further tag has to pass: a tag marking a move that crosses no
pending/ready boundary adds a state name without adding a caught error class,
and is refused.
The feature/debt litmus is new names: a task that adds any name to a governed surface — a script, a config knob, a file or directory convention, a tag, a contract another component must honor — is a feature and needs the amendment, however small the diff; debt converges behavior on names the spec already carries. The tell for misfiling: a queue entry whose body carries more than a few lines of design ruling is an amendment inlined where this gate cannot see it — the entry format has no home for causal completeness, and the rationale evaporates when the done section is cleared. The gate cannot decide which section a task belongs in (that is the semantic residue); it enforces the pairing once the section is chosen, so the litmus runs at filing time, in the scope skill’s triage.
The causal-completeness check
Before an amendment is ready, every new state, event, and interface it introduces passes five points:
- Producer named and reachable — what code path, call, or timer triggers it; a named producer whose enabling config no deployed configuration actually sets is dead everywhere but unit tests.
- Consumer named — what component receives it, by what mechanism (which stream, which call, which poll).
- Existing integration sections updated — any affected spec section describing the prior flow is updated in the amendment itself.
- Every field has a named reader — for each field on a new message, name the consumer that reads it and the transition where it is read; a field with no reader is removed, and a field read at one transition is not populated at others.
- Each reader’s red condition named, not merely its subject — binding
on a delta that narrows a corpus (a prune, a tighter glob, a dropped
file). A reader is safe to clear by inspection under a narrowing only
if its verdict is monotone in the violation set, and three ordinary shapes
are not: a reader that reds on finding none, one asserting an exact
count, and one holding a minimum or a coverage floor. So enumerate what
makes each reader red, never what it is about, and inspect only the
monotone ones. Attested: pruning the file that held a declaration’s sole
instance flips
check-install-claimgreen to red, because its red condition is a zero count — the narrowing added a violation. “A narrower corpus can only remove violations” is false, and it is the first argument a narrowing delta reaches for.
A cross-component causal gap that surfaces during build is not a deferred TODO: stop, resolve it that session, update the spec before resuming. lifecycle-kit’s scope/build skill templates carry these hooks; canon-kit supplies the checklist they invoke and the gate behind the promotion rule.
Merging an amendment (on task completion)
- Read the canonical spec fully, then the amendment.
- Integrate — do not append: each addition lands in its proper section,
and the merged spec reads as one coherent document a reader who never
saw the amendment can use alone. The merge separates by tier: design
rationale relocates into the spec’s prose (its permanent home), and any
embedded wire-delta becomes a citation to the now-existing contract
file — the amendment’s exemption is file-scoped, so once step 3 deletes
the file
check-spec-embedded-sourcere-arms and a kept embed goes red. - Delete the amendment file; verify none remain for the component. The none-remain half is discharged at the iteration, not at the commit — with sibling amendments in flight for one component, only the batch merging the last of them can satisfy it, and the earlier batches’ identical assertions are unsatisfiable at their own commits. The horizon is the iteration because the build stage stays resident until the queue empties and close refuses entry on a non-empty active queue.
- Move the queue entry to
## Done, dropping its[spec:]tag — the amendment it referenced is gone, andcheck-amendment-queuerequires every[spec:]ref to resolve to a file on disk. Unless the entry outlives the amendment. Where an entry’s deliverable is a corpus and the amendment delivered one increment of it, the terminal move is a demotion rather than a Done move: drop the[spec:]tag and return the entry to the deferred section under its design-pending tag, so the next increment re-promotes with a fresh amendment. A Done move there asserts a finished deliverable that is not finished. It also costs a second thing that is easy to miss: a done entry is a bare slug, so every tag the entry carried goes with it — including any tag a generated public projection reads, which silently drops the item from that projection while the work remains outstanding. This is the branch to state explicitly because it is the uncatchable one — the Done-move contract has no gate behind either half, so the wrong terminal move reds nothing and is found only by a later reader. A demotion also lands the entry back inside a size cap, where a Done move would not. A done entry is a bare slug and nothing measures it; a deferred entry is measured per-entry (queue-kit/SPEC.md §check-queue-entry-budget), so any roster or table this amendment instructs the build to transcribe onto the entry is priced against that cap, and an entry already near it is compressed in the same commit that demotes it. Neither owner states this alone — the cap is queue-kit’s and the demotion is this section’s — and they meet only here. - Propagate removals (grep every spec for names the change retired), file discovered gaps as debt tasks, and commit the merge with the work.
The shipped amendment template ends in a Definition-of-Done checklist that includes causal completeness, merged-with-no-information-lost, the file-deleted assertions, and gap filing.
Content tiering — the star topology
Every governed prose surface owns exactly one content tier and points to — never restates — a fact owned by another surface. Which surfaces exist and what each owns is the consumer’s tier contract (each consumer’s is its own instance); the kit’s rules are the topology itself:
- One owner per fact. A parallel copy of a gated fact is the defect — a second source no gate reads, drifting silently. The fix keeps each surface’s tier and replaces the foreign-tier slab with a pointer.
- Definitions have one home. A canonical bold-lead-in definition of a
glossary term belongs only to the glossary; another surface may use
the term, explain why it exists, or carry its local mechanism — never
the definition (
check-surface-duplication, with per-site valves for a surface that legitimately introduces a concept). - Quantitative literals are code-owned. A count or enumerated set
transcribed into prose is a parallel copy; cite the owning source
instead (
check-manifest-countbans a bare cardinal quantifying a governed collection in a manifest — the collection is the count’s owner). A literal stays verbatim only when load-bearing, and then only gate-coupled — andcheck-measured-claimis the mechanism that clause names: ameasured:marker binds the literal to an oracle the gate re-runs, which turns a transcribed number into a generated, freshness-gated copy and is the second remedy this bullet offers beside citing the owner. Prefer citing the owner where the number need not be stated; mark it where a reader is served by reading it. What makes marking happen at all is a third mechanism, because the two above both wait for an author to act:check-unmarked-claimlets a consumer declare a class of claim that may not go unoracled, so the sentences most worth binding are pressured into a marker rather than left to authorial memory — and the class reaches a claim carrying no number, which is where this bullet’s own rules stop. A user-facing claim takes the same treatment when a reader acts on it:check-install-claimgives the primary-install-path claim one declared owner and holds every governed install section to it, so the topology reaches beyond the internal facts the rules above are stated over. A path’s version-control tracking status is code-owned in the same sense a count is — git owns it — so prose states the rule andcheck-tracking-claimverifies it, rather than prose transcribing membership. - Comments cite, never restate. The code surface is a tier too: a
full-line comment on a governed source is a machine or reason directive,
rides the contiguous run a directive opens, or is exempt — design
rationale lives in the owning SPEC section, not re-derived in a comment
block; the pointer names where the why lives and couples the code to it —
never relocating the why into the comment (
check-comment-tier; the FP-prone trailing-comment judgment stays a review tripwire). - Honest mechanizability. Only the structural sub-rules gate (banned headings, fence density, duplicate definitions, verbatim copies, temporal-narration markers, restated collection totals); the core judgment — is this sentence a definition or a narration, a why or a mechanism — is FP-prone and stays a review tripwire, explicitly not a blocking gate.
Layout and configuration
The kit is vendored beside gate-sdk (conventionally at canon-kit/); its
gates are registered in the consumer’s gates.list by name — each
where its surface exists (a consumer with no glossary does not register
check-surface-duplication) — and resolve through gate-sdk’s multi-kit
path.
Brand tokens and generic vocabulary sit on opposite sides of a seam. Brand
tokens carry the kit’s identity and move with the kit name: the kit dir
(canon-kit/), the CANON_KIT_ env-knob prefix, the discovered config
filename (canon-config.sh, the <kit>-config.sh convention), and the
docs-site page dir (docs/canon-kit/). Generic vocabulary names the spec
artifact discipline rather than the brand and is fixed regardless of the
kit’s name: the SPEC.md canonical-spec filename, the SPEC-*.md amendment
glob, the spec: and contract: source directives, the [spec:] and
[design-pending] queue tags, the check-spec-* gate names, and the internal
lib/spec.sh loader (where “spec” names the discipline). The consumer gate
check-kit-ref-liveness (declared by a .gate descriptor in scripts/ and
dispatched to the binary substrate — the dangling-reference hazard is a kit
author’s, so it is not templated into gate-sdk) holds the brand-token side
honest tree-wide: every slash- or
line-anchored <name>-kit / gate-sdk path segment must name a
gate_kit_roots dir, and every live-prefix kit knob must resolve to a tracked
kit knob (check-docs-cmd’s resolver, reused so the non-uniform knob-prefix map
has one home). A knob resolves through a family stem in either direction: a
scanned <FAMILY>_ stem resolves when kit source defines any member under it,
and a scanned member resolves when kit source defines the stem. The second
direction is what a dispatch-composed knob name needs — gate-sdk’s
GATE_SDK_KNOB_<NAME> bridge builds the full name at runtime from the knob it
is carrying (gate-sdk/SPEC.md §lib/gate.sh), so kit source contains the stem and
never the members. Spelling the members out to satisfy an exact match would be a
hardcoded roster of consumer knob names in a kit literal, which is the one shape
that crosses the provenance seam here. It valves the surfaces that legitimately
name design-ahead or frozen brands — gate-tests/ fixture bodies, docs/posts/*, the generated
trajectory data, SPEC-*.md amendments, and the queue — so a rename cannot
leave a dangle without turning a gate red.
Config follows queue-kit’s pattern: copy templates/canon-config.sh into
the gates dir as canon-config.sh (or point CANON_KIT_CONFIG_FILE
elsewhere) and override any knob; defaults fill what the consumer left
unset, and the loader exits 2 on a malformed config. Knobs:
CANON_KIT_SPEC_NAME— canonical spec filename, defaultSPEC.md.CANON_KIT_AMENDMENT_GLOB— defaultSPEC-*.md.CANON_KIT_QUEUE_FILE— default${GATE_SDK_QUEUE_FILE:-TASK-QUEUE.md}.CANON_KIT_FEATURE_SECTIONS— array, default("New Features"): active sections whose entries require[spec:].CANON_KIT_ACTIVE_SECTIONS— array, default("New Features" "Technical Debt"): sections where[design-pending]is a violation.CANON_KIT_DEFERRED_SECTION— defaultDeferred: the section whose every entry requires[design-pending].CANON_KIT_ICEBOX_SECTION— scalar, default empty: the second design-pending section, queue-kit’s optional icebox tier. Empty means the set is the deferred section alone. Naming the deferred section is malformed config. What generalizes here is the section set, not the tag set — no icebox-specific tag exists or is wanted.CANON_KIT_DOD_HEADING— defaultDefinition of Done;CANON_KIT_DOD_MODE—exactly-one(the default) orat-most-one(a reference-spec corpus like this repo’s kits carries no DoD).CANON_KIT_SCAN_KIT_ROOTS—0(default) or1. At0the shared finders skip vendored kit roots (gate_kit_roots): a kit’sSPEC.md/SPEC-*.mdand itsREADME.mdalike are a dependency’s documentation, not governed content, so theexactly-onedefault holds out of the box on a tree that merely vendored the kits beside gate-sdk and no consumer is asked to answer for a dependency’s own internal links. Set1when the kit docs are the consumer’s own first-party content (this repo does, and keepsat-most-onefor them).CANON_KIT_BANNED_HEADINGS— array, default("Directory Structure" "Public API" "Cargo.toml Dependencies");CANON_KIT_DERIVABLE_DENSITY— default60(percent fenced);CANON_KIT_DERIVABLE_POINTER_REGEX— the index-pointer marker that exempts a shed section, defaultpub-index|proto-index(consumer index tooling).CANON_KIT_EMBED_THRESHOLD/CANON_KIT_EMBED_MINLINES— defaults0.70/8;CANON_KIT_EMBED_LANGS— the scanned fence-language → source mapping, onekind|fence-alias,…|file-glob,…entry per language family, default = the bundled list;CANON_KIT_EMBED_ILLUSTRATIVE— fences illustrative-by-default, default(json);CANON_KIT_EMBED_WIRE_KIND— the one fence an amendment may embed as a not-yet-merged contract delta, defaultproto.CANON_KIT_GLOSSARY_FILE— defaultGLOSSARY.md;CANON_KIT_DUP_SURFACES— array of surfaces scanned for foreign definitions, default(VISION.md)plus every component spec.CANON_KIT_MANIFEST_FILES— array of globs, default empty ⇒ derive the manifest set (canonical specs,README.mdat any depth,CLAUDE.md);CANON_KIT_PROSE_SURFACE_GLOBS— array of candidate globs, default empty: each matched file joins the manifest set iff it is slot-free (no lifecycle-kit binding slot*<name: …>*, noCONSUMER BINDINGheader), so slot-free kit-template markdown and agent definitions come under the manifest doc gates while slot-bearing surfaces self-exclude — which surfaces a consumer governs is their config, the discriminator is kit mechanism (§lib/spec.sh). This repo sets("*/templates/*.md" ".claude/agents/*.md").CANON_KIT_TEMPORAL_MARKERS— the temporal-narration marker set scanned bycheck-manifest-temporal, default a generic-English list (previously,formerly,renamed from, …), matched case-insensitively and extended throughCANON_KIT_TEMPORAL_MARKERS_EXTRAunder the_EXTRAsemantics below;CANON_KIT_TEMPORAL_EXEMPT_SECTIONS— array of heading names whose whole section is exempt, default empty (this repo setsOut of scope).CANON_KIT_MDREF_EXCLUDE— array of globs, default empty: manifest-set docscheck-md-refsskips (a consumer’s generated documentation whose links a build tool owns).CANON_KIT_COUNT_COLLECTIONS— array of collection-noun pluralscheck-manifest-counttreats as growing governed sets, default("gates" "meta-gates" "checks" "kits" "stages" "rules" "KPIs")(a consumer appends its own governed plurals);CANON_KIT_COUNT_WEDGE_WORDS— how many words may sit between the cardinal and the noun, default2;CANON_KIT_COUNT_ALLOWED_PHRASES— exact-phrase allowlist for fixed named sets a doc may cite inline, default empty (a consumer names its own fixed sets, and only a phrase whose noun it governs needs the valve).CANON_KIT_ENUM_SETS_CMD— a consumer command emitting the governed setscheck-prose-enumholds, one<set-name>⇥<member>line per member, default empty ⇒ clean skip (no declared sets). This repo setsbash scripts/enum-sets.sh, which derives the queue tag sets from queue-kit’s own parser rather than restating them.CANON_KIT_INSTALL_TRANSPORTS_CMD— a consumer command emitting the install transportscheck-install-claimholds, one<transport-id>⇥<ERE>line per transport, default empty ⇒ clean skip.CANON_KIT_INSTALL_SECTION_RE— an ERE matched against##-or-deeper heading text to select the sections that gate scans, default empty ⇒ clean skip; either knob left empty skips the whole gate, since neither assertion has a vocabulary to judge against without the other.CANON_KIT_INSTALL_CLAIM_EXCLUDE— array of globs dropped from that gate’s scanned set on top ofCANON_KIT_MDREF_EXCLUDE, default empty. A transport vocabulary is one project’s distribution model, so no spelling of it ships as a kit literal (the provenance seam). This repo setsbash scripts/install-transports.sh,^(Quick start|Install), and("docs/posts/*").CANON_KIT_INSTALL_TRANSPORT_IDS/CANON_KIT_INSTALL_TRANSPORT_PATTERNS— the index-aligned halves of that command’s parsed output, default empty arrays, filled by this kit’s library only while one of the two is the knob under resolution; they are the bridged form the compiled member reads and are not a consumer-authored knob (§lib/spec.sh).CANON_KIT_PAYLOAD_CLAIMS_CMD— a consumer command emitting the payload disclosure classescheck-payload-claimholds, one<claim-id>⇥<ERE>line per class, default empty ⇒ clean skip (the correct posture for a tree whose payload discloses one thing only).CANON_KIT_PAYLOAD_CLAIM_EXCLUDE— array of globs dropped from that gate’s scanned set on top ofCANON_KIT_MDREF_EXCLUDE, default empty. A spelling of what a payload discloses is one project’s distribution model, so it is consumer config for the same provenance-seam reason the transport vocabulary above is. This repo setsbash scripts/payload-claims.shand("docs/posts/*").CANON_KIT_PAYLOAD_CLAIM_IDS/CANON_KIT_PAYLOAD_CLAIM_PATTERNS— that command’s parsed output in the same bridged, index-aligned shape as the transport pair above, default empty arrays.CANON_KIT_MEASURED_CLAIMS_CMD— a consumer command emitting the oraclecheck-measured-claimre-runs, one<key>⇥<value>line per measurable fact, default empty ⇒ clean skip (no oracle, so no marker has anything to disagree with).CANON_KIT_MEASURED_SURFACE_GLOBS— array of globs naming the scanned surface, default empty ⇒ empty corpus and a clean gate; the two are set together or not at all. What a project measures is that project’s vocabulary, so no key ships as a kit literal (the provenance seam). This repo setsbash scripts/measured-claims.shand theCANON_KIT_MANIFEST_FILESglobs plus.claude/commands/*.md— the shims the manifest set omits and the prose surface excludes on a copy-shape ownership this rule is not covered by. The surface knob has a second reader,check-unmarked-claim, which shares it rather than forking a knob of its own: the pair composes over one corpus (§check-unmarked-claim), and the recorded cost is that a consumer cannot scan claim classes over a different surface than its measured claims.CANON_KIT_CLAIM_CLASSES_CMD— a consumer command emitting the claim classescheck-unmarked-claimholds, one<class-id>⇥<ERE>line per class, default empty ⇒ clean skip (no declared class, so no sentence falls in one). A class is a spelling of what one project claims about itself, so it is consumer config for the same provenance-seam reason the vocabularies above are, and for one more: this kit’s own SPEC is governed prose, so a kit literal here would match its own class while describing it. This repo setsbash scripts/claim-classes.sh. The gate readsCANON_KIT_MEASURED_SURFACE_GLOBSabove for its corpus and adds no surface knob.CANON_KIT_CLAIM_CLASS_IDS/CANON_KIT_CLAIM_CLASS_PATTERNS— that command’s parsed output in the same bridged, index-aligned shape as the transport and payload pairs above, default empty arrays.CANON_KIT_COMMENT_MACHINE/CANON_KIT_COMMENT_REASON— arrays, default empty: extra directive prefixes appended to the built-in kit-mechanism roster (a consumer’s product vocabulary).CANON_KIT_COMMENT_SURFACE— array of globs, default empty ⇒ derive: shell sources under the root (kit roots perCANON_KIT_SCAN_KIT_ROOTS,templates/pruned) plus the${GATE_SDK_WORKFLOW_DIR:-.workflow}/*.txtstate files.CANON_KIT_COMMENT_POSITIONAL— the language construct roster for positional rescue, default empty (the kit is language-agnostic; a Rust consumer sets.unwrap( .expect( unsafe #[allow().CANON_KIT_COMMENT_WHITELIST— array of globs, default empty: the consumer’s not-yet-swept sources, its array tagged# exception-list:with a per-entry# until: <drain-task>.CANON_KIT_COMMENT_RUN_CAP— positive integer, default 3: total physical comment lines a directive blesses (its own line plus continuations, blank#lines counted).CANON_KIT_PROSE_TELL_GLOBS— array of repo-root-relative globs, default empty ⇒ nothing scanned, a clean pass: the reader-facing prose surfacescheck-prose-tellsreads. Which surfaces carry authored prose is the consumer’s editorial posture, and per the provenance seam it never lands as a kit literal. The threshold knobs, each read bycheck-prose-tellsalone:CANON_KIT_PROSE_TELL_EMDASH_MAX— em-dashes a paragraph may carry, default2;CANON_KIT_PROSE_TELL_CONTRAST_MAX— “not X, it’s Y” contrast turns a section may carry, default1;CANON_KIT_PROSE_TELL_RHYTHM_MIN_SENTENCES— sentences a paragraph needs before its rhythm is judged, default4;CANON_KIT_PROSE_TELL_RHYTHM_CV_MIN— the word-count coefficient-of-variation floor beneath which a paragraph reads as metronomic, default0.25;CANON_KIT_PROSE_TELL_TRICOLON_MAX— “A, B, and C” triples a section may carry, default2.CANON_KIT_PROSE_TELL_PHRASES— array of throat-clearing phrases matched case-insensitively, default a bundled generic-English set (It's worth noting,That said, …);CANON_KIT_PROSE_TELL_ABBR_ALLOW— array of abbreviations exempt from the undefined-abbreviation tell, default a bundled universal set (API,CLI,URL, …). A consumer extends any bundled vocabulary — these two andCANON_KIT_TEMPORAL_MARKERSabove — with its own through the matching_EXTRAknob:CANON_KIT_PROSE_TELL_PHRASES_EXTRA,CANON_KIT_PROSE_TELL_ABBR_ALLOW_EXTRAandCANON_KIT_TEMPORAL_MARKERS_EXTRA, all default empty, which the lib unions onto the base after the base defaults resolve: the effective set is base plus extra. Extension therefore costs one token, never a restatement of the bundled default that would silently diverge from it. Assigning a base array keeps its replace semantics, and that is the narrowing valve: a consumer wanting a bundled member gone replaces the base array. Generic English is kit-shippable; a consumer’s own vocabulary never becomes a kit literal (the provenance seam).
Cross-kit note: the section knobs carry the same defaults as queue-kit’s;
the knobs are independent (either kit runs without the other), so a
consumer renaming its sections sets both — and the icebox section name spans
every kit that reads the tier on that same shape (queue-kit’s
QUEUE_KIT_ICEBOX_SECTION, this knob, drift-kit’s DRIFT_KIT_ICEBOX_SECTION),
each degrading to “no icebox” rather than to a wrong section when left unset. Valve and marker spellings
([design-pending], [spec:], vision-introduces / spec-introduces,
spec-embedded-source-exempt: <reason>) are mechanism, not config.
Per-component contracts
Each gate below owns its own section — the assertion, its fixture pair, and the
reason for any bespoke unit test beside it. The bespoke unit tests are named
together here, because a roster spread across per-gate sections is one no reader
and no oracle sees whole: check-amendment-update-target.test.sh,
check-comment-tier.test.sh,
check-deprecation-task.test.sh, check-docs-link-convention.test.sh,
check-knob-citation.test.sh, check-knob-default-coupling.test.sh,
check-manifest-count.test.sh, check-md-refs.test.sh,
check-measured-claim.test.sh, check-prose-enum.test.sh,
check-spec-dod-singleton.test.sh, check-tracking-claim.test.sh and
check-unmarked-claim.test.sh.
lib/spec.sh
The sourced config loader plus shared adapters — values and adapters only, never gate structure. It centralizes the surfaces and vocabularies the spec-scanning gates share, so a rule enters once and every sibling matches the same shapes:
- Section grammar and queue resolution: the section-regex builders the
queue-facing gates use (queue-kit’s rule — both sides of a section boundary
must parse identically), and the one queue walk that emits a live slug for a
bold lead-in bullet in an active or design-pending section — deferred
plus a configured icebox, built as one regex so the tag rule and the walk can
never disagree on the set — and a done slug for a bare-slug bullet outside
them; the single grammar
check-todo-task-livenessandcheck-deprecation-taskresolve atask: <slug>binding through. The walk isnative/src/spec.rs’squeue_slugs: both members compile, so the shell walk went with its last caller, and so did the shell library’s section builders — the compiled classifier matches a section name against the configured sets directly rather than through a built regex, so those builders are defined here with no reader left in the tree, a residue whose disposition is filed rather than taken at a close. That walk’s bullet lead-line predicate is written again in the crate rather than pointed atnative/src/queue.rs’s adapters, which keeps canon-kit one of the format’s independent holders under the re-implement-and-cite-from-both-ends rule (queue-kit/SPEC.md §The queue format) — two modules in one crate are still two holders where one shared function is not; the holder census and the residue it leaves live at gate-sdk/SPEC.md §check-gate-exemption-tasks. - The governed comment surface:
comment_surfaceinnative/src/spec.rs— the one corpus primitivecheck-spec-pointer,check-comment-tier,check-todo-task-livenessandcheck-deprecation-taskall call, its single parameter deciding whethertemplates/sources are pruned. Its file set spans both gate declaration spellings, the ported implementation, and the workflow directory’s tracked tier:*.sh, the*.gatedescriptor,*.rs, and every tracked member ofGATE_SDK_WORKFLOW_DIRwhatever its extension. Stated once here rather than at each of the four callers, since a gate whose rule became a compiled subcommand must not have its# spec:pointer, its comment tier, or aTODO(task:)marker silently drop out of coverage — the widening is made at the shared primitive so no caller can inherit a narrower corpus than its siblings (gate-sdk/SPEC.md §Meta-gate conservation for the binary substrate). The shell forms are deleted rather than duplicated: all four callers compiled in one cohort, so criterion 6 is satisfied in the form it calls strongest — the duplication is absent rather than machine-held. A consumer whose own gate calledspec_comment_surface,spec_comment_surface_with_templatesorspec_comment_whitelistedshadows the gate, which is the answer this section already gives for the finder-choice question below. -
Finders: the canonical-spec / amendment finders the spec-scanning gates share, and the manifest-set finder the narration-gate family shares — canonical specs plus
README.md/CLAUDE.md, amendments excluded — so its members read one identical set. The finder then folds in eachCANON_KIT_PROSE_SURFACE_GLOBScandidate that is slot-free: it bears no lifecycle-kit binding slot (*<name: …>*, slot-name[a-z][a-z0-9-]*— the grammar lifecycle-kit/SPEC.md §templates/stages/ owns) and noCONSUMER BINDINGheader. A slot-bearing candidate is silently excluded — a template still awaiting binding is a placeholder, not finished prose, and its coverage stays the shim/binding gates (§Layout and configuration states the knob). All three markdown finders — canonical specs, amendments, and the manifest set’sREADME.mdhalf — skip atemplates/skeleton (a copyable stub, not governed content, the same rationale as the gate-tests prune) and, unlessCANON_KIT_SCAN_KIT_ROOTS=1, any vendored kit root under the scan root. The kit-root prune reaches the README because the knob’s rationale turns on whose documentation a file is, not on which filename carries it: a vendored kit’sREADME.mdis a dependency’s docs exactly as itsSPEC.mdis, and pruning one while governing the other reds a consumer on a dependency’s own internal links — measured, on a profile that vendors canon-kit without queue-kit, wherecanon-kit/README.md’s../queue-kit/link dangles on a bare install tree. An ancestor kit root — the case when a kit’s own fixture dir is the scan root — never prunes.One asymmetry inside the manifest set, written down because a port that tidies it changes the corpus. The canonical-spec half and the
README.mdhalf are bothtemplates/-filtered and kit-root pruned; theCLAUDE.mdhalf is neither. A consumer’s agent manifest is governed content wherever it sits, including inside a vendored kit root and inside atemplates/skeleton — so regularizing the three finders into one loop silently drops two files that belong in the set. The rule is the asymmetry, not the tidy form.Which primitive is on which substrate.
spec_manifest_fileshas a Rust implementation (native/src/spec.rs) carrying all three branches — explicit globs, the default walk, and the prose-surface fold — plus the kit-root path prune, because its family is partly compiled; the shell form stays, on a ground that has moved. That module carries the claim-gate primitives too: the declaration grammar, the declaration roster, the governed-doc set behind its two exclude valves, and the bridged vocabulary loader. The shell kept a copy of the last two per member; the compiled form has one, ported once and proved by each member that calls it.comment_surfaceandqueue_slugsare compiled only, and so are all four of their callers (gate-sdk/SPEC.md §The first cohort, and the rule that selects the next): they emptied their caller sets in one cohort and their shell forms were removed in the same commit. The manifest finder’s caller set is empty too, and its shell form stays —check-surface-duplicationwas the last member outside the family to call it and ported withshell-gate-tail-port, so what keeps the shell form is the dead twin disposition’s undocumented-surface bound rather than a live caller: these are helpers this section names, and that disposition reaches only helpers no section does (gate-sdk/SPEC.md §The port-candidate criteria, criterion 6). Read the two together and the rule is legible: an emptied caller set removes an unnamed helper and leaves a documented one standing. The two implementations of the manifest finder are held together by the config bridge rather than by a copied default: every knob either reads crosses it as a resolved value, so there is exactly one place each is computed (gate-sdk/SPEC.md §The port-candidate criteria, criterion 6). - The emitter-backed vocabularies — the enum sets, the install transports
and the payload-disclosure classes a consumer supplies as a command. For a
compiled member the command’s output is the interface, never the command:
the library runs the emitter at knob-resolution time and the binary receives
data, so no compiled gate spawns an interpreter to read consumer config and no
spelling of a consumer’s vocabulary becomes a kit literal. A pair rides two
index-aligned arrays (
CANON_KIT_ENUM_SET_NAMES/..._MEMBERS) because the bridge’s wire format separates elements with a tab and refuses one inside an element, so a<name>⇥<member>line cannot cross as a single string. The resolution is gated onGATE_SDK_RESOLVING_KNOB(gate-sdk/SPEC.md §lib/gate.sh), because it costs a subprocess and this library is sourced once per owning kit per bridge call. That knob is a set of the names under resolution, so each gated block tests membership through_spec_resolvingrather than equality: under the per-kit batch one subshell carries a whole slice, and an equality test would match no batch of more than one name — every one of these blocks would silently stop computing and every vocabulary would cross the bridge empty. Four vocabularies ride that shape, one pair each: the enum sets,CANON_KIT_INSTALL_TRANSPORT_IDS/..._PATTERNS,CANON_KIT_PAYLOAD_CLAIM_IDS/..._PATTERNS, andCANON_KIT_CLAIM_CLASS_IDS/..._PATTERNS(§Layout and configuration). The emitting command knob is bridged beside each pair, because it is what tells none configured from configured, and it declared nothing — the two clean skips a claim gate reports apart. The tab a POSIX ERE may legitimately carry cannot reach the bridge:spec_claim_vocabularybelow rejects a line with an extra tab before the value is ever serialized. - The count adapter the restated-total gates share, so a consumer’s
CANON_KIT_COUNT_COLLECTIONSvocabulary enters once and every such gate matches the same total shapes — including a total whose cardinal and noun straddle a prose wrap, reported at the cardinal’s physical line. That wrap-straddling normalization now has a second reader:check-unmarked-claimmatches a class ERE against the same normalized paragraph and maps the match’s span back to the physical line it starts on (FlatPara,native/src/spec.rs), so both cross a prose wrap by one rule rather than by a copy each. What the count adapter owns is the total grammar; what the flatten adapter owns is the subject a pattern is matched against, and separating them is why a second reader cost a call rather than a fork. The boundary rule and the mechanical exemptions live in one shared fragment, so no sibling drifts from another in what it counts as a total (§check-manifest-count). That boundary guards a prose noun against gluing to a following word (“gate” inside “gatekeepers”) — a different rule from the enum matcher’s identifier boundary (§check-prose-enum), which the two once spelled alike and only half-alike at that. English nouns do not compound across underscores, so the noun rule stays as it is; the shared spelling was never a shared rule. - The enum sets arrive through the consumer’s
CANON_KIT_ENUM_SETS_CMD, validated and fail-closing (exit 2) on a command error or an unparsable line (§check-prose-enum). - The claim vocabularies arrive the same way, through
spec_claim_vocabulary <command> <label>— one loader for every<id>⇥<ERE>vocabulary a claim gate reads, taking the command as an argument rather than from one named knob so a second claim axis costs a caller and not a second copy. That argument is attested rather than anticipated:check-unmarked-claimis the fourth caller and it arrived as a caller and nothing else — no fork of the loader, no second copy of the shape checks. It carries the same fail-closed contract its enum-set sibling does — a command error, an unparsable line, an id that is not slug-shaped, or a repeated id is exit 2 — and the<label>it is given names the failing vocabulary in every message, so a fail-closed exit still says which one failed. The duplicate check is what a bare emit grammar cannot express and both gates need: two lines claiming one id would give a class two patterns and make its match order arbitrary. Its callers arespec_install_transports, overCANON_KIT_INSTALL_TRANSPORTS_CMD(§check-install-claim);check-payload-claimdirectly, overCANON_KIT_PAYLOAD_CLAIMS_CMD(§check-payload-claim);spec_measured_claims, overCANON_KIT_MEASURED_CLAIMS_CMD(§check-measured-claim) — whose second field is a measured value rather than an ERE, which the loader neither interprets nor needs to, since every check it makes is on the line’s shape; andcheck-unmarked-claimdirectly, overCANON_KIT_CLAIM_CLASSES_CMD(§check-unmarked-claim). The two-field shape is what makes that last caller coverage-only: a class cannot carry a required key beside its ERE without a third field this loader refuses, and the refusal is kept rather than relaxed. - The comment-surface adapters: the comment gates read different surfaces,
which is the one thing the primitive’s parameter decides.
check-spec-pointerscans the template-pruned surface — a template’sspec:line is an unresolvable-by-design placeholder (§check-spec-pointer);check-comment-tierscans the with-templates surface — a copied-out template’sspec:pointer resolves against the vendored kit path (kit SPECs travel with the vendor-whole install), so its comments are governed like any source (§check-comment-tier). Which finder a gate uses is kit contract, not consumer config — a consumer wanting the old blanket exemption shadows the gate. - The amendment finder
spec_amendments, the amendment-glob counterpart ofspec_canonical_specsabove. Its last shell caller left at §The sixth budget batch (gate-sdk/SPEC.md), and the function stays: the dead-twin disposition (gate-sdk/SPEC.md §The port-candidate criteria, criterion 2) reaches undocumented surface only, and this bullet documents it. The live holder isnative/src/spec.rs. Its consumers arecheck-amendment-queueandcheck-amendment-update-target, and they differ in fail-closed posture on purpose — so the crate holds two spellings of one walk,amendmentsandamendments_strict, rather than one that reads as drift. The best-effort form swallows an unwalkable scan root and yields no amendments, which the queue gate can afford because an empty amendment set cannot hide a violation there (every[spec:]ref then dangles and the run reds). The update-target gate has no second surface to contradict an empty set, so for it an empty set hides every violation silently and the walk refuses instead. Each section states its own half; the difference is a reasoned divergence, never an inconsistency to reconcile. - The default-statement grammar both knob gates share, so the rule for
what reads as a stated default has one home.
sk_literal_atrecognizes the value literal opening a window (a backticked non-knob string, a quoted string, or a number) andsk_default_literalreturns the literal the word “default” binds; a caller-suppliedsk_is_knobnamekeeps a bare knob name from reading as a value.check-knob-citationreads it to reject a restated value in prose;check-knob-default-couplingreads it to confirm the SPEC states the source’s literal (§check-knob-default-coupling). Both callers are compiled since §The sixth budget batch, so the live holder isnative/src/spec.rs’sDefaultGrammar— promoted there out of the first gate’s private implementation at that port, because the second caller arriving is exactly when a private copy becomes the second owner this bullet exists to prevent. The shell form’s caller set emptied with it and it is not deleted: the dead-twin disposition (gate-sdk/SPEC.md §The port-candidate criteria, criterion 2) bounds itself to undocumented surface, and this bullet documents it.
check-amendment-queue
Invariant: the bidirectional rule holds — (a) no feature-section entry
without [spec:], no [design-pending] anywhere in the active sections
(entries or prose; a prose mention masks the tag’s absence), and no
[spec:]-tagged entry in an active non-feature section (a spec-ready
entry is misfiled there — it belongs in a feature section); (b) every entry in
a design-pending section carries [design-pending], and one already
carrying [spec:] must be promoted; (c) every amendment on disk pairs
with a [spec:] queue entry and every [spec:] ref resolves to a file.
Calibration: a ref is a bare amendment basename (searched tree-wide) or a
repo-relative path (resolved directly — the generalization that lets a
consumer point a task at any design artifact, e.g. this repo’s kit-SPEC
drafts). Sub-bullets and prose notes are outside the entry grammar;
precommit tier.
Coverage limit, stated because the tag’s section-wide reach invites the
stronger claim: any heading that is not a feature, active, or deferred
section classifies as other and skips every arm, so the done section is
an exempt population — an entry moved out of design-pending into it still
carrying the tag reds nowhere, and queue-kit’s lead-line gate misses it too
(its scanned surface is the task sections alone). In practice the done
grammar is a bare slug line, so the tag is dropped by that grammar rather
than by a gate. The guard is total over the promotion moves it exists to
catch — design-pending → an active section — and silent on the disposition
move. This is a limit on this gate’s axis only: the amendment artifact is
held on a second axis by check-amendment-update-target
(§check-amendment-update-target), whose corpus is the same amendment set and
whose subject is what the file says about itself rather than how it pairs with
the queue.
The amendment finder is best-effort, and the port had to reproduce that
rather than harden it. spec_amendments ends 2>/dev/null … || true, so an
unwalkable scan root yields no amendments instead of a refusal. Read against
gate-sdk/SPEC.md §Fail-closed contract the compiled form would refuse there —
and that would be a verdict change across the seam, which is exactly what the
port’s parity run holds invariant. It is also not the vacuity that contract
guards: an empty amendment set cannot hide a violation in either direction,
because every [spec:] ref then dangles and the run reds. Recorded here
because the natural port instinct is to add the refusal, and the sibling
check-todo-task-liveness — which does refuse on a bad scan root — makes the
instinct look precedented. That member’s shell form carried the -d guard;
this one never has.
check-amendment-update-target
Invariant: in every amendment on disk, each entry under
## Existing sections updated cites at least one delta, and every cited delta is
defined under ## What changes in the same amendment. The grammar it reads —
what a delta is, what a citation looks like — is contract, stated at
§The amendment lifecycle and shown to the author in
templates/SPEC-amendment.md.
The failure it closes is attested: an update target no delta claims reaches build as an orphan a batch adopts on its own authority, which is the template’s own words for it. That is not hypothetical — one iteration dropped a tightened-gates declaration across all three of its build batches, and a fourth batch repaired it at validate.
Three arms and a valve.
- A — the grammar. Red (exit 1) when a
###heading under## What changesdoes not match### (<N>) <title>, or when the delta numbers, read in document order, are not1..nunique and ascending. This is the arm that makes B and C possible at all. Only the first ordering breach in a file is reported: inserting one delta shifts every number after it, so reporting each would bury the single edit that caused them. - B — the uncited target. Red when a top-level
-bullet under## Existing sections updatedcarries no citation. The bullet’s entry is the bullet line plus its indented continuation, so a citation that wrapped across a newline is still one subject — the same wrap-straddling boundary §check-unmarked-claim crosses, for the same reason. - C — the dangling citation. Red when a citation names an
<N>no### (<N>)heading defines,all deltasin an amendment that defines none included. Without C, arms A and B both pass on an amendment whose targets cite deltas that were renumbered out from under them. - Valve —
<!-- update-target-exempt: <reason> -->on the bullet’s first line or the one above, riding the shared exempt-window (§lib/spec.sh — the line or the one above), and the reason is mandatory (thecomment-tier-exempt:convention). An exempt bullet leaves the target count as well as the finding.
A fenced block is skipped whole, on the ground §The amendment lifecycle already gives the fence its exemption for: an amendment may embed a wire-contract delta until merge, and a heading or a bullet inside one is grammar being shown rather than a delta being defined.
Fail-closed (exit 2): a scan root that is not a directory; an amendment
carrying ## Existing sections updated but no ## What changes, where no entry
can be owned and no arm could say which to blame; a file the finder returns and
the reader cannot read; and an unwalkable scan root, where
§check-amendment-queue’s best-effort finder returns empty instead. That last
divergence is deliberate and is the one to read carefully: the queue gate can
afford an empty amendment set because its other direction contradicts it, while
here an empty set hides every violation silently. The two spellings of the walk
live at §lib/spec.sh, which owns the asymmetry.
No new knob, and the reason is not laziness. The corpus is spec_amendments
(§lib/spec.sh) — the same finder §check-amendment-queue uses, already applying
the templates/ exclusion that keeps a shipped SPEC-amendment.md skeleton from
being read as a live amendment, so the skeleton’s illustrative headings cannot
red the gate that governs its copies. The two heading names are kit constants,
not config: they are canon-kit’s own template’s headings, and a consumer
editing them has edited canon-kit’s artifact rather than configured it. The
contrast with §check-spec-dod-singleton’s configurable Definition-of-Done heading
is real, and the line is where the surface is authored — that heading appears in
consumer-authored canonical specs, these appear in copies of a kit-shipped
skeleton. A knob is available later behind an attested consumer rename; adding
one now would be a knob whose only reader is §check-knob-citation.
Deliberately not asserted: roster completeness. The gate cannot check that
the update-target roster names every surface the change obliges a write to —
that is a claim about the world, not about the file, and no scanner reaches it.
It asserts the decidable half (every listed target is owned) and the align stage
keeps the other half, whose duty is unchanged: arm B catches a target that was
listed and unowned, never one that was never listed at all. Two stronger arms
were weighed and refused. Requiring every delta to be cited by some target is
false — a delta adding a wholly new section legitimately touches no existing one.
Requiring every path or § reference in a delta body to appear in the roster
would be high-false-positive, because a delta body names many surfaces for
context, and a gate that cries wolf trains its readers to bypass it (gate-sdk/SPEC.md
§When a gate earns its place).
Criterion 4 (gate-sdk/SPEC.md §The port-candidate criteria) clears: the
corpus is spec_amendments, which reaches no gate declaration path. Born
native, no shell form authored — a crate-carrying tree births a gate native by
default and none of the three exception classes applies. Its good/+bad/ pair
is its oracle: the bad/ case carries a malformed heading, a non-sequential
delta number, an uncited target and a dangling citation together, so each arm has
an executable statement, while the good/ case exercises the wrapped citation,
the list and possessive forms, all deltas, the fenced decoy and the valve.
check-amendment-update-target.test.sh holds the fail-closed exits and the
empty-corpus clean, which a one-pair harness cannot spell.
Producer of nothing but a verdict; its consumer is the committing session through
the output contract, on the generated pre-commit hook, run-gates.sh and CI, and
run-gate-tests.sh through the fixture pair. Its input is spec_amendments’
output — an existing producer with an existing enabling path, so nothing new must
be configured for the gate to see a live corpus. precommit tier.
check-spec-dod-singleton
Invariant: no canonical spec carries the configured Definition-of-Done
heading more than once (a duplicate checklist is the two-sources defect on
the completion contract); under exactly-one mode a spec with none is
also flagged.
Calibration: heading match is level-insensitive within ##–####;
at-most-one exists because a reference-spec corpus legitimately has no
DoD. The good/bad pair covers the DoD count; check-spec-dod-singleton.test.sh
covers the finder’s kit-root scoping — a DoD-less vendored kit SPEC.md is
pruned by default (so exactly-one holds on a vendored tree) and re-included
by CANON_KIT_SCAN_KIT_ROOTS=1. align-only tier.
check-spec-derivable-section
Invariant: no canonical-spec section under a banned heading (the configured code-derivable set) whose body exceeds the density budget in fenced lines — such a section is a code dump that drifts; it sheds to a one-line index pointer (which is exempt).
Calibration: density counts non-blank lines; the heading set and budget
are config because what is derivable depends on the consumer’s index
tooling. align-only tier.
check-spec-embedded-source
Invariant: no fenced block in a canonical spec verbatim-copies a tracked
source file above the overlap threshold — cite the path instead. Overlap
detection needs no author opt-in; the two valves are the amendment
wire-delta exemption — an amendment is a short-lived design-to-code bridge,
so a not-yet-cite-able wire contract legitimately embeds there until merge
promotes it to a file the canonical spec cites — and the per-site
spec-embedded-source-exempt: <reason> marker.
Calibration: blocks shorter than CANON_KIT_EMBED_MINLINES are ignored;
languages in CANON_KIT_EMBED_ILLUSTRATIVE are skipped by default; the
threshold is calibrated against real specs, not synthetic fixtures.
precommit tier.
The wire-delta exemption is scoped to the configured wire kind, not to amendments. An amendment quoting enough distinct non-trivial lines of a tracked file in a fence of any other language fires exactly as a canonical spec would, which is what keeps the valve a bridge for a wire contract rather than a blanket licence for amendment prose. The per-site marker is the general escape, and it is a path-identity test against the amendment set rather than a name pattern — which is what re-arms the exemption the moment the amendment is deleted, instead of leaving a spelling that outlives its subject.
Ported to the binary substrate at §The sixth budget batch (gate-sdk/SPEC.md).
Criterion 4’s verdict on it was taken conservatively without ruling the class:
its walk opens and content-compares every source file the language roster names,
its own declaration and every sibling’s included, but as a diff reference rather
than as its assertion target, and whether that satisfies the criterion is the open
ruling spec-embedded-source-criterion-4-membership owns. The batch took the
binding verdict for its own member because that costs a fixture widening and
cannot be wrong in the harmful direction, while clearing wrongly ships the hole
the criterion exists to point at; a later reader must not read the disposition as
that entry’s answer. The widening the verdict bought is the one delta the pair was
missing: its cases derive a real corpus and do reach the finder, but neither valve
was exercised by any committed case, and both now are.
One second-order fact the port prices whichever way that class falls: every port removes a shell declaration from this gate’s candidate index and adds a Rust module to it, so its verdicts move under every sibling’s port — the narrowing is a swap rather than a removal, and a port can therefore create a violation here by moving a derivation’s text from a file the specs do not quote into one they do.
check-manifest-temporal
Invariant: no temporal-narration marker in governed manifest prose outside an
exempt site. A manifest states current behavior only — history is derivable
from git, and a formerly… line is standing context cost documenting the old
cost, taxing every session that reads it. This gate mechanizes the lexical
share of that judgment; context-kit’s close-stage brevity pass keeps the
semantic residue (is this sentence about the past?); a by-eye
narration-marker KPI is superseded by this gate (drift-kit/SPEC.md
§Out of scope).
The scanned set is the shared spec_manifest_files finder (§lib/spec.sh):
canonical specs, README.md at any depth, and CLAUDE.md; amendments are
excluded by construction (a transition artifact describes change — that is its
nature). Markers are CANON_KIT_TEMPORAL_MARKERS, the base array merged with
CANON_KIT_TEMPORAL_MARKERS_EXTRA (§Layout and configuration), matched
case-insensitively;
fenced code blocks are skipped and a marker inside an inline-code span is a
meta-reference, not narration — so a gate-output example or this section’s own
vocabulary may name one. Three valves suppress a legitimately past line: a
per-site manifest-temporal-exempt: <reason> comment on the line or the one
above; CANON_KIT_TEMPORAL_EXEMPT_SECTIONS — heading names whose whole
section (subsections included) is exempt, this repo’s config naming
Out of scope (a deliberate-absence ruling may narrate what the kit
excludes); and CANON_KIT_TEMPORAL_EXEMPT_PATHS — path
globs whose whole file is exempt, for an immutable dated-narrative surface a
heading name cannot address (this repo’s config naming docs/posts/*, the
dated announcement posts, which take link and command resolution but not
narration governance). Producer: the generated pre-commit hook /
run-gates.sh; consumer: the committing operator via the output contract; each
marker hit is read at the single scan transition (file, line, marker in the
message), no persistent state. Fail-closed on an unreadable manifest.
The rule is a compiled subcommand (gate-sdk/SPEC.md §The POSIX ERE matcher):
each marker is a consumer-supplied ERE, compiled through the crate’s matcher
before the first corpus line is read, so a pattern outside the POSIX grammar
exits 2 naming the pattern and the knob rather than scanning past what it meant.
Two fidelity points a transliteration loses, recorded because both are invisible
in a green run: the marker test runs against a case-folded subject and the
pattern is not folded, so a marker written with an upper-case letter matches
nothing; and CANON_KIT_TEMPORAL_MARKERS_EXTRA unions onto the base array inside
this kit’s library before the bridge reads it, so the compiled member declares
the base knob alone and a second _EXTRA knob would double every added marker.
Calibration: the marker set is tuned against this repo as the FP corpus — bare
used to is excluded (it collides with instrumental “used to build/filter”).
At build every hit is dispositioned: reword (preferred — narration is standing
cost), section-exempt (provenance), or site-exempt with reason. precommit
tier.
check-manifest-count
Invariant: no bare cardinal quantifying a governed collection noun
in manifest prose outside an exempt site. A pinned total for a growing
collection — six gates, seven meta-gates — is a second source no gate reads:
the count’s owner is the collection itself (gates.list, a checks/ dir, the
stages config), and a restated total drifts the moment the collection grows.
Ban, don’t validate — a validating gate carries
the standing token cost context-kit’s brevity machinery rejects plus FP-prone
entity mapping; a lexical tripwire eliminates the copy. The motivating find: this
repo landed one gate and left the same total in four disagreeing copies (across
two READMEs and a SPEC), caught only by close-stage review.
The scanned set is the shared manifest-set finder (§lib/spec.sh) —
canonical specs, README.md, CLAUDE.md; amendments excluded, fenced blocks
skipped, an inline-code cardinal a meta-reference (so this section may name its
own examples). Both the grammar and the matcher come from the shared count
adapter, and the prose walk is the shared manifest-prose driver (§lib/spec.sh),
so this gate, its check-prose-enum sibling, and its comment-tier cousin
read one vocabulary and one exemption behavior, and a total wrapped across a
prose break is caught and reported at its first physical line; a blank line, a
fence, and a manifest-count-exempt: site each end the paragraph — an exempted
line cannot join its neighbours into a total. The cardinal
grammar is digit sequences and the spelled
two…twelve, case-insensitive; one is deliberately outside it — singleton and
cardinality-rule idioms (“one owner per fact”, “one iteration per kit”) are
invariants, not totals. Collection nouns are CANON_KIT_COUNT_COLLECTIONS
(default the plurals the kits themselves grow: gates, meta-gates, checks,
kits, stages, rules, KPIs) — the one place consumer vocabulary enters,
and it enters as config.
Two match shapes carry the cardinal to the noun: the quantifier shape, which
allows up to CANON_KIT_COUNT_WEDGE_WORDS (default 2) modifiers wedged between
cardinal and noun (so nine generic rules pins a total as surely as the
adjacent six gates, adjacency being the zero-wedge case), and the
noun-then-range shape (rules 1-8), which pins both endpoints of an ordered
collection and rots on every append. The exempt contexts an author writes into:
a threshold or comparator on the same line (a bound is a rule, not a total); the
all but <cardinal> partition idiom; a partitive marker (of, out of) on
either side of the match (in three of the twelve gates neither cardinal is a
restated total); CANON_KIT_COUNT_ALLOWED_PHRASES, an exact-phrase allowlist for
fixed named sets a doc legitimately enumerates (default empty — fixed-set naming
is consumer judgment, config not mechanism, biting only on a phrase whose noun
the consumer governs); and the per-site manifest-count-exempt: <reason> marker
on the line or the one above. The exact operator and phrase tokens each context
recognizes are the check’s own regex, generic-English mechanism rather than
config.
The sanctioned discharge is the measured: marker (§check-measured-claim),
and it is the only one of the four that answers the ban rather than stepping
around it: the objection this invariant encodes is a transcribed total with no
owner, and a marker binds the total to an oracle a gate re-runs. So a cardinal
under a <!-- measured: <key>=<value> --> line satisfies the ban the way a
generated copy satisfies derivation-first, which strictly shrinks this gate’s
violation set. It rides the same per-site window the exempt marker does — the
marker line and the claim below it — so the two discharges are one behavior with
two spellings rather than a second walk. Rewording to cite the owning collection
stays preferred where the total is not worth stating at all; the exempt tag drops
to a genuine last resort, because “deliberately unchecked” is now the one thing
it means.
Producer: the generated pre-commit hook / run-gates.sh; consumer: the
committing operator via the output contract; each hit read at the single scan
transition (file, line, matched span in the message), no persistent state.
Fail-closed on an unreadable manifest.
Calibration shares the sibling’s FP corpus and procedure
(§check-manifest-temporal): the default noun list is tuned against this tree,
every hit dispositioned — reword to cite the owning collection (preferred),
extend CANON_KIT_COUNT_ALLOWED_PHRASES (a genuinely fixed set), or site-exempt
with reason. The good/bad pair covers every match shape and the mechanical
exemptions;
check-manifest-count.test.sh covers the config-driven paths (a consumer-governed
noun and the allowlist containment) the stock defaults cannot reach, and the
measured-marker discharge’s window — that the marked claim is exempt and the
next paragraph is not, which a pair cannot spell because a fixture file either
trips or does not. precommit tier.
check-measured-claim
Invariant: a measured count or extent claim that names an oracle agrees with it. The class this closes is a claim authored into governed prose with no oracle at all — a total, a corpus size, a swept set — which goes stale the moment the thing it measured moves, and whose detection is a human re-measuring by hand at review time if at all. The sibling ban (§check-manifest-count) reaches the tractable slice of that class by refusing a bare cardinal over a governed collection; it cannot reach a claim carrying no cardinal, and an extent claim (“the kit SPECs came back clean”) is exactly that shape. Both halves need the author to say what was measured, so this is a marker an author applies rather than a scanner inferring intent: a scanner triggers on a numeral, and the claims that cost the most carry none.
The marker binds a claim to an oracle key and its measured value, as a full-line HTML comment on the line immediately above the claim:
<!-- measured: <key>=<value> -->
It joins the family install-primary: (§check-install-claim) and
payload-discloses: (§check-payload-claim) rather than inventing a form — a
full-line comment pairing a prose claim with a machine-readable id drawn from a
consumer-owned vocabulary behind a *_CMD knob. It is deliberately not the
-exempt: family: those are suppression valves that make a gate look away, and
this is an attachment that gives a gate something to check. The reader-facing
form of the claim stays prose; the marker is the tier beside it.
The oracle is consumer-owned. CANON_KIT_MEASURED_CLAIMS_CMD names a command
emitting one <key>⇥<value> line per measurable fact, loaded through
spec_claim_vocabulary (§lib/spec.sh) and so carrying that loader’s fail-closed
contract. The marker grammar and the comparison are kit mechanism; every key,
every oracle command and every measured fact is consumer config, because a kit
literal enumerating what a project measures would publish that project’s
vocabulary. An unset knob means the gate has no oracle and reports clean — the
inactive-by-default posture its *_CMD siblings take.
Adding a key costs more than the emitter, and the extra cost is invisible from
here: the key and value rosters are bridged knobs, so a consumer generating a
hook bakes them into it. Where this member is dispatched through gate-sdk’s
config bridge, the resolved <KIT>_MEASURED_KEYS and _VALUES sit literally in
the generated pre-commit hook’s invocation, which a freshness gate holds byte for
byte. The consequence is the one to weigh before minting a key: every input of
that key’s oracle becomes an input of a byte-gated artifact, so a high-churn
key stales the hook on every commit that moves it, whether or not the commit
touches anything the hook is about. A low-churn key costs nothing extra. This is
an instance of the general resolved-knob-baking property rather than a second
mechanism, and it is named here because the emitter is authored on this surface
while the property lives on gate-sdk’s.
A key’s meaning is consumer-owned too, and that is the one failure none of the three arms can catch. Redefining an existing key’s oracle to answer a wider or narrower question leaves every arm satisfied while the referent moves: arm A compares the marker to the emitter and arm C compares the marker to the prose, and both stay green because neither knows what the key is about. The public sentence above the marker then asserts something nobody wrote and no gate can see. So a measurement whose question differs from an existing key’s takes its own key, however close the two numbers happen to be at the moment — the closeness is what makes the substitution tempting and the drift invisible. What holds a key’s meaning honest is review at the diff, which is the same answer §check-comment-tier gives for a free-text cause and for the same reason: nothing here parses meaning.
A key joins the roster before a marker names it, and a key no marker names is the ordinary state rather than a reservation. Arm B runs marker-to-roster — it fires on a marker whose key nobody emits — and there is deliberately no converse assertion, because an emitter is a consumer’s own program and a kit is in no position to rule which of its facts a document must cite. The ordering matters when a key is minted: the emitter gains the line first, and prose binds it whenever a sentence is written that needs it. Reading it the other way — treating arm B as something that exercises every emitted key — would be a false comfort, since a key no marker names is touched by no arm at all.
Three arms.
- A — the oracle disagrees. Red when the emitter’s current value for
<key>differs from the marker’s<value>. This is the point: the number in the document is now checked against the tree. - B — the key is unknown. Fail closed (exit 2) when
<key>is absent from the emitter’s roster. A marker naming a key nobody emits is a claim with no oracle wearing the costume of one, which is worse than an unmarked claim. A marker that does not parse fails closed for the same reason. - C — the marker drifted off its own sentence. When
<value>is a bare cardinal, red unless that cardinal appears as a token in the claim the marker binds. Without arm C the marker and the prose can disagree while the marker and the oracle agree, and the gate would go green over a false sentence — the exact failure being closed.
Arm C’s cardinal grammar is §check-manifest-count’s, read as a value: a digit run
or a spelled two…twelve normalized to digits, so a marker’s 12 and a
sentence’s “twelve” are one cardinal rather than two. The claim a marker binds is
the paragraph below it, ending at a blank line, a fence, a second marker or the
end of file. The authoring contract arm C prices: a bound claim carrying more
than one distinct cardinal is ambiguous, and the gate fails closed rather than
guessing which one the marker holds — the remedy in the help line is to split the
sentence or move the marker onto the clause that carries the measurement. That
cost is bounded: it applies only to sentences an author chose to mark.
Extent claims are covered by arms A and B alone, and that is the design rather
than a gap. An extent claim carries no cardinal, so arm C does not apply and arm
A does the work, with <value> whatever the author declares the extent to be and
the emitter recomputes: a corpus size, a sorted membership list, a digest over
the swept set. This is the axis no scanner reaches and it costs nothing extra —
the same arms, with a set-valued rather than integer-valued oracle. Because the
config bridge refuses an element containing a tab and tab is this protocol’s own
field separator, a set-valued oracle joins its members with something else. This
repo’s first such inhabitant is gate-substrates, the live substrate set the
enforcement core runs on, its members joined with +; it is the shape that makes
a definitional or qualitative sentence self-correcting, since such a sentence
carries no cardinal for arm C to check and would otherwise be marked with nothing
an emitter could recompute.
The known limit, stated rather than hidden: a claim nobody marks is uncaught here. It is narrowed from both sides, along different axes. §check-manifest-count’s discharge makes marking pressured rather than voluntary wherever the ban already bites — a narrowing by shape, reaching whatever carries a bare cardinal over a governed collection. §check-unmarked-claim narrows by subject, reaching a claim a consumer has declared must not go unoracled whether or not it carries a number. Neither closes the limit; between them the uncaught remainder is a claim that is both unnumbered and undeclared, which is a smaller set than either alone leaves.
The scanned surface is its own glob knob, CANON_KIT_MEASURED_SURFACE_GLOBS,
not the manifest set. The motivating class ranges over SPEC sections and
binding shims alike, and neither existing surface reaches a shim: the manifest set
excludes them by omission, with no documented rationale (a reader should not infer
a ruling from a silent absence), and the prose surface (§lib/spec.sh) excludes them
by a documented decision about which gate owns a shim — check-shim-restatement,
which holds copy shape. That ownership is not this rule’s: a restatement that is
wrong has diverged from its owner’s wording and is therefore not a copy. So
reusing either surface would under-scan by exactly the instance that motivated the
class. Fenced blocks are skipped (a fence is grammar being shown, not a claim being
made), and a per-site measured-claim-exempt: <reason> marker on the line or the
one above suppresses a marker a document is exhibiting rather than asserting.
The valve stays, and its zero live uses are not evidence against it (ruled at
the native-cohort-canon-kit close, on a filed observation that it shipped unused).
The use count was taken over this tree, which is the wrong corpus: canon-kit is a
kit, so the valve’s readers are adopters whose prose this repo cannot see. It is a
fail-safe for an author who must exhibit a full-line marker outside a fence, and a
fail-safe carrying live uses would mean the fence-and-inline escapes had already
failed. It stands beside the family boundary drawn above rather than against it —
that boundary rules what the measured: marker is, not whether a suppression
valve may accompany it.
Producer: the generated pre-commit hook / run-gates.sh; consumer: the committing
operator via the output contract; each marker read at the single scan transition
(file, line, key, value), no persistent state. precommit tier.
Born native, and this is the first gate with no shell original — operator-ruled
2026-08-12 with the cost below in view. TRAJECTORY.md §The objectives’ sixth
shrinks the interpreter surface to the unavoidable, so a new shell gate is debt
created knowingly; landing it as a Rust module plus a .gate descriptor avoids
that, and it needs no unbuilt substrate (its glob corpus is served by the walker
already in production, which is why the corpus was designed as its own surface
rather than a union with the manifest set). The port criteria govern ports, so a
gate with one implementation has no second substrate to prove parity against: its
oracle is its good/+bad/ pair, exactly like any new shell gate’s, with
check-measured-claim.test.sh holding the fail-closed arms a pair cannot spell.
The accepted cost: a .gate-declared member is omitted from the gates.list of
a consumer whose host the release publishes no artifact for, so on an uncovered
platform this gate does not run where a shell gate would have. That is the port’s
standing cost, already accepted for the members whose shell forms were deleted, and
it is why the omit-and-declare path exists — a paid price, not an open risk. Its
reach stayed deliberately open here — this gate settled, a second one weighing
the cost again rather than citing this one as blanket precedent — and that
question has since been closed by ruling rather than by precedent: new gates
in a crate-carrying tree are born native by default, with shell an exception
under one of three stated classes (gate-sdk/SPEC.md §The port-candidate
criteria). The refusal above is unaffected in its own terms: what settles the
second gate is the ruling, never this section.
check-unmarked-claim
Invariant: on the scanned prose surface, a paragraph matching a
consumer-declared claim-class pattern carries a measured: marker. The class
this closes is the axis §check-measured-claim names as its own known limit: the
marker is voluntary, so the claims that most need an oracle are exactly the ones
an author never thought to mark. This gate makes marking pressured inside a
declared class, the same narrowing §check-manifest-count already performs for
the bare-cardinal shape — there by shape, here by subject.
It declares a class; it does not ban a phrase, and the difference is the whole design. A ban is discharged forever the moment the banned phrase is deleted, and it can only ever face one direction. A class assertion fires again on the next author who states the same thing in new words, and it covers a claim that overstates in either direction, because what puts a sentence in the class is what it asserts rather than which words it reaches for. The two properties are one property: the class is written over the claim, never over a canonical phrasing.
Two arms and a valve.
- A — an unmarked claim. Red (exit 1) when a paragraph matches a declared
class ERE and no
measured:marker binds it. The finding names the class id, the file, and the physical line where the match starts, and itshelp:line states the three remedies below. A paragraph yields at most one finding, at the first class in roster order that matches it. - B — the vocabulary fails to load. Exit 2 on a command error, an unparsable
line, a non-slug id, or a repeated id — inherited unchanged from
spec_claim_vocabulary(§lib/spec.sh), which already names the failing vocabulary by its<label>in every message — and on a declared ERE that does not compile, which is the same unreadable-roster failure one step later. - Valve —
<!-- unmarked-claim-exempt: <reason> -->on the flagged line or directly above it, riding the shared exempt-window (§lib/spec.sh — the line or the one above), and the reason is mandatory (thecomment-tier-exempt:convention — a deliberate keep carries its cause in-line, a reasonless valve is red).
The three remedies, and the gate is indifferent between them. Rewrite the
sentence out of the class (the doctrine’s preference — a claim not made cannot go
stale); attach a measured: marker binding it to an oracle; or land a reasoned
valve. Naming all three on the finding is what keeps the gate from reading as a
ban, which it deliberately is not.
Matching is whitespace-normalized across lines, and this is load-bearing rather than a nicety. A claim spans a prose wrap as readily as it fits on one line, so a line-keyed predicate is blind by construction on whatever fraction of its corpus happens to wrap — a fraction no author controls and no reviewer can see. The ERE is matched against the paragraph with every run of whitespace, the newline at a wrap included, collapsed to one space; the finding is reported at the physical line the match starts on. This is not a new mechanism: it is the boundary §check-manifest-count already crosses for a total whose cardinal and noun straddle a wrap, reported at the cardinal’s physical line, and the shared adapter (§lib/spec.sh) is where that normalization already lives. The subject is ASCII-lowercased before matching and a class pattern is authored in lower case, so a sentence’s opening capital is not a way past the class.
A paragraph is the unit, and it is §check-measured-claim’s paragraph, so the both gates agree on what a marker binds: the block ending at a blank line, a fence, or the end of file, with the marker line riding inside the block it heads — which is what makes carrying a marker a property of the paragraph rather than of a line. Fenced blocks are skipped for the reason that section gives: a fence is grammar being shown, not a claim being made.
Coverage-only, and the reason is a constraint rather than a preference. The
gate asserts that a matched claim carries a marker; it does not assert which
key. spec_claim_vocabulary is a two-field loader that rejects a line carrying
an extra tab, so a class cannot declare a required key beside its ERE without a
third field the loader refuses. Adding one would fork the loader every claim gate
shares — the cost §lib/spec.sh explicitly took the shared-loader shape to avoid.
The key is checked by check-measured-claim on the very next arm (its arm B fails
closed on a key nobody emits, its arm A on a value the oracle now contradicts),
so the composition covers it and neither gate duplicates the other.
The scanned surface is CANON_KIT_MEASURED_SURFACE_GLOBS — shared with
check-measured-claim, not forked, because they compose: a claim this
one pressures into a marker is a claim that one then re-measures, and holding them
over one corpus is what makes that handoff total. The cost of sharing is stated
rather than hidden: a consumer wanting claim classes over a different surface
than its measured claims cannot express that, and the split is available later
behind an attested need.
The roster is consumer config and the seam here is sharper than its siblings’.
Every class id and every pattern arrives through CANON_KIT_CLAIM_CLASSES_CMD
(§Layout and configuration); the scan, the normalization, the marker lookup and
the verdict are kit mechanism. Beyond the provenance seam every claim gate
observes, this one has a reason of its own: this SPEC is itself governed prose,
so a kit that enumerated a consumer’s claim phrasings would match its own class
while describing it and need a valve to say what it does. Holding the roster in
consumer config means the kit’s prose never spells a member. An unset command
means no class is declared and the gate reports clean — the inactive-by-default
posture its *_CMD siblings take, and the correct posture rather than a gap: only
a consumer’s own vocabulary can turn this gate on.
The honest limit, and it is a property of what a roster can hold. A claim
stated in a single implementation-tool token with no predicate around it is out
of reach: rostering the bare token would fire on every passing mention of the tool,
and a gate that cries wolf is a gate its readers learn to bypass — the failure §When
a gate earns its place (gate-sdk/SPEC.md) names as a defect in the gate rather than
in the corpus. The general move for a noisy member is to roster the predicate the
noisy subject attaches to rather than the subject, which recovers the site with
none of the noise; a bare token offers no predicate to move to, and that is the
residue. Two further bounds are deliberate: the gate cannot tell a true class
member from a false one — it asserts the claim is bound to an oracle and
check-measured-claim then asserts the oracle agrees — and neither reaches a
sentence whose prose is true today and whose stated reason is wrong, which stays
a human judgment.
Criterion 4 (gate-sdk/SPEC.md §The port-candidate criteria) clears: the
corpus is a pure glob expansion of the configured surface set, reaching no gate
declaration path in this tree — the same verdict and the same reasoning as
§check-prose-tells, and flippable by the same consumer config. Born native, with
no shell form authored: a crate-carrying tree births a gate native by default and
none of the three exception classes applies here (gate-sdk/SPEC.md §The
port-candidate criteria). Its good/+bad/ pair is its oracle — the bad/ case
carries a claim wrapped across a newline, so the line-keyed blindness above is
executable rather than asserted — with check-unmarked-claim.test.sh holding the
fail-closed vocabulary arms and the two distinct clean skips a pair cannot spell.
Producer of nothing but a verdict; its consumer is the committing session through
the output contract, on the generated pre-commit hook, run-gates.sh and CI. Each
paragraph is read at the single scan transition, no persistent state. precommit
tier.
check-prose-enum
Invariant: within one manifest-prose paragraph, a delimited hand list naming two
or more members of one declared governed set must name every member of that set,
unless an exempt site holds; a member absent from the paragraph is reported as
omitted. This is the enumeration sibling of check-manifest-count’s restated
totals and the prose analog of check-kit-enum’s hand-list doctrine: the count
gate catches a bare cardinal over a growing collection, but a row that
enumerates instead of counting drifts just as silently when the set grows.
The attested drift: a README row’s blocked-by/needs-spec/spec tag algebra went
incomplete when a sibling tag landed — same restatement, different surface form,
no scanner. Engagement mirrors check-kit-enum — two members are a list, a lone
member (or two scattered across a paragraph, not adjacent) is a mention, not a
list.
Set declarations are consumer config, never gate literals (the provenance seam):
CANON_KIT_ENUM_SETS_CMD (default empty — clean skip) names a consumer command
emitting one <set-name>⇥<member> line per member, loaded through
spec_enum_sets (§lib/spec.sh); a command that fails or a line that does not
parse is fail-closed (exit 2). A member matches word-bounded — bracketed
([spec:]) or bare (spec), neither an alphanumeric, a hyphen, nor an
underscore abutting, so a stem never matches inside a longer tag, and an
underscore-separated identifier never reads as present inside a longer
sibling: guard_allow is named by “the guard_allow helper” and not by “the
guard_allow_match pair”, which is what a reader means by both sentences. A
member that falsely reads as present is one the gate stops asking about, so the
paragraph omitting it reports clean — the silent direction, and the reason the
class holds the whole identifier rather than most of it. . and / stay
outside the class deliberately, and that is what makes the identifier reading
exactly one character wide: a basename member matches prose spelling the file
kit-relative, repo-relative or bare (below), which admitting either would break.
The attested drift used bare stems.
The grammar stays two fields. A third, scoping a set to a whole file rather than
a paragraph, was weighed against a cross-document absence — members spread
over per-section prose, which a paragraph-scoped judge structurally cannot see —
and refused: it changes a wire grammar whose fail-closed rule rejects a third
field, and a document-scoped completeness test has a far worse false-positive
profile, since any mention anywhere satisfies it. Reshaping such prose into a
roster the paragraph judge can read closes the same case with no grammar change.
The scanned set and the paragraph walk are the shared spec_manifest_files
finder and spec_manifest_walk_awk driver (§lib/spec.sh); this gate’s
sk_on_pflush hook judges each flushed paragraph. Two or more members present
count as a hand list only when a run of them is chained by list separators
(comma, slash, brackets, whitespace, or and/or); members merely co-occurring
in one paragraph with prose between them stay mentions. Omitted members are set
members absent from the whole paragraph — the sibling that landed but never
joined the list. Exempt contexts follow the count gate’s family shape,
mechanical markers first, per-site tag last: a subset marker in the window
(e.g., such as, among them — the list declares itself partial), a
partitive marker on the match (some of, one of, of the set — a selection,
not an enumeration), and the per-site prose-enum-exempt: <reason> on the line
or the one above. The corrective names both legitimate fixes: cite the owning
set by name, or complete the enumeration — never trim to a silent subset.
Producer: the generated pre-commit hook / run-gates.sh, coupled to the
manifest set plus the consumer’s config and kit sources (a set change re-fires
the gate over the docs). Consumer: the committing operator via the output
contract — file, line, set name, count, and the omitted members all read once at
the scan transition. CANON_KIT_ENUM_SETS_CMD is read at startup; both fields of
each emitted line are read at match time (set name in the report, member in the
matcher). This repo’s consumer config is scripts/enum-sets.sh, which derives
the queue task-tag set and the Lessons-channel set from queue-kit’s own
lead-line tag parser plus QUEUE_KIT_LESSON_TAGS — derived, not restated; the
two roles are separate sets because a paragraph naming one role’s tags is not
enumerating the other. A set a consumer can only hand-list is that consumer’s
own drift to own; the kit contract asks only for the emit grammar.
The same emitter adds two roster families over the kit tree, one set per kit
root: a <kit>-lib set of the tracked top-level lib/*.sh basenames and a
<kit>-gate-test set of the tracked top-level gate-tests/*.test.sh basenames,
with the roots read from gate_kit_roots_rel (gate-sdk/SPEC.md §lib/gate.sh) so
the sets cannot enumerate a tree the battery does not. A new kit, lib or unit
test enrols with no edit, which is the property a hand correction per stale
roster would not have. Members are basenames rather than paths because a
basename matches prose spelling the file kit-relative, repo-relative or bare —
the boundary rule above accepts the leading slash. The families fail closed
where an empty result contradicts the shape they read (no kit roots at all, or a
lib/ tracking no top-level *.sh) and stay silent where emptiness is real: a
gate-tests/ holding only good/+bad/ fixture directories ships no bespoke
unit test, and that is a normal kit.
Two further families — a per-lib set of its function names, and a per-lib set of
the files sourcing it — were derived, measured against this tree and refused;
the reason is recorded so neither is re-minted. Both fail on one property: a
lib’s functions and its sourcers are a vocabulary prose discusses, not a set
prose rosters, so the set-difference is real while the finding is false. The
function family reported an omission at every passage explaining how two helpers
interact, none of them a roster — the measurement that is dispositive on its own,
and the one that produced the rule this paragraph ends on. The refusal rests on
that ground alone. It rests not at all on the matcher, which bounds an
identifier on the underscore too (above), so guard_allow is absent from a
paragraph naming only guard_allow_match: a refusal resting partly on a broken
matcher would invite exactly the wrong inference — fix the matcher, re-mint the
family. That matcher defect was sighted while measuring this very family, which
is where the identifier boundary rule came from and why the family is named here
as its origin rather than as a second reason to refuse.
The caller family’s one intended site is a caller roster written as narrative, which
the hand-list adjacency rule correctly declines to judge, so it was inert there
while still firing falsely on a README row naming two of a kit’s tools. The rule
the pair teaches: declare a derived set when the tree shape it reads is one the
prose rosters — a layout is such a shape, an incidental relation is not.
Calibration follows the count gate’s procedure: tuned against this tree, every
hit dispositioned — cite the set, complete the list, or site-exempt with reason.
The good/bad pair covers a bare comma hand list dropping a member and the
marked-subset cases; check-prose-enum.test.sh covers the config-driven paths
(the empty-default skip, the fail-closed escapes, bracketed matching, multi-set
independence, the exempt escapes, and the identifier boundary’s prefix-sibling
pair) the pair cannot reach — the boundary case needs a declared set holding
underscore members, which is a config no pair running on the consumer’s own
hyphenated sets can supply. precommit tier.
check-knob-citation
Invariant: no kit knob stated with its value in governed manifest prose outside the owning kit’s SPEC. This is the enforcement half of the de-literalization rule (doctrine-kit/DOCTRINE.md §Methodology-maintenance rules): a knob default copied into a README or a sibling SPEC is a second source no gate reads, and it drifts the moment the owner changes. Where the count and enum siblings ban a restated total and a restated enumeration, this bans a restated knob value.
The knob-token vocabulary is derived, never listed — the provenance seam holds
(a kit literal carrying a private vocabulary would publish it). Each
gate_kit_roots member yields two SCREAMING_SNAKE prefix forms: the dir name
uppercased with hyphens mapped to underscores (gate-sdk → GATE_SDK_,
delegation-kit → DELEGATION_KIT_), and for a -kit-suffixed dir the
suffix-dropped short form too (lifecycle-kit → LIFECYCLE_), so a knob on
either the long or the short form is governed. Both forms derive mechanically,
so adding a kit widens the vocabulary with no edit here and the gate ships no
term list. A SCREAMING_SNAKE token is a candidate
only when it starts with a derived prefix; the short form widens the token
space, and the value-marker leg below is what holds the false-positive rate.
A candidate fires on the value-marker triad, every leg required: a
derived-prefix token; a same-line value marker; and a surface that is not the
token’s owning SPEC (<owning-kit>/ joined to CANON_KIT_SPEC_NAME). The
marker is one of two shapes — = appended directly to the token
(<KIT>_<KNOB>=<value>), or the word “default” bound within a short window to a
value literal (a backticked value, a quoted string, or a number). Bare knob
names stay legal everywhere: prose cites the name and points at the owning
roster, the fixed instance the gate must never flag; a bare number with no knob
token in reach never fires — a tripwire left to human judgment, not a gate rule.
Code and config are out of scope: code owns values, and only the prose manifest
is scanned.
Two calibrations hold the false-positive rate against this tree, the corpus the
leg is tuned on. A token named inside a ${…} shell parameter expansion is a
name citation — another knob’s default expression, a fallback source — never a
value statement of itself, so expansions are blanked before the token scan. And
the “default” leg binds: the word must be followed within a short window by a
literal that is not itself a backticked knob name, so a stated default like
default `docs/CNAME` fires while a knob cited bare after the verb (“these
paths default through <KIT>_<KNOB>”) does not.
The scanned set is the shared spec_manifest_files finder and the prose walk is
the shared spec_manifest_walk_awk driver (§lib/spec.sh); this gate supplies the
sk_on_line hook (the per-line triad) and leaves sk_on_pflush unused — the
marker is same-line, so no paragraph join is needed. Fences are skipped, and a
knob-citation-exempt: <reason> on the line or the one above is the per-site
valve for a genuine local restatement (a wiring example whose value is the
citing kit’s own path). Producer: the generated pre-commit hook / run-gates.sh,
coupled to the manifest set and the kit SPECs; consumer: the committing operator
via the output contract — file, line, the knob token, and the owning SPEC the
value belongs in, each read once at the scan transition, no persistent state.
Fail-closed on the awk status.
Calibration follows the family procedure (§check-manifest-temporal): every hit
dispositioned — cite the knob by name and point at its SPEC roster (preferred),
or exempt a genuine local restatement with reason. The good/bad pair covers both
marker shapes, the short-derived prefix, the ${…} name citation, the
default-as-verb non-hit, and the per-site valve; check-knob-citation.test.sh
covers the owning-SPEC exemption (a kit may state its own knob’s value in its own
SPEC but not in its README) the fixture pair cannot reach. precommit tier.
This gate polices where a value is placed (prose may not restate it); its
sibling check-knob-default-coupling polices whether the value agrees between
the source fallback and the SPEC that owns it. The two share the
default-statement grammar (§lib/spec.sh) — one reads it to reject a value in
prose, the other to confirm the SPEC states the source’s literal.
check-knob-default-coupling
Invariant, for every literal kit-knob default in kit executable source: the
literal (1) agrees across every site that supplies it and (2) equals the default
the owning kit’s SPEC states. Where check-knob-citation bars a knob value
copied into prose outside the owning SPEC, this closes the channel that gate
leaves open — the :- fallback (or guarded assignment) in the source that
actually supplies the default is coupled to the SPEC statement, so the two
cannot drift silently.
Two default idioms are scanned: a ${PREFIX_KNOB:-value} fallback expansion, and
the guarded assignment that is the dominant form in the kits’ lib/*.sh — a
[[ -v PREFIX_KNOB ]] || PREFIX_KNOB=value scalar or a
declare -p PREFIX_KNOB &>/dev/null || PREFIX_KNOB=(…) array. The knob prefix is
derived, never listed — one SCREAMING_SNAKE form per gate_kit_roots member
(the dir uppercased, hyphens to underscores), so the gate ships no term list and
the provenance seam holds; a token is a candidate only when it opens with a
derived prefix. The scanned surface is the kit source under those roots (the
roster the meta-gates walk, templates/ and fixtures pruned); a knob’s owning
SPEC is resolved by its prefix, not the citing file’s kit, so a canon-kit
source citing a GATE_SDK_ knob couples to gate-sdk’s SPEC.
Assertion 1 (source self-agreement): every literal site for one knob carries the
same literal; two disagreeing is drift inside the source before any SPEC is
read, and it suppresses the knob’s assertion-2 check (fix the source first).
Assertion 2 (SPEC agreement): the owning SPEC states that same literal as the
knob’s default, read through the default-statement grammar check-knob-citation
shares (§lib/spec.sh). A default stated as the same ${…:-tail} deferral the
source uses is reduced tail-to-tail before the compare, so a knob inheriting
another kit’s default reads as agreement. A knob whose SPEC carries no default
statement at all reds — the SPEC owns knob defaults.
The coupling is literal-to-literal, and the calibration draws the boundary of
what carries a single literal to couple. Skipped-and-counted, never coupled: a
computed default (any expansion, substitution, or arithmetic in the value), an
array default (no single literal), an empty fallback (${X:-} is the set -u
presence-guard idiom, not a value — the real default lives at the else-branch or
guarded-assignment site the gate reaches there), and a default the SPEC states
descriptively rather than as a literal (a regex, a phrase set, a bundled list —
code owns the complex value and the SPEC describes it, the widest-true tier). The
honest limit: agreement is confirmed by the source literal appearing as the
SPEC’s stated default, so a value the SPEC never pins to a literal cannot be
coupled — only a literal disagreement or a wholly undocumented default reds.
Ported to the binary substrate at §The sixth budget batch (gate-sdk/SPEC.md),
where it took criterion 4 in its sharpest form: its couples= field is one level
deep and so does not cover its own declaration path, while the recursive kit-root
walk beneath it — which prunes templates/ and nothing else — reads that path as
content on every run. Couple clears, walk binds. Its assertion target is the
grammar of a default, never a gate’s dispatch relation, so the criterion is paid
by the good/bad pair rather than answered by staying shell (§Meta-gate
conservation for the binary substrate).
Producer: the generated pre-commit hook / run-gates.sh, coupled to kit source
and the kit SPECs; consumer: the committing operator via the output contract —
file, the knob, the source literal, and the owning SPEC, each read once at the
scan transition, no persistent state. Fail-closed on an unreadable kit root or an
unresolved knob. The good/bad
pair covers self-agreement, the SPEC disagreement, and both idioms;
check-knob-default-coupling.test.sh covers the cross-kit owning SPEC, the
absent-default red, and the descriptive/array/empty/deferral skips the pair
cannot reach. precommit tier.
check-surface-duplication
Invariant: no unvalved bold-lead-in definition of a glossary term on a
configured non-glossary surface — the canonical definition form belongs
only to the glossary; elsewhere it is a second definition that drifts.
Valves vision-introduces: / spec-introduces: (same line or the line
above) make a legitimate narrative or local introduction a reviewable
decision in the diff.
Calibration: a bold lead-in naming a component (a directory owning a
canonical spec) is never flagged — a component’s definition lives in its
own spec, so there is nothing to restate; gate-test fixtures are excluded;
exits 2 when the glossary file is absent (register the gate only where the
topology exists). align-only tier.
.gate-dispatched since shell-gate-tail-port, declared at
canon-kit/checks/check-surface-duplication.gate with its rule in
native/src/gates/surface_duplication.rs. It is one of the two members that
ported on the directive’s scope rather than the registry oracle’s: this tree
registers it in no gates.list, so port-blockers.sh’s two registry arms never
counted it and the port moved no number of theirs
(gate-sdk/SPEC.md §The port-candidate criteria, criterion 7). Criteria 1
and 3 therefore both fail on their own terms rather than by a proxy, and what
replaces the dispatch proof they would have bought is the fixture pair plus
canon-kit’s own smoke — named as the only oracle available, not offered as an
equivalent one.
It is not a wrapper, and that is a measured verdict rather than a reading of
its text. Every program the pre-port form reached for was on
GATE_SDK_PROGRAM_FLOOR, so none of them was ever a requirement to declare, and
none survives the port either: the text scans run in process and the
canonical-spec walk is spec::canonical_specs, already compiled for the
spec_manifest_files cohort. The compiled form spawns nothing, so its
declared requirement set is empty rather than floor-filtered to empty.
Two awk semantics are reproduced rather than tidied, and both are narrower
than a reader expects. The comment stripper is <!--[^>]*-->, so a comment
carrying a > is not a comment to this rule and the lead-in behind it is not
seen — widening it to a shortest-span match would silence a valve tag the shell
form still reads. The valve reads the last keyword occurrence on its line and
stops at the first --> with the whitespace behind it, so a line naming the tag
twice tags the term it ends with. Both are pure functions of a page’s bytes and
both are held by crate unit tests, which is where the negative half of every arm
lives: a fixture pair asserts a finding’s presence and never its absence.
Criterion 4 binds and the live-tree arm was not demoted. The pre-port rule
was restored at canon-kit/checks/zz-parity-probe.sh — inside the resolve dir,
outside the check-* glob, so no member resolves to it — and both forms were
driven from the same cwd with the same argv over the post-descriptor corpus.
Eight comparisons, all byte-identical including exit codes: this tree’s own root
(where the member refuses, because no glossary exists here), both fixture cases,
a constructed corpus exercising the table’s alternates, its parenthesised and
backticked cells, both valve arms, the wrong-tag arm, the component exemption,
the block-opening rule and both comment shapes, plus the absent-root, the
absent-glossary and the no-configured-surface refusals. The arm carries no
bound: this member’s corpus is markdown, so the restored .sh probe sits
outside the corpus it probes.
One narrowing is asserted rather than normalised away. An unreadable surface still refuses at exit 2, and the text of that refusal is the gate’s own rather than a child’s, because the compiled form has no child to quote. No third-party diagnostic is reproduced, and none was reproducible on either substrate.
check-comment-tier
Invariant: every full-line comment on a governed source is one of — a
machine directive (a comment a tool parses: graph:, shellcheck,
contract:, install:, smoke-unregistered: — the last two read off a kit’s
vendored source, install: by the installer’s recipe module and
check-install-disposition, smoke-unregistered: by run-consumer-smoke.sh
off smoke/install.sh, gate-sdk/SPEC.md §The install disposition and
§Consumer smoke), a reason directive (a spec pointer, usage synopsis, or
positional justification: spec:, usage:, exception-list:,
no-fixture:, no-port:, port-until:, assertion, permanent:, TODO(task:,
TODO(spec-ambiguity), which blesses a bounded window — its own line plus
continuation lines up to CANON_KIT_COMMENT_RUN_CAP total physical comment
lines, blank # lines counted; a directive mid-run opens a fresh window
from its own line, and every comment line beyond a window classifies on its
own), comment-tier-exempt: <reason>, or the
comment immediately above a positional construct from the language roster.
Anything else is flagged. Code is the WHAT, its SPEC the WHY — the seam the
align stage checks each side against; a comment that restates the code, or
paraphrases the SPEC section it cites, is deleted, not blessed. A spec:
directive earns its place by the coupling it makes — a bare pointer binding
this code site to the requirement that governs it, as contract: / assertion
/ graph: bind a site to a manifest contract, an enumerated assertion, or the
gate graph. The binding is the value: either side’s drift breaks it visibly
(gate-checked both sides for graph: and assertion; for spec: /
contract: check-spec-pointer gate-checks the forward side — the target
resolves — leaving only the reverse, an uncovered requirement, a review
concern), which is why it blesses only its own one-line binding, never a
relocated block — restatement couples nothing, it just forks the why into a
second copy no gate reads. comment-tier-exempt: <reason> is the honest
directive for a genuinely-local fact below SPEC altitude that neither tier
owns.
The governed-source corpus reaches the implementation of a ported gate, not
just shell — comment_surface with templates kept (§lib/spec.sh), which owns
the file set. This is the load-bearing half of the reader
partition gate-sdk/SPEC.md §The # graph: manifest states — locality-class
directives bind to a line of implementation and therefore stay there, so the
tier rule has to follow them across the substrate or go dark exactly where it
still applies. A descriptor’s own lines are directives by construction. Marker
matching is on the token, independent of the comment leader, so // and #
carry the same directives and no language earns a quieter standard.
The colon side’s sharp edge is a naming rule, not a matcher one. A colon
directive matches as a substring (the joining rule under Calibration below), so a
short token nests inside ordinary vocabulary: a directive named port: would
silently bless support:, transport:, export: and report:. Roster names on
that side are chosen not to nest; a bare word is bounded and carries no such
hazard. Unenforced by construction — the hazard is in a name that does not exist
yet, and a scanner over a roster of literals would be checking the roster against
itself.
One shape overrides the window: a full-line comment carrying a restated
collection total — the count grammar of §check-manifest-count, over the same
CANON_KIT_COUNT_COLLECTIONS vocabulary — is flagged even where a directive
blesses it, and positional rescue does not reach it either. A count is never
directive wording. A directive’s blessing covers its own wording physically
wrapped, never a total pinned beside it, and such a total is exactly the second
source the manifest gate bans: # rules 1-8 sat stale in this repo’s own guard
while its ruleset grew past eight, invisible because no gate read comments for
counts. The fix is deleting the count or citing the owning collection; the sole
valve is comment-tier-exempt: <reason>, whose window suppresses the override
as it suppresses the tier rule. The override still catches a total hidden in a
directive’s own wrapped wording — the wrap is exactly where one would escape a
line-at-a-time scan — and an exempt line cannot launder its neighbours into one.
The weighed alternative — a source-coupled
numeral scan with an allowlist — is rejected: legitimate numerals abound in
source (exit codes, indices, field positions) and the false-positive rate would
exceed the catch. Counts in code stay a review concern; counts in prose,
wherever the prose lives, are gated.
Calibration: the built-in roster is Checkwright’s own kit-mechanism
directive names; CANON_KIT_COMMENT_MACHINE / _REASON append a consumer’s
product vocabulary (the same split as check-graph’s vocab — the mechanism
ships, the rule content is config). The window cap is
CANON_KIT_COMMENT_RUN_CAP (default 3 — one wrapped sentence): a
within-window continuation is the directive’s own wording physically
wrapped, blessed as such, while prose beyond the window is presumed
relocated restatement and deleted. comment-tier-exempt: is reserved for a
genuinely-local fact neither tier owns, and exempting a restatement rather
than deleting it is itself the defect — a long roster (a usage: option
list, a header) restructures into directive-anchored short paragraphs or
trims, never launders prose past the cap. The count-shape override reads the
shared count adapter (§lib/spec.sh) rather than a second grammar, so it
inherits that gate’s carve-outs unchanged: a comparator bound or a per-phrase
in a directive is a rule and not a total, a partitive proportion exempts both
its cardinals, and a cardinal in inline code is a meta-reference — which is why
a directive may still say at most three checks per run or name a
`six gates` example. No knob is added: the noun vocabulary enters through
CANON_KIT_COUNT_COLLECTIONS and the wedge window through
CANON_KIT_COUNT_WEDGE_WORDS. The default surface is shell
(#) — templates/ stubs included: a copied-out template’s spec: line
resolves against the vendored kit path (kit SPECs travel with the
vendor-whole install), so its comments are directives like any source and
this gate governs them, where
check-spec-pointer exempts them as placeholders-by-design — with the
workflow directory’s tracked members blessing only contract:/see,
whatever their extension (the capture tier is gitignored and headerless, so
tracking is the filter, not the suffix — gate-sdk/SPEC.md §The workflow
directory). On a markdown member of that directory a ##-or-deeper line is
the surface’s own block grammar rather than a comment, and is read as an
ordinary content line: a workflow surface whose records are ## blocks
(lifecycle-kit/SPEC.md §The survey record) would otherwise have every record
heading flagged as untagged prose, and no tagging is available that would not
put a directive inside the data. The narrowing costs no coverage — the member’s
# contract: header is still classified here, and its tier and header payload
are check-workflow-tiering’s (gate-sdk/SPEC.md §The workflow directory) — and
it is deliberately extension-scoped rather than content-sniffed, so a .txt
data member’s # lines stay comments;
slash-comment parsing (//, /* */, doc-comments, heredoc skipping) ships
as mechanism and activates when a consumer widens CANON_KIT_COMMENT_SURFACE
to a language that needs it. Positional rescue is language-agnostic — its
construct roster (CANON_KIT_COMMENT_POSITIONAL) is consumer config, empty
by default so inert on the shell surface (a Rust consumer supplies the
unwrap/allow-class tokens; a consumer’s language is not a kit
literal). Trailing inline comments are out of scope by construction — the
FP-prone half of the judgment stays a review tripwire. Not-yet-swept
sources ride CANON_KIT_COMMENT_WHITELIST (its array tagged
# exception-list:, each # until: a live drain task per
check-gate-exemption-tasks), draining kit by kit. precommit tier.
Declared by check-comment-tier.gate, dispatching to the binary, with the
four members of this family ported as one cohort
(gate-sdk/SPEC.md §The first cohort, and the rule that selects the next). Its
couples= carries *.gate and *.rs as bare globs beside the shell and
workflow spellings: the corpus scans both declaration forms and the ported
implementation, and a token naming this repo’s crate directory would publish one
consumer’s layout into a kit file (CLAUDE.md §The provenance seam) and be false
for every other. The gate reads no regex engine — its blessing roster is a set of
literal directive tokens, matched as a substring for a keyword: and
non-alphanumeric-bounded for a bare word, which is joining rather than
interpreting (gate-sdk/SPEC.md §The POSIX ERE matcher).
check-spec-pointer
Invariant, two passes, forward direction only: every spec: / contract:
pointer directive on a governed source resolves, and every free-prose
<path>.md §<heading> citation on a governed manifest resolves. The
directive set is exactly what check-comment-tier blesses by shape: full-line
spec: / contract: comments on the governed sources, plus the # contract:
headers on the workflow directory’s tracked members (gate-sdk/SPEC.md §The
workflow directory owns that tier). That section rules two contract: payloads,
and this gate honours both: a version-marker payload (<format-name> v<N>,
matched on that section’s own anchored shape, and only after contract: — a
spec: directive wearing the shape still resolves as a path) is a wire-format
version its owning gate parses, so it names no path, resolves as none, and is
counted on the clean line rather than skipped silently. Discriminating it here is
what keeps the form usable without a per-consumer whitelist entry. A directive’s
target grammar is
<path> [§<heading>]: <path> (repo-relative) must be a tracked file, and when
a §<heading> fragment is present the file must carry a matching markdown
heading; a pointer without § resolves file-only. Reddens on a missing or
untracked target file, on a named heading the target lacks, and — fail-closed —
on a directive that matched the pointer shape but carries no target path.
The prose-citation pass closes the gap where a citation woven into a sentence
(context-kit/SPEC.md §The memory-off doctrine) resolved neither file nor
heading while the structured directive and the markdown-link anchor were both
verdict-checked. It scans the manifest set (spec_manifest_files, the same
surface the manifest-narration gate family reads) over a blank-line-delimited
paragraph join so a heading that wraps a line reassembles, and resolves the
heading through one shared resolution path — the directive pass and the
citation pass call the same helper, so they cannot diverge. Free prose runs on past the heading with no delimiter, so
the citation pass matches a heading as a boundary-anchored prefix of the
fragment where the directive pass matches it whole. A cited path that is not a
tracked file is out of this pass’s scope — path liveness stays with the gates
that own it (check-md-refs, check-kit-ref-liveness); ruling only on headings
of resolvable files is what holds the false-positive rate at the directive
pass’s level.
check-comment-tier owns the directive’s shape; this gate adds resolution
on top, the binding a spec: pointer makes being only as good as its liveness
— a renamed or deleted heading leaves every inbound pointer or citation
dangling, otherwise caught only on review. Heading match tolerates a trailing
(qualifier) on either side: a pointer narrowing a section to a labelled point
(§check-graph (assertion G) → the check-graph heading) or a heading carrying
a locator the pointer omits (§The guard framework → ## The guard framework
(lib/guard.sh)). Two § carve-outs keep the false-positive floor and must not
be conflated: a directive’s em-dash prose tail (spec: <path> §<h> — <gloss>)
strips at the — so a § in the gloss is not read as a heading, while a
free-prose citation is a heading marker — the citation pass fires on a
tracked .md path immediately followed by §, and a bare § with no tracked
path before it (the deliberate non-citation use) never fires. Fenced code
blocks are skipped in both passes — a quoted example is not a citation.
Calibration: forward direction only. The reverse — flagging a requirement with
no inbound pointer as uncovered code — needs a “what counts as a requirement”
notion that risks false positives against the cheap-and-FP-free bar, so it is
ruled out (a separate task with its own ruling if ever wanted; this gate
reserves no syntax for it). Configuration is shared with check-comment-tier —
the whitelist predicate and the same
CANON_KIT_COMMENT_* knobs, so no new config knob — but this gate scans
the pruned comment surface, which drops templates/ sources where
check-comment-tier keeps them. The split is by design, not a “SPECs don’t
travel” claim: a template’s spec: <your SPEC> §check-<area> line is a
placeholder the consumer fills in, unresolvable by design in the kit’s own
tree — resolving it there would check the consumer’s homework against a stub.
The tier gate still governs those template comments by shape (they must be
directives), so each gate draws the templates/ line where its own semantics
put it. precommit tier.
Retention ruling: the standing doubt — forward-only checking plus the basename↔§heading convention make the pointer largely redundant, its gloss a restatement risk — is answered and the roster slot kept. The convention derives a section’s name, never its liveness: absent an inbound pointer, renaming or deleting a SPEC heading reddens nothing, and the pointer is the only mechanized code→prose coupling (the reverse direction stays a review concern per the paragraph above). Sites away from a file’s header bind sections no naming convention can derive. Narrowing the directive to a bare pointer buys nothing: the gloss is already capped at the one-line binding (§check-comment-tier), and a gloss restating the cited section is deleted under that doctrine, not re-gated here. Dropping the slot would also orphan the citation coverage the convention carries — the reason a dedicated script↔doc citation gate stays unbuilt.
Declared by check-spec-pointer.gate, dispatching to the binary, with its
couples= widened to *.gate, *.rs and every tracked workflow member on the
same terms check-comment-tier’s is. The gate that reads a # spec: directive
is now itself declared by one, so a member of this family reads its own cohort’s
descriptors — which is why the fixture pair carries a descriptor arm rather than
resting on the live tree to exercise it.
check-todo-task-liveness
Invariant: every TODO(task: <slug>) marker on a governed source resolves to a
live queue task. A <slug> naming an active or deferred task resolves; a slug
sitting in Done is stale (the entry left the live pool, the marker did
not); a slug absent from the queue is unresolved (a typo or an unfiled
task). Stale and unresolved both redden — a marker referencing nothing is a
dangling forward reference, the source-side twin of a blocked-by tag left
pointing at a slug that has exited. The verdict does not turn on whether the
work shipped: a Done line records an exit from the live pool and not a
delivery (queue-kit/SPEC.md §The queue format), and a marker waiting on an
entry that was mooted is as dangling as one waiting on an entry that landed.
check-comment-tier owns the marker’s shape — it blesses TODO(task: as a
reason directive — and this gate adds resolution on top, exactly as
check-spec-pointer adds resolution to the spec: shape the same tier gate
blesses. It closes the liveness gap those siblings already guard elsewhere:
check-task-names flags a blocked-by gone stale on a done slug and
check-gate-exemption-tasks resolves an exemption’s # until: slug against the
live set, but a TODO(task:) bound to a cleared task passed forever because
nothing read the source side. The marker requires a resolvable slug after the
colon, so a tool carrying the bare roster literal (check-comment-tier’s own
directive name) never self-matches, and full-line versus trailing placement is
immaterial: resolution governs the referent, not the comment tier. Bare
TODO/FIXME/HACK markers are out of scope — trailing-comment scanning is a
separate ruling if the need attests.
Placement: the marker is a comment directive on the governed comment surface, so
it is canon-kit’s, not queue-kit’s (which disclaims source-file conventions in
its Out of scope). The gate scans the pruned comment surface, dropping templates/
as placeholders-by-design like check-spec-pointer, and reads the queue through
CANON_KIT_QUEUE_FILE with no new knob — the live/done split is the shared
queue_slugs adapter (§lib/spec.sh), one queue walk reading a bare-slug
bullet outside the active and deferred sections as the queue’s done shape. That
task: <slug> binding grammar — a task: key naming a slug that must resolve
to a live queue entry — is one grammar both liveness gates share:
check-deprecation-task is the twin, resolving the same binding on a
deprecation marker through the same adapter (§check-deprecation-task). Latent at landing: no such marker exists in
the tree yet — the gate ships before the first one, so a future TODO(task:)
cannot outlive its task silently. A queue-read failure is fail-closed (exit 2).
precommit tier. Declared by check-todo-task-liveness.gate, dispatching to
the binary, its couples= widened to *.gate and *.rs and its
.workflow/*.txt spelling corrected to .workflow/*, which is the tier the
corpus actually takes.
check-deprecation-task
Invariant: every deprecation marker on a governed source binds a task: <slug>
that resolves to a live queue task. The marker vocabulary is consumer config —
CANON_KIT_DEPRECATION_MARKERS, a roster of regexes (one per element) matched
against the governed comment surface. A language’s marker spelling is never a
kit literal: the roster ships empty, so a repo that sets none is clean-skipped
(the check-graph/graph-vocab seam — the kit ships the resolution mechanism,
the consumer names its own #[deprecated], @deprecated, or @Deprecated).
The scan itself stays consumer toolchain — a clippy/ESLint-class linter already
inventories deprecation markers; this gate adds the governance coupling no
linter ships: a deprecated surface that names no decommission task, or names a
done or absent one, has nothing tracking its removal.
A marker line carrying no task: <slug> is unbound; a bound slug sitting in
Done is stale (the decommission finished, the marker did not); a bound
slug absent from the queue is unresolved. All three redden — each finding
names the file, the line, the matched marker, and the offending slug. The
binding grammar and the queue-resolution pass are check-todo-task-liveness’s:
the same task: <slug> key over the same queue_slugs adapter
(§check-todo-task-liveness, §lib/spec.sh), one grammar both liveness gates
share. The gate scans the pruned comment surface (templates dropped as
placeholders-by-design) and reads the queue through CANON_KIT_QUEUE_FILE, no
new knob beyond the roster. An unreadable queue or source is fail-closed
(exit 2); an empty roster is the clean skip, not an error.
Declared by check-deprecation-task.gate, dispatching to the binary, with
the same widened couples= its twin carries. The roster is where this member
parts from its three siblings: it is a consumer array joined into one alternation
and interpreted, so the compiled form compiles it through the crate’s POSIX
ERE matcher (gate-sdk/SPEC.md §The POSIX ERE matcher) and reports the offending
knob by name on a pattern the engine refuses. One shell defect went with the
port, found by widening the fixture pair rather than by review: the unbound
finding named the marker as '', because the record carried an empty slug field
between two populated ones and IFS=$'\t' read collapses a tab run. It was
repaired in the shell before the port, so the parity run proves the repaired
message on both substrates rather than freezing the defect into the compiled
form.
This repo sets no roster, so the gate clean-skips here — the good/bad fixture
pair and check-deprecation-task.test.sh carry the resolved and reddened paths
under a fixture-local roster (the check-manifest-count config-path precedent).
The release-boundary disposition walk over the standing marker inventory
(decommission now, re-justify and carry the task forward, or un-deprecate) is
lifecycle-kit’s release-sweep skill template, and the between-major backlog
trend over the same roster is drift-kit’s kpi-deprecated-surface example
(lifecycle-kit SPEC §templates, drift-kit SPEC §Out of scope). precommit tier.
check-tracking-claim
Invariant: every tracking claim on a governed manifest surface agrees with git. A tracking claim is a fixed-vocabulary predicate bound to the backticked repo-relative path token it follows:
| Predicate | Holds when |
|---|---|
is committed, is tracked |
the path has tracked members and no ignored ones |
is gitignored, is local-only |
the path has ignored members and no tracked ones |
is two-tier |
both classes are non-empty — the path holds tracked and ignored members |
It exists because the defect class it catches — an always-loaded surface
asserting a directory is committed while part of it is gitignored — is invisible
to every other gate the kit ships: check-md-refs resolves paths,
check-spec-pointer resolves headings, and neither reads what the sentence
claims about the path. Resolution: a path naming a directory expands to its
members (git ls-files for the tracked side, git check-ignore --no-index on
the path for the ignored side); a path naming a file is its own single member.
The ignored side is read from the rules, not the working tree, so a gitignored
runtime path with no file in a fresh checkout still resolves — a presence read
(ls-files --others --ignored, which lists only files that exist) made the same
claim verify locally and red in CI. --no-index is load-bearing for the mixed
predicate: plain check-ignore refuses to report a directory that has a tracked
member as ignored, and a two-tier directory always has one. The mixed predicate
is what lets an honestly-mixed directory have a true sentence at all — without
it, the always-loaded tier could only be given a false one. Its rule-based proof
reaches only a directory the rules match whole (a dir/ pattern) that also
carries a tracked member; a directory whose ignored members are matched by file
patterns, the directory itself matching no rule, has no rule-based two-tier proof
and stays a prose description (the runtime-dir-two-tier-detector debt).
Reddens on a predicate whose verification fails, and — fail-closed — on a bound path that exists in neither the index, the ignore rules, nor the working tree, since an unresolvable path makes the claim unverifiable rather than true.
Surface: the manifest set (spec_manifest_files, the same surface the
manifest-narration gate family reads). No new knob: the predicate vocabulary
is kit-owned generic English, and which surfaces are governed is a knob that
already exists. Fenced code blocks are skipped, matching check-spec-pointer —
a quoted example is not a claim — and a predicate inside an inline code span is
a meta-reference, not an assertion (check-manifest-count’s carve-out, same
reason), which is what lets this section name its own vocabulary.
Calibration, stated as the gate’s honest limit: forward direction only, fixed
vocabulary only, adjacency only. The gate fires on the listed predicates and
rules on nothing else; a claim phrased any other way (“the workflow directory
ships in the repo”) is out of scope and stays a review concern — the same
forward-only bar check-spec-pointer holds, and for the same reason: widening to
“any sentence asserting tracking” needs a notion of assertion that cannot hold
the false-positive floor. Adjacency is the binding: only whitespace may sit
between the path’s closing backtick and the predicate, so a clause in between
unbinds it (`core-files.list` manifest exists in the worktree and is
tracked is correctly not a claim). That is deliberate under-detection, and it is
what buys the false-positive floor: the fixture pair covers both directions.
The gate landed green over this repo — two claims (.tmp/ and .metric/, both
gitignored), each verified rule-based against the ignore rules; a third
directory, .workflow/, is described in prose citing gate-sdk/SPEC.md §The
workflow directory rather than as a bound claim, because its ignored members are
file-pattern-matched and the directory itself matches no rule, so no rule-based
two-tier proof exists for it (the runtime-dir-two-tier-detector debt). No
backfill. That is the expected shape: it is a regression gate for a defect that already shipped on the
always-loaded tier, not a discovery tool, and §When a gate earns its place in
gate-sdk/SPEC.md governs that class. No per-site valve is taken: a claim that
cannot be made true is a claim that must be reworded, and a valve would restore
exactly the unverified-prose state the gate exists to end. Tier precommit; the
# graph: manifest couples the manifest set and .gitignore.
check-spec-fence-balance
Invariant: every governed markdown file carries an even count of code-fence
delimiters (lines opening with ```). The fence-skipping parsers across
the kits — check-spec-embedded-source, check-tag-lead-line, the queue
scanners — all toggle a fence flag line by line; an odd count leaves the flag
stuck and the rest of the file is read inside a phantom fence, so every later
finding silently fails open. This gate turns that silent hole into a red. The
surface is the manifest set (spec_manifest_files) plus the configured queue
file (CANON_KIT_QUEUE_FILE) — two motivating parsers (check-tag-lead-line,
check-queue-wrap) scan the queue, which the manifest set excludes — with no
new knob. A grep error (not a no-match) is fail-closed (exit 2).
check-md-refs
Invariant: every internal markdown link in the governed doc set resolves. A
relative-path target (with the source file’s directory as the base) must be a
tracked file, or a directory holding tracked files; a #anchor — alone
(same-file) or trailing a path — must match the GitHub heading slug of a
heading in the target file. External URLs (scheme://, mailto:) are out of
scope: the network is not a gate dependency. The doc set is the manifest set
(CLAUDE.md included; a vendored kit’s own README.md only where
CANON_KIT_SCAN_KIT_ROOTS re-includes it) minus the CANON_KIT_MDREF_EXCLUDE globs
(default empty, for a consumer’s generated docs); the scan runs over tracked
sources only, so an untracked local-only file (BRIEF.local.md) is a legitimate
link source that is never scanned and, being git-ignored-and-present, a
legitimate target that resolves without being tracked. A grep error is
fail-closed (exit 2). The link extractor is purely syntactic — it matches the
bracket-then-paren link shape (a ] immediately followed by a parenthesized
target) without stripping code spans, so a literal markdown link written in
governed prose is scanned as a real link even inside inline backticks; to name
such a link in prose without tripping the gate, separate the ] and the (
(a space, or a line break — the scan is per-line). The amendment SPEC-*.md
files escape only by lying outside the scanned doc set, not by any code-span
exemption. Links are this gate’s charge; the sibling check-docs-cmd takes the
invoked commands and env knobs written inside fences and backticks, over the
same governed doc set (one shared set, no second knob).
The self-repo blob-link pass. An absolute link is normally out of scope —
the network is not a gate dependency — with one exception: a link into this
same repository’s GitHub tree, which resolves against the working tree exactly
as a relative link does. The repo identity is derived at gate runtime from
git remote get-url origin through the shared gate_self_repo_prefix adapter
(gate-sdk/SPEC.md §lib/gate.sh), so the resolver here and any reference-link
producer draw one identity; the git@host:owner/repo and
https://host/owner/repo[.git] remote forms normalize to one
https://host/owner/repo identity, so the kit ships no repo name (the
provenance seam holds) and CI’s checkout and a local clone alike supply it. A
link whose prefix is <identity>/blob/<CANON_KIT_DOCS_BLOB_REF>/ (the ref
default master) is a self-repo reference link: its <path> must be a
git-tracked file, and a trailing #anchor must slug — under the same GitHub
heading-slug rules the same-file anchors use — to a heading in that file. A
repo with no origin skips the pass: a self-repo link cannot be identified, so
it falls through to the external-URL skip. The ref is a knob rather than a
literal because it is a policy choice, not a derived fact — the site is living
documentation of the current tree, so a reference link pins to the default
branch (this repo’s master), and a tag-pinned reference copy would buy
staleness, not stability.
Coverage the good/bad pair cannot hold, and where it lives:
gate-tests/check-md-refs.test.sh. The harness resolves exactly one good/ and
one bad/ case dir per gate, and this gate’s pair is spent on the core
link-resolution logic, so two behaviors ride a bespoke test instead. The
self-repo pass is the first: each case builds a throwaway repo with a controlled
origin, which a case dir run inside this repo cannot do. The kit-root prune on
the manifest set’s README half is the second, and the two cases differ only in
CANON_KIT_SCAN_KIT_ROOTS over one tree — a shape the pair’s
one-tree-per-verdict form cannot express even with a slot free. That second half
is the only executable oracle the prune has: this repo sets the knob, so its
battery is a no-op on it, and a consumer’s knob is 0 either way, so no
consumer-smoke run distinguishes a knob-gated prune from an unconditional one.
The sibling gate-tests/check-spec-dod-singleton.test.sh carries the same
prune’s canonical-spec half for the same reason.
The reference-link grammar
A docs-site page cites two kinds of in-repo target, and the grammar splits on
which. A rendered-document reference — a kit’s SPEC.md, README.md, or
DOCTRINE.md — cites the on-site mirror of that document relatively when the
site publishes one: a generated, freshness-gated projection under docs/<kit>/
that keeps reference reading on the served site. The mirror preserves the
documents’ cross-citation topology one-to-one, so the relative shape a page
uses is the same shape the source tree uses. A source reference — a script, a
gate body, config, a directory: files the site does not render — cites the tree
with an absolute GitHub blob link
https://<host>/<owner>/<repo>/blob/<ref>/<path>[#anchor], anchored when it
names a section, because a relative link into the unrendered surrounding tree
would 404 on a site that serves docs/ alone. That prescription is held by
check-docs-link-convention’s off-root rule (§check-docs-link-convention
below): a relative link whose target resolves outside the site root is a
violation, so the requirement above is enforced rather than merely stated.
Both are the downward-citation shape of the tiering topology, one on-site and
one off. Resolution of the blob form belongs to check-md-refs’ self-repo pass
(identity derivation and the ref knob live there); check-docs-link-convention
owns the shape of the relative links that stay inside docs/. The mirror is
what a site opts into, not a precondition of the grammar: absent a published
mirror, rendered-document references fall back to the same off-site blob form
as source references.
check-docs-link-convention
checks/check-docs-link-convention.gate (precommit, binary-dispatched).
Invariant: every relative markdown link on a docs-site page obeys the
downward-citation shape — the resolution of those links is check-md-refs’
charge, and this gate owns shape alone. Absolute reference links (the off-site
blob grammar above) are out of this gate’s scope entirely: their resolution is
check-md-refs’ self-repo pass, and this gate turns only on the relative links
that stay inside the tree. Each rule is scoped to the docs tree
because the “kit page” it turns on (a <root>/<kit>/index.md) exists only
there:
- No directory-target link. A relative link whose target names a directory
(a trailing
/, or a path that resolves to a tracked directory) must instead name the file —kit/index.md, neverkit/. A bare directory link is ambiguous about what the page is citing and defeats anchor-level citation. - Anchored kit back-links. On a kit page (
<root>/<kit>/index.md), a link back to that same kit’s ownREADME.mdorSPEC.mdmust carry a#sectionanchor. A page cites downward into a named section, never at the whole spec — the anti-restatement doctrine expressed as a link shape. - No off-root relative link. A relative link whose target resolves to an
existing path outside
CANON_KIT_LINK_ROOTmust instead cite the absolute self-repo blob form (§The reference-link grammar). The rule turns on the resolved path, not the link text, so a../-prefixed link that resolves back under the root is silent. Only resolving targets are classified — a relative target that resolves to nothing ischeck-md-refs’ finding alone, and this rule does not double-report it; a directory target outside the root still satisfies the first rule’s predicate first and is reported there, once. Anchors and absolute URLs never fire, by the gate’s existing scope. Generated mirror pages (generated: truefront matter) are in scope, deliberately: their off-root conformance is a property of the generator that emits them, which is a thing that can regress, and exempting them would blind the corpus’s larger half. The existingdocs-link-exempt: <reason>valve suppresses this rule as it does the other two — no second valve.
The scanned tree is CANON_KIT_LINK_ROOT (default docs; the fixture pair
overrides it with a positional arg pointing at a synthetic tree), walked for
every *.md. The link extractor is the same syntactic bracket-then-paren match
check-md-refs uses; scheme:// and mailto: targets are out of scope, and a
pure #anchor (no path) satisfies neither rule. Per-site valve: a
docs-link-exempt: <reason> HTML comment on the link line or the one directly
above suppresses that one finding — for the rare legitimate directory link a
consumer’s layout demands. A missing scan root is fail-closed (exit 2).
The walk is unpruned, deliberately, and the port carries that rather than the
kit’s shared prune set (gate-sdk/SPEC.md §The fourth budget batch): the shell
form reached for a bare find rather than gate_find, so a docs tree with a
directory named like a prune entry is still scanned whole. The compiled form
takes the prune-free traversal for the same reason, and orders its pages by
bytes where the shell’s | sort ordered them by the invoking locale’s
collation — a report-order narrowing, never a change to which pages are read.
Target resolution is likewise lexical where the shell called
realpath -m --relative-to=.: the two agree on a tree with no symlinked docs
directory, which every case compared at the port confirmed, including an
absolute scan root and a scan root of .. The bespoke
gate-tests/check-docs-link-convention.test.sh holds the off-root rule’s edges
and dispatches through gate_run.
check-docs-cmd
Invariant: every invoked repo-relative .sh path and every kit-prefixed env
knob written inside a fence or inline backticks in the governed doc set resolves
against the tree — the command/knob analog of check-md-refs, since a broken
bash <path> line or a retired knob name drifts silently where a broken link
would be caught. Two assertions:
- (A) invoked command paths. Inside a fenced block, a
.shpath in invocation position — the first word of a;/|/&&-separated segment, or the first non-flag argument when that word isbash/sh/source/.— must resolve to a tracked file, tried doc-directory-relative first (a kit SPEC’s ownbin/x.sh) then repo-root-relative (a cross-kitgate-sdk/bin/x.sh). Only invocations are checked, so a path in argument position — acp templates/x.sh scripts/x.shinstall destination, which the consumer creates and this repo need not track — is never a finding. That is the deliberate calibration: the invariant’s failure mode is a broken invocation, and scoping to the two named forms (a bare<dir>/…/<name>.shand thebash <path>form) drops the hypothetical-install-target class by construction, with no whole-file exemption. - (B) env knobs. Any backticked or fenced ALL-CAPS name carrying a kit
prefix (the roster is each
gate_kit_rootsmember’s basename uppercased, hyphens to underscores, trailing_:gate-sdk→GATE_SDK_) must occur in the kits’ tracked code — their shell sources and config templates, never their own prose, so a knob name-dropped only in markdown cannot self-satisfy. The corpus is the union across all kits, not the prefix owner alone: a namespaced knob may be read by a dependent kit (GATE_SDK_LIBis gate-sdk’s, resolved in delegation-kit and evidence-kit), and the prefix marks scope, not location. A family stem — a caps run ending_because a placeholder or glob follows it (EVIDENCE_KIT_RUN_<suite>,CANON_KIT_COMMENT_*) — resolves when any code name extends it. Names with no kit prefix are out of scope, so generic shell vars never false-positive.
The governed doc set is exactly check-md-refs’ — the manifest set minus
CANON_KIT_MDREF_EXCLUDE — shared, with no gate-specific knob. Prose outside
fences and backticks is never scanned; a hypothetical example path is written
unfenced, or its whole doc joins the per-file CANON_KIT_MDREF_EXCLUDE valve.
The knob set is built by a repo-root-anchored git grep, so it holds when the
fixture runner invokes from a case directory. Not a git repository, or a
git grep that errors, is fail-closed (exit 2). The # graph: manifest couples
the doc set to scripts/*.sh and every kit’s shell sources (kit:*.sh), so a
script rename or a knob retirement re-fires the gate over the docs.
check-install-claim
Invariant: exactly one governed doc declares which install transport is primary, and no scanned install section leads with a different one. This is the star topology’s first user-facing application — the same one-owner-per-fact rule the kit applies to internal facts, aimed at a claim a first-time reader acts on.
What it does not attempt. Whether a documented install command resolves against a live registry needs network egress at gate time and is out of a hermetic battery’s reach. It is not built, and this paragraph says so rather than leaving a reader to over-read a green run: the command that drove this gate’s filing was syntactically fine, and only a registry could have contradicted it. What is decidable from the tree alone is the consistency half — two surfaces cannot name different primary transports — and that is the whole of what runs.
The claim’s machine-readable owner is a full-line
<!-- install-primary: <transport-id> --> HTML comment. A marker rather than a
visible sentence, because the reader-facing form of this claim already exists as
prose and must stay prose; the marker is the tier beside it, not a replacement.
The transport vocabulary is consumer config — one <transport-id>⇥<ERE> line
per transport through CANON_KIT_INSTALL_TRANSPORTS_CMD, loaded by
spec_install_transports (§lib/spec.sh) — because a kit literal spelling a
transport publishes one project’s distribution model as a kit fact.
Where this gate ends and §check-payload-claim begins. The two answer different questions and share only the vocabulary loader. This one is positional and install-section-scoped: naming a secondary transport is correct prose, so what it judges is which transport a selected section leads with. Its sibling is membership over the whole governed document, because a disclosure class other than the declared one has no correct-but-secondary form — it is wrong wherever it appears. The scopes do not coincide either, so neither gate is the other’s second assertion pair: an install section under this repo’s section regex is not where the disclosure claim is ruled, and folding a whole-document membership rule into a section-scoped positional gate would make this gate’s name false and give one gate two unrelated calibrations to reason about on a red.
The scanned set is check-md-refs’ governed doc set (the manifest set minus
CANON_KIT_MDREF_EXCLUDE) minus CANON_KIT_INSTALL_CLAIM_EXCLUDE. Two
assertions:
- (A) Singleton owner. Exactly one declaration exists across the scanned set. Zero is the defect the gate was built for — nothing owns the claim, so two pages can drift apart with nothing watching — and two owners is that same defect wearing a different shape. An id outside the configured vocabulary is fail-closed (exit 2) rather than a violation: the gate then holds no primary to compare a section against, so it must not run rather than pass.
- (B) Leading transport. Within each scanned document, for every
##-or-deeper section whose heading text matchesCANON_KIT_INSTALL_SECTION_RE, the earliest line matching any transport pattern must match the declared primary. Later matches are never flagged, and a section matching no transport pattern is silent. One line matching two patterns passes when either id is the primary — a sentence naming both transports is not leading with the secondary one. Fenced content is scanned, because a recipe is exactly where a transport shows, but a fenced line is never read as a heading; the declaration line is skipped, since a claim is not evidence for itself.
The rule is a compiled subcommand, and one piece of it is load-bearing beyond
this gate. The section regex and every transport pattern are consumer EREs
compiled through the crate’s matcher (gate-sdk/SPEC.md §The POSIX ERE matcher);
the install-primary: declaration grammar and the ^#{2,6}[[:space:]]+ heading
grammar are kit literals. The heading one goes through the matcher anyway, and
deliberately: taking a heading’s text is a span read, so routing it through
find gives the matcher’s leftmost-longest span API a production reader on every
invocation of a precommit-tier gate, rather than one alive only in unit tests.
One declaration grammar, where two spellings once disagreed. The declaration
is detected and its id extracted by the same grammar, so the terminator is part
of it: an id run and the --> that closes the comment share the hyphen, and the
run gives hyphens back until the terminator matches. A space-less
<!--install-primary:tarball--> therefore declares tarball, and the rule runs.
The compiled form is the single spelling; a detecting pattern that required the
terminator beside an extracting one that did not made that same line read as a
declaration of tarball-- and fail-closed on a vocabulary it was never checked
against.
Assertion B is the answer to the leading-versus-mentioning question: naming a secondary transport is correct prose and must stay green, so the rule is positional. The scope in which “leading” is well-defined is the install section, not the file, and that calibration was verified rather than assumed — this repo’s own install page names its npm path in the H1 preamble about 120 lines above the tarball recipe, so a whole-file first-match rule would red the owner page on correct prose.
Three honest limits, each stated because a reader would otherwise over-trust a
green run. Registry reachability is the first, above. Pattern quality is the
second, and it is the consumer’s own drift to own: the gate is only as sharp as
the emitted EREs, and a loosely-written pattern wins matches by accident — the
kit contract asks for the emit grammar, exactly as check-prose-enum says of a
hand-listed set. The third is bought by the section scope: the prose tier of
the declared claim may itself sit outside any scanned section, as this repo’s
does, in which case rewriting that prose to name a different transport while
leaving the declaration alone stays green. That binding is documentary — held by
this section and by a reader, not by the gate. It is the price of the scoping
rather than an argument against it, since the alternative whole-file rule reds
the owner page today, and the recurrence path that actually fired is unaffected:
the drift was a ## Quick start section leading with the wrong transport, which
is in scope.
Producer: the maintainer editing the section that owns the claim, plus the consumer’s transport command; both are live tracked configuration in this repo, not fixture-only. Consumer: the committing operator via the output contract — assertion B’s report names the section, the offending line, and which transport it led with, because “wrong transport” alone would leave the reader grepping. The declaration carries exactly one field and both assertions read it; each emitted line’s id is read at the membership check and in the report, its ERE at the per-line match.
The gate lands greenfield: the drift that filed it was fixed inline at the close
that filed it, so this pins a currently-consistent claim and its value is
recurrence. That claim drifted at two consecutive releases with nothing watching
it, on the surface a first-time reader runs first. The good/bad pair covers the
recurrence path itself — a quick-start section leading with the secondary
transport — beside a silent no-transport section, a heading the regex does not
select, and a published note the path valve keeps out of the scanned set. The
# graph: manifest couples the gate to the doc set and scripts/*.sh, so a
transport rename re-fires it over the docs. precommit tier.
check-payload-claim
Invariant: exactly one governed doc declares what a gate on the vendored payload discloses, and no scanned governed doc asserts a different disclosure class. Two assertions: (A) singleton owner — exactly one declaration exists across the scanned set; (B) no contradicting assertion — no scanned line matches the pattern of a class other than the declared one.
The claim’s machine-readable owner is a full-line
<!-- payload-discloses: <claim-id> --> HTML comment, the same
tier-beside-the-prose shape §check-install-claim uses and for the same reason:
the reader-facing form of this claim already exists as prose and must stay prose.
It belongs in the section that rules the fact, which in this repo is
gate-sdk/SPEC.md §Consumer payload. The disclosure vocabulary is consumer config
— one <claim-id>⇥<ERE> line per class through
CANON_KIT_PAYLOAD_CLAIMS_CMD, loaded by spec_claim_vocabulary (§lib/spec.sh)
— because a spelling of what a payload discloses is one project’s distribution
model, and a kit literal carrying one would publish it. The scanned set is
check-md-refs’ governed doc set (the manifest set minus
CANON_KIT_MDREF_EXCLUDE) minus CANON_KIT_PAYLOAD_CLAIM_EXCLUDE — the same
composition its sibling uses, so a consumer configures one scanned-set idiom
rather than two.
Assertion A. Zero declarations is the defect the gate exists for: nothing
owns the claim, so an unbounded number of surfaces drift with nothing watching,
and no reader can tell which surface is the one to believe. Two owners is that
same defect wearing a different shape. A <claim-id> outside the configured
vocabulary is fail-closed (exit 2) rather than a violation — with no
resolvable declared class the gate holds nothing to compare a line against, so
it must not run rather than pass.
Assertion B. Across each scanned document, any line matching the pattern of a class other than the declared one is a violation, wherever in the document it sits. Membership rather than position, because a non-declared disclosure class has no correct secondary form. The declaration line itself is skipped, since a claim is not evidence for itself. Fenced content is scanned, because a quoted recipe is exactly where a disclosure claim shows up in passing. The report names the offending class, because “wrong disclosure class” alone would leave the reader grepping.
The rule is a compiled subcommand — structurally the simplest consumer of the crate’s matcher, membership over the whole document rather than position inside a section, so the engine and the vocabulary bridge were both proved by its two cohort siblings before it landed. It shares the declaration grammar §check-install-claim states, including that section’s single-spelling rule.
The declared class’s own pattern is latent rather than unread. Assertion B matches only the classes that are not declared, so the declared one’s pattern does no work while it holds the declaration — and starts doing it the moment the declaration moves to another class. The vocabulary is a set of classes, not a set of live matchers, so a class is emitted with its pattern whether or not it is currently the declared one.
One claim axis rather than a claim registry. The shape behind this gate and its sibling — a declared owner, an id vocabulary, a scanned corpus — invites a registry keyed by claim name. Refused for now, with the reason recorded so it is not re-derived: one axis exists, this gate and its sibling already share the only piece worth sharing (the vocabulary loader), and a registry would mint an axis-name knob and an indirection to hold a single member. The refusal is cheap to reverse — a second axis is the trigger, and both gates would collapse into the registry with their vocabularies unchanged.
Honest limits. The gate is only as sharp as the emitted patterns, and a loosely-written one wins matches by accident — the same consumer-owned drift §check-install-claim states, since the kit contract asks for the emit grammar and never for the patterns. A claim phrased in a shape no emitted pattern recognizes is out of reach; what the gate converts is an unbounded prose corpus into a checkable one, not an English-language reader. And the scanned set is the manifest set, so a claim made on a surface the consumer has not brought into that set stays unheld.
Producer: the maintainer editing the section that rules the fact, plus the consumer’s disclosure command; both are live tracked configuration in this repo, not fixture-only. Consumer: the committing operator via the output contract, and both assertions read the declaration’s one field — at the membership check, at the declared-versus-other comparison, and in the violation report.
The good/bad pair covers the reintroduction path itself — one corrected sentence
restored verbatim to its original overclaiming form — beside the near-miss
sentences the pattern boundary must stay silent on and a published dated note the
path valve keeps out of the scanned set. The # graph: manifest couples the gate
to the governed doc set and scripts/*.sh, so an edit to a claim surface or to
the vocabulary re-fires it. precommit tier.
check-prose-tells
The mechanical subset of the AI-prose tells — the machine-detectable patterns
that mark agent-authored reader-facing prose — over the consumer-configured
markdown surfaces (CANON_KIT_PROSE_TELL_GLOBS; empty ⇒ nothing scanned, the
correct unconfigured-consumer no-op). Judgment-dependent tells (voice, argument
shape, hedging) are out of scope permanently: the gate never grows heuristics
that would make it probabilistic; that read stays human.
It runs six mechanical assertions over each surface, every one threshold-gated:
em-dash density (A), throat-clearing phrases (B), contrast cadence (C),
undefined abbreviations (D), sentence-rhythm variance (E), and tricolon density
(F). A paragraph is a blank-line-delimited block and a section is a ##-headed
span, except that each markdown list item (-/*/+/N.) is its own unit for
every paragraph and section assertion — a definition list carrying one em-dash
per item is well-formed markdown, not flowing prose, so its items are never
lumped into one block (the same structural holdout the non-prose surfaces below
earn). Because the tells measure authored prose, three non-prose surfaces are
held out before any assertion runs: inline `code` spans, markdown table
rows, and generated <!-- name:begin -->…<!-- name:end --> regions (each
byte-gated elsewhere — a prose gate that forced edits to generated content
would contradict its generation, the same reasoning that keeps docs/posts/
out of the opt-in). Fenced code the shared walk driver already drops.
- A. Em-dash density — a paragraph carrying more than
CANON_KIT_PROSE_TELL_EMDASH_MAXem-dashes. - B. Throat-clearing phrases — a case-insensitive match of any
CANON_KIT_PROSE_TELL_PHRASESmember, the base array merged withCANON_KIT_PROSE_TELL_PHRASES_EXTRA(§Layout and configuration). - C. Contrast cadence — the “not X — it’s Y” shape (a
not …clause resolved across an em-dash or, butintoit's/it is) more thanCANON_KIT_PROSE_TELL_CONTRAST_MAXtimes in a section. - D. Undefined abbreviations — an all-caps token of length ≥ 3 the file
never expands (no parenthesized expansion at any occurrence) and absent from
CANON_KIT_PROSE_TELL_ABBR_ALLOWmerged withCANON_KIT_PROSE_TELL_ABBR_ALLOW_EXTRA(§Layout and configuration). - E. Sentence-rhythm variance — a paragraph of at least
CANON_KIT_PROSE_TELL_RHYTHM_MIN_SENTENCESsentences whose sentence word-count coefficient of variation falls belowCANON_KIT_PROSE_TELL_RHYTHM_CV_MIN(metronomic cadence). - F. Tricolon density — more than
CANON_KIT_PROSE_TELL_TRICOLON_MAXA, B, and Cconstructions in a section.
Exact detection regexes are implementation, owned by the gate source; the
good/+bad/ fixture pair is the executable statement of each boundary
(bad/ trips every assertion, good/ passes all and demonstrates the valve).
Thresholds are the CANON_KIT_PROSE_TELL_* knobs (§Layout and configuration).
Ported to the binary substrate at §The sixth budget batch (gate-sdk/SPEC.md). Two properties of the port are stated here because neither is visible from the assertion list. The corpus is walked once per file, not once per surface set: assertion D is an in-file assertion, so a shared walk would pool every surface’s tokens into one buffer and an abbreviation expanded in any file would clear it in all of them. And the thresholds are coerced, not validated — the port reproduces that rather than hardening it, because a refusal the shell form never made is a verdict change across the seam. A malformed threshold therefore reads as zero on both substrates, which makes the gate fire on everything or never; the repair belongs in the library, where one computation would serve both, and is filed as its own unit rather than taken here.
Criterion 4 clears on this member and the verdict is a property of a consumer’s config rather than of the gate: its corpus is a pure glob expansion of the configured prose-surface set, which reaches no gate declaration path in this tree. A consumer whose prose globs named descriptors would flip it.
Valve — an HTML comment <!-- prose-tell-exempt: <reason> --> on the
flagged line or directly above it suppresses the tell at that site; it rides the
shared exempt-window (§lib/spec.sh — the line or the one above), and the reason
is mandatory (the comment-tier-exempt: convention — a deliberate stylistic
keep carries its cause in-line, a reasonless valve is red). The gate is a
producer of nothing but a verdict: the committing session reads the red and
either fixes the prose or lands a reasoned valve (oracle-first), the fixture
pair feeds run-gate-tests.sh, and every CANON_KIT_PROSE_TELL_* field is read
by check-prose-tells at scan time and no other component.
templates/
canon-config.sh — the consumer config template: a two-line # spec: pointer
to the §Layout and configuration knob table, so the table stays the one owner
of the knob roster rather than a parallel copy in the template drifting
against it.
SPEC-amendment.md — the amendment skeleton: delta sections plus the
Definition-of-Done checklist (causal completeness, no information lost on
merge, amendment deleted, none remaining, gaps filed as debt).
Out of scope
A consumer’s tier contract — which surfaces exist and what each owns — is its
own instance; canon-kit ships the topology rules, not the table. Glossary and
vision structural gates (check-glossary-tiering,
check-glossary-entry-types, check-vision-tiering,
check-retired-term-coupling) encode one instance’s surface roles, entry
taxonomy, and ubiquitous-language couplings — rule content; their generic
cross-surface axis ships here as check-surface-duplication.
check-comment-tier splits: a consumer’s product directive vocabulary
(glossary:, diagram:, domain-enum, …) is rule content, supplied as
CANON_KIT_COMMENT_* config, while the classifier, the kit-mechanism directive
roster, and the surface/positional parsing machinery ship here — the comment
surface is one of the enforced tiering surfaces. A check-root-tiering is a
pure consumer-filename allowlist with no mechanism residue. Global-constant
literal gates (the pagination-literal pattern) are per-constant rule content.
Diagram/spec annotation couplings are unclaimed, a later kit’s scope.