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 and is named after the feature (SPEC-sqlite.md, never SPEC-PHASE3-SQLITE.md). It is a transition artifact: merged into the canonical spec and deleted when the work completes, never outliving its implementation.
- Whether a kit directory is in the amendment corpus is consumer config, not a kit guarantee. Both substrates prune a vendored kit root under the scan root unless
CANON_KIT_SCAN_KIT_ROOTS=1(§Layout and configuration). An amendment placed in one falls out of the amendment set at the default silently, with no pairing gate and no verdict. - The one sanctioned copy exemption. An amendment may embed a wire-contract delta (e.g. a fenced proto block) until merge, as the design home for a contract that does not exist yet. The canonical spec cites the contract file, never re-embeds it.
- A root-level amendment carries a ruling with no owning component (governance, workflow, gates): same lifecycle, at the repo root, merging into the consumer’s rulebook instead of a component spec.
- A component is any top-level directory owning a body of work, kit or not. Otherwise the repo-root fallback reads as the default for anything not plainly a kit, and once two units answer the case differently, neither precedent decides the third.
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 by its section and the queue tag whose syntax queue-kit defines:
- Design-pending — no amendment yet; the entry sits in a design-pending section, 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 reaches it unchanged. A promotion drops queue-kit’s two deferred board tags (queue-kit/SPEC.md §The tag algebra): both are read off the deferred pool alone, and wherecheck-deferred-board-tagsis registered its assertion B is the checksum on the drop. - 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 at least one queue entry pointing at it — several entries may pair one amendment. 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 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: each update target names the delta that owns it, and a gate cannot match a grammar no surface 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. A whole-amendment target, such as a generated mirror stale the moment any delta lands, would otherwise need an enumeration that drifts 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 finds in one pass, and it survives reordering under a diff more legibly. A delta’s tags, the work-class tag ({mechanical} / {design-bearing}) and the user-facing mark ({user-facing: …}), are outside this grammar. Their owner is the roster’s authoring-stage template, and their readers are the iteration lead at batch-cut and the audit stage, so inside the delta token they would tie independent conventions to one string. Enforced by check-amendment-update-target.
The ## Retired spellings block. Every amendment carries a ## Retired spellings section, and its body is one of two forms:
- The negative form — a single bullet whose first word is
None(case-insensitive), an em dash, and a non-empty reason:- None — no delta of this amendment retires a spelling. - The positive form — one or more bullets, each opening with a backticked spelling and carrying a delta citation in the citation grammar above. That grammar is reused rather than restated, so the two blocks cannot drift into two dialects of one token, and a bullet’s subject is its leading backticked token on the same convention the update-target roster names a path by.
A bullet’s entry is the bullet line plus its indented continuation, so a citation wrapped across a newline is still one subject — the boundary check-amendment-update-target crosses. A delta’s tags stay outside this grammar too. The valve is §check-amendment-retired-spelling’s.
Why a declaration, when the roster’s own omission is what failed. A declaration looks to share the roster’s hole: an author who forgot a surface will equally forget a spelling. But the roster has no negative form — a short roster and a complete one are the same document, so there is no line an author fails to write. The retired-spelling block has one, and it is mandatory, so skipping the section reds. What stays uncaught is an author who declares the section, retires a spelling and does not name it: real, and strictly smaller than what is uncaught without the block. Nor is the obligation new: the template’s Definition of Done already owes the grep, and the block records its run in a form a gate re-executes. Enforced by check-amendment-retired-spelling.
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 [spec:] tag is a checksum on the promotion move, not a second source of the state its section carries. A promotion crosses a section boundary and adds the ref, so a feature entry without the ref catches a move that dropped the ref, and [spec:] in a design-pending section catches a ref that dropped the move. A debt entry’s deferred ↔ active move is held by the board tags instead: an active entry carrying them and a deferred entry lacking them each red. Honest limit: only where check-deferred-board-tags is registered, and an icebox one-liner moved into an active section by accident reds nowhere, since the icebox carries no board tags.
That is also the test a further tag passes. A state tag is admitted only when it carries information its section does not. A tag restating its section is the two-sources defect and is refused, as the retired design-pending tag is (§check-amendment-queue arm (d) reds a leftover).
That test admits state tags and is the wrong test for an attribute. An attribute a lead-line reader consumes — the session board, a ranking or eviction arm, anything scanning the tag line alone — is a field tag on that line, since body prose is invisible to that reader. The lead-line gate (queue-kit/SPEC.md §check-tag-lead-line) holds the value where the reader looks; the error class it adds is a value written into the body. What no lead-line reader consumes stays prose. The minting amendment states the attribute’s value set: closed and consumer-configured, as the roadmap tag’s is, or a date. A new field tag is a feature by the litmus below whichever test admitted it.
The feature/debt litmus is new names. A task adding 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 is a queue entry whose body carries more than a few lines of design ruling: 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; 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, interface and obligation it introduces passes these points:
- Producer named and reachable — what code path, call, or timer triggers it; a producer whose enabling config no deployed configuration sets is dead everywhere but unit tests.
- Consumer named — what receives it and by what mechanism (stream, call, poll), and every roster-holding reader of the surface it lands on. Such a reader reds on a name its roster lacks, so a minted name lists that roster as an update target (a comment directive meets
check-comment-tier’s). - Existing integration sections updated — any spec section describing the prior flow is updated in the amendment itself.
- Every field has a named reader — the consumer and transition reading each field of a new message; a field with no reader is removed, and one 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). Only a verdict monotone in the violation set clears by inspection, and three ordinary shapes are not: a reader that reds on finding none, one asserting an exact count, one holding a minimum or coverage floor. “A narrower corpus can only remove violations” is false: pruning the file holding a declaration’s sole instance flips
check-install-claimred on a zero count. - Every member’s satisfying value named — binding on a delta obliging each member of a corpus enumerable at authoring time. Enumerate the members by a named probe and name each one’s value. A member with none narrows the assertion here, because build can neither satisfy nor narrow it.
A cross-component causal gap surfacing at build is not a deferred TODO: stop, resolve it that session, update the spec before resuming. lifecycle-kit’s stage templates carry these hooks; canon-kit owns the checklist and the promotion gate.
Merging an amendment (on task completion)
- Read the canonical spec fully, then the amendment.
- Integrate by re-phrasing, never by appending: a spec is not expected to grow each merge, so an addition rewrites the instruction it refines — clearer, briefer, phrase-shaped — and adds text only where no rewrite carries it. The merged spec reads as one document to a reader who never saw the amendment. Design rationale relocates into the spec’s prose; an embedded wire-delta becomes a citation to the contract file — the exemption is file-scoped, so once step 3 deletes it
check-spec-embedded-sourcere-arms on a kept embed. - Delete the amendment file, and in the same commit repoint every sibling amendment’s filename citation of it to the canonical section it merged into (arm (e) of
check-amendment-queuereds the commit otherwise); 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
## Donewith--queue done <slug>, dropping its[spec:]tag: the amendment is gone, andcheck-amendment-queuerequires every[spec:]ref to resolve to a file. A move waiting on a remote run (a push need, an[observed-by:]) bridges the push with a[spec:]path ref to the canonical spec. Unless the entry outlives the amendment — its deliverable is a corpus and the amendment delivered one increment of it. Then the terminal move is a demotion: drop the[spec:]tag and return the entry to its design-pending section, so the next increment re-promotes with a fresh amendment. No gate holds either half of the Done-move contract, so a wrong terminal move reds nothing.- A Done move there asserts an unfinished deliverable finished. A done entry is a bare slug, so every tag goes with it, one a generated public projection reads included, dropping the item there while the work remains outstanding.
- A demotion lands the entry back inside a size cap. queue-kit’s size cap measures a deferred entry (queue-kit/SPEC.md §check-queue-entry-budget). Any roster or table this amendment has the build transcribe onto the entry is priced against that cap, and an entry near it is compressed in the demoting commit.
- Return it to the position it was promoted from, with the board tags the promotion dropped:
--queue demote <slug>recovers both from the revision before the promoting commit (queue-kit/SPEC.md §The queue verbs). No surface owns a demoted entry’s position, so otherwise two batches demoting into one section in sequence reverse their relative order. A deferred entry carries the board tags (queue-kit/SPEC.md §check-deferred-board-tags).
- 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, instruction-only replacement text (the doctrine rule named, not linked: the prose profile vendors no doctrine-kit), 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). A literal stays verbatim only when load-bearing, and then gate-coupled: ameasured:marker binds it to an oraclecheck-measured-claimre-runs, making a transcribed number a generated, freshness-gated copy. Prefer citing the owner where the number need not be stated; mark it where a reader is served by reading it. Both remedies wait for an author, socheck-unmarked-claimlets a consumer declare a class of claim that may not go unoracled, a class reaching claims that carry no number. - User-facing claims and tracking status have owners too.
check-install-claimgives the primary-install-path claim one declared owner and holds every governed install section to it. A path’s version-control tracking status is git-owned, 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: 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, 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 knob filename (canon-config.knobs, the <kit>-config.knobs convention), and the docs-site page dir (docs/canon-kit/). Generic vocabulary names the spec artifact discipline rather than the brand, and is fixed whatever the kit’s name: the SPEC.md canonical-spec filename, the SPEC-*.md amendment glob, the spec: and contract: source directives, the [spec:] queue tag, the check-spec-* gate names, and the shared native/src/spec.rs adapters.
The consumer gate check-kit-ref-liveness holds the brand-token side honest tree-wide. It is declared by a .gate descriptor in scripts/ and dispatched to the binary substrate, and is not templated into gate-sdk, since the dangling-reference hazard is a kit author’s. Every slash- or line-anchored <name>-kit / gate-sdk path segment must name a gate_kit_roots dir. Every live-prefix kit knob must resolve to a tracked kit knob, through 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, and the second direction is bounded by a declaration. A scanned <FAMILY>_ stem resolves when kit source defines any member under it. A scanned member resolves through a stem only when that stem is a declared family. For a static kit that is a family its knob table declares, as evidence-kit’s table declares EVIDENCE_KIT_RUN_ for the suite it is running (evidence-kit/SPEC.md §Layout and configuration). For a prefix no static table owns, it is a stem its kit source spells. The member direction exists for a dispatch-composed name, built at runtime and spelled in no kit literal. Spelling the members out to satisfy an exact match would put a hardcoded roster of consumer knob names in a kit literal, the one shape that crosses the provenance seam here. A static kit’s table is the complete answer for its prefix, though: a stem its source spells only as a wildcard in a help line or a comment is not a family, and resolving members through one would pass any misspelling under it.
The gate valves the surfaces that legitimately name design-ahead or frozen brands:
gate-tests/fixture bodies anddocs/posts/*;- the release declaration surface those notes are composed from, whose Knob changes section names a removed knob by construction;
- the generated trajectory data and
SPEC-*.mdamendments; - the three design-ahead records, the queue and the two inboxes (
LIFECYCLE_KIT_GAP_INBOX_FILE,LIFECYCLE_KIT_CONSULT_INBOX_FILE), each read by its knob. Each names knobs and paths not yet minted; the gap inbox cannot hold one past the close that truncates it, and a consult item holds one until a consultation disposes of it; - the survey record (
LIFECYCLE_KIT_SURVEY_RECORD_FILE, read by its knob), valved as a frozen record. A block quotes the names its oracle searched at its recorded revision, and a later delta in the same iteration may retire one. The record is never edited in place (lifecycle-kit/SPEC.md §The survey record) and holds nothing past the boundary that truncates it. - the disposed-findings record (
LIFECYCLE_KIT_DISPOSED_FILE, read by its knob), valved as a frozen record: a discarded finding may name a knob or path that was never minted, and a record outlives the iteration that wrote it (lifecycle-kit/SPEC.md §The disposed-findings record).
So a rename cannot leave a dangle without turning a gate red.
Config is a knob file: copy templates/canon-config.knobs into the gates dir as canon-config.knobs (or point CANON_KIT_KNOB_FILE elsewhere) and set any knob below; defaults fill what the file leaves unset. canon-kit’s knobs are static: the binary resolves them in process from its own defaults table and the consumer’s knob file, and bash gate-sdk/bin/run-gates.sh --emit knob-roster prints each with its shape and rendered default. A gitignored canon-config.local.knobs in the gates dir holds a private value a tracked file cannot carry. The grammar, that overlay, the environment-over-file precedence for a scalar, the knob reference and the refusals are gate-sdk/SPEC.md §The knob file’s. The kit’s table validator refuses a malformed config at exit 2 with every finding. A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement.
The five CANON_KIT_*_CMD knobs are command knobs. Each is an argv, one NAME[] = element line per word, spawned directly with no shell (gate-sdk/SPEC.md §The knob file; the spawn and its output validation are §The shared spec adapters’). An empty command knob is no command, which its reader takes as the clean skip. Each claim gate declares the knob that arms it with # armed-by:, so doctor names a registered one still skipping (gate-sdk/SPEC.md §The install disposition).
A corpus knob below widens the declaring gates’ triggers as well as their scanned corpus. A gate whose walk a knob bounds carries knob:<NAME> in its # graph: couples=, so widening the knob widens what fires the gate (gate-sdk/SPEC.md §The # graph: manifest). Which knobs this reaches is derived from the walks, so a knob added later is reached with no edit here. CANON_KIT_DUP_SURFACES is read as named files rather than walked, and is coupled all the same, as knob:CANON_KIT_DUP_SURFACES, by that section’s file-knob conversion: couples= covers every path a gate reads, and a named-file read is still a read.
A glob-array corpus knob spells “any depth” as **, never as one glob per depth. Its value is a walk::glob_files pattern, whose ** spans any number of directories and skips hidden ones as * does. Its knob: couples token expands a ** member to a pattern covering every path it selects (gate-sdk/SPEC.md §The # graph: manifest). A value enumerating depths to mean every depth is a maintained copy that drops a file one level below its last rung with no diagnostic. A depth bound excluding a subtree on purpose is a selection, and stays the consumer’s to spell.
The roster states the provenance seam once. A knob whose value is one consumer’s vocabulary, layout, editorial posture or distribution model ships empty or with a bundled generic set. It never ships a spelling of one project’s, which would be a kit literal the seam refuses (gate-sdk/SPEC.md §The provenance seam). Generic English and universal utility names are kit-shippable. A bullet below does not restate this ground.
A bundled vocabulary is extended, never restated. These bases each take a matching extra, all defaulting empty:
CANON_KIT_TEMPORAL_MARKERSandCANON_KIT_TEMPORAL_MARKERS_EXTRA;CANON_KIT_SEAM_AUTHORITY_MARKERSandCANON_KIT_SEAM_AUTHORITY_MARKERS_EXTRA;CANON_KIT_FENCE_PROGRAMSandCANON_KIT_FENCE_PROGRAMS_EXTRA;CANON_KIT_PROSE_TELL_PHRASESandCANON_KIT_PROSE_TELL_PHRASES_EXTRA;CANON_KIT_PROSE_TELL_ABBR_ALLOWandCANON_KIT_PROSE_TELL_ABBR_ALLOW_EXTRA;CANON_KIT_PROSE_TELL_ABSENCE_TOKENSandCANON_KIT_PROSE_TELL_ABSENCE_TOKENS_EXTRA;CANON_KIT_PROSE_TELL_ABSENCE_OPENERSandCANON_KIT_PROSE_TELL_ABSENCE_OPENERS_EXTRA;CANON_KIT_PENDENCY_PENDINGandCANON_KIT_PENDENCY_PENDING_EXTRA;CANON_KIT_PENDENCY_LANDEDandCANON_KIT_PENDENCY_LANDED_EXTRA.
Each reader unions the extra onto the resolved base (spec::vocabulary): the effective set is the base followed by the extra. The union is the reader’s rather than a derived default’s, because a file value replaces a knob whole and a derived default fires only at the default, so a consumer who set the base keeps the extra. Extension costs one token, never a restatement of the bundled default that would silently diverge from it. Setting a base array keeps its replace semantics, and that is the narrowing valve: a consumer wanting a bundled member gone replaces the base array. A temporal or authority marker set whose base and extra are both empty is 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 a[spec:]-tagged entry is misfiled unless the section is also a feature section.CANON_KIT_DEFERRED_SECTION— defaultDeferred: the deferred section, the first member of the design-pending section set.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, and 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 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 answers for a dependency’s own internal links. Set1when the kit docs are the consumer’s own first-party content.CANON_KIT_MIRROR_ROOT— the directory the on-site mirror is written under and the canonical-spec finder prunes, defaultdocs. It must sit at or underCANON_KIT_LINK_ROOTso the mirror’s links stay on-root, whichcheck-docs-link-convention’s off-root rule holds; empty,., absolute or..-bearing is malformed config.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, default the bundled list. It holds onekind|fence-langs|file-globsentry per language family, the two list fields,-separated. The field names are the row’s declaration, so a descriptor couples the candidate globs asknob:CANON_KIT_EMBED_LANGS.file-globs(gate-sdk/SPEC.md §The# graph:manifest).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_GLOSSARY_TERM_SECTION— the heading text opening the glossary’s term-table section, defaultQuick reference, empty for none (§check-surface-duplication);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. Slot-free kit-template markdown and agent definitions so come under the manifest doc gates, while slot-bearing surfaces self-exclude. Which surfaces a consumer governs is its config; the discriminator is kit mechanism (§The shared spec adapters).CANON_KIT_TEMPORAL_MARKERS— the temporal-narration marker setcheck-manifest-temporalscans, default a generic-English list (previously,formerly,renamed from, …), matched case-insensitively;CANON_KIT_TEMPORAL_EXEMPT_SECTIONS— array of heading names whose whole section is exempt, default empty.CANON_KIT_PENDENCY_PENDING/CANON_KIT_PENDENCY_LANDED— arrays of construction phrases, each carrying one{}slot, thatcheck-pendency-contradictionreads as awaiting and as delivered the code spans in the slot. Defaults are bundled generic-English sets (waits on {},lands after {},{} does not exist yet…;implemented by {},{} landed,{} ships…;--emit knob-rosterprints each whole). A member without exactly one slot is malformed config.- The
check-provenance-seamknobs:CANON_KIT_SEAM_AUTHORITY_MARKERS— the authority-marker ERE array matched against a case-folded sentence, default a bundled attribution-shaped set:operator( |-)(ruled|ruling|direction|directed|decided|ratified|approved|chose),lead,[ ],own-authority,ruled,ratifiedandconsult. The set is attribution-shaped rather than role-shaped: a bareoperator,lead,rulingordirectionis this kit family’s role vocabulary, and adding one reds role vocabulary that is not provenance.CANON_KIT_SEAM_AGENT_FILES— array of always-loaded agent-file names, default("CLAUDE.md"), the harness’s public file name; a consumer on another harness adds its own.CANON_KIT_SEAM_PRIVATE_SURFACES— array of the publisher’s private paths (a ruling record, a local-only brief), default empty, which switches the private-surface arm off.CANON_KIT_SEAM_SLUG_MIN_LEN— positive integer, default12: the shortest queue slug the slug arm matches, so a short slug cannot match ordinary hyphenated prose.CANON_KIT_SEAM_SURFACE_GLOBS— array of repo-root-relative globs, default empty: the consumer’s own published documents the gate holds to the provenance class.
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_UNWRAP_GLOBS/CANON_KIT_UNWRAP_EXCLUDE— arrays of git pathspecs, default empty: the tracked markdowncheck-md-unwrappedholds to one line per paragraph, less the excluded paths. An empty include set is the clean skip, since which files a tree keeps unwrapped is its own editorial choice.CANON_KIT_UNWRAP_DECLARATION_LEADS— array of line leads, default empty: a line whose content starts with a member opens a block, so the gate never reds it and--emit md-unwrapnever joins it. An empty member is malformed config (§check-md-unwrapped).CANON_KIT_RESTATEMENT_PAGES— array of globs, default empty: the pagescheck-docs-restatement-parityholds to theREADME.mdbeside each.CANON_KIT_PAGE_REPEAT_PAGES— the pagescheck-docs-page-repeatholds, an array of globs, empty by default.CANON_KIT_PAGE_REPEAT_MIN_WORDS— default8: the shortest sentencecheck-docs-page-repeat’s arm B counts.CANON_KIT_PAGE_LENGTH_PAGES/CANON_KIT_PAGE_LENGTH_EXCLUDE— arrays of globs, default empty: the pagescheck-docs-page-lengthmeasures, less the excluded ones.CANON_KIT_PAGE_LENGTH_MAX_WORDS— defaultoff: its bound, a positive integer oroff.CANON_KIT_CITATION_LINK_PAGES— the pagescheck-citation-linkholds, an array of globs, empty by default.CANON_KIT_FENCE_PASTE_PAGES— default empty: the globs naming each pagecheck-fence-paste-unitreads.CANON_KIT_TASK_LIST_GLOBS— globs naming the task listscheck-task-path-claimandcheck-task-label-resolutionread, default empty.CANON_KIT_TASK_LABEL_CITESandCANON_KIT_TASK_LABEL_DEFINES— keyed, default empty: per label family, the one-group ERE that cites a label and the one that defines it. The table validator holds the two to one key set and each value to the one-group shape (§check-task-label-resolution).CANON_KIT_FENCE_PROGRAMS— array of program names a shell fence may start a command with, default a bundled generic utility set (cat,grep,git, …;--emit knob-rosterprints it whole). Read bycheck-fence-command-head. The toolchain a tree’s recipes name, a compiler, a fetch tool or a package manager, rides the extra.CANON_KIT_FENCE_RUN_PROGRAMS— array of program names a fencecheck-fence-runexecutes may start a command with, default("git"). It is the whole set, not an extra overCANON_KIT_FENCE_PROGRAMS: every name in it runs with the scratch’s network reach, so the default admits only the one program the fixed environment confines to local protocols. It is a vocabulary rather than a walk filter, so it takes noknob:couples token, on the fence-program pair’s own ground.CANON_KIT_RETIRED_SPELLING_EXCLUDE— array of globs, default empty: tracked paths held out ofcheck-amendment-retired-spelling’s reconciliation corpus. Which surfaces are history-bearing is a consumer fact: a work queue, a ruling record and a scratch directory are named by that consumer’s own configuration, and a retired spelling survives in all three legitimately. The empty default is the conservative one, not a fail-open: the exclusion can only remove findings, so a consumer configuring nothing gets a noisier gate rather than a blinder one.CANON_KIT_COUNT_COLLECTIONS— array of collection-noun pluralscheck-manifest-counttreats as growing governed sets, default("gates" "meta-gates" "checks" "kits" "stages" "rules" "KPIs"), to which 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). The bundled emitter,bash gate-sdk/bin/run-gates.sh --emit enum-sets, derives the queue tag sets from queue-kit’s own parser rather than restating them. It is an emitter an adopter can name without authoring a script; the knob still takes any consumer command.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.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.-
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. A consumer commonly derives the surface by reference,CANON_KIT_MEASURED_SURFACE_GLOBS[] <- CANON_KIT_MANIFEST_FILES, and widens it with what the manifest set omits. That is binding shims, which the prose surface excludes on a copy-shape ownership this rule is not covered by, and a work queue, which takes the marker without the manifest tier’s bare-cardinal ban (§check-manifest-count).Deriving this knob from the manifest array makes a manifest widening wider than it looks. The readers of both knobs move at once, so widening
CANON_KIT_MANIFEST_FILESwidens this knob’s pair as well as the manifest pair. A manifest widening also owes a# graph:coupling edit that a widening of this knob does not: the measured pair couples markdown broadly, while the manifest pair couples a narrower manifest-shaped set. Widening the manifest array without widening those gates’ coupling manifests ships a knob whose gate never triggers on the file it was widened for, a miss only a full-battery run surfaces.The surface knob has a second reader,
check-unmarked-claim, which shares it rather than forking a knob of its own, so the pair composes over one corpus (§check-unmarked-claim). The cost is that a consumer cannot scan claim classes over a different surface than its measured claims. CANON_KIT_MEASURED_SPAN— the claim a full-linemeasured:marker binds, whichcheck-measured-claimarm C reads for its cardinal andcheck-unmarked-claimdischarges:paragraph,sentenceoroff, defaultparagraph; any other value exits 2 (§check-measured-claim).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 consumer config for a second reason beyond the seam: this kit’s own SPEC is governed prose, so a kit literal here would match its own class while describing it. The gate readsCANON_KIT_MEASURED_SURFACE_GLOBSfor its corpus and adds no surface knob.- The comment-surface knobs, read by
check-comment-tierand its family: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:.shand.gatesources under the root (kit roots perCANON_KIT_SCAN_KIT_ROOTS,templates/pruned) plus every tracked member of${GATE_SDK_WORKFLOW_DIR:-.workflow}. Another language joins only when the knob names it.CANON_KIT_COMMENT_ACTIONS—off(default) oron:onadds every actions-shaped YAML file (actions_shaped, a top-leveljobs:orruns:key) to the surface whichever branch selected it.CANON_KIT_COMMENT_POSITIONAL— the language construct roster for positional rescue, default empty, since 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_SPEC_POINTER_TITLE_ONCE—off(default) oron:onarmscheck-spec-pointer’s one-title-per-file rule (§check-spec-pointer).- The
check-prose-tellsknobs, each read by that gate alone:CANON_KIT_PROSE_TELL_GLOBS— array of repo-root-relative globs, default empty ⇒ nothing scanned, a clean pass: the reader-facing prose surfaces the gate reads.CANON_KIT_PROSE_TELL_ABSENCE_GLOBS— the hand-prose surfaces held to the absence section, as repo-root-relative globs; empty, the default, turns assertion G off.CANON_KIT_PROSE_TELL_ABSENCE_TOKENSandCANON_KIT_PROSE_TELL_ABSENCE_OPENERS— arrays, each defaulting to a bundled English set (none,n/a, a dash, …;none,there is no,no …, …;--emit knob-rosterprints both whole): the lone tokens and the sentence openers assertion G reads as an absence (§check-prose-tells). Empty turns that half of G off.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, …).
The threshold knobs are validated by this table, so a malformed value refuses at exit 2 with every finding. The three
_MAXceilings each take a non-negative integer: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, an integer of at least2, default4. A coefficient of variation over one sentence is zero by construction, so a floor of one would red every one-sentence paragraph.CANON_KIT_PROSE_TELL_RHYTHM_CV_MIN— the word-count coefficient-of-variation floor beneath which a paragraph reads as metronomic, default0.25. It is a non-negative decimal, unbounded above one, unlike the embed threshold’s unit fraction.CANON_KIT_PROSE_TELL_TRICOLON_MAX— “A, B, and C” triples a section may carry, default2.CANON_KIT_PROSE_TELL_ABBR_MIN_LEN— the shortest all-caps token the undefined-abbreviation tell reads, an integer of at least2oroff, default3.
CANON_KIT_PROSE_BOUND_GLOBS, the governed prose;CANON_KIT_PROSE_BOUND_SENTENCE_MAX(default45words) andCANON_KIT_PROSE_BOUND_PARAGRAPH_MAX(default200);CANON_KIT_PROSE_BOUND_REPEAT_WORDS(default6, at least3) andCANON_KIT_PROSE_BOUND_REPEAT_MIN(default3units, at least2);CANON_KIT_PROSE_BOUND_CEILING_FILE, empty for no ceiling. Every bound but the unit floor takesoff. Read by §check-prose-bounds alone.
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. The icebox section name spans every kit that reads the tier on that same shape: queue-kit’s QUEUE_KIT_ICEBOX_SECTION, this knob and drift-kit’s DRIFT_KIT_ICEBOX_SECTION. Each degrades to “no icebox” rather than to a wrong section when left unset. Valve and marker spellings ([spec:], vision-introduces / spec-introduces, spec-embedded-source-exempt: <reason>) are mechanism, not config.
Per-component contracts
Each gate below owns its section: the assertion, its fixture pair, and the reason for any bespoke unit test beside it. The bespoke unit tests are named together here, since a roster spread across sections is one no reader sees whole: check-amendment-update-target.test.sh, check-comment-tier.test.sh, check-deprecation-task.test.sh, check-docs-cmd.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-bounds.test.sh, check-prose-enum.test.sh, check-provenance-seam.test.sh, check-spec-dod-singleton.test.sh, check-tracking-claim.test.sh and check-unmarked-claim.test.sh.
The shared spec adapters
native/src/spec.rs is the sole holder of the adapters the spec-scanning gates share — values and adapters only, never gate structure. It centralizes the surfaces and vocabularies those gates read, so a rule enters once and every sibling matches the same shapes. Every primitive here is compiled only and has one holder, ported once and proved by each member that calls it:
-
Section grammar and queue resolution:
queue_slugs, the one queue walk. It emits a live slug for an entry heading (queue-kit/SPEC.md §The queue format) in an active or design-pending section — deferred plus a configured icebox, one section set the pairing arms share — and a done slug for a bare-slug bullet outside them.check-todo-task-livenessandcheck-deprecation-taskresolve atask: <slug>binding through it. Its classifier matches a section name against the configured sets directly, since both sides of a section boundary must parse identically (queue-kit’s rule).Its bullet lead-line predicate is written again rather than pointed at
native/src/queue.rs’s adapters. That keeps canon-kit an independent holder of the format under queue-kit/SPEC.md §The queue format’s re-implement-and-cite-from-both-ends rule: two modules in one crate are two holders where one shared function is not. The holder census lives at gate-sdk/SPEC.md §check-gate-exemption-tasks. -
The governed comment surface:
comment_surface, the one corpus primitivecheck-spec-pointer,check-comment-tier,check-todo-task-livenessandcheck-deprecation-taskcall. Its single parameter decides whethertemplates/sources are pruned. Its derived file set spans*.sh, the*.gatedescriptor and every tracked member ofGATE_SDK_WORKFLOW_DIRwhatever its extension. Those are the kit family’s own gate file types. An adopter authors no compiled gate, so its sources in any other language are its own, and the derive names none (gate-sdk/SPEC.md §Consumer payload). UnderCANON_KIT_COMMENT_ACTIONS=onit adds the actions-shaped YAML filesactions_run::actions_filesreturns, the setcheck-action-run-shelllints.The widening lives in the shared primitive so no caller inherits a narrower corpus than its siblings. A tree carrying a gate crate names the crate’s language in
CANON_KIT_COMMENT_SURFACE, so a gate whose rule became a compiled subcommand keeps its# spec:pointer, comment tier andTODO(task:)markers in coverage (gate-sdk/SPEC.md §Meta-gate conservation for the binary substrate). The shell forms are deleted rather than duplicated, since all four callers compiled in one cohort. The price: a consumer whose own gate calledspec_comment_surface,spec_comment_surface_with_templatesorspec_comment_whitelistedshadows the gate, as for the finder choice below. - Finders: the canonical-spec and amendment finders the spec-scanning gates share, and the manifest-set finder the narration-gate family shares, so its members read one identical set: canonical specs plus
README.mdandCLAUDE.md, amendments excluded.- The prose-surface fold. The manifest finder folds in each slot-free
CANON_KIT_PROSE_SURFACE_GLOBScandidate: one bearing 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 awaiting binding is a placeholder, not finished prose, and the shim and binding gates cover it (§Layout and configuration states the knob). - The two prunes. All three markdown finders — canonical specs, amendments and the manifest set’s
README.mdhalf — skip atemplates/skeleton, a copyable stub rather than governed content. UnlessCANON_KIT_SCAN_KIT_ROOTS=1they also prune any vendored kit root under the scan root; an ancestor kit root, as when a kit’s own fixture dir is the scan root, never prunes. The kit-root prune reaches the README because a vendored kit’sREADME.mdis a dependency’s docs exactly as itsSPEC.mdis. Governing one while pruning the other reds a consumer on a dependency’s internal links, such ascanon-kit/README.md’s../queue-kit/link on a tree vendoring canon-kit alone. - The configured branches narrow by the walker’s prune set, on the rule the comment-surface adapters bullet states. A set
CANON_KIT_MANIFEST_FILESorCANON_KIT_PROSE_SURFACE_GLOBSexpands through the pruning walk. A value spelling any depth as**, as §Layout and configuration’s glob-array rule advises, then governs its own prose and not a vendored fixture tree’s, and never stats a build directory a concurrent build is rewriting. - Narrowing a corpus this wide costs the reader asserting a count rather than a violation. Most members red on a violating member, so a smaller corpus only removes findings.
check-install-claimandcheck-payload-claiminstead red on no declaration or more than one, so pruning the file carrying the sole declaration reds a green tree on a zero count. A consumer whose declaration lives under a pruned directory names that path in the knob outright, and the fail is loud either way. - The
CLAUDE.mdhalf is neithertemplates/-filtered nor kit-root pruned, where the canonical-spec andREADME.mdhalves are both. A consumer’s agent manifest is governed content wherever it sits, inside a vendored kit root or atemplates/skeleton included. Regularizing the three finders into one loop would silently drop files that belong in the set: the rule is the asymmetry, not the tidy form. - The set is git’s tracked set inside a work tree. The finder keeps a walked file only where
git ls-fileslists it at the scan root, so the index decides membership and a staged file joins before its commit. A member’s contents are read from the working tree, never from the index. An adopter’s untracked vendored or generatedREADME.mdis never graded. Membership takes both halves: a tracked manifest a sparse checkout leaves absent is never walked, and a submodule’s files never join, since the superproject’s index lists the submodule as one entry. A walked path matches an index entry byte for byte,\folded to/on Windows alone: on a POSIX host a name holding a backslash is its own path. The filter runs before the prose-surface fold reads a candidate, so an untracked candidate is never opened. - Outside a work tree the walk stands, as in a bespoke test’s
mktemp -dsandbox, and a repository the crosser refuses exits 2 rather than walking. The walk would grade untracked files with no notice, and an enclosing repository’s empty tracked set would drop them. Both are the crosser’s answers at the scan root (gate-sdk/SPEC.md §The crate’s crosser), which owns what marks a repository. A scan root inside an enclosing repository’s ignored directory has an empty tracked set, so a sandbox sits outside every repository or initializes its own. - The claim-gate primitives sit beside the manifest finder, which carries its three branches (explicit globs, the default walk, the prose-surface fold) and the kit-root prune. They are the declaration grammar, the declaration roster, the governed-doc set behind its two exclude valves, and the command adapters below.
- The prose-surface fold. The manifest finder folds in each slot-free
- The emitter-backed vocabularies — the enum sets, the install transports, the payload-disclosure classes, the measured claims and the claim classes a consumer supplies as a command knob. An adapter spawns the argv once per process and caches the parsed
<first>⇥<second>lines, so no spelling of a consumer’s vocabulary becomes a kit literal and a member that never asks spawns nothing. The empty argv is checked before any spawn, telling none configured from configured, and it declared nothing — the two clean skips a claim gate reports apart.- Two validation contracts, both exit 2. For the enum sets: a command that fails or exits non-zero, a line with no tab, an empty field or an extra tab. For the three
<id>⇥<ERE>vocabularies and the measured claims: the same, plus a first field that is not slug-shaped or repeats. Every message names the command knob as its label. A tab a POSIX ERE may legitimately carry is refused as an extra tab before a pattern is compiled. - A command that is a front-end arm must not declare the knob that names it, or the resolution recurses:
--emit-enum-setsdeclaresGATE_SDK_KIT_DIRSandQUEUE_KIT_LESSON_TAGSand may never gainCANON_KIT_ENUM_SETS_CMD. Nothing reds if it does — the failure is a hang or an unbounded recursion, not a verdict — so the constraint is stated here, beside the adapter. - The residue: the spawned front-end still sources
lib/gate.shto locate the binary before exec’ing it, one extrabash, one source and one binary exec per read of that vocabulary. It is a residue to file, never a licence for the in-process shortcut §check-prose-enum refuses.
- Two validation contracts, both exit 2. For the enum sets: a command that fails or exits non-zero, a line with no tab, an empty field or an extra tab. For the three
-
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. A total whose cardinal and noun straddle a prose wrap is included, reported at the cardinal’s physical line.check-unmarked-claimmatches a class ERE against the same normalized paragraph and maps the match back to the physical line it starts on (FlatPara), so both cross a wrap by one rule. The count adapter owns the total grammar; the flatten adapter owns the subject a pattern is matched against.The boundary rule and the mechanical exemptions live in one shared fragment, so no sibling drifts in what it counts as a total (§check-manifest-count). That boundary keeps a prose noun from gluing to a following word (“gate” inside “gatekeepers”). The enum matcher’s identifier boundary is a different rule (§check-prose-enum): English nouns do not compound across underscores.
- The enum sets arrive through
enum_sets, overCANON_KIT_ENUM_SETS_CMD(§check-prose-enum). -
The claim vocabularies arrive through
claim_vocabulary(<command knob>), one loader for every<id>⇥<ERE>vocabulary a claim gate reads. It takes the command knob as an argument, so a second claim axis costs a caller and not a second copy of the loader or its shape checks. It compiles every pattern before the first corpus line is read. It refuses a repeated id, which a bare emit grammar cannot express: two lines claiming one id would give a class two patterns in arbitrary match order.Its callers are
check-install-claimoverCANON_KIT_INSTALL_TRANSPORTS_CMD,check-payload-claimoverCANON_KIT_PAYLOAD_CLAIMS_CMDandcheck-unmarked-claimoverCANON_KIT_CLAIM_CLASSES_CMD.measured_claimsreadsCANON_KIT_MEASURED_CLAIMS_CMDunder the same keyed contract without compiling, its second field being a measured value (§check-measured-claim). The two-field shape keeps the unmarked-claim caller coverage-only: a class cannot carry a required key beside its ERE without a third field the adapter refuses, and the refusal stands. - The comment-surface adapters: the comment gates read different surfaces, which is what the primitive’s parameter decides.
check-spec-pointerscans the template-pruned surface, since 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, 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 a blanket exemption shadows the gate.- The configured branch narrows exactly as the default branch does. A set
CANON_KIT_COMMENT_SURFACEdraws the surface from its globs instead of the extension walk. The knob selects which files the corpus is drawn from and nothing else: a corpus selector, never a corpus replacement. Every later narrowing belongs to the corpus: thetemplates/parameter, the kit-root prune, the byte sort, the workflow tier §check-spec-pointer requires, and the walker’s prune set (GATE_SDK_PRUNE_DIRS,GATE_SDK_PRUNE_EXTRA_DIRS). The prune set, not the kit-root prune, is what spares a configured value enumerating around directory shapes. - The prune set bounds the walk, not only its result. The configured branch expands through
walk::glob_files_pruned, whose**never descends into a pruned directory, then drops any hit a glob reached by naming a pruned directory outright. Filtering the result alone would still stat every entry undertarget/. A build beside the battery creates and deletes temporary directories there, so an entry listed and then gone would exit 2 on a sound tree.
- The configured branch narrows exactly as the default branch does. A set
- The canonical-spec finder prunes the generated on-site mirror, as a directory prune beside the
templates/one it already applies. A prose gate grading a generated page is unfixable at the file — the repair is to the source and the regeneration — so the finding can only be absorbed or ignored. The mirror is excluded because it is generated, and the generator is the kit’s own:--emit docs-mirrorwrites under one mirror root, and the prune is that root’s children. The root isCANON_KIT_MIRROR_ROOT, read by the generator, the walk, the docs-mirror freshness comparator and — as a knob-path prune — every member’s registry declaration, so the four cannot disagree about it (gate-sdk/SPEC.md §check-reads-couples). An adopter whose ownSPEC.mdsits at<site>/<dir>/moves the mirror beneath it, to<site>/reffor instance, and the finder discovers that spec again. - The amendment finder
amendments, the amendment-glob counterpart of the canonical-spec finder above. 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,
DefaultGrammar, so the rule for what reads as a stated default has one home. It recognizes the value literal opening a window (a backticked non-knob string, a quoted string, or a number) and returns the literal the word “default” binds. The caller supplies the window’s width and a knob-name predicate that keeps a bare knob name from reading as a value.check-knob-citationpasses its literal span and reads the grammar to reject a restated value in prose (§check-knob-citation).check-knob-default-couplingpasses the tail of its own SPEC window and reads it to confirm the SPEC states the source’s literal (§check-knob-default-coupling). - The fence reader
Fence, the one state every fence-skipping parser in the crate steps, so no two disagree on where a block ends.- It opens on a line whose first non-blank text is three or more backticks, or a list-item marker (
-,*,+or<digits>.) and a blank followed by them, since kramdown renders that item’s body as a code block. kramdown reads no<digits>)marker, so that line is prose. - It closes on the next line whose first non-blank text is a backtick run at least as long as the opener’s. A shorter run is the block’s text, and a list-item marker never closes a fence.
- A reader admitting
~~~reads tildes on the same shape, and a fence closes only on its opener’s character. fence_openingis the opener test alone, for a reader asking whether one line would open a fence.- Honest limits: a closer may carry trailing text, an opener indented four or more columns still opens, and one in a block quote does not. A renderer reads none of them that way.
- It opens on a line whose first non-blank text is three or more backticks, or a list-item marker (
- The front-matter span
front_matter_span, the one reading of a page’s leading block every reader that skips it takes. The block runs from a first line of---through the next---line, trailing blanks ignored on both. An opening---that no later one closes delimits no block, which is Jekyll’s reading, so the page is read whole, where a reader holding the block open to the end of the file would pass it unread. - The manifest-prose walk
walk_prose, the driver the prose-scanning members share: it tracks fences on the fence reader, resets the paragraph at a blank line, and hands each line and each flushed paragraph to the member’s sink. Its shared exempt window is the flagged line or the one above: a member’s valve marker, a kit literal matched as a substring, on either suppresses the finding at that site. The per-site valves citing this section ride that window. - The prose unit and the sentence split that §check-prose-tells and §check-prose-bounds share: the list-item, heading, table-row and generated-region-marker line tests that cut a block into units. A sentence closes at a run of
.,!or?followed by whitespace or the span’s end.
check-amendment-queue
Invariant: the bidirectional rule holds.
- (a) No feature-section entry lacks
[spec:], and no active non-feature section holds a[spec:]-tagged entry: a spec-ready entry belongs in a feature section. - (b) An entry in a design-pending section 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. - (d) The retired design-pending tag appears on no line of a feature, active or design-pending section, lead line or body alike. Section membership is the state (§The amendment lifecycle), and prose about the state spells it without brackets (queue-kit/SPEC.md §check-tag-lead-line). An inert leftover would keep two sources alive in every adopter queue, and habit re-mints a spelling the record still shows — the ground queue-kit/SPEC.md §check-queue-entry-budget assertion (D) gives for its retired token. The migration is mechanical and the finding names it.
- (e) Every amendment-glob filename cited in the body of an amendment on disk resolves to a file. An amendment is deleted on merge, so a filename citation of a sibling dangles from that moment; inside a transition artifact it always reads as governing input. Its durable form is the canonical section the cited amendment merged into.
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.
Arm (e)’s calibration:
- The token is the basename pattern of
CANON_KIT_AMENDMENT_GLOB, its*matching one or more of[A-Za-z0-9._-], behind an optional repo-relative directory prefix of the same characters plus/. It stands between characters outside that class or a line edge. Backticks do not change the match. A placeholder (SPEC-<feature>.md) or the glob itself carries a character outside the class and is no token, and a sentence-final period is punctuation rather than part of one. - Resolution is a
[spec:]ref’s, so the two cannot diverge: a token containing/resolves when that path is a file, a bare basename when an amendment on disk carries it, so a self-citation resolves. - Skipped: fenced blocks, where a quoted example is grammar being shown, and HTML comments, which carry template guidance — nothing else.
- No valve: every filename citation of an amendment inside an amendment reads as input and every history use has a durable form, so a per-site exemption would only license the defect. An illustration that must show a filename goes in a fence.
- Exit 2 on an unreadable amendment, as for (a)–(c). The finder stays best-effort: an empty amendment set makes (e) vacuous and hides nothing, since nothing is left to cite from.
Coverage limit. A heading that is not a feature, active or design-pending section classifies as other and skips every arm, so the done section is an exempt population for the [spec:] arms and arm (d). An entry carried into done still carrying [spec:] reds only through arm (c)’s ref resolution, and queue-kit’s lead-line gate, scanning the task sections alone, misses it too. The done grammar is a bare slug line, so that grammar drops the tag rather than a gate. The [spec:] guard is total over the promotion moves it exists to catch — design-pending → a feature section — and silent on the disposition move. The limit is this gate’s axis only: check-amendment-update-target holds the same amendment set on a second axis, what the file says about itself rather than how it pairs with the queue.
Arm (c)’s authoring consequence. The pairing is bidirectional, so deleting a merged amendment and moving its task out of the live sections land in one commit; neither half is legal alone. Everything the entry’s body carried goes with it: a ruling’s grounds, an authority stamp, a measurement the entry alone held. So prose worth more than the entry’s own lifetime belongs in a governed surface before the landing commit, never on the entry as its permanent home. No gate catches the loss, which is indistinguishable from an ordinary disposition.
Arm (e) holds the decidable slice of the owner-position question, over the amendment corpus alone. Prose may cite a merged amendment as settled history, never as a live owner. Tense separates the two and is not decidable; spelling is, and an owner citation between sibling amendments takes the bare path form in running prose rather than a § form. The queue half is an honest limit: queue bodies’ filename citations of amendments are mostly lawful history, so a filename assertion there would punish valuable prose to catch the rare owner citation. A slug-form owner citation is not decidable either. Both stay review’s.
The finder is best-effort (§The shared spec adapters): an unwalkable scan root yields no amendments rather than a refusal. That is not the vacuity gate-sdk/SPEC.md §Fail-closed contract guards, since every [spec:] ref then dangles and the run reds. Adding the refusal would change the verdict, and check-todo-task-liveness, which refuses on a bad scan root, is no precedent for this member.
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: an update target no delta claims reaches build as an orphan a batch adopts on its own authority, in the template’s words.
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, in the shared exempt window (§The shared spec adapters), the reason mandatory on thecomment-tier-exempt:convention. An exempt bullet leaves the target count as well as the finding.
A fenced block is skipped whole: an amendment may embed a wire-contract delta until merge (§The spec model), 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 or is unwalkable; an amendment carrying ## Existing sections updated but no ## What changes, where no entry can be owned; and a file the finder returns and the reader cannot read. The unwalkable root refuses where §check-amendment-queue’s finder returns empty, since here an empty set hides every violation silently; §The shared spec adapters owns the asymmetry.
No new knob. The corpus is spec_amendments (§The shared spec adapters), the finder §check-amendment-queue uses. Its templates/ exclusion keeps a shipped SPEC-amendment.md skeleton’s illustrative headings from reddening the gate that governs its copies. The two heading names are kit constants, not config: they are canon-kit’s own template’s headings, so a consumer editing them has edited canon-kit’s artifact rather than configured it. §check-spec-dod-singleton’s Definition-of-Done heading is configurable because it appears in consumer-authored canonical specs; these appear in copies of a kit-shipped skeleton. A knob waits for an attested consumer rename.
Deliberately not asserted: roster completeness. Whether the roster names every surface the change obliges a write to is a claim about the world, not the file; arm B catches a target listed and unowned, never one never listed. §check-amendment-retired-spelling mechanizes one narrow slice: retiring a literal leaves the retired and replacing spellings in disjoint token spaces, so a survivor scan reconciles against this roster. The align stage carries the residue, reading the roster against the tree, and the build stage re-derives it before the merge counts as complete (lifecycle-kit/SPEC.md §templates/stages/). So a roster names the probe that produced it rather than claiming completeness (templates/SPEC-amendment.md), a claim inviting the merge to skip that re-derivation. The residue is a stale prose sentence, a semantic over-claim, a cross-reference dangled by a deletion, and the renumber case, whose two spellings share one token space.
Three stronger arms are refused:
- Every delta cited by some target is false: a delta adding a new section touches no existing one.
- Every path or
§reference in a delta body rostered cries wolf, since a delta names many surfaces for context (gate-sdk/SPEC.md §When a gate earns its place). - Every comment directive a delta mints rostered cries wolf too. A name obliges
check-comment-tier’s roster only where it lands as a full-line comment on the governed surface, which the amendment does not say; names minted for fixtures, workflow files, descriptors or a trailing position owe no row.check-comment-tierreds at an obliged site regardless, and §The causal-completeness check point 2 prompts the row at authoring.
Deliberately not asserted either: a rostered target’s non-vacuity. Two arms were weighed for the over-count direction, where a bullet names a target with nothing to update. The first requires that a rostered path exist. It is refused because a target the change creates legitimately does not exist before the merge, and because the attested over-count names a file that exists and simply does not hold the text attributed to it. The second requires that the amendment’s cited string be present in the named file. It is refused because an update target’s text is what the merge will write, so at authoring time it has no subject to find. What remains is a claim the merging session checks: the build stage re-derives the roster and declines a target only by naming the passage that already meets its delta’s need (lifecycle-kit/SPEC.md §templates/stages/). That separates a met target from a skipped one.
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. Its good/+bad/ pair is its oracle. bad/ carries a malformed heading, a non-sequential delta number, an uncited target and a dangling citation together, one executable statement per arm. good/ 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 pre-commit hook, run-gates.sh and CI, and the --run-gate-tests arm through the fixture pair. Its input is spec_amendments’ output, an existing producer with an existing enabling path, so nothing new is configured for it to see a live corpus. precommit tier.
check-amendment-retired-spelling
Invariant: every amendment on disk carries a ## Retired spellings block in one of the two forms §The amendment lifecycle pins, and every spelling that block declares survives in the tree only at a path the same amendment’s ## Existing sections updated roster names.
The failure it closes is the roster’s own omission, whose evidence is in the tree rather than the document: a roster short by a surface is found only by a grep. The template’s Definition of Done already owes that grep; nothing obliged or recorded its run.
Why only this slice. Which surfaces an amendment should have listed is the semantics of the change, which §check-amendment-update-target refuses. The literal-substitution slice is decidable: after a delta replaces literal X with literal Y, every remaining X is a site the change should have reached or one deliberately left standing. The roster is the discriminator between them.
The renumber slice is not decidable and is a stated non-target. Inserting a rule and shifting the numbers after it leaves the retired and replacing spellings in the same token space. A site reading rule 19 is a stale citation to the old 19 or a correct one to the new, byte-identical, and correct post-change sites are dense in the renumbered range. A survivor scan there returns a hit set dominated by non-violations, the cry-wolf shape §check-amendment-update-target refuses a stronger arm for (gate-sdk/SPEC.md §When a gate earns its place). Its durable fix is not a gate: a cross-corpus citation naming its referent rather than its number has no numeric relation left to decay. Declaring a pattern instead of a literal reaches only some of the renumber’s site classes — markdown ordinals, parenthesized placement citations and a comma-list roster are three spellings of one shifted number.
Three arms and a valve.
- A — the grammar. Red (exit 1) when an amendment carrying
## What changeshas no## Retired spellingssection, or that section carries no bullet. Red too when its body is neither the negative form nor bullets each carrying a backticked spelling and a citation, or when the negative form carries an empty reason. This arm closes the omission hole the roster has no shape for; B and C depend on it. - B — the survivor reconciliation. For each declared spelling, scan the reconciliation corpus and red on every surface carrying an occurrence at a path no
## Existing sections updatedbullet of that same amendment names. A roster bullet names a path by its leading backticked token, the convention made contract here. The finding’s subject is the surface, so one line is reported per surface rather than per hit: a second hit in a file the author must open anyway adds no worklist item. The amendment set is out of the corpus, so an amendment is never its own violation. - C — the dangling citation. Red when a bullet cites an
<N>no### (<N>)heading defines,all deltasin an amendment defining none included. Without C, A and B both pass on a block whose bullets cite deltas renumbered out from under them — the same failure arm C of §check-amendment-update-target closes, reached through the second block. - Valve —
<!-- retired-spelling-exempt: <reason> -->on the bullet’s first line or the one above, in the shared exempt window (§The shared spec adapters), the reason mandatory on thecomment-tier-exempt:convention. An exempt bullet leaves the declared-spelling count as well as the finding.
A fenced block is skipped whole: an embedded wire-contract delta (§The spec model) is grammar being shown rather than a bullet being declared.
The reconciliation corpus is git ls-files minus the amendment set, minus CANON_KIT_RETIRED_SPELLING_EXCLUDE. Tracked files only: an untracked scratch file is no violation, and a green run before staging is vacuous for exactly the file it most needs to see (gate-sdk/SPEC.md §Enforcement tiers). The corpus resolves as gate-sdk’s §check-tree-terms does and carries the git tool dependency the crate’s registry declares.
The corpus adds no walk root, yet the member declares one. gate-sdk/SPEC.md §check-reads-couples rules a git ls-files corpus outside the walk class, but the member also resolves the amendment set through the shared finder, a walk. So the registry declares the ? its cohort declares for a scan root that is the member’s own first argument. An empty root set would red the crate’s observed-roots-⊆-declared assertion.
Fail-closed (exit 2): a scan root that is not a directory or is unwalkable; an amendment carrying ## Retired spellings but no ## What changes, where no bullet can cite a delta; a file the reader cannot read. So is a failure to enumerate the reconciliation corpus, a non-repository cwd included. The unwalkable root refuses as §check-amendment-update-target’s does, not as §check-amendment-queue’s; §The shared spec adapters owns the asymmetry.
Output. On clean, the amendments scanned, the spellings declared, and how many took the negative form — a count on the clean line and not only on the red one, so the block’s uptake is readable without a failure. On red, each finding as <amendment>:<line>: <spelling> survives at <path>:<line>, named by no roster bullet.
Criterion 4 (gate-sdk/SPEC.md §The port-candidate criteria) clears as for its sibling: the corpus is spec_amendments plus the tracked tree, neither reaching a gate declaration path. Born native, no shell form authored. Its good/+bad/ pair is its oracle. bad/ carries a missing section, a positive bullet with no citation, an unreconciled survivor and a dangling citation, one executable statement per arm. good/ exercises the negative form, a wrapped citation, the valve, and a survivor at a path the roster names.
Producer of nothing but a verdict. Its consumers are the committing session through the output contract on the pre-commit hook, run-gates.sh and CI; the --run-gate-tests arm through the fixture pair; and the align stage, whose roster duty is the complement of what arm B covers. The build stage’s roster re-derivation shares that duty (lifecycle-kit/SPEC.md §templates/stages/). precommit tier, trigger=*: a survivor can appear in any tracked file.
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 (§The spec model), under which a not-yet-cite-able wire contract embeds until merge promotes it to a file the canonical spec cites, and the per-site spec-embedded-source-exempt: <reason> marker.
Calibration: a file named CANON_KIT_SPEC_NAME or matching CANON_KIT_AMENDMENT_GLOB is never indexed as a source; 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. A block is the fence reader’s (§The shared spec adapters). Its language is a bare alphabetic info string, so a block whose opener carries anything richer is never judged. 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 as a canonical spec would. That 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. The wire-delta exemption is a path-identity test against the amendment set rather than a name pattern, so deleting the amendment re-arms it instead of leaving a spelling that outlives its subject.
Criterion 4 is taken as binding, without ruling the class (gate-sdk/SPEC.md §The port-candidate criteria). The 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 its assertion target. Whether that satisfies the criterion is a question about the class. The binding verdict costs a fixture widening and cannot err in the harmful direction, so this member’s disposition is not the class’s answer. Its pair exercises both valves.
Every port moves this gate’s verdicts. A port removes a shell declaration from its candidate index and adds a Rust module, a swap rather than a removal. 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 costs context in 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?), and the gate supersedes a by-eye narration-marker KPI (drift-kit/SPEC.md §Out of scope).
The scanned set is the shared spec_manifest_files finder (§The shared spec adapters): canonical specs, README.md at any depth, and CLAUDE.md. Amendments are excluded by construction, since a transition artifact describes change. Markers are CANON_KIT_TEMPORAL_MARKERS merged with CANON_KIT_TEMPORAL_MARKERS_EXTRA (§Layout and configuration), matched case-insensitively. Fenced code is skipped, and a marker inside an inline-code span is a meta-reference rather than 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, so a deliberate-absence section may narrate what the kit excludes;CANON_KIT_TEMPORAL_EXEMPT_PATHS, path globs whose whole file is exempt, for an immutable dated-narrative surface a heading cannot address: dated announcement posts, which take link and command resolution but not narration governance.
The same valves admit a retired path under §check-docs-cmd assertion C and a retired knob under its assertion B, so one exemption clears both gates, and this section, which owns the valves, states their reach once. A finding names the file, line and marker. An unreadable manifest fails closed.
The rule is a compiled subcommand (gate-sdk/SPEC.md §The POSIX ERE matcher): each marker is a consumer-supplied ERE compiled before the first corpus line is read, so a pattern outside the POSIX grammar exits 2 naming the pattern and the knob. Two properties are invisible in a green run:
- the marker test runs against a case-folded subject and the pattern is not folded, so a marker with an upper-case letter matches nothing;
- the member declares both marker knobs and unions them once through
spec::vocabulary, so no second union can double an added marker.
Calibration: the default marker set leaves out bare used to, which collides with the instrumental “used to build”. Each hit is dispositioned by rewording (preferred), a section exemption (provenance) or a site exemption with its reason. precommit tier.
check-pendency-contradiction
Invariant: no governed manifest file binds one code-span token both to a pending construction and to a landed one. A file calling one span both awaited and delivered falsifies itself, so the check needs no tree comparison and no judgment of what is live. The corpus is the manifest set (§The shared spec adapters) over a paragraph join, read through §check-citation-link’s prose reader. Fences, HTML comments, the front-matter span (§The shared spec adapters) and generated regions are skipped, a generated region’s repair landing at its source.
A construction is a CANON_KIT_PENDENCY_PENDING or CANON_KIT_PENDENCY_LANDED phrase with one {} slot, matched case-insensitively in prose outside code spans. The slot binds the code span, or the list of code spans joined by commas, and or or, directly after or before it, and nothing else. Adjacency keeps a pair clean where one span landed and a second still waits on “it”, while a sentence naming the landed span in the waiting slot reds. It also lets the grain be the whole file, the only grain that reaches a pair sections apart. Any code span is a subject, not only a queue slug, since prose cites work, gates and arms alike. The fixture pair states both sides of the boundary; this section binds no span in either class, being governed by the gate it describes.
The two vocabularies take canon-kit’s bundled-vocabulary shape (§Layout and configuration), their phrases being generic English. check-graph’s optional-document shape fits a vocabulary no two consumers share, such as a layer roster, and is refused here.
Red is one finding per token bound both ways, naming the first line of each binding. The valve is pendency-exempt: <reason> in the shared exempt window. A member without exactly one slot is malformed config (exit 2), and a class with no members is a clean skip. tier=precommit, install: zero-config.
Honest limits. A contradiction spelled without a code span, or through a construction outside the configured sets, passes. So does a pending claim contradicted by the tree rather than by the file, which stays the close-stage audit’s. A pending sentence and its landed correction in different files pass by design.
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, such as six gates or 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. The gate bans rather than validates: a validating gate carries the standing token cost context-kit’s brevity machinery rejects and an error-prone entity mapping, while a lexical tripwire removes the copy.
The scanned set is the shared manifest-set finder (§The shared spec adapters): canonical specs, README.md and CLAUDE.md, amendments excluded and fenced blocks skipped. A cardinal in inline code is a meta-reference, so this section may name its own examples. The grammar and the matcher have one holder, the compiled count adapter, and the walk is the shared manifest-prose driver (§The shared spec adapters). So this gate, check-prose-enum and the comment-tier gate read one vocabulary and one exemption behavior. A total wrapped across a line 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, so an exempted line cannot join its neighbours into a total.
The wedge’s unit is the clause, not the line, and unwrapping a corpus does not widen it. CANON_KIT_COUNT_WEDGE_WORDS bounds how many words may sit between the cardinal and the noun, and the matcher’s character classes bound how far those words reach. The gap class is whitespace and the wedge-word class is alphanumerics with _ and -, so neither crosses a comma, a period, a semicolon or an em-dash. A tree keeping one paragraph per line therefore hands this gate no wider window than a wrapped one. So the gate takes no code-point reach of the kind check-knob-citation’s default leg needed (§check-knob-citation): its bound is already tighter than a reach would be.
The cardinal grammar is stated as a rule, and the list is the matcher’s. It is digit sequences, unbounded, plus every cardinal English spells as a single token, case-insensitive. The words are owned by native/src/spec.rs and cited here rather than transcribed, since a transcription drifts the next time the boundary moves. Both ends of the range are rules a reader can apply to a spelling the table lacks:
oneis outside it, because singleton idioms (“one owner per fact”, “one iteration per kit”) are invariants, not totals;- the upper end is the notation’s: the matcher reads a word between word boundaries, and every cardinal past the single-token range is a hyphenated or multi-word construction no single-token pattern reaches.
That residue is a stated limit, not a closed one. A hyphenated compound stays unreachable where its digit spelling does not. A compound grammar is refused: it would have to tell a quantifying compound from the far commoner partitive and ordinal prose using the same tokens, and that false-positive surface is worse than the hole it closes.
Collection nouns are CANON_KIT_COUNT_COLLECTIONS, default the plurals the kits themselves grow (gates, meta-gates, checks, kits, stages, rules, KPIs). It is the one place consumer vocabulary enters, and it enters as config.
Two match shapes carry the cardinal to the noun:
- the quantifier shape allows up to
CANON_KIT_COUNT_WEDGE_WORDS(default2) modifiers between cardinal and noun, sonine generic rulespins a total as surely assix gates, adjacency being the zero-wedge case; - the noun-then-range shape (
rules 1-8) 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, since 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, since inthree of the twelve gatesneither cardinal is a restated total. It is read across one line break of the paragraph, so a wrap between marker and cardinal is no red, and not across the whole paragraph, which would exempt a total beside any partitive in it; CANON_KIT_COUNT_ALLOWED_PHRASES, an exact-phrase allowlist for fixed named sets a doc legitimately enumerates, default empty, since naming a fixed set is consumer judgment. It bites only on a phrase whose noun the consumer governs;- the per-site
manifest-count-exempt: <reason>marker on the line or the one above.
The 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), the only one that answers the ban rather than stepping around it. The ban’s objection is a transcribed total with no owner, and the 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. The marker rides the exempt marker’s per-site window, its own line and the line below for a full-line and an inline marker alike, so the two discharges are one behavior with two spellings. Rewording to cite the owning collection stays preferred where the total is not worth stating. The exempt tag is the last resort, and means “deliberately unchecked”. A finding names the file, line and matched span. An unreadable manifest fails closed.
Calibration shares §check-manifest-temporal’s procedure: the default noun list is tuned against this tree, and each hit is dispositioned by rewording to cite the owning collection (preferred), extending CANON_KIT_COUNT_ALLOWED_PHRASES for a genuinely fixed set, or a site exemption with its reason. The good/bad pair covers every match shape and the mechanical exemptions. check-manifest-count.test.sh covers what the stock defaults cannot reach: a consumer-governed noun, the allowlist’s containment, and the measured-marker discharge’s window, which a pair cannot spell because a fixture file either trips or does not. precommit tier.
check-provenance-seam
checks/check-provenance-seam.gate (precommit, binary-dispatched). Invariant: no kit SPEC carries a publisher-provenance marker or quotes a roster one consumer configured, the two classes gate-sdk/SPEC.md §The provenance seam bars from a kit file. No seam surface a consumer declares carries the first. A kit SPEC is vendored and rendered wherever the kit goes. An attribution there publishes who decided what and when, a pointer into the publisher’s tree is dead in every copy, and a configured roster reads to a second consumer as the shape of its own. The gate holds the lexical shapes of the provenance class and the one roster shape of the content class that an oracle can hold without flooding; the voice-not-content judgement and the whose-truth discriminator behind them stay a review concern.
Corpus. Two sets. The canonical spec (CANON_KIT_SPEC_NAME) at the root of every kit root (gate-sdk/SPEC.md §Layout and configuration), derived rather than listed, is scanned only when CANON_KIT_SCAN_KIT_ROOTS is 1, that knob’s existing meaning: the kit docs are the consumer’s own first-party content. At the default 0 they are a dependency’s, since an adopter vendoring a SPEC-bearing tree would otherwise have its own queue slugs and private names red someone else’s document. The files CANON_KIT_SEAM_SURFACE_GLOBS matches are the consumer’s own by declaration, so they are scanned whatever the kit-roots knob says. They take the provenance arms (dated-attribution, dated, agent-file-pointer, private-surface, queue-slug and hex-reference) and not the consumer-roster arm: that arm bars a kit from quoting a consumer’s configuration, and a consumer’s own record describing its own configuration is that consumer’s content. A file both sets reach is scanned once, as a kit SPEC. With both sets empty the gate passes, and its clean line names which set was off. It declares no # armed-by:, because that default is the intended one (gate-sdk/SPEC.md §The install disposition). The gate ships in a kit rather than as a consumer gate because the seam is a rule for every kit publisher.
Fenced blocks are skipped; nothing else is. There is no per-site valve, no section carve-out and no path exemption. A fence holds grammar being shown, and a shown instance is a specimen, not a stamp. An inline-code span is scanned: a pointer written in backticks is exactly as dead in a vendored copy. Each paragraph is rejoined across its line wraps, so a marker split by a wrap still matches, and a finding reports its first physical line.
Arms. Each finding names file, line, arm and matched span.
- dated-attribution — an ISO
YYYY-MM-DDdate (no digit abutting it) and an authority marker fromCANON_KIT_SEAM_AUTHORITY_MARKERSin one sentence. A sentence ends at.,?,!or;followed by whitespace. A date with no marker is thedatedarm’s; a marker with no date passes, because an unattributed rule about the kit family’s roles is mechanism. - dated — an ISO
YYYY-MM-DDdate (no digit abutting it) in a sentence the dated-attribution arm does not already report, split as that arm splits. A kit SPEC carries no date: a measurement that grounds a rule is stated undated, and a specimen spells its dateYYYY-MM-DDor sits in a fence (gate-sdk/SPEC.md §The provenance seam). - agent-file-pointer — a name from
CANON_KIT_SEAM_AGENT_FILESfollowed by a section citation (§, with or without a joining comma or space) or by a possessive's. A closing backtick may sit between the name and either. A bare mention passes, because a knob default naming a consumer’s agent file is mechanism; a name preceded by a word character or.is a different file. - private-surface — any mention of a path in
CANON_KIT_SEAM_PRIVATE_SURFACESas a whole path token: no word character,/or.before it, and no word character,/or.-plus-alphanumeric after it, so a longer name containing the path does not match. - queue-slug — a lead-line slug of the queue file (
CANON_KIT_QUEUE_FILE) at leastCANON_KIT_SEAM_SLUG_MIN_LENcharacters long, matched with[A-Za-z0-9_-]as word characters on both sides. A lead line is an entry heading or a bare done slug, in any section. A slug equal to a mechanism name the tree defines is not a finding: a kit root’s directory name, a gate declared in the gates directory or a kit’schecks/, or agates.listmember. That exclusion is structural, not a valve: a unit named after the gate or kit it mints would otherwise red that gate’s own section while the unit’s entry is live. - hex-reference — a run of 7 to 40 lowercase hex characters with no letter, digit or
_on either side, carrying at least one digit and at least one lettera–f. The bounds are an external format rather than a calibration: 40 is a full SHA-1 object name and 7 git’s default abbreviation, so the run is the shape of an abbreviated or full object name and of a digest prefix. A SHA-256 repository’s 64-character names, and names acore.abbrevbelow 7 shortens, fall outside it — an honest limit of the format chosen, not a threshold to tune. An object name or a digest prefix points into one publisher’s history, so it is dead in every vendored copy. Requiring both classes keeps a decimal literal and an all-letter word such asdefacedout, and the arm matches bare prose as well as inline code, since the reference is as dead either way. - consumer-roster — two or more distinct candidates of one knob in one paragraph, each as a whole inline-code span, reported at the paragraph’s first line with the knob and the matched spans. A candidate is an element of a static indexed or keyed knob whose layered value the consumer set, absent from that row’s default, spelled
<key>=<value>for a keyed pair, and carrying a/or=. The shape is the attested leak’s: a kit SPEC illustrating a keyed wire by enumerating one tree’s configured pairs. Matching any single configured value floods, since most have a lawful reading (a kit default, a generic noun, a kit’s own path), and so does co-occurrence of bare values on word-valued elements. A whole-span quote of two slash- or pair-shaped consumer-only elements measured zero hits over the kit SPECs and fires on the attested instance.
The possessive shape is one class with the section citation — both point into the publisher’s always-loaded file — so leaving it out would carve an exemption by omission into a gate that ships exemption-free.
Honest limits, stated so a green run is not read as a clean seam:
- undated attribution passes, since a marker-only arm would red the role vocabulary;
- a stamp dated in words rather than ISO passes;
- a retired slug or a cut ordinal passes, because deriving retired slugs reads queue history, which a fixture cannot pin and a shallow clone does not have;
- a slug below the length floor passes;
- an all-digit abbreviated object name passes, which is about one in thirty at seven characters and rarer beyond; so does an uppercase one, and a decimal CI run or job id, which no shape tells from a measured number;
- a UUID segment or an eight-digit hex colour in prose reds as a hex reference; fence the specimen, since fences are the gate’s one escape;
- an attributive agent-file pointer (the agent file’s ban) passes, because telling it from a consumer-side mention (the consumer’s agent file carries …) is judgement;
- kit templates, kit READMEs, kit smoke scripts and a kit’s doctrine file are outside the corpus;
- a singleton roster quote passes, as do a word-valued element, an unbackticked mention and a scalar knob’s value — the content class beyond the roster shape is judged by gate-sdk/SPEC.md §The provenance seam’s discriminator at review.
Those shapes are held by the close-stage review, not by this gate.
A queue edit can red a kit SPEC. Filing an entry whose slug appears as text in a kit SPEC reds the commit that files it, naming the SPEC line; renaming a fresh slug costs nothing. The descriptor therefore couples knob:CANON_KIT_QUEUE_FILE beside kit:SPEC.md, so the hook fires on the commit that introduces the finding. A consumer knob edit can too, so the member declares every static indexed and keyed row. The declaration reaches every static kit, so each kit’s knob file joins its couples= as a derived couple (gate-sdk/SPEC.md §The # graph: manifest), and the hook fires on the knob edit with no path literal in the descriptor.
Fail-closed (exit 2):
- a kit SPEC or the queue file that exists but cannot be read;
- an authority marker that does not compile, every pattern compiling before the first line is read, as §check-manifest-temporal’s do;
- an authority marker set empty in both base and extra;
- a non-positive or non-integer slug floor.
An absent queue file switches the queue-slug arm off and says so in the clean line, as an empty private-surface list does for its arm: a kit publisher with no work queue is an ordinary configuration, and the other arms still judge it.
Producer: the pre-commit hook and run-gates.sh, on a commit touching a kit SPEC, a seam surface or the queue file. Consumer: the committing session via the output contract, each finding read once at the scan transition (file, line, arm, span), no persistent state. The arm names the remedy: delete an attribution, state a measurement undated, restate what a pointer stood for, rename a fresh slug, or name the knob rather than quote its configured roster.
The fixture pair trips each arm and carries a fenced copy of every trip that must add no finding. Its consumer knob file sets a keyed knob. good/ quotes one configured pair and two pairs equal to the default, and carries a decimal 2147483646, an all-letter hex word, a fenced object name and a specimen dated YYYY-MM-DD. check-provenance-seam.test.sh holds the paths a pair cannot spell:
- the kit-roots-off default and an absent queue file;
- the
_EXTRAunion, with thedatedarm silent on a sentence dated-attribution reports; - a seam surface redding at kit-roots
0, and one quoting two configured roster elements with no consumer-roster finding.
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 (a total, a corpus size, a swept set): it goes stale the moment the thing it measured moves, and only a human re-measuring at review would notice. The sibling ban (§check-manifest-count) reaches the tractable slice 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 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 costliest claims carry none.
The marker binds a claim to an oracle key and its measured value, as an HTML comment in one of two positions:
<!-- measured: <key>=<value> -->
Full-line, alone on the line immediately above the claim, binding the paragraph below. Inline, anywhere else in a line of prose, binding the one sentence the marker follows. A mid-paragraph claim takes the inline form, because a full-line comment there would split the rendered paragraph. An occurrence inside an inline code span is the grammar being shown, not a marker, just as an occurrence in a fence is.
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, read through the measured_claims adapter (§The shared spec adapters) and so carrying its keyed 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.
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 the marker to the prose, and neither knows what the key is about, so the sentence above the marker then asserts something nobody wrote. So a measurement whose question differs from an existing key’s takes its own key, however close the two numbers are today: the closeness is what makes the substitution tempting and the drift invisible. Review at the diff holds a key’s meaning honest, as it does a free-text cause under §check-comment-tier, since 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, firing on a marker whose key nobody emits. There is deliberately no converse: 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. So when a key is minted the emitter gains the line first, and prose binds it when a sentence needs it. Arm B exercises no emitted key a marker does not name; such a key 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 single-token spelled cardinal normalized to digits, so a marker’s 12 and a sentence’s “twelve” are one cardinal. Widening that grammar strictly shrinks arm C’s violation set, since it reds on a marker cardinal absent from its claim and an unreachable spelling reads as absent.
The claim a marker binds.
- A full-line marker binds what
CANON_KIT_MEASURED_SPANsets. Atparagraphthat is the paragraph below the marker, ending at a blank line, a fence, a second full-line marker or the end of file, less any sentence an inline marker in it binds. Atsentenceit is that text’s first sentence, and atoffarm C reads no full-line marker. - The span is the consumer’s because the right unit depends on the tree. A short wrapped paragraph binds tightly enough whole; an unwrapped paragraph is one line of any length, where a marker whose sentence drifted still finds its cardinal elsewhere. A code-point reach is refused, since arm C would then find or miss a cardinal by where the cut fell.
- An inline marker binds the sentence ending at it. Paragraph text is rejoined across its wraps, and a sentence ends at
.,?,!or;followed by whitespace, §check-provenance-seam’s sentence. A terminator separated from the marker only by whitespace closes the bound sentence rather than opening it. - Marker text is never part of any claim.
The authoring contract arm C prices: a bound claim carrying more than one distinct cardinal is ambiguous, and the gate fails closed rather than guess which one the marker holds. The help line’s remedy is to split the sentence, or mark the sentence carrying the measurement inline. The cost applies only to sentences an author chose to mark.
Extent claims are covered by arms A and B alone, by design. An extent claim carries no cardinal, so arm A does the work. Its <value> is whatever extent the author declares and the emitter recomputes: a corpus size, a sorted membership list, a digest over the swept set. It is the axis no scanner reaches, at no extra cost. Tab is the protocol’s field separator and the adapter refuses an extra one, so a set-valued oracle joins its members with something else, such as +. The shape makes a definitional or qualitative sentence self-correcting, since it carries no cardinal for arm C and would otherwise bind 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 omits them, and the prose surface (§The shared spec adapters) excludes them because check-shim-restatement owns a shim’s copy shape. That ownership is not this rule’s: a wrong restatement has diverged from its owner’s wording and is no copy, so either surface would under-scan by the very instance that motivated the class. Fenced blocks are skipped, a fence being grammar shown rather than a claim made. A per-site measured-claim-exempt: <reason> marker on the line or the one above suppresses a marker a document exhibits rather than asserts.
The valve is a fail-safe for an author who must exhibit a full-line marker outside a fence, so a valve with live uses would mean the fence-and-inline escapes had failed. Its readers are adopters, so a zero use count over the kit’s own tree is no evidence against it. It stands beside the family boundary above: that boundary rules what the measured: marker is, not whether a suppression valve may accompany it.
Producer: the 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, with no shell original. A new shell gate is debt created knowingly, and this one needs no unbuilt substrate. The walker in production serves its glob corpus, which is why the corpus is its own surface rather than a union with the manifest set. A gate with one implementation has no second substrate to prove parity against, so its oracle is its good/+bad/ pair, with check-measured-claim.test.sh holding the fail-closed arms a pair cannot spell. Criterion 5 charges it nothing, since a host the release publishes no artifact for receives no gate of any substrate. A second gate is settled by gate-sdk/SPEC.md §The port-candidate criteria, never by 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 once the banned phrase is deleted, and faces one direction. A class assertion fires again on the next author who states the same thing in new words, and covers a claim overstating in either direction, since what puts a sentence in the class is what it asserts. Both follow from 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 from
claim_vocabulary(§The shared spec adapters), whose every message names the failing vocabulary by its command knob. A declared ERE that does not compile is the same unreadable-roster failure one step later. - Valve —
<!-- unmarked-claim-exempt: <reason> -->on the flagged line or directly above it, the shared exempt window (§The shared spec adapters). The reason is mandatory, on thecomment-tier-exempt:convention: 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.
Matching is whitespace-normalized across lines, and this is load-bearing. A claim spans a prose wrap as readily as it fits on one line, so a line-keyed predicate is blind on whatever fraction of its corpus wraps, which no author controls. The ERE is matched against the paragraph with every run of whitespace, the newline at a wrap included, collapsed to one space, and the finding is reported at the physical line the match starts on. It is the wrap-straddling boundary §check-manifest-count crosses for a total, and the normalization lives in the shared adapter (§The shared spec adapters). The subject is ASCII-lowercased before matching and a class pattern is authored in lower case, so an opening capital is no way past the class.
A paragraph is the unit, and a marker discharges exactly the claim it binds: §check-measured-claim’s claim, read through one helper so this gate and that one cannot delimit it differently. A full-line marker binds the text below it up to the next full-line marker or the paragraph’s end, narrowed by CANON_KIT_MEASURED_SPAN. That is its first sentence at sentence, and the whole block at paragraph and at off, since off withdraws only arm C’s cardinal read and arms A and B still hold the marker. A class match above a full-line marker, or past the sentence it binds, is unmarked. An inline marker discharges a class match that starts inside the sentence it binds, and no other, so a marked figure does not vouch for an unmarked claim beside it. Fenced blocks are skipped for the reason that section gives: a fence is grammar being shown, not a claim being made.
Coverage-only, by constraint. The gate asserts that a matched claim carries a marker, not which key. 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, and adding a field would fork the loader every claim gate shares (§The shared spec adapters). check-measured-claim checks the key: its arm B fails closed on a key nobody emits, its arm A on a value the oracle contradicts, so the composition covers it with no duplication.
The scanned surface is CANON_KIT_MEASURED_SURFACE_GLOBS, shared with check-measured-claim, not forked, because they compose: a claim this gate pressures into a marker is one that gate re-measures, and one corpus makes the handoff total. The cost: a consumer cannot declare claim classes over a different surface than its measured claims, a split available later behind an attested need. This gate’s corpus also widens whenever that knob does, so a widening is re-run here rather than reasoned about, since a declared class may match a surface nobody widened it for.
The roster is consumer config, and the seam is sharper here than for its siblings. Every class id and pattern arrives through CANON_KIT_CLAIM_CLASSES_CMD (§Layout and configuration); the scan, the normalization, the marker lookup and the verdict are kit mechanism. This SPEC is itself governed prose, so a kit enumerating a consumer’s claim phrasings would match its own class while describing it. In consumer config the roster is never spelled in the kit’s prose. An unset command declares no class and the gate reports clean, the inactive-by-default posture of its *_CMD siblings: only a consumer’s own vocabulary can turn it on.
The honest limit 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 gate-sdk/SPEC.md §When a gate earns its place names a gate that cries wolf a defect in the gate. The general move for a noisy member is to roster the predicate the noisy subject attaches to, which recovers the site with none of the noise; a bare token offers no predicate, 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-claimasserts the oracle agrees; - neither reaches a sentence true today 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, as for §check-prose-tells and flippable by the same consumer config. Born native, none of the three exception classes applying. Its good/+bad/ pair is its oracle, the bad/ case carrying a claim wrapped across a newline so the line-keyed blindness above is executable. check-unmarked-claim.test.sh holds 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 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 as silently when the set grows. Engagement mirrors check-kit-enum: two adjacent members are a list, and a lone member, or two scattered across a paragraph, is a mention.
The sets are consumer config, never gate literals (the provenance seam). CANON_KIT_ENUM_SETS_CMD (default empty, a clean skip) names a consumer command emitting one <set-name>⇥<member> line per member, read through enum_sets (§The shared spec adapters). 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), with no alphanumeric, hyphen or underscore abutting.
- A stem never matches inside a longer tag, and an underscore-separated identifier never reads as present inside a longer sibling: “the
allow_envelopehelper” namesallow_envelope, and “theallow_envelope_withpair” does not. A member falsely read as present is one the gate stops asking about, so the paragraph omitting it reports clean, the silent direction. .and/stay outside the class, which keeps the identifier reading one character wide: a basename member matches prose spelling the file kit-relative, repo-relative or bare (below).- The grammar stays two fields. A third, scoping a set to a whole file, would reach a cross-document absence, members spread over per-section prose that a paragraph judge cannot see. It is refused: the wire grammar’s fail-closed rule rejects a third field, and a document-scoped completeness test is satisfied by any mention anywhere. Reshaping such prose into a roster the paragraph judge reads closes the same case.
A hand list is a chained run. The scanned set is the shared manifest finder, and the manifest-prose walk hands this gate each flushed paragraph (§The shared spec adapters). Two or more members present are a hand list only when a run of them is chained by a separator carrying a delimiter (a comma, slash, bracket, parenthesis, colon, period, backtick or pipe) or the word and/or. Whitespace alone never chains: two members side by side in running prose are ordinary words (“a cost surface”), and a list spells its separator. The chaining test is structural, not a count: a separator is a gap carrying no letter, holding a delimiter or a single and/or, whatever its length. Members co-occurring with prose between them stay mentions. The omitted members are those 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 and the per-site tag last:
- a subset marker in the window (
e.g.,such as,among them), the list declaring itself partial; - a partitive marker on the match (
some of,one of,of the set), a selection rather than an enumeration; - 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 trimming to a silent subset.
Producer: the pre-commit hook / run-gates.sh, coupled to the manifest set plus the consumer’s config and kit sources, so a set change re-fires the gate over the docs. Consumer: the committing operator via the output contract, reading file, line, set name, count and the omitted members once at the scan transition. CANON_KIT_ENUM_SETS_CMD is read at startup, and both fields of each emitted line at match time, the set name in the report and the member in the matcher.
The bundled --emit-enum-sets arm is one value the knob takes. It derives the queue task-tag set and the Lessons-channel set from queue-kit’s own lead-line tag vocabulary plus QUEUE_KIT_LESSON_TAGS. The two roles are separate sets, since a paragraph naming one role’s tags is not enumerating the other. A set a consumer can only hand-list is its own drift to own; the kit contract asks only for the emit grammar.
The gate must not call the emitter in process, though both sit in one binary. The adapter spawns the configured argv and reads its lines (§The shared spec adapters). An in-process call looks free, but it would resolve the bundled producer for a consumer who configured a different one, the extension point the knob exists to protect.
The tag vocabulary is referenced from the gate that owns it, not parsed out of it. check-tag-lead-line’s class table is the single holder of both the match literal and the tag name. The emitter reads that table directly, terminator strip and all, so a rename cannot leave two spellings disagreeing. A compiled reference cannot be ambiguous about which table it names, so no check that the file holds exactly one class table is owed.
Two roster families over the kit tree, one set per kit root, come from the same emitter: 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.
- The roots are the binary’s derivation from
GATE_SDK_ROOTandGATE_SDK_KIT_DIRS(gate-sdk/SPEC.md §Layout and configuration), so the sets cannot enumerate a tree the battery does not. - Tracked is contract. The listing comes from
git, so an untracked new sibling does not enrol, where a filesystem walk would silently widen a set another surface reasons about. A battery run beforegit addof a new sibling reads the old set and can turn red at the commit that stages it, so run it after staging. A new kit, lib or unit test enrols with no edit. - Members are basenames, since a basename matches prose spelling the file kit-relative, repo-relative or bare, and 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. They stay silent where emptiness is real: agate-tests/holding onlygood/+bad/fixture directories is a normal kit. - One set is not kit-rooted:
kpi-builtin, the ids of the binary’s bundled KPI table (drift-kit/SPEC.md §Bundled KPIs), referenced from that table as the tag vocabulary is fromcheck-tag-lead-line’s.
A per-lib set of function names and a per-lib set of sourcing files are refused, so neither is re-minted. 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. Measured, the function family reported an omission at every passage explaining how two helpers interact. The caller family was inert at its one intended site, a caller roster written as narrative that the adjacency rule declines to judge, yet fired falsely on a README row naming two of a kit’s tools. The refusal rests on that ground alone and not on the matcher, whose identifier boundary (above) was found while measuring the function family. The rule: 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.
A set is what a registry holds, and a contract is cited, not enumerated. A derived set reads a tracked listing, a registry file or a compiled table. A value set that exists only as a conditional’s arms or an argument parser’s modes is behaviour. An extractor for it would be a per-site oracle that a refactor breaks. Prose names the behaviour and cites its owning section, so nothing restates the members. An invocation block rostering an arm’s modes is the residue, held by review.
Calibration follows the count gate’s procedure: tuned against the kit’s own tree, every hit dispositioned by citing the set, completing the list or site-exempting 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 pair cannot reach: the empty-default skip, the fail-closed escapes, bracketed matching, multi-set independence, the exempt escapes, the whitespace-only gap that never chains, and the identifier boundary’s prefix-sibling pair. That last needs a declared set holding underscore members, which no pair running on the consumer’s own hyphenated sets supplies. 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, so the provenance seam holds. 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 (lifecycle-kit → LIFECYCLE_). Adding a kit widens the vocabulary with no edit here. 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 holds the false-positive rate.
A candidate fires on the value-marker triad, every leg required: a derived-prefix token; a value marker bound to it on its line; and a surface that is not the token’s owning SPEC (<owning-kit>/ joined to CANON_KIT_SPEC_NAME). The marker takes one of two shapes:
=appended directly to the token (<KIT>_<KNOB>=<value>);- the word “default” following the token within the reach
CANON_KIT_KNOB_CITATION_REACHsets, bound to a value literal (a backticked value, a quoted string, or a number) by the literal span below. A marker before the token binds nothing, since a stated default reads knob first, then value.
The reach defaults to 100 code points, the reach the leg shipped with, so an upgrade moves no verdict. It also takes:
- any positive integer;
sentence, binding a marker only when no sentence terminator (.,?,!or;before whitespace) sits between it and the token, with the honest limit that an abbreviation’s period ends a sentence early;off, which stops reading the default shape so only the=shape fires, and the clean line says so.
Any other value exits 2. The reach is the consumer’s, because the right distance depends on the tree: a wrapped tree’s line already bounds the leg, while an unwrapped paragraph is one line along which an unbounded leg would bind a knob to another knob’s default.
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. 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 only a literal lying wholly within the CANON_KIT_KNOB_CITATION_LITERAL_SPAN bytes after the word, default 24, 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, and neither does the adverbial “by default” with a literal later in its sentence. The span takes any positive integer, and any other value exits 2. It has no off, since the reach’s off already stops the leg, and no sentence or clause value, since either admits the adverbial reading the span exists to refuse.
The scanned set is the shared manifest finder, walked by the manifest-prose walk (§The shared spec adapters), and this gate judges each line, since the marker is same-line and needs no paragraph join. 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, such as a wiring example whose value is the citing kit’s own path. Producer: the pre-commit hook / run-gates.sh, coupled to the manifest set and the kit SPECs. Consumer: the committing operator via the output contract, reading file, line, the knob token and the owning SPEC the value belongs in once at the scan transition, no persistent state. An unreadable manifest fails closed.
Calibration follows the family procedure (§check-manifest-temporal): every hit is dispositioned by citing the knob by name and pointing at its SPEC roster (preferred), or exempting 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 the pair cannot reach: a kit may state its own knob’s value in its own SPEC but not in its README. precommit tier.
This gate polices where a value is placed; its sibling check-knob-default-coupling polices whether the value agrees between the source fallback and the owning SPEC. Both read the default-statement grammar (§The shared spec adapters), one 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. check-knob-citation bars a knob value copied into prose outside the owning SPEC. This gate closes the channel that leaves open, coupling the source site that supplies the default to the SPEC statement so the two cannot drift silently.
Three default idioms are read:
- a
${PREFIX_KNOB:-value}fallback expansion; - the guarded assignment dominant in the kits’
lib/*.sh, a[[ -v PREFIX_KNOB ]] || PREFIX_KNOB=valuescalar or adeclare -p PREFIX_KNOB &>/dev/null || PREFIX_KNOB=(…)array; - the static knob roster (gate-sdk/SPEC.md §The non-gate arm,
--emit knob-roster). A static kit’s defaults live in the binary’s knob table rather than in kit source, and each scalar default rendered there is a record coupled as a guarded assignment is, read from the table in process and reported at its roster line.
The knob prefix is derived, never listed: one SCREAMING_SNAKE form per gate_kit_roots member, the dir uppercased with 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, with templates/ and fixtures pruned. A knob’s owning SPEC is resolved by its prefix, not by the citing file’s kit, so a canon-kit source citing a GATE_SDK_ knob couples to gate-sdk’s SPEC.
A row the table derives renders with the host’s executable suffix where the row appends one (gate-sdk/SPEC.md §Layout and configuration), so its SPEC comparison first removes that standard-library suffix. A Windows host couples …checkwright-gates.exe to the SPEC’s suffix-less literal, a suffix-less host compares unchanged, and a spelled row is never trimmed. An indexed or keyed roster default is an array default, skipped as below.
The two assertions:
- 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 until the source is fixed.
- Assertion 2, SPEC agreement: the owning SPEC states that same literal as the knob’s default, within
CANON_KIT_DEFAULT_COUPLING_WINDOWcode points after a mention of the knob’s name. The statement is read through the default-statement grammarcheck-knob-citationshares (§The shared spec adapters). 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, since the SPEC owns knob defaults.
CANON_KIT_DEFAULT_COUPLING_WINDOW defaults to 400 and takes any positive integer, or off, which skips assertion 2, keeps assertion 1, and reports the comparison as not run on the clean line; any other value exits 2. off suits a consumer whose SPECs state defaults in a shape this grammar does not read. The window is the consumer’s to set: a SPEC stating each default beside its knob can hold it tight, one describing a knob at length first needs it wider, and every value keeps the assertion a comparison between two sites. A paragraph bound is refused, because the SPEC is read as one blob so a wrapped statement still binds, and a SPEC keeping its knob roster in one paragraph would get a bound wider than the count.
An absent owning SPEC is a skip, where a silent one reds. Assertion 2 compares two sites rather than requiring that a second site exist, so a kit whose SPEC file is not there has no statement to drift from. Every knob of that kit is skipped and counted on the clean line beside the other skip classes. Reading the two cases as one would turn a missing file into a finding per knob, which says nothing about drift. The live case is a consumer whose kits arrived through a payload that withholds their SPECs (gate-sdk/SPEC.md §Consumer payload). In a tree that authors its kits, every SPEC is present and the skip count is zero.
The coupling is literal-to-literal, so only a default carrying a single literal is coupled. These are skipped and counted, never coupled:
- a computed default, any expansion, substitution or arithmetic in the value;
- an array default, which has no single literal;
- an empty fallback:
${X:-}is theset -upresence-guard idiom, not a value, and the real default lives at the else-branch or guarded-assignment site the gate reaches there; - a default the SPEC states descriptively rather than as a literal (a regex, a phrase set, a bundled list), since 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, and only a literal disagreement or a wholly undocumented default reds.
Port criterion 4 binds this member in its sharpest form (gate-sdk/SPEC.md §The port-candidate criteria). Its couples= field is one level deep and 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: the couple clears and the walk binds. Its assertion target is the grammar of a default, never a gate’s dispatch relation, so the good/bad pair pays the criterion (§Meta-gate conservation for the binary substrate).
A finding names the file, the knob, the source literal and the owning SPEC. An unreadable kit root or an unresolved knob fails closed. The good/bad pair covers self-agreement, the SPEC disagreement and both source idioms. check-knob-default-coupling.test.sh covers what the pair cannot reach: the cross-kit owning SPEC, the absent-default red, and the descriptive, array, empty and deferral skips. 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.
The glossary’s term set is every bold lead-in in its body, plus the first column of each table in the term section, alternates split at /. The term section opens at a heading of any level whose text begins with CANON_KIT_GLOSSARY_TERM_SECTION. It closes at a heading of that level or above, or at a line opening ---. A table’s header row is the row its delimiter row follows, read off the table grammar rather than a column’s name.
Calibration: a bold lead-in naming a component, a directory owning a canonical spec, is never flagged, since a component’s definition lives in its own spec. Gate-test fixtures are excluded. An absent glossary file exits 2, so register the gate only where the topology exists. align-only tier.
.gate-dispatched, declared at canon-kit/checks/check-surface-duplication.gate with its rule in native/src/gates/surface_duplication.rs. This tree registers it in no gates.list, so the port arm’s two registry arms never select it (gate-sdk/SPEC.md §The port-candidate criteria, criterion 7), and criteria 1 and 3 fail on their own terms. Its oracle is the fixture pair plus canon-kit’s own smoke, the only one available rather than an equivalent of the dispatch proof.
It spawns nothing, so its declared requirement set is empty: the text scans and the canonical-spec walk run in process.
Two semantics 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. A shortest-span match would silence a valve tag the rule 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, held by crate unit tests, where the negative half of every arm lives: a fixture pair asserts a finding’s presence, never its absence.
Criterion 4 binds, and the live-tree arm stays proof rather than smoke: this member’s corpus is markdown, so a .sh probe beside its descriptor sits outside the corpus it probes.
An unreadable surface refuses at exit 2 in the gate’s own words, since the compiled form has no child diagnostic to quote.
check-comment-tier
Invariant: every full-line comment on a governed source is one of the following, and anything else is flagged:
- a machine directive, a comment a tool parses (rostered below);
- 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). It blesses a bounded window, its own line plus continuation lines up toCANON_KIT_COMMENT_RUN_CAPtotal physical comment lines, blank#lines counted. A directive mid-run opens a fresh window from its own line, and each comment line beyond a window classifies on its own; comment-tier-exempt: <reason>;- the comment immediately above a positional construct from the language roster.
The machine directives are these, each with its reader:
graph:,shellcheckandcontract:;knob:, a descriptor’s declaration of a knob no static kit owns, by the knob resolver (gate-sdk/SPEC.md §The declaration cohort);- four read off a kit’s vendored source (gate-sdk/SPEC.md §The install disposition and §Consumer smoke):
install:, by the installer’s recipe module andcheck-install-disposition;armed-by:, bydoctorandcheck-install-disposition;projection:, bycheck-projection-roster; andsmoke-unregistered:, by--run-consumer-smokeoffsmoke/install.sh; - the site valves, each read at its own site by the gate its owning section mints:
portability-declared: <reason>(gate-sdk/SPEC.md §check-portability-floor),harness-binding: <reason>(gate-sdk/SPEC.md §check-harness-literal),path-dialect-exempt: <reason>(gate-sdk/SPEC.md §check-path-dialect),door-contributor: <reason>(guard-kit/SPEC.md §check-door-binding, assertion C), andgh-repo-exempt: <reason>andaction-permissions-exempt: <reason>(gate-sdk/SPEC.md §check-action-gh-repo and §check-action-permissions); enforce:, the monitor marker--emit enforcement-mapreads off a non-gate surface, usually a scheduled workflow (gate-sdk/SPEC.md §enforcement-map).
A site valve joins the built-in roster rather than a consumer’s extras because it is kit mechanism. It is not comment-tier-exempt:, whose question is whether a comment earns its place, because each answers its own: path-dialect-exempt: says this value is not a filesystem location, and door-contributor: says this door’s reader is a contributor. An emitter writing door-contributor: into generated output carries it in a source string, which the roster must bless where the emitter also comments it.
Code is the WHAT and 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 a code site to the requirement governing it, as contract:, assertion and graph: bind a site to a manifest contract, an enumerated assertion or the gate graph. Either side’s drift breaks the binding visibly. graph: and assertion are gate-checked both ways, and for spec: and contract: check-spec-pointer checks the forward side, leaving the reverse, an uncovered requirement, to review. So a directive blesses only its own one-line binding, never a relocated block: restatement couples nothing and forks the why into a second copy no gate reads. comment-tier-exempt: <reason> is the directive for a genuinely local fact below SPEC altitude that neither tier owns.
The corpus reaches the implementation of a ported gate, not just shell, in a tree whose CANON_KIT_COMMENT_SURFACE names the crate’s language: comment_surface with templates kept (§The shared spec adapters) 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 stay there, so the tier rule follows them across the substrate. A descriptor’s own lines are directives by construction. A marker matches on the token, whatever the comment leader, so // and # carry the same directives.
The colon side’s sharp edge is a naming rule, not a matcher one. A colon directive matches as a substring, so a short token nests inside ordinary vocabulary: a directive named port: would bless support:, transport:, export: and report:. Roster names on that side are chosen not to nest, and a bare word is bounded and carries no such hazard. The rule is unenforced by construction, since the hazard lies in a name that does not exist yet, and a scanner over a roster of literals would check the roster against itself.
One shape overrides the window: a full-line comment carrying a restated collection total, in §check-manifest-count’s count grammar over the same CANON_KIT_COUNT_COLLECTIONS, is flagged even where a directive blesses it, and positional rescue does not reach it. A directive’s blessing covers its own wording physically wrapped, never a total pinned beside it, and such a total is the second source the manifest gate bans. The fix deletes the count or cites 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 inside a directive’s own wrapped wording, where one would escape a line-at-a-time scan, and an exempt line cannot launder its neighbours into one. Counts in code stay a review concern, because legitimate numerals abound in source (exit codes, indices, field positions) and a numeral scan would false-fire more than it catches. Counts in prose, wherever it lives, are gated.
Calibration:
- The built-in roster is the kits’ own mechanism directive names.
CANON_KIT_COMMENT_MACHINEand_REASONappend a consumer’s product vocabulary, the splitcheck-graph’s vocabulary makes: the mechanism ships and the rule content is config. - The window cap is
CANON_KIT_COMMENT_RUN_CAP, default 3, one wrapped sentence. A continuation within the window is the directive’s own wording physically wrapped, while prose beyond it is presumed relocated restatement and deleted. - Exempting a restatement rather than deleting it is itself the defect. A long roster, such as a
usage:option list or a header, restructures into directive-anchored short paragraphs or trims, and never launders prose past the cap. - The count override reads the shared count adapter (§check-manifest-count) and inherits its carve-outs. A comparator bound or a
per-phrase is a rule and not a total, a partitive proportion exempts both its cardinals, and a cardinal in inline code is a meta-reference, so a directive may sayat most three checks per runor name a`six gates`example. No knob is added: the nouns enter throughCANON_KIT_COUNT_COLLECTIONSand the wedge throughCANON_KIT_COUNT_WEDGE_WORDS. - The default surface is shell (
#),templates/stubs included. A copied-out template’sspec:line resolves against the vendored kit path, so its comments are directives like any source and this gate governs them, wherecheck-spec-pointerskips them as placeholders. - An actions-shaped YAML member reads as shell too, since a
#line in arun:body is a shell comment and the classifier reads no YAML structure. - The workflow directory’s tracked members bless only
contract:andsee, 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 block grammar and reads as a content line. A surface whose records are##blocks (lifecycle-kit/SPEC.md §The survey record) would otherwise have every record heading flagged, with no tag available that keeps directives out of the data. The member’s# contract:header is still classified here, and its tier and payload arecheck-workflow-tiering’s. The narrowing is extension-scoped rather than content-sniffed, so a.txtmember’s#lines stay comments. - Slash-comment parsing (
//,/* */, doc-comments, heredoc skipping) ships as mechanism and activates when a consumer widensCANON_KIT_COMMENT_SURFACEto a language that needs it. - Positional rescue is language-agnostic: its construct roster,
CANON_KIT_COMMENT_POSITIONAL, is consumer config, empty by default and so inert on shell. A Rust consumer supplies theunwrap- andallow-class tokens. - Trailing inline comments are out of scope by construction, the false-positive-prone half of the judgment left to review.
- Not-yet-swept sources ride
CANON_KIT_COMMENT_WHITELIST, its array tagged# exception-list:and each# until:a live drain task percheck-gate-exemption-tasks.
precommit tier.
Declared by check-comment-tier.gate, dispatching to the binary. Its couples= carries *.gate as a bare glob beside the shell and workflow spellings, since the derived corpus scans both declaration forms. The implementation couples through knob:CANON_KIT_COMMENT_SURFACE, which carries whatever language a tree names there. A token naming this repo’s crate directory would publish one consumer’s layout into a kit file (gate-sdk/SPEC.md §The provenance seam) and be false for every other. The gate reads no regex engine: its roster is a set of literal 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 §<heading> citation on a governed manifest resolves.
The directive set is 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> on that section’s anchored shape and only after contract:, is a wire-format version its owning gate parses. It names no path, resolves as none, and is counted on the clean line, which keeps the form usable with no per-consumer whitelist entry; a spec: directive wearing the shape still resolves as a path. A directive’s target grammar is <path> [§<heading>]. <path>, repo-relative, must be a tracked file, and a §<heading> fragment must match a markdown heading in it; a pointer without § resolves file-only. The pass reds on a missing or untracked target, on a named heading the target lacks, and, fail-closed, on a directive matching the pointer shape with no target path.
The prose-citation pass holds a citation woven into a sentence (context-kit/SPEC.md §The memory-off doctrine) to the same standard as the directive and the markdown-link anchor. It scans the manifest set (spec_manifest_files, the manifest-narration family’s surface) over a blank-line-delimited paragraph join, so a heading wrapped across lines reassembles, and maps a hit back to its physical line. Both passes resolve through one shared 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.
The pass reads three citation forms:
- a
<path>.md §citation, the path bare or as a whole code span, resolved against that file; - a markdown link followed by
§, resolved against the link target from the citing file’s directory, its#anchorstripped; - a
§with no path, resolved against the headings of the whole manifest set, the citing file included.
An adjacent path that is not a tracked file is out of scope, since path liveness stays with check-md-refs and check-kit-ref-liveness, and a link target that is not a tracked file (a URL, an untracked path) drops to the third form. The third form’s intended file is not syntactic, because prose puts the owning path before the heading, after it or nowhere. So it asserts liveness and not provenance: the heading exists somewhere in the governed set, and a citation aimed at the wrong file that carries a live title passes, its finding reading “in no governed file”. A fragment not opening with a letter, a digit or a backtick is a placeholder (§<heading>) or the mark itself and never fires, and neither does a § inside a code span. In the third form one opening with an ASCII digit (0–9) never fires either, being a section-number mark (§3, §2.1); another script’s digit opens a heading.
Honest limits. A short heading is a prefix of many fragments, so the union under-reds around short titles. A heading opening with an ASCII digit (## 3. Terms) is checked only when cited with a path. It over-reds once: a citation naming a lead and running on with the heading’s own separator as prose is the separator bound’s.
A title names one section per file, where the consumer turns the rule on. Under CANON_KIT_SPEC_POINTER_TITLE_ONCE=on, two headings in one manifest file whose qualifier-stripped text is equal are red, at any levels, and the finding names the file, both line numbers and the title. A resolver takes the first match, so a pointer meaning the second binds to the first from the day it is written. The rule is off by default because ordinary prose repeats a title under two sections without being wrong (gate-sdk/SPEC.md §The install disposition); a citation is then held to the first match alone.
A lead clause names one section or none. A prose citation may name a heading by its lead clause, the text before its first comma, em dash or colon, since a sentence-shaped heading is cited by that clause and the fragment runs on into prose. The directive pass stays exact. Two bounds hold the lead. It is not admitted where another heading in the same file has the same lead clause, since a family such as Requirement: <name> shares a lead that names none of its members. Nor is it admitted where the fragment continues past it with the heading’s own separator, since a citation spelling the separator cites the whole heading and must match it whole.
A withheld target is not a dangling one, and the kit root tells them apart. A target <dir>/spec name whose <dir> resolves to a directory while only the SPEC file is absent is reported as withheld and counted on the clean line. The live case is a tree whose kits came from a payload that publishes that file rather than packing it (gate-sdk/SPEC.md §Consumer payload): the pointer is correct and its document reachable, as a shipped gate’s # spec: pointer already is there. The discrimination is narrow. A path naming a kit root that does not exist still dangles, a present target missing the named heading still reds, and no other absent file is excused. Rewriting such a pointer would treat a correct binding as the defect, and a published URL is no option, since a directive’s target is a repo-relative path and the grammar admits no URL form.
The vendored-root prune and the withheld case cover different halves. At the default a vendored kit root is pruned from this gate’s corpus, so a pointer inside a kit root is never scanned. A pointer into one from outside is the withheld case’s: the # contract: headers an installer seeds on the workflow directory’s tracked members (gate-sdk/SPEC.md §The workflow directory) are such a tier. A consumer setting CANON_KIT_SCAN_KIT_ROOTS=1 brings the pointers inside those roots into the corpus, and the withheld case does not silence a heading fragment they carry, its subject being the target file rather than the source.
check-comment-tier owns the directive’s shape, and this gate adds resolution: a renamed or deleted heading leaves every inbound pointer or citation dangling, otherwise caught only on review. A heading match tolerates a trailing (qualifier) on either side, so a pointer may narrow a section to a labelled point (§check-graph (assertion G) → the check-graph heading) and a heading may carry a locator the pointer omits (§Merging an amendment → ### Merging an amendment (on task completion)). Two § carve-outs keep the false-positive floor and are not to be conflated. A directive’s em-dash prose tail (spec: <path> §<h> — <gloss>) strips at the —, so a § in the gloss is no heading, while a free-prose § is a heading marker, read in the three forms above. Fenced code is skipped in both passes, since a quoted example is not a citation, and a fenced line of a markdown file is never a heading.
Calibration: forward direction only. The reverse, flagging a requirement with no inbound pointer, needs a notion of what counts as a requirement that fails the cheap-and-false-positive-free bar, so it is ruled out and no syntax is reserved for it. Nor does the gate judge ownership, whether a resolving section is the one carrying the cited claim: that is comprehension, and the one mechanical proxy measured flagged no wrong-section citation among its hits, so a red there would train readers to skip the gate. Green means the section exists, and review owns the rest. Configuration is shared with check-comment-tier, the whitelist predicate and the CANON_KIT_COMMENT_* knobs, plus one knob of its own, CANON_KIT_SPEC_POINTER_TITLE_ONCE. This gate scans the pruned comment surface, which drops the templates/ sources check-comment-tier keeps: 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. The tier gate still governs those comments by shape, so each gate draws the templates/ line where its own semantics put it. precommit tier.
The pointer is the only mechanized code-to-prose coupling. A basename-to-§<heading> naming convention derives a section’s name, never its liveness, so without an inbound pointer a renamed or deleted SPEC heading reddens nothing, and a site away from a file’s header binds a section no convention can derive. The gloss is already capped at the one-line binding (§check-comment-tier), where a gloss restating its section is deleted. The pointer also carries the citation coverage that keeps a dedicated script-to-doc citation gate unbuilt.
Declared by check-spec-pointer.gate, dispatching to the binary, its couples= widened to *.gate, the comment-surface knob and every tracked workflow member on the same terms as check-comment-tier’s. The gate reading a # spec: directive is itself declared by one, so a member of this family reads its own cohort’s descriptors, and 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. One sitting in Done is stale, the entry having left the live pool while the marker did not, and one absent from the queue is unresolved, a typo or an unfiled task. Both redden, since a marker referencing nothing is a dangling forward reference, the source-side twin of a stale blocked-by tag. The verdict does not turn on whether the work shipped: a Done line records an exit from the live pool, not a delivery (queue-kit/SPEC.md §The queue format).
check-comment-tier owns the marker’s shape, blessing TODO(task: as a reason directive, and this gate adds resolution, as check-spec-pointer does for spec:. It reads the source side of the liveness gap check-task-names (a blocked-by on a done slug) and check-gate-exemption-tasks (an # until: slug) hold elsewhere. The marker requires a resolvable slug after the colon, so a tool carrying the bare roster literal never self-matches. Full-line versus trailing placement is immaterial: resolution governs the referent, not the comment tier. Bare TODO, FIXME and HACK markers are out of scope.
The marker is a comment directive on the governed comment surface, so it is canon-kit’s; queue-kit disclaims source-file conventions. The gate scans the pruned comment surface, templates/ dropped as placeholders as for 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 (§The shared spec adapters). The task: <slug> binding is one grammar both liveness gates share: check-deprecation-task resolves it on a deprecation marker through the same adapter. A queue-read failure exits 2. precommit tier. Declared by check-todo-task-liveness.gate, dispatching to the binary, its couples= widened to *.gate, the comment-surface knob and .workflow/*, the tier the corpus 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, one regex per element matched against the governed comment surface. It ships empty, so a repo setting none is clean-skipped and each consumer names its own #[deprecated], @deprecated or @Deprecated. A language the derived surface does not read joins through CANON_KIT_COMMENT_SURFACE. The descriptor declares # armed-by: on the roster, so doctor names a registered member still skipping (gate-sdk/SPEC.md §The install disposition). A linter already inventories deprecation markers; this gate adds the coupling none ships, since a deprecated surface naming no live decommission task has nothing tracking its removal.
A marker carrying no task: <slug> is unbound, one whose slug sits in Done is stale, and one whose slug is absent from the queue is unresolved. All three redden, each finding naming the file, the line, the matched marker and the slug. The binding grammar and the queue resolution are check-todo-task-liveness’s, over the same queue_slugs adapter (§The shared spec adapters), the same pruned comment surface and CANON_KIT_QUEUE_FILE, with no knob beyond the roster. An unreadable queue or source exits 2; an empty roster is the clean skip.
Declared by check-deprecation-task.gate, dispatching to the binary, with its twin’s widened couples=. The roster is joined into one alternation and compiled through the crate’s POSIX ERE matcher (gate-sdk/SPEC.md §The POSIX ERE matcher), and a pattern the engine refuses is reported under the knob’s name.
A tree setting no roster gets the clean skip, so the fixture pair and check-deprecation-task.test.sh carry the resolved and reddened paths under a fixture-local roster. The release-boundary disposition walk over the marker inventory (decommission, re-justify and carry the task forward, or un-deprecate) is lifecycle-kit’s release-sweep template, and the backlog trend over the same roster is drift-kit’s kpi-deprecated-surface member (lifecycle-kit/SPEC.md §templates/release-sweep.md, drift-kit/SPEC.md §Bundled KPIs). 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 |
The defect it catches, an always-loaded surface asserting a directory committed while part of it is gitignored, is invisible to check-md-refs and check-spec-pointer: they resolve paths and headings, and neither reads what a sentence claims about a path.
Resolution. A directory expands to its members, through git ls-files for the tracked side and git check-ignore --no-index on the path for the ignored side; 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 lists only files that exist, so it verifies a claim locally and reds it in CI. --no-index is load-bearing for the mixed predicate: plain check-ignore never reports a directory with a tracked member as ignored, and a two-tier directory always has one. The mixed predicate is what gives an honestly mixed directory a true sentence. Its rule-based proof reaches only a directory a rule matches whole (a dir/ pattern) that also carries a tracked member. A directory whose ignored members only file patterns match has no such proof and stays a prose description.
Reddens on a predicate whose verification fails, and, fail-closed, on a bound path 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). No new knob: the predicate vocabulary is kit-owned generic English. Fenced code is skipped, since a quoted example is not a claim. A predicate inside an inline code span is a meta-reference, not an assertion, which lets this section name its own vocabulary.
The calibration, and the gate’s honest limit, is forward direction only, fixed vocabulary only, adjacency only. A claim phrased any other way (“the workflow directory ships in the repo”) stays a review concern, on check-spec-pointer’s forward-only bar: “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. A clause between unbinds it, so `core-files.list` manifest exists in the worktree and is tracked is not a claim. That deliberate under-detection buys the false-positive floor, and the fixture pair covers both directions.
It is a regression gate for a defect the always-loaded tier already shipped, not a discovery tool (gate-sdk/SPEC.md §When a gate earns its place). No per-site valve is taken: a claim that cannot be made true must be reworded, and a valve would restore the unverified prose the gate exists to end. Tier precommit; the # graph: manifest couples the manifest set and .gitignore.
check-task-path-claim
Invariant: every ticked task in a configured task list names only paths that exist. A ticked task is a done claim, and a path it names that the tree does not hold is a phantom completion. A task list is a markdown file CANON_KIT_TASK_LIST_GLOBS matches, walked through the pruning walk. A ticked task is a list item whose marker, -, * or +, is followed by [x] or [X]. Its text is the item’s first line and its continuation lines: each following line indented past the marker, up to a blank line, a list item or a fence. Fenced code blocks are skipped, since a quoted example is not a claim. An unticked task claims nothing and is not read.
A path token is a maximal run of [A-Za-z0-9._/@+-] in the task’s text, inline code spans included, with a trailing . dropped. It must hold a / that is not its first character, and either end in / or carry a final segment with an extension, . then one or more alphanumerics. A token is not a path when it opens with / or follows a ~, holds // (so no URL survives the alphabet’s : cut) or a .. segment, or touches [, {, < or * on either side, since that is a placeholder. A path token resolves from the repository root, and it holds when a file or directory is there. That is existence, not behaviour: a path that exists says nothing about what the file does.
Reddens on each path token of a ticked task that does not hold, naming the file, the line, the token and the task’s text. The help: names the three remedies: restore the path, correct the task, or untick it. Valve: task-path-exempt: <reason> on the task’s line or the one above, in the shared exempt window (§The shared spec adapters), for a task whose path is absent by design, such as one that deleted it. The reason is mandatory, and a valve without one exempts nothing.
Declared by checks/check-task-path-claim.gate, # install: zero-config, # armed-by: CANON_KIT_TASK_LIST_GLOBS, dispatching to the binary. Its couples= carries knob:CANON_KIT_TASK_LIST_GLOBS for the list and * for the tree its tokens resolve against. An empty knob is the clean skip, and its clean line says no task list is configured. The good/ case holds a ticked task naming an existing file and one naming an existing directory, an unticked task and a fenced example naming absent paths, a placeholder token and an exempted task. The bad/ case holds a ticked task in each marker case naming an absent file. Tier precommit.
check-task-label-resolution
Invariant: every label a task list cites resolves to a definition in a markdown file beside it, such as a user-story label naming a story the spec beside the list defines. A label family is a key of CANON_KIT_TASK_LABEL_CITES and of CANON_KIT_TASK_LABEL_DEFINES. Each value is an ERE holding exactly one group, the one-group capture shape gate-sdk/SPEC.md §The POSIX ERE matcher admits. The group’s text is the label. Across each task list (§check-task-path-claim), fences skipped, every match of a family’s cite pattern cites its label. The line is matched whole: each leftmost-longest match is found in turn, and the group is captured within that match’s span. The label resolves when some line of a *.md file in the task list’s own directory, the task list included, matches the family’s define pattern with the same group text. The directory is read as a single-level listing, never walked.
Reddens on each unresolved citation, naming the file, the line, the family and the label. Valve: task-label-exempt: <reason>, in the shared exempt window, reason mandatory. The two knobs’ key sets differing, or a pattern outside the one-group shape, is malformed config, which the table validator refuses at exit 2 naming the knob and family (§Layout and configuration). The crate’s unit tests hold that exit-2 path, which a bad/ case held to exit 1 cannot express.
Declared by checks/check-task-label-resolution.gate, # install: zero-config, # armed-by: CANON_KIT_TASK_LABEL_CITES, dispatching to the binary, with knob:CANON_KIT_TASK_LIST_GLOBS and *.md in couples=. It also asserts nothing while CANON_KIT_TASK_LIST_GLOBS is empty, which check-task-path-claim’s declaration already names to doctor. The pair: good/ holds a family whose citations resolve in a sibling file, two on one line, and a fenced citation naming an undefined label; bad/ holds an unresolved citation. Tier precommit.
check-spec-fence-balance
Invariant: no governed markdown file ends inside an open code fence, read on the fence reader (§The shared spec adapters). The fence-skipping parsers across the kits (check-spec-embedded-source, check-tag-lead-line, the queue scanners) step that same reader line by line. An unclosed fence reads the rest of the file as its body, so every later finding silently fails open, and this gate turns that hole into a red. The guarantee holds because the gate and those parsers share one reader: a file this gate passes desyncs none of them, a list-item fence or a shorter run inside a longer fence included. The surface is the manifest set (spec_manifest_files) plus CANON_KIT_QUEUE_FILE, since check-tag-lead-line and check-queue-wrap scan the queue the manifest set excludes; no new knob.
Red names each file with the line of the opener left open. The help: line is close the fence with a backtick run at least as long as its opener’s, or delete the stray opener. An unreadable file exits 2.
Honest limits. A tilde fence is outside this gate’s reader, so an unclosed ~~~ block is not reported. check-md-unwrapped and check-docs-render-fidelity model fences in scanners of their own, which already match a closer to its opener.
check-md-refs
Invariant: every internal markdown link in the governed doc set resolves. A relative-path target, based at the source file’s directory, 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. That slug is the heading lowercased, every character but a letter, digit, space, - or _ dropped, and each space a hyphen, so an em dash between spaces leaves two. External URLs (scheme://, mailto:) are out of scope, since the network is not a gate dependency. A grep error fails closed (exit 2).
The doc set is the manifest set, CLAUDE.md included and 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, the finder’s tracked set (§The shared spec adapters). An untracked local-only file (a *.local.md) is so a legitimate link source that is never scanned, and, being git-ignored and present, a legitimate target that resolves without being tracked. Links are this gate’s charge. The sibling check-docs-cmd takes the commands and env knobs written inside fences and backticks, over the same doc set with no second knob.
The link extractor is purely syntactic: it matches a ] immediately followed by a parenthesized target without stripping code spans, so a literal markdown link in governed prose is scanned as a real link even inside backticks. To name such a link in prose, separate the ] and the ( with a space. An amendment SPEC-*.md escapes only by lying outside the doc set, not by any code-span exemption.
The self-repo blob-link pass. An absolute link is out of scope with one exception: a link into this same repository’s GitHub tree, which resolves against the working tree as a relative link does. The repo identity is derived at runtime from git remote get-url origin through the shared gate_self_repo_prefix adapter (gate-sdk/SPEC.md §lib/gate.sh), so this resolver 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 and CI’s checkout and a local clone alike supply it. A link prefixed <identity>/blob/<CANON_KIT_DOCS_BLOB_REF>/ (ref default master) is a self-repo reference link: its <path> must be a tracked file, and a trailing #anchor must slug, under the same rules, to a heading in that file. A repo with no origin skips the pass, since a self-repo link cannot be identified, and such a link falls through to the external-URL skip. The ref is a knob rather than a literal because it is a policy choice: the site documents the current tree, so a reference link pins to the default branch, and a tag-pinned copy would buy staleness rather than stability.
Coverage the good/bad pair cannot hold rides gate-tests/check-md-refs.test.sh, since the harness resolves one good/ and one bad/ case dir per gate and this pair is spent on core link resolution. The self-repo pass is the first behavior there: each case builds a throwaway repo with a controlled origin, which a case dir inside this repo cannot. The second is the kit-root prune on the manifest set’s README half, whose two cases differ only in CANON_KIT_SCAN_KIT_ROOTS over one tree, a shape the one-tree-per-verdict pair cannot express. That half is the prune’s only executable oracle. A kit-authoring tree sets the knob, so its battery never exercises the prune, and a vendoring consumer’s knob is 0 either way, so no consumer-smoke run tells a knob-gated prune from an unconditional one. gate-tests/check-spec-dod-singleton.test.sh carries the prune’s canonical-spec half for the same reason. The third is the finder’s tracked set: an untracked README.md with a dangling link is unscanned, and reds once staged, a state no tracked case dir can hold.
check-md-unwrapped
Invariant: in the governed markdown set, no paragraph is broken across physical lines. Each paragraph, list-item paragraph and block-quote paragraph is one line, however long. A consumer that wraps registers queue-kit’s check-queue-wrap instead, and one that does neither registers neither: the convention is the tree’s choice, and this gate is the oracle for one of them.
The block scanner. A line is a soft break, and red, when it is non-blank, opens no block, and follows paragraph text in the same container. A line opens a block when, after indentation, it is one of:
- an ATX heading, a fence delimiter, a table row (
|-led), or a thematic break or setext underline (---,***,___,===); - a list-item marker (
-,*,+, or<digits>./<digits>)) followed by a space, at any indent; - a block-quote marker
>whose quoted content itself opens a block; - an HTML block start (
<followed by a tag name,!--or/), or a link-reference definition ([label]:); - a line whose content, after any block-quote markers, starts with a member of
CANON_KIT_UNWRAP_DECLARATION_LEADS.
The interiors of fences, HTML blocks and a leading ----delimited front-matter block are outside the grammar; a comment runs to its -->, any other HTML block to a blank line. A line after a hard break (two trailing spaces, or a backslash outside code) is deliberate and exempt. Inside a block quote, the comparison is made on the quoted content. The scanner is a CommonMark subset. An indented code block is read only where no list is open, and lazy continuation inside nested containers joins the innermost open paragraph, so a non-indented line under a list item continues that item. Anything the subset does not model is an honest limit, and the fixture pair pins what it does model.
A declared lead is the one block start taken from configuration rather than from CommonMark. CommonMark joins such a line into the paragraph above unless the line before ends in a hard break, so there the gate asserts what a line-start reader needs, the reader the lead was declared for, rather than what a renderer does.
Corpus. Tracked files matching CANON_KIT_UNWRAP_GLOBS less those matching CANON_KIT_UNWRAP_EXCLUDE, with git pathspec semantics, both default empty. An empty include set is a clean skip whose clean line names the unset knob, since which files a tree keeps unwrapped is its editorial choice. Gate-test fixtures are the canonical exclusion, a fixture’s line shape being the input under test. The roster is the index’s and each file is read off the worktree, so a path deleted in the worktree and not staged exits 2: a commit splitting a unit keeps its worktree equal to its index.
Findings name file, line and the first code points of the offending line, and the help text prints the arm’s command. Fail-closed: an unreadable file or an unresolved knob exits 2.
--emit md-unwrap [--write] <file>… is the gate’s remedy, a non-gate arm sharing its scanner (gate-sdk/SPEC.md §The non-gate arm). Each soft break is joined onto its predecessor with one space, the continuation’s indentation, or its > prefix inside a quote, dropped. The arm prints the rewritten text, or with --write rewrites each file in place, checked, naming a failed path at exit 2. Its postcondition is the gate’s clean verdict on its output, which a unit test holds over both fixture trees. It reads the gate’s knob, so a join never crosses a blank line, a block start, a declared lead or an exempt interior. Only a declared lead’s own line can render differently from its source, and a hard break on the line before it keeps even that one unchanged.
A tool that needs a line-capped form reads one generated from the uncapped tracked file, rostered and freshness-gated as every generated projection is, never a hand-wrapped tracked copy.
The width-straddling readers stay. check-manifest-count’s wrapped-paragraph arm, check-unmarked-claim’s flattened paragraph, check-spec-pointer’s prose-citation join and check-scratch-citation’s paragraph accumulator each cross a line break so a wrapped token still matches. Over an unwrapped corpus each is the identity, and a kit gate may not assume its consumer’s convention.
The reference-link grammar
A docs-site page cites two kinds of in-repo target, and the grammar splits on which. Both are the tiering topology’s downward-citation shape, one on-site and one off.
- A rendered-document reference (a
SPEC.md, its siblingREADME.md, or the doctrine deliverable) cites the on-site mirror of that document relatively when the site publishes one. The mirror is a generated, freshness-gated projection under<mirror root>/<dir>/(CANON_KIT_MIRROR_ROOT, §Layout and configuration) that keeps reference reading on the served site. It preserves the documents’ cross-citation topology one-to-one, so a page uses the relative shape the source tree uses. Absent a published mirror, these references take the source references’ blob form: the mirror is a site’s opt-in, not a precondition of the grammar. - 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. A relative link into the unrendered tree would 404 on a site servingdocs/alone, andcheck-docs-link-convention’s off-root rule holds that prescription. Resolving the blob form ischeck-md-refs’ self-repo pass, where the identity derivation and the ref knob live.
The mirror’s corpus is every top-level directory holding a SPEC.md, not every kit root. The name is literal, not CANON_KIT_SPEC_NAME. A directory joins by acquiring one, kit or not, and the freshness gate’s coupling declares that corpus rather than a per-kit expansion, which would leave the wider half ungated.
The emitter wraps each mirrored body in a Liquid raw block, its two tags inside HTML comments. A Jekyll site runs Liquid over every page before markdown, and a source is prose rather than a template, so an Actions expression quoted in a SPEC would render blank or, unbalanced, fail the site build. A source carrying the raw block’s own closing tag would end the block early, so the emitter refuses it, fail-closed.
The emitter also renders each section citation in a mirrored SPEC or the doctrine deliverable as a link, resolved through §check-spec-pointer’s reader. The citation’s text becomes the link text, and the target is the cited file’s mirrored or blob form with the heading’s anchor. A citation with no path resolves to the citing file first, then to the one other governed file holding the heading. One held by several files or by none stays plain, as does one already inside a link. A mirrored README is copied as it stands, its citations being hand links §check-citation-link holds. A cited heading may sit in any markdown file, so the freshness gate couples every one. The honest limit is §check-spec-pointer’s: a path-less citation of another file’s section whose title the citing file shares links the citing file’s own, and naming the path before the § pins it.
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. Resolving those links is check-md-refs’ charge, as is resolving an absolute reference link through its self-repo pass, so this gate turns only on the relative links that stay inside the tree. Each rule is scoped to the docs tree, where the kit page it turns on (<root>/<kit>/index.md) exists:
- No directory-target link. A relative link whose target names a directory (a trailing
/, or a path resolving to a tracked directory) must name the file,kit/index.mdand neverkit/. A bare directory link is ambiguous about what the page cites and defeats anchor-level citation. - Anchored kit back-links. On a kit page, a link back to that kit’s own
README.mdor canonical spec (CANON_KIT_SPEC_NAME) must carry a#sectionanchor: a page cites downward into a named section, never the whole spec. - No off-root relative link. A relative link whose target resolves to an existing path outside
CANON_KIT_LINK_ROOTmust cite the absolute self-repo blob form (§The reference-link grammar). The rule turns on the resolved path, not the link text, so a../link resolving back under the root is silent. A target resolving to nothing ischeck-md-refs’ finding alone, and a directory target outside the root is reported once, by the no-directory-target rule. Generated mirror pages (generated: truefront matter) are in scope: their conformance is their generator’s, which can regress, and they are the corpus’s larger half.
The scanned tree is CANON_KIT_LINK_ROOT (default docs; the fixture pair passes a synthetic tree positionally), walked for every *.md. The link extractor is check-md-refs’ bracket-then-paren match. scheme:// and mailto: targets are out of scope, and a pure #anchor satisfies neither rule. Valve: a docs-link-exempt: <reason> HTML comment on the link line or the one above suppresses that finding under any of the three rules, for the rare directory link a consumer’s layout demands. A missing scan root exits 2.
Each rule carries its own help: line, printed only when that rule fired, and the valve’s prints on any red (gate-sdk/SPEC.md §Output contract). The adopter registering a docs-host gate is the one likeliest to write an off-root link to its own repo root, and a first red answered with all three conventions reads as a false red.
The walk is unpruned, deliberately, so a docs tree with a directory named like a prune entry is still scanned whole. Pages are ordered by bytes, and target resolution is lexical, which agrees with a filesystem resolution on a tree with no symlinked docs directory. 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 in a fence, every backticked or fenced kit-prefixed env knob, and every inline-span path citation in the governed doc set resolves against the tree, or names no path the tree has retired. It is the command, knob and citation analog of check-md-refs: a broken bash <path> line, a retired knob or a citation of a deleted file otherwise drifts silently. Three assertions:
- (A) invoked command paths. Inside a fence, a
<dir>/…/<name>.shpath in invocation position must resolve to a tracked file. Invocation position is the first word of a;,|or&&-separated segment, or the first non-flag argument when that word isbash,sh,sourceor.. The path is tried doc-directory-relative first (a kit SPEC’s ownbin/x.sh), then repo-root-relative (a cross-kitgate-sdk/bin/x.sh). A path in argument position, such as acp templates/x.sh scripts/x.shinstall destination the consumer creates, is never a finding, the failure mode being a broken invocation. gate-sdk’scheck-action-run-pathapplies the same predicate torun:bodies by calling this gate’s holder, so a change to the scoping changes that gate’s too (gate-sdk/SPEC.md §check-action-run-path). - (B) env knobs. A backticked or fenced ALL-CAPS name carrying a kit prefix must occur in the kits’ tracked code, their shell sources and config templates, never their prose, so a knob named only in markdown cannot self-satisfy. The prefix roster is each
gate_kit_rootsmember’s basename uppercased, hyphens to underscores, with a trailing_(gate-sdk→GATE_SDK_). The corpus is the union across all kits, since a dependent kit may read a namespaced knob (GATE_SDK_NATIVE_BINin other kits’ smokes): the prefix marks scope, not location. A family stem, a caps run ending_before a placeholder or glob (EVIDENCE_KIT_RUN_<suite>,CANON_KIT_COMMENT_*), resolves when any code name extends it. Names with no kit prefix are out of scope. A static kit’s knob names leave kit-root code with its library, so the set also takes, from the binary’s table in process, the static knob roster’s names (gate-sdk/SPEC.md §The non-gate arm,--emit knob-roster), each static kit’s<KIT>_KNOB_FILElocator, and the retired<KIT>_CONFIG_FILEits legacy refusal reads.check-kit-ref-livenesstakes the same union. -
(C) retired cited paths. Outside a fence, a path-shaped token in an inline code span reds when it names a path the repository has retired and tracks under none of its resolutions. A token is path-shaped, after (A)’s quote and punctuation trims and a leading
./stripped, when it has two or more/-separated segments of[A-Za-z0-9._-], an extension on its final segment, and no... A single-segment token matchingCANON_KIT_AMENDMENT_GLOBis path-shaped too and resolves by basename against the whole tracked tree, an amendment being cited by bare name: it reds when no tracked path carries that basename and a retired one did. Every other token’s resolutions are the doc’s directory, the repo root, and eachgate_kit_rootsmember, so a kit-relative citation such aslib/gate.shresolves as its reader resolves it. A token resolves when any resolution is a tracked file or a directory holding one.The retired set is every path deleted in the history this clone holds (
git log --no-renames --diff-filter=D, so a rename retires its old name), plus every path the index deletes againstHEAD. The second half reds the deleting commit at pre-commit; an unbornHEADhas retired nothing. A token resolving nowhere and naming no retired path is admitted: a hypothetical, a consumer-side file or a gitignored capture stream is not a stale identifier.History is admitted where
check-manifest-temporaladmits it, through its three valves and no fourth: amanifest-temporal-exempt: <reason>marker on the line or the one above, a section named inCANON_KIT_TEMPORAL_EXEMPT_SECTIONS, or a file matchingCANON_KIT_TEMPORAL_EXEMPT_PATHS. Naming a retired path or knob is narration about the past, which that gate rules, and the valves are read through its own line classifier so neither gate can disagree with the other. On a valved line (C) admits a retired path, and (B) admits a retired knob, a name some held commit carried in a tracked path outside markdown and the fixture trees. A name never carried still reds there, and a valved line’s fenced invocations stay scanned. In a flowing paragraph the marker rides a line’s end as inline HTML, since a line-leading<!--opens an HTML block that ends the paragraph; one marker covers its own line and the next.(C) reds on retirement and (A) on absence, deliberately. An invocation of a never-tracked path is a broken command, while a citation of one is usually a hypothetical, a consumer-side file or a capture stream, which a valve would record nothing true about. Admission never turns on a line’s age against the deleting commit, because the deleting commit is the one that rewrites the paragraphs citing what it deleted. A historical mention names the path, not the commit, since a kit SPEC states its rules undated. A shallow clone reads less history: (C) then reports fewer findings and no false ones, while (B) admits fewer retired knobs, so a removed knob on a valved line reds until the history is fetched. gate-sdk’s workflow template fetches full depth for history-reading gates, and the clean line names the shallow case. The same dependence keeps (C) and (B)’s admission out of the
good/+bad/pair, which runs inside whatever repository vendored it, so a retirement case builds its own history in the bespoke test.
The governed doc set is check-md-refs’, the manifest set minus CANON_KIT_MDREF_EXCLUDE, with no gate-specific knob. Prose outside fences and code spans is never scanned for paths. A hypothetical invocation is written outside a fence, or its doc joins CANON_KIT_MDREF_EXCLUDE; a hypothetical citation needs nothing, a never-tracked path not being retired. A path outside every code span stays unscanned, a residue the close-stage audit roster owns. The knob, tracked and retired sets come from repo-root-anchored git reads, so they hold when the fixture runner invokes from a case directory. No git repository, or an erroring git read, exits 2. The # graph: manifest couples the doc set to scripts/*.sh and every kit’s shell sources (kit:*.sh), so a script rename, a knob retirement or a valve edit re-fires the gate. The two valve knobs take no knob: token: one narrows the corpus and the other names headings, and neither is a walk filter’s pattern set (gate-sdk/SPEC.md §The # graph: manifest).
check-docs-restatement-parity
Invariant: every arm token and kit-knob token a declared restating page carries in its code still occurs in the page’s source. A restating page — a site’s per-component landing page that repeats the component README’s runnable line — is generated by nothing, so a README edit leaves it silently behind.
Remove first, then gate what remains. A restated step told in prose is not gated: it is removed, and the page points at the source’s own section instead. What a page cannot drop without losing its purpose — its one runnable line — is what this gate holds. Generation is the wrong remedy here, because it copies the restatement rather than removing it and would need a per-page map of which source section and which subset a page restates.
- Corpus: files matching
CANON_KIT_RESTATEMENT_PAGES, an array of globs expanded like every canon-kit glob knob, default empty. An empty knob, or globs matching nothing, is a clean0 page(s): the pages are the consumer’s list, and an empty expansion under a set knob shows on that line. - Source: the
README.mdin the page’s own directory. The pairing needs no layout knowledge beyond the page glob. Under the reference-link mirror topology (§The reference-link grammar) that sibling is the generated mirror of the kit-root README. A declared page with no source beside it is exit 2, naming the page, since a declared restating page with no source is a configuration error rather than a pass. Abad/fixture is held to exit 1, so the module’s unit test asserts this arm. - Tokens: read only from the page’s code: inline code spans and fenced lines. Prose outside code is not scanned, because a narrative mention of a flag is not a restated command. There are two classes. An arm token is
--, then a lowercase letter, then[a-z0-9-], not glued to a preceding word character. A kit-knob token is matched by §check-docs-cmd (B)’s matcher, reused rather than copied: a caps run carrying a prefix derived from the kit roots. - Assertion: each page token occurs in its source as a whole token of the same class. The source is read whole, prose included. Whole-token matching is what stops
--runfrom being found inside--run-gate-tests. A page may carry a subset of its source’s tokens; the gate asserts only that what the page names is still named there.
Red is one finding per missing token, naming the page, the line, the token and its source. The clean line counts pages and tokens, so an armed corpus whose pages carry no code tokens reads 0 token(s) rather than passing unseen. tier=precommit: a drift is restorable in the commit that causes it, by editing the page. The descriptor couples knob:CANON_KIT_RESTATEMENT_PAGES and */README.md, the sources. install: zero-config: the empty default scans nothing.
The honest limit. The gate catches a renamed or removed arm or knob. It cannot catch a changed install step told in prose, which is why such prose is removed rather than left for a gate that cannot read it.
check-docs-page-repeat
Invariant: no declared page states a relative link target, or a sentence of CANON_KIT_PAGE_REPEAT_MIN_WORDS words or more, twice.
- Corpus: files matching
CANON_KIT_PAGE_REPEAT_PAGES, an array of globs expanded like every canon-kit glob knob, default empty. An empty expansion is a clean0 page(s). - Prose only. Fenced blocks, HTML comments, the front-matter span (§The shared spec adapters) and table rows (a line whose first non-blank character is
|) are skipped. A table is a record set, and a registry table links one target per row by design. - Arm A — a repeated link. The link extractor is §check-md-refs’ bracket-then-paren match. A target with a scheme, a
mailto:target and a pure#anchorare out of scope. The target is compared as written, path and fragment together, after a leading./is dropped, so two sections of one page are two targets. - Arm B — a repeated sentence. A prose line is split into sentences after a
.,!or?followed by whitespace. A link reduces to its text and a heading, list or quote marker is dropped. Case and runs of whitespace are folded. A sentence of at leastCANON_KIT_PAGE_REPEAT_MIN_WORDSwords (default8) that occurs again on the page is a repeat. - Valve:
page-repeat-exempt: <reason>on the later occurrence’s line or the one above, in the shared exempt window (§The shared spec adapters). The reason is mandatory: a valve without one exempts nothing, and the finding names it.
Red is one finding per later occurrence, naming the page, its line, the first occurrence’s line and the repeated target or sentence. Each arm carries its own help: line. A link’s is link the first mention and make the later one plain text or an in-page anchor. A sentence’s is state it once and point back to it. The clean line counts pages, links and sentences. Exit 2: an unreadable page, or a CANON_KIT_PAGE_REPEAT_MIN_WORDS that is not a positive integer. tier=precommit, install: zero-config, armed by CANON_KIT_PAGE_REPEAT_PAGES.
Deliberately not asserted: a page-grain link rule, or a repeat across pages. A page citing several sections of one README downward is the anchored shape §check-docs-link-convention requires, so the grain is the target, fragment included. A restatement across pages is usually a paraphrase, which no scanner decides.
check-docs-page-length
Invariant: no declared page runs past CANON_KIT_PAGE_LENGTH_MAX_WORDS words.
- Corpus: files matching
CANON_KIT_PAGE_LENGTH_PAGESless those matchingCANON_KIT_PAGE_LENGTH_EXCLUDE, each expanded like every canon-kit glob knob. An empty expansion is a clean0 page(s). - The measure is the page’s whitespace-separated tokens on every line after the front-matter block: words, never lines, since unwrapped prose puts a paragraph on one line and a line count reads a long page as short. HTML comments are skipped. Table rows and fenced blocks count, since a reader scrolls them, and a comment marker inside a fenced block is the block’s text. A block is the fence reader’s and the front-matter block the front-matter span’s (§The shared spec adapters), so a page whose opening
---nothing closes is counted whole. - The bound is
CANON_KIT_PAGE_LENGTH_MAX_WORDS. Which length is too long is the consumer’s calibration. With the boundoffthe gate prints a clean line saying nothing was asserted. - No valve. A page over the bound is shortened or split. A generated page, whose length is its source’s, is left out through
CANON_KIT_PAGE_LENGTH_EXCLUDE.
Red is one finding per page over the bound, naming the page, its count and the bound. The help: line is shorten the page, or split it into a parent and sub-pages. The clean line counts the pages and names the longest with its count, so the headroom is readable without a red. Exit 2: an unreadable page, or a bound that is neither a positive integer nor off. tier=precommit, install: zero-config, armed by CANON_KIT_PAGE_LENGTH_PAGES.
Honest limits. A word count is not reading time (§check-prose-bounds). A split moves length and removes none, and what goes on which page is review’s. A page whose words sit in comments passes.
check-citation-link
Invariant: on a declared page, every section citation sits in a link’s text, and a link whose text carries a citation targets the section it names.
- Corpus: files matching
CANON_KIT_CITATION_LINK_PAGES, an array of globs expanded like every canon-kit glob knob, default empty. An empty expansion is a clean0 page(s). - A citation is a
§§check-spec-pointer’s prose-citation reader finds in one of its three forms, read through the same reader, so this gate and that one cannot disagree on what a citation is. Form is shape alone here: an adjacent.mdpath is a citation whether or not the tree tracks it, since resolution is §check-spec-pointer’s charge. The placeholder rule holds for every form, and the code-span and section-number rules for the bare form. Fenced blocks, HTML comments, the front-matter span (§The shared spec adapters) and generated<!-- name:begin -->…<!-- name:end -->regions are skipped, the last because a region is its emitter’s to fix (§check-prose-tells). - Arm A — an unlinked citation. A citation whose
§lies outside every link’s text is a finding. A link followed by§is one, since its heading is not linked. Admitted: a later mention, whose text at the citation’s start begins with the text of a citation link ending earlier on the page. The compare drops backticks and folds whitespace, and the character after the match is no letter or digit. That is the plain-text later mention §check-docs-page-repeat requires. - Arm B — a link that misses its section. A link whose text carries a citation must carry a
#anchor. The anchor must be the heading slug of the link text after the§(up to the next§), or begin with that slug and a-. The second form admits a citation naming a heading by its lead clause, as §check-spec-pointer admits it. The slug is §check-md-refs’ heading slug. Whether the anchor exists is §check-md-refs’ finding and is not repeated here. - Valve:
citation-link-exempt: <reason>on the citation’s line or the one above, in the shared exempt window (§The shared spec adapters). The reason is mandatory, and a valve without one exempts nothing.
Red is one finding per citation, naming the page, the line and the citation. Arm A’s help: line is make the citation a link to its section, printing the link form with the anchor on its target. Arm B’s is point the link at the section its text names. The clean line counts pages, linked citations, admitted later mentions and valved citations. Exit 2: an unreadable page. tier=precommit, install: zero-config, armed by CANON_KIT_CITATION_LINK_PAGES.
Deliberately not asserted: the link target’s file against the citation’s path. A README targets SPEC.md, a docs page the mirror, and an off-site citation a blob URL, so agreement needs a resolution per target shape. The anchor is where a renamed section leaves a stale link.
check-fence-command-head
Invariant: every command in a bash, sh or shell fence of the governed doc set starts with a word that can run when the fence is pasted, as written, into a shell at the repository root. A reader copies a shell fence whole, so a line opening on an arm’s flag, the binary’s name left to the prose above, fails at the reader’s prompt. check-docs-cmd (A) resolves an invoked script path and says nothing of a head that is no path; this gate is the other half.
Command position is the crate’s shell scanner’s, the one the port report reads (gate-sdk/SPEC.md §port-blockers). A head is the first word of each command after ;, &, |, &&, ||, a newline, or a subshell or group opener, and after a resuming reserved word (if, then, do, !, …) or an assignment prefix. A heredoc body, a case pattern, a continuation line and a quoted word are never a head; a command inside $(…) is, judged like any other. A head runs when it is one of these, in order:
- a word beginning with
-never runs: a flag names no command, and no valve below admits it; - an expansion — a
$-led or backquoted word, quoted or bare — whose value the fence’s reader holds, so"$gates"and"$(tool)"both run; - an elision (
...,…) or a placeholder (<name>); - a shell builtin or reserved word, the bash set, a literal of the shell’s grammar rather than config;
- a function the fence defines (
name()orfunction name) or a file it sources defines. A.orsourceoperand resolves like a path below, its${NAME:-default}read as the default, the value a tree that sets no locator runs with; an operand still an expansion after that contributes nothing; - a configured program:
CANON_KIT_FENCE_PROGRAMSfollowed byCANON_KIT_FENCE_PROGRAMS_EXTRA(§Layout and configuration). A rooted or~/path is judged by its basename against the same set, since its location is one machine’s; - a tracked path: a relative word carrying a
/that names a file the index tracks, tried against the doc’s directory first and the working directory second, the (A) order. Tracked rather than present, so an untracked build output that happens to exist in one clone reds in every clone alike.
Anything else reds, a bare program name the configured set lacks included, which is what catches a binary named by its basename with nothing putting it on PATH. A copied prompt ($ cmd) reds by the same rule, because the $ is the head.
The fence shape is the fence reader’s (§The shared spec adapters), shared by every reader of this gate’s fences, check-fence-paste-unit and check-fence-run among them. The info string’s first word, case-folded, is the fence’s language.
The valve is the info string. A fence written to be read rather than pasted — output, a transcript, a grammar sketch — takes another language (text, console), which is also what renders it truthfully; there is no line marker and no path knob. The corpus is check-md-refs’ governed doc set, the same one (A) reads, and the one knob pair is a vocabulary rather than a walk filter, so it takes no knob: couples token (gate-sdk/SPEC.md §The # graph: manifest).
What it does not attempt. A head that runs is not a command that succeeds: the gate never executes a fence, so a wrong operand, a missing environment and a variable the fence reads but never assigned all pass. A command led by a redirection (>out cmd) is not judged, because the scanner leaves command position at the operator. Executing a fence is check-fence-run’s, for the fences a doc marks runnable.
Red names each doc, line and head with the rule it failed; the clean line counts docs, shell fences and heads, so an empty corpus shows as zero rather than passing silently. The tracked set is one repository-root-anchored git ls-files, and a failed read, or no repository, exits 2.
check-fence-run
Invariant: every shell fence a governed doc marks runnable passes a narrowed static pass, then runs, and exits with the status it declares. check-fence-command-head shows only that a head can run, so a wrong operand or an unassigned variable passes it; this gate executes the fences a doc opts in. It is tier=align-only (gate-sdk/SPEC.md §The # graph: manifest), running in the full battery and never in the pre-commit hook.
The marker. A <!-- fence-runnable --> line immediately above a bash, sh or shell fence’s opening line marks it runnable. <!-- fence-runnable: exit=<n> --> declares a non-zero status for a fence showing a failure, n a decimal from 0 to 255, defaulting to 0. An unmarked fence never runs, so a tree marking nothing executes nothing. A misspelled marker that silently disarms is the failure a marker exists to prevent, so the marker is judged strictly, and each of these reds:
- a marker whose next line is not a
bash,shorshellfence opener; - a marker operand other than
exit=<decimal>; - a line that looks like the marker and misses the grammar — one whose text, after indentation, opens
<!--, optional space, thenfence-run.
A marker-shaped line inside a fence is fence content, and prose naming the marker mid-line marks nothing. check-fence-command-head treats a marked fence as any other, the comment sitting outside the fence body. The corpus is check-md-refs’ governed doc set, which both fence gates read; at the default CANON_KIT_SCAN_KIT_ROOTS it prunes the kit roots, so a consumer runs only its own first-party docs.
The static pass, before anything runs. Every command in a marked fence must have a head check-fence-command-head admits, narrowed:
- a configured program must be in
CANON_KIT_FENCE_RUN_PROGRAMS(§Layout and configuration), neverCANON_KIT_FENCE_PROGRAMS; - an expansion head (
"$gates"), or a path naming the gate binaryGATE_SDK_NATIVE_BINresolves to, has its first operand checked against the fence-safe arm set (gate-sdk/SPEC.md §The non-gate arm). A--led operand must be a member,--emit <name>must name a member of that family, and a bare word must be a registered gate; - any other tracked path reds, because a script’s body is outside the pass;
- a builtin that runs its operand as a command —
exec,eval,trap, andcommandother than its-v/-Vprobe — reds, because the head scan never sees the command it runs.
A red head is reported and its fence is not executed. The pass is what makes no network hold for the shipped defaults: no admitted head dials out, and the crate’s only network-spawning arms are outside the fence-safe set. Honest limit: a function the fence sources is admitted as a function, so the pass trusts what a sourced library runs.
The run. No portable process sandbox exists on the three adopter operating systems (gate-sdk/SPEC.md §The adopter constraints), so no network is held by the construction above rather than by a kernel facility.
- One scratch per doc, under
DEMO_TMP_DIR, else the platform temp directory, the base §Consumer smoke’s builder takes. It is gate-sdk’s tracked-tree scratch (gate-sdk/SPEC.md §Consumer smoke) over the gate’s working directory, seeded under a fixed identity so a fence reading git sees a repository. At the repository root that is the whole tracked tree; in a fixture case dir, the case dir alone. - The binary is placed.
GATE_SDK_NATIVE_BINis resolved against the scratch root. Where that lands inside the scratch, the running binary is copied there through the builder’s own placement, which a source clone’s untracked build output needs; a value pointing outside the scratch is used as it stands. - Marked fences run in document order, each as its own
bash -euo pipefailprocess in the scratch root. Files carry over from fence to fence and shell state does not, since a reader pasting a later fence into a fresh shell has no variable an earlier one set either;-ureds that unassigned variable. - The environment is fixed and not inherited. It carries:
- the host’s
PATHvalue andLC_ALL=C; HOMEat a directory inside the scratch’s.git,GIT_CONFIG_NOSYSTEM=1,GIT_CONFIG_GLOBALat an empty file there, andGIT_ALLOW_PROTOCOL=file;http_proxy,https_proxyandall_proxy, in both cases, at an unroutable loopback port;GATE_SDK_NATIVE_BINat the placed binary, a nesting marker, and on Windows the system root a process cannot start without.
Honest limits. The proxies are defense in depth for a consumer-added program that honors them, not a sandbox: a program named in
CANON_KIT_FENCE_RUN_PROGRAMSthat ignores them can reach the network, and naming it is the consumer’s act. A toolchain finding its packages throughHOME, such as a Ruby user gem install, finds none in the scratch, so a fence reaching one cannot be marked on a host installed that way. No passthrough is offered: the toolchain derives its path fromHOMEitself, and passingHOMEthrough would undo the pin keeping a fence off the user’s files. Such a fence stays unmarked, its spelling unwitnessed, while a gate it exercises still runs wherever the battery runs with the toolchain installed. - the host’s
- The scratch is removed on every exit path.
The nesting marker. A fence may run the battery, whose check-fence-run would re-enter the same fences in a scratch of the scratch without end. The marker carries how many fence scratches deep a run sits. A corpus walk at any depth executes nothing and says so on its clean line, while a run over named docs, such as this gate’s fixture suite run by a fence, still executes. A backstop depth of three ends a doc that names itself. It is a termination bound, not a calibration: one above the deepest nesting the kit’s own fence chain reaches (a README fence running this gate’s fixture suite, which runs its named docs), so it changes no other verdict.
Red is a fence whose exit status differs from the declared one, naming the doc, the fence’s opening line, both statuses and the last twenty lines of its merged output. A static-pass red names the doc, the line, the head and the rule it failed; a marker red, the doc, the line and the defect. The clean line counts docs, marked fences, executed commands and scratches, so a tree with no marked fence prints 0 marked fence(s). A failed copy, a missing bash or a failed git init exits 2.
On a host with no bash, and on Windows. The gate spawns bash only when a marked fence passes the static pass. With zero markers it spawns nothing and is clean on every host, so marking a fence, a bash fence by its own info string, is the act that takes the interpreter dependency. The bash audience’s third derived arm records that spawn (context-kit/SPEC.md §bin/env-probe), calling this gate’s corpus and marker functions rather than copies.
check-fence-paste-unit
Invariant: on a declared page, every shell fence is one paste, and every PowerShell fence of more than one statement is one script block. A reader copies a fence whole, and a pasted sequence keeps running past a failed step. check-fence-command-head shows each head can run and check-fence-run runs what a doc marks; neither asks whether the fence stops where a step fails.
- Corpus: files matching
CANON_KIT_FENCE_PASTE_PAGES, an array of globs expanded like every canon-kit glob knob, default empty. An empty expansion is a clean0 page(s). - A shell fence is a
bash,shorshellfence, the setcheck-fence-command-headreads, scanned by the crate’s shell scanner. Its top-level lists are the runs of commands that a newline,;or&separates outside every subshell, group, compound command and substitution;&&,||,|and a line continuation join commands into one list, and a compound command is one command whatever it holds. A heredoc body is data. - Arm A — a list a failure does not stop. Every top-level list but the last must be a bare assignment: every word a
NAME=valuewhose value carries no command substitution. Any other list followed by a further one is a finding, since its failure leaves the next one running. - A PowerShell fence is a
powershell,pwshorps1fence. Its statement lines are its non-blank lines whose first non-blank character is not#. - Arm B — an unwrapped PowerShell sequence. A fence of two or more statement lines must open with the line
. {and close with the line}, its first and last statement lines. Windows PowerShell 5.1 has no&&, and the dot-sourced block stops at a terminating error while keeping the variables it sets for a later block. - Valve: the info string. A fence shown for reading takes another language (
text,console), as forcheck-fence-command-head; there is no line marker.
Red names the page, the fence’s opening line and, for arm A, the line of the list a failure would not stop. Arm A’s help: line is join the commands with &&, or split the block into one per step; arm B’s, wrap the statements as . { … }. The clean line counts pages, shell fences and PowerShell fences, so an armed corpus with no fence reads zero rather than passing unseen. Exit 2: an unreadable page. tier=precommit, install: zero-config, armed by CANON_KIT_FENCE_PASTE_PAGES.
Honest limits. A native program’s non-zero exit is no terminating error under Windows PowerShell 5.1, so arm B’s block runs on past it; a step a later one depends on is checked in the block itself, with a throw where it fails. Arm A reads the separators, not the commands: a joined list whose commands do not depend on each other passes, and whether a sequence should be joined or split is review’s.
check-install-claim
Invariant: exactly one governed doc declares which install transport is primary, and no scanned install section leads with a different one. It is the star topology’s one-owner-per-fact rule aimed at a user-facing claim, one 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, out of a hermetic battery’s reach, so a green run says nothing about it. What the tree decides is the consistency half, that two surfaces cannot name different primary transports, and that is the whole of what runs.
The corpus is the governed-doc set, and a package manifest is not in it. The declarations are read out of what governed_docs returns, so files[] arrays, bin maps and lockfiles are outside the scan however plainly they name a transport, whatever the gate’s name suggests.
The claim’s machine-readable owner is a full-line <!-- install-primary: <transport-id> --> HTML comment, the tier beside the claim’s reader-facing prose rather than a replacement for it. The transport vocabulary is consumer config, one <transport-id>⇥<ERE> line per transport through CANON_KIT_INSTALL_TRANSPORTS_CMD, loaded by claim_vocabulary (§The shared spec adapters). A kit literal spelling a transport would publish one project’s distribution model as a kit fact.
Where this gate ends and §check-payload-claim begins. The two share only the vocabulary loader. This one is positional and install-section-scoped: naming a secondary transport is correct prose, so it judges which transport a selected section leads with. Its sibling is membership over the whole governed document, since a disclosure class other than the declared one is wrong wherever it appears. Their scopes differ too, so folding one into the other would 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 leaves nothing owning the claim, so pages drift apart unwatched, and two owners is the same defect in another shape. An id outside the configured vocabulary exits 2 rather than reporting a violation, since the gate then holds no primary to compare against.
- (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 pattern is silent. A line matching two patterns passes when either id is the primary. Fenced content is scanned, a recipe being where a transport shows, but a fenced line is never a heading; the declaration line is skipped, since a claim is not evidence for itself.
The scope in which “leading” is well-defined is the install section, not the file. An install page may name a secondary transport in its preamble long before its primary recipe, so a whole-file first-match rule would red the owner page on correct prose.
The heading grammar goes through the matcher. The section regex and every transport pattern are consumer EREs compiled through the crate’s matcher (gate-sdk/SPEC.md §The POSIX ERE matcher), while the install-primary: declaration grammar and the ^#{2,6}[[:space:]]+ heading grammar are kit literals. 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 precommit run, not one alive only in unit tests.
One declaration grammar detects the declaration and extracts its id, so the terminator is part of it. An id run and the comment’s closing --> share the hyphen, and the run gives hyphens back until the terminator matches, so a space-less <!--install-primary:tarball--> declares tarball. Separate detecting and extracting patterns would read that line as a declaration of tarball--.
Honest limits. Registry reachability is the first, above. Pattern quality is the consumer’s: the gate is only as sharp as the emitted EREs, and a loose pattern wins matches by accident, as check-prose-enum says of a hand-listed set. The section scope buys the third. The declared claim’s prose may sit outside every scanned section, and rewriting it to name another transport while the declaration stands stays green. That binding is held by a reader, not the gate, the price of a scope whose alternative reds correct prose; a section leading with the wrong transport, the drift a quick-start section shows, is in scope.
Producer: the maintainer editing the owning section, plus the consumer’s transport command, both live tracked configuration. Consumer: the committing operator via the output contract. Assertion B’s report names the section, the offending line and which transport it led with. The declaration carries one field, which both assertions read; each emitted line’s id is read at the membership check and in the report, its ERE at the per-line match.
The good/bad pair covers the recurrence path, 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.
The claim’s machine-readable owner is a full-line <!-- payload-discloses: <claim-id> --> HTML comment, §check-install-claim’s tier beside the reader-facing prose, in the section that rules the fact (here gate-sdk/SPEC.md §Consumer payload). It shares that section’s declaration grammar, one grammar detecting and extracting. The disclosure vocabulary is consumer config, one <claim-id>⇥<ERE> line per class through CANON_KIT_PAYLOAD_CLAIMS_CMD, loaded by claim_vocabulary (§The shared spec adapters), since a kit literal would publish one project’s distribution model. The scanned set is check-md-refs’ governed doc set (the manifest set minus CANON_KIT_MDREF_EXCLUDE) minus CANON_KIT_PAYLOAD_CLAIM_EXCLUDE, its sibling’s composition. Two assertions:
- (A) Singleton owner. Exactly one declaration exists across the scanned set. Zero leaves surfaces drifting with no reader able to tell which to believe, and two owners is the same defect in another shape. A
<claim-id>outside the configured vocabulary exits 2 rather than reporting a violation, since the gate then holds no class to compare a line against. - (B) No contradicting assertion. Any line of a scanned document matching the pattern of a class other than the declared one is a violation, wherever it sits: membership rather than position, because a non-declared disclosure class has no correct secondary form. The declaration line is skipped, a claim being no evidence for itself. Fenced content is scanned, since a quoted recipe is where a disclosure claim shows up in passing. The report names the offending class.
The declared class’s own pattern is latent, not unread. Assertion B matches only the undeclared classes, so the declared one’s pattern works only once the declaration moves to another class. The vocabulary is a set of classes, not of live matchers, so every class is emitted with its pattern.
One claim axis, not a claim registry. A registry keyed by claim name would mint an axis-name knob and an indirection to hold one member, while this gate and its sibling already share the one piece worth sharing, the vocabulary loader. A second axis is the trigger to build it, and both gates would collapse into it with their vocabularies unchanged.
Honest limits. The gate is only as sharp as the emitted patterns, the consumer-owned drift §check-install-claim states. A claim in a shape no pattern recognizes is out of reach: the gate turns a prose corpus into a checkable one, and is no English reader. A claim on a surface outside the manifest set stays unheld.
Producer: the maintainer editing the section that rules the fact, plus the consumer’s disclosure command, both live tracked configuration. Consumer: the committing operator via the output contract; both assertions read the declaration’s one field, at the membership check, the declared-versus-other comparison and the report.
The good/bad pair covers the reintroduction path, one corrected sentence restored to its 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 means nothing is scanned, the 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, and that read stays human.
It runs six threshold-gated assertions over each surface, A to F below, and a seventh, the absence section (G), over its own surface set. 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. Three non-prose surfaces are held out before any assertion runs, since the tells measure authored prose:
- inline
`code`spans; - markdown table rows;
- generated
<!-- name:begin -->…<!-- name:end -->regions, a marker being a line whose whole trimmed content is the comment, so prose naming one opens nothing. Each region is byte-gated elsewhere, and a prose gate forcing edits to generated content would contradict its generation.
The shared walk driver already drops fenced code.
No assertion here reads a line, so the consumer’s wrapping convention does not calibrate this gate. Each per-line test is a block-identity question: is this line a heading, a table row, a list item, a generated-region marker. A line’s block identity holds in every markdown dialect. The judged span is always wider: a paragraph for A, B and E, a section for C and F, a file for D. Re-wrapping an unwrapped corpus to 80 columns leaves A’s and E’s finding sets unchanged. The one wrap-sensitive part runs the other way: inline code is stripped per line, so a wrapped `span` is never stripped and reaches the assertions as prose. One line per paragraph is the shape this gate is most precise on.
- 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 at least
CANON_KIT_PROSE_TELL_ABBR_MIN_LENlong (default3;offskips this assertion alone) the file never expands (no parenthesized expansion at any occurrence) and absent fromCANON_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. - G. Absence section — over
CANON_KIT_PROSE_TELL_ABSENCE_GLOBSalone: a section, a heading of any level to the next, whose whole body is a placeholder token (CANON_KIT_PROSE_TELL_ABSENCE_TOKENS) or one sentence opening with a negative-existential opener (CANON_KIT_PROSE_TELL_ABSENCE_OPENERS). Each set is merged with its_EXTRA(§Layout and configuration). An opener ending in a space and…needs a word after it, so the bundledno …reads No changes and not a bare No. An empty set turns its half off. Generated regions, comment-only lines and the front-matter span (§The shared spec adapters) are held out of the body, and a body of two sentences is clean, its second read as the reason. The surface is the consumer’s: bind the hand prose whose sections show their emptiness by the heading alone. Leave out a checklist or declaration grammar whoseNonetoken a reader needs (an amendment’s## Retired spellings, a work queue). G mechanizes the decidable shape of doctrine-kit’s Absence statements rule.
The exact detection regexes are implementation, owned by the gate source. Each is the grammar of the shape its assertion names, bounded by the sentence (contrast) or the clause (tricolon) and never by a count, its calibration being that assertion’s *_MAX knob. The good/+bad/ fixture pair is the executable statement of each boundary: bad/ trips every assertion, and good/ passes all and demonstrates the valve.
The corpus is walked once per file, not once per surface set, because assertion D is an in-file assertion: 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. The thresholds are validated by the kit’s knob table (§Layout and configuration), so a malformed one refuses every canon-kit gate at exit 2 with the knob named, never reads as zero.
Port 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 two configured surface sets, 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 (§The shared spec adapters), and the reason is mandatory, on the comment-tier-exempt: convention: a deliberate stylistic keep carries its cause in-line, and a reasonless valve is red. The gate produces nothing but a verdict. The committing session reads the red and either fixes the prose or lands a reasoned valve, and every CANON_KIT_PROSE_TELL_* knob is read by this gate at scan time and by no other component.
check-prose-bounds
Invariant: governed prose keeps its sentences and paragraphs within a bound and states a repeated rule once. The corpus is the files CANON_KIT_PROSE_BOUND_GLOBS matches; empty scans nothing and passes. Which prose is governed, and at what bound, is the consumer’s calibration, so every bound has an off.
The unit is §check-prose-tells’: a blank-line block, with each list item and its continuation lines a unit of its own, so a bullet run is never one paragraph. Headings, table rows, fenced blocks, generated regions and comment-only lines are held out. A word is a whitespace-separated token, and an inline code span counts as one word. A sentence ends at a run of ., ! or ? followed by whitespace or the unit’s end, the split §check-prose-tells assertion E uses. A period closing a bold span, .**, is followed by *, so it ends nothing and a bold lead-in fuses with the sentence after it. A colon or semicolon ends nothing, because a chain of them is the run-on this gate exists for.
- A. Sentence length — a sentence over
CANON_KIT_PROSE_BOUND_SENTENCE_MAXwords. - B. Unit length — a unit over
CANON_KIT_PROSE_BOUND_PARAGRAPH_MAXwords. - C. Repeated phrase — a run of
CANON_KIT_PROSE_BOUND_REPEAT_WORDSwords recurring in at leastCANON_KIT_PROSE_BOUND_REPEAT_MINunits of one section. Case is folded, and code spans, link targets, path-shaped tokens and punctuation are dropped first, so a shared citation or knob name does not count as prose. A section is the span from one heading to the next heading of any level. A rule stated on every bullet of a roster is the shape: state it once, above the roster.
A finding names its file, line, assertion, the measured figure and its section. C reports one finding per section, at the first unit carrying a repeated phrase, and lists the phrases, overlapping runs shown as the one longer phrase they cover.
The ceiling. A tree adopting the gate over existing prose sets CANON_KIT_PROSE_BOUND_CEILING_FILE. It holds a # contract: header, then one <n> <path> row per governed file with findings, sorted by path. With a ceiling, a file’s findings count against its row and must equal it. More findings than the row is red: shorten the prose. Fewer is red too, and the message prints the lowered row to write, so a pass that removes findings records it in the same commit and the slack cannot hide a regression. A governed file with findings and no row is red. A row naming a file outside the corpus is ignored, so narrowing the corpus never reds. With the knob empty every finding is red. The gate never writes the file.
The worklist behind a ceiling is the gate run with the knob emptied in the environment: CANON_KIT_PROSE_BOUND_CEILING_FILE= bash gate-sdk/bin/run-gates.sh --only check-prose-bounds.
Valve: <!-- prose-bound-exempt: <reason> --> on the flagged line or the one above, riding the shared exempt window with a mandatory reason. An exempt finding leaves the count too.
Fail-closed (exit 2): a threshold the knob table refuses; a ceiling file named but absent or unreadable; a row that does not parse, or a second row for one path.
Honest limits. A paraphrased restatement shares no phrase and passes C. A word count is not reading time. Under a ceiling, a commit that removes one finding and adds another in the same file keeps the count and passes. Undated narrative of how a rule was reached passes all three assertions. The dated half is §check-provenance-seam’s, and the rest is the close-stage brevity pass’s, which a consumer’s surface ratchet over its specs brings forward to the growing commit (context-kit/SPEC.md §The surface ratchet).
Producer: the pre-commit hook and run-gates.sh, on a commit touching a governed file or the ceiling file. Consumer: the committing session through the output contract. Each field is read at the one scan and nothing persists. good/ holds a bounded agent file and a bounded spec with a valved long sentence; bad/ trips A, B and C, one of them in an agent file’s paragraph section. check-prose-bounds.test.sh holds the ceiling arms: equal, above, below, missing row, stray row, duplicate row and the absent file.
templates/
canon-config.knobs — the consumer knob-file template: a one-line # 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. Rule content stays the consumer’s:
- glossary and vision structural gates (
check-glossary-tiering,check-glossary-entry-types,check-vision-tiering,check-retired-term-coupling), which encode one instance’s surface roles, entry taxonomy and term couplings; their generic cross-surface axis ships here ascheck-surface-duplication; - a consumer’s product directive vocabulary (
glossary:,diagram:,domain-enum, …), supplied asCANON_KIT_COMMENT_*config, whilecheck-comment-tier’s classifier, kit-mechanism directive roster and parsing machinery ship here; - a
check-root-tiering, a pure consumer-filename allowlist, and global-constant literal gates such as a pagination literal’s.
Diagram and spec annotation couplings are unclaimed, a later kit’s scope.
A numeral-notation convention for a work queue is refused. The single-token grammar (§check-manifest-count) settles notation inside the manifest corpus. A work queue takes the measured: marker without the bare-cardinal ban, so a spelling choice there decides nothing a gate reads, and what remains is a style preference. And a queue’s notation is the queue kit’s to rule, not the manifest kit’s.