doctrine-kit — the experience-packaging rung
The delivery doctrine the other kits enforce piecemeal — the cross-kit practice rules a session must hold to work this methodology — becomes one kit-shipped, customer-deliverable rules file, installed by reference into a consumer’s always-loaded agent file and held there by a gate. The other kits own the mechanisms; doctrine-kit owns the statements that name why those mechanisms exist, at one tier, in one place.
The kit is a new remit rather than a fold into canon-kit: the doctrine spans kit charges — enforcement-first is gate-sdk-flavoured, load-trigger residency is context-kit-flavoured, content-tiering is canon-kit-flavoured — and canon-kit’s own out-of-scope boundary holds that a consumer’s tier contract is not canon-kit’s to own.
The doctrine deliverable
DOCTRINE.md is the deliverable. It carries each cross-kit rule as its
statement, why it holds under coding-agent work, and a pointer to the kit
mechanism that enforces it; the mechanism and its knob rosters live in the cited
kit SPEC, never restated in the doctrine. The rule statements are the
doctrine’s to own — a governed surface elsewhere cites a rule by name and links
the doctrine rather than restating it.
The roster carries two registers: methodology-maintenance rules, which govern how the methodology’s own surfaces stay honest and bear on every surface edit, and engineering-craft rules, which govern how the work built under the methodology is written and are triggered by test, rename, git-rewrite, config-edit, and dispatch work. The maintenance register forms the installed digest; the craft register lives behind the link, load-triggered — the doctrine applying its own load-trigger residency rule to itself.
The file is referenced in place, never copy-installed. A consumer vendors the kit and links the vendored path; re-vendoring the kit is the doctrine upgrade. A copied doctrine drifts the moment the source moves and the copy does not; a linked one cannot. This is point-never-restate applied to the doctrine mechanism itself.
DOCTRINE.md joins the consumer’s spec manifest (this repo wires it into
scripts/canon-config.sh), so its links and commands resolve under the
canon-kit doc gates like any governed page.
install-doctrine
bin/install-doctrine.sh writes the reference block into the always-loaded
agent file, idempotently. The block is bounded by fixed marker lines
(<!-- doctrine-kit:begin --> … <!-- doctrine-kit:end -->); a run replaces
the content between the markers when they are present and appends the block when
they are not, so re-running never duplicates and a marker set left in place is
updated where it sits. A begin marker without its end is a malformed target: the
installer refuses (exit 2) rather than guess the block bounds. The agent file
must already exist — the installer edits an always-loaded file, it does not mint
one — so a missing target is exit 2. The marker insert/replace itself is not
this installer’s code: it rides gate-sdk’s shared lib/inject.sh helpers
(inject_marker_block to write, read_marker_block to read the block as it
stands), the single copy lifecycle-kit’s injector also uses;
install-doctrine.sh supplies the block content (the digest) on stdin and owns
every rule about what survives from one run to the next.
The block is the always-loaded shape applied to the doctrine itself: a one-line
digest of the methodology-maintenance rules plus a markdown link to the doctrine
file. The engineering-craft register is not digested — it is load-triggered and
reached through the link, so the always-loaded surface carries only what bears
on every edit. The installer is the single source of the block text — the
heading, the link paragraph, the bullet shape — so a manual insertion for a
harness-less consumer copies what the tool would emit; the README documents that
manual path. It is not the source of the block’s content: the rule bullets are
derived from DOCTRINE.md (below), so the installed digest names every
methodology rule by construction rather than by an author remembering to, and a
fresh consumer is in per-rule lockstep out of the box (installer and gate agree
on the ## Delivery doctrine heading — that agreement is part of
check-doctrine-registration’s contract). Trimming a rule the consumer does not
keep resident stays legal, but rides a declared-trim marker rather than a silent
deletion: the gate asserts name-lockstep modulo declared trims (§check-doctrine-registration
assertion B).
The digest is derived from the doctrine, one bullet per rule. Each
methodology rule carries a *Digest:* <one-line summary> trailer, and the
installer emits that rule’s bullet as - **<name>** — <summary>: the name read
exactly as §check-doctrine-registration assertion C reads it, the summary taken
from the trailer verbatim. The bullet roster is therefore the rule roster, and
the two cannot come to differ in count — the drift axis is removed rather than
watched, which is what Derivation-first asks for before Enforcement-first is
even consulted. A hand-maintained bullet list is what this replaces, and its
failure mode was not hypothetical: a rule that landed in DOCTRINE.md and in
this repo’s own agent file never reached the installer’s list, so every fresh
install vendored a digest one rule short while the consumer-side gate stayed
green — the skew is invisible from the tree that has both copies right.
Why a trailer and not the rule’s own prose. The obvious cheaper design is to
extract the summary from the rule body’s opening sentence and add no format at
all. It was measured rather than assumed, and it does not hold: of the twelve
bullets the digest shipped, none occurs as contiguous text anywhere in
DOCTRINE.md. The digest register is deliberately terser and more imperative
than the doctrine’s statement register — the rule states the convention and its
scope, the bullet is what survives in a file read in full at every session start
— so extraction would either degrade the doctrine’s own prose into digest
shorthand or invent a summary the author never wrote. The trailer is the
authoring surface that makes derivation honest: the summary is authored once,
beside the rule it summarizes, and read from there by everything that needs it.
The derivation’s refusals. A rule carrying no *Digest:* trailer, or two,
leaves its bullet undecidable, and the installer exits 2 naming the rule rather
than emitting a digest silently short — the exact defect the derivation
replaced, so failing closed is the point rather than a courtesy. A missing
doctrine file and a methodology section that resolves to nothing are exit 2 on
the same ground: the digest cannot be certified against an unreadable rule set.
The derivation runs once, at top level, before the block is composed —
digest() is read through process substitution, where an exit would end only
the subshell and let the malformed doctrine through as a short digest, which is
the failure mode being guarded. §check-doctrine-registration assertion E is the
tree-side half of the same guard, so an untrailered rule reds at commit time
instead of waiting for a consumer install to discover it.
What this asks of a consumer. Nothing, for a consumer who re-vendors
DOCTRINE.md unchanged — the kit ships it, and its rules ship trailered. A
consumer who appends a local rule to their vendored copy now owes that rule a
*Digest:* trailer. That is a real obligation and worth stating rather than
claiming zero impact, but it replaces a larger one: assertion B already required
such a rule to be reflected in the digest, and the trailer is what makes its
bullet generate rather than have to be hand-written and kept in step.
The declared-trim round-trip. A generated block that is unconditionally
rewritten would revoke that right on the next run — and revoke it silently,
because a trim the installer restores to a bullet leaves an agent file whose
hash matches what the installer last recorded. So the run is read-compute-emit
rather than emit: read_marker_block returns the block as it stands, the
installer harvests every
<!-- doctrine-digest-trim: <rule name> — <reason> --> line out of it, and each
digest rule is emitted as either its bullet or — when that rule’s name is
trimmed — the harvested marker line verbatim, in the bullet’s position.
Substitution, not addition: a block carrying the trim and its bullet would
satisfy assertions B and C while handing the consumer back the rule they
removed, which is the customization defeated while appearing honored. In-place
rather than appended so the consumer’s reason stays where the rule it answers
would have been; the gate accepts the marker anywhere in the digest section, so
readability decides. The rule names the substitution keys on are the digest’s
own bullet names, read exactly as assertion C reads them — installer and gate
never disagree about which rule a marker names.
Four behaviors the round-trip fixes:
- No trims declared → byte-identical output. Load-bearing rather than incidental: without it every consumer’s agent file churns on every upgrade, and the installer’s caller starts reporting a file nobody edited.
- A trim naming no rule in the current digest — a rule renamed or dropped upstream — is carried forward and reported. It has no bullet position to take, so it is carried at the digest’s end. Surfacing it at the re-vendor moment is the declared-not-silent design working: that is the moment the consumer’s decision gets reconciled.
- Duplicate trims for one rule — the first is carried, the duplicate dropped and reported. Silently collapsing them would be the same class of quiet edit-loss one layer up.
- The report has two channels, and the split is deliberate. The standing rule it instances: an installer a machine may drive puts findings on stderr and narration on stdout, and stays silent on a clean run — stderr chatter on success is how a channel gets ignored. So each finding above goes to stderr, and the action line on stdout gains the count of trims carried and a tally of those findings. What makes the rule binding here rather than merely tidy is the caller class this script has: a vendoring installer whose own stdout is a machine-read channel of seeded paths must discard this script’s, so stdout is exactly where a reconciliation the consumer owes would be lost. The honest consequence is that under such a caller the count has no reader — the carried trims are read from the commit that install makes — while every finding still reaches whoever ran it.
The honest bound. The block is generated, and the declared trim is the only customization preserved across a run. An unsanctioned edit inside the span — a reworded bullet, an added line — is reverted by design, and would red §check-doctrine-registration assertion C independently; everything outside the markers is the adopter’s and is never read or written. Stating the bound is what makes the preserved case a contract rather than an accident.
The removal mode. install-doctrine.sh --remove [agent-file] is the insert
path reversed over the same marker pair, riding gate-sdk’s remove_marker_block
exactly as the insert path rides its sibling — so the marker strings keep their
one writer and a caller reversing an installation needs no copy of them. It
harvests no trims and emits no digest: a removal has nothing to carry forward,
which is what makes it the reverse of the round-trip above rather than a second,
emptier pass through it — and, for the same reason, it reads no doctrine file
and so none of the derivation’s refusals can arise on this path. An agent file
carrying no block is not an error —
the run says there was nothing to remove and exits 0, so a second --remove is
as quiet as the first and a caller that cannot know whether the block was ever
written there needs no way to ask. Its refusals are the two the insert path
holds before it reads any doctrine, unchanged and the only two on this path: a
missing agent file is exit 2, and a begin marker without its end is exit 2
rather than a guess at the block bounds. What it removes is the
reference block and nothing else — everything outside the markers is the
adopter’s, on the same terms the honest bound above states for a rewrite.
The round-trip’s acceptor is smoke/install.sh, per the coverage tier
gate-sdk/SPEC.md §lib/inject.sh sets for a sourced library with no gate surface:
it declares a trim in the block the installer just emitted, re-runs the
installer, and holds that the marker survived in the trimmed bullet’s position,
that the bullet is gone, and that the gate is green across the re-run. The same
acceptor covers the removal mode on that tier and for that reason: it strips the
block, holds that a second --remove is an idempotent no-op, and reinstalls to
restore the baseline. It also drives the derivation’s refusals, on the same tier
and for a further reason: a refusal is the installer’s behavior rather than the
gate’s, so no gate fixture can reach it. Pointed by the positional overrides at
a scratch doctrine whose second rule carries no *Digest:* trailer — and again
at one whose second rule carries two — it holds that the run exits 2, that the
message names the offending rule, and that the agent file is byte-unchanged.
That last assertion is the one worth having: an installer that refuses after
writing has failed closed in its exit status only.
Positional overrides install-doctrine.sh [agent-file [doctrine-file]] let a
smoke or a fixture point both paths at a scratch tree without touching consumer
config; unset, they fall to the knob defaults.
check-doctrine-registration
Invariant, in five assertions: the configured agent file (A) carries a markdown
link to the configured doctrine file and (B, C) holds its methodology-rule digest
in per-rule lockstep with the doctrine, and the doctrine’s two registers tag
every rule with exactly one per-rule trailer — the craft register with a
stage-routing trailer (D), the methodology register with the digest trailer its
bullet is generated from (E). The digest is the surface the
always-loaded-shape rule requires, and a re-vendored DOCTRINE.md that adds or
renames a methodology-maintenance rule staling every consumer’s digest by
construction — on the exact path the kit advertises as its upgrade story — is
the drift-prone-surface-that-must-exist case where a gate is owed (the
enforcement-first weighing). Extending the existing gate rather than adding one
keeps the gate count flat; the coupling is unchanged (the # graph: manifest
already couples the agent file and the doctrine file).
- Assertion A (link). The gate greps the agent file for a
](<doctrine-file>link token; absent, it is a finding with the install remedy. It asserts link presence only — that a session loading the agent file is pointed at the doctrine, not that the link was followed or the doctrine read (the honest limit: a link is not a read) — and leaves link-target resolution to the consumer’s doc gates (canon-kit’scheck-md-refsover the manifest). - Assertion B (doctrine → digest). Every rule name under the doctrine’s
## Methodology-maintenance rulessection — the bold text of each numbered rule, trailing period dropped (e.g.Content-tiering / SSOT) — appears as a bold digest lead-in (- **<name>**) in the agent file’s digest section. Engineering-craft rules are exempt: they live behind the link by the doctrine’s own two-register design, so the gate scans only the methodology section for the required set. The consumer’s right to reject a rule survives as a declared trim: a<!-- doctrine-digest-trim: <rule name> — <reason> -->line inside the digest section satisfies assertion B for that rule; a silent omission stays red. Declared-not-silent is the reconciliation — the re-vendor moment surfaces every added or renamed rule, and the consumer’s decision (adopt the bullet or trim it with cause) is recorded beside the digest it governs. - Assertion C (digest → doctrine). Every bold bullet lead-in in the digest section matches a methodology-maintenance rule name — a digest line with no owning rule is a rule stated nowhere the doctrine governs.
- Assertion D (craft-trailer coverage). Every numbered rule under the
doctrine’s
## Engineering-craft rulessection carries exactly one*Stages:*trailer matching the tag grammar (§stage-rules). A craft rule with no trailer, or two, or a malformed value is a finding: a re-vendoredDOCTRINE.mdthat adds an untagged craft rule reddens here instead of silently dropping out of the stage routing the emitter derives. Stage-name validity is deliberately unasserted — doctrine-kit does not depend on lifecycle-kit’s stage config, and the emitter’s empty-output posture already covers an unknown stage; the gate holds only that the trailer is present and well-formed. - Assertion E (digest-trailer coverage). Every numbered rule under the
doctrine’s
## Methodology-maintenance rulessection carries exactly one*Digest:*trailer with a non-empty value. This is D’s sibling one register over, and the sentence D is argued from transposes exactly: a re-vendoredDOCTRINE.mdthat adds an untrailered methodology rule reddens here instead of silently dropping out of the digest the installer derives (§install-doctrine). Zero trailers and two are both findings, for the reason they are on D — a rule with two has no decidable bullet, and picking one silently is exactly the guess a gate exists to refuse. The value is graded only for emptiness: what makes a one-line summary good is not machine-decidable, and a check that pretended otherwise would be the noisy gate the Enforcement-first carve-out warns against. D and E run one walk over a register’s numbered rules, parameterized by the trailer name and by how its value is graded, so the two registers cannot drift apart in how a rule is read.
Section resolution fails closed. The digest section is the agent-file heading
named by DOCTRINE_KIT_DIGEST_SECTION (Layout and configuration); a configured
heading matching nothing exits 2 — a renamed digest section must not disarm the
gate into passing an empty set. The doctrine-side headings
(Methodology-maintenance rules and Engineering-craft rules) are kit
mechanism, not config: the kit ships DOCTRINE.md, so it owns those names, and
either one’s absence is likewise exit 2 (the gate cannot certify the digest or
the craft trailers against an unreadable rule set). A missing agent
or doctrine file is fail-closed for the same reason, as is a grep or awk that
errors rather than simply not-matching.
The gate satisfies the four gate-sdk contracts (gate-sdk/SPEC.md §The gate
model): the single DOCTRINE-REGISTRATION: clean success line and a help:
remedy on each finding path (output); exit 2 on an unreadable file, an
unresolved section, or an errored capture (fail-closed); a good/+bad/
fixture pair under gate-tests/ — the pair carries the lockstep-clean and the
digest-missing-a-rule cases (both craft-tagged so assertion D passes there), and
a sibling *.test.sh drives the extra-line, declared-trim, link-absent,
craft-untagged, craft-malformed, and four fail-closed cases the one-pair harness
cannot hold (fixture-pair); and registration in this repo’s gates.list where
its own always-loaded file is the scan target (self-lint). Positional form
check-doctrine-registration [agent-file [doctrine-file]] lets the fixtures
point at a synthetic agent and doctrine file.
stage-rules
The engineering-craft rules are load-triggered — behind the doctrine link, not
digested (§The doctrine deliverable) — and a stage is itself a load trigger.
bin/stage-rules.sh <stage> closes that gap: it derives, for a given stage, the
craft rules that bear on it and emits one pointer line per hit, so a session
entering a stage is reminded of the craft rules to follow before the matching
action, without the always-loaded surface carrying the prose.
The tag grammar (single source: the rule owns its stage). Each rule under
## Engineering-craft rules in DOCTRINE.md carries a machine-parsable trailer
line, *Stages:* <stage>[, <stage>…] — a comma list of lowercase stage tokens
naming the kit’s default stage vocabulary — or *Stages:* — for a rule that
routes to no stage. The mapping lives on the rule, so a re-vendored DOCTRINE.md
carries its own routing and no consumer-side stage↔rule table exists to drift;
check-doctrine-registration assertion D holds every craft rule to exactly one
well-formed trailer.
The emitter. bin/stage-rules.sh <stage> [doctrine-file] scans the craft
section for rules whose *Stages:* line names the given stage and prints one
pointer line each — rule number, name, and the doctrine path — so the reader
follows the link to the rule body. An unknown stage name yields empty output:
the tags name the kit-default stages, so a consumer with a renamed stage set
gets no routing rather than wrong routing (the stated honest limit — a stage
remap knob is deferred until such a consumer exists). It sources
lib/doctrine.sh for the doctrine path and takes the same positional override
the gate and installer do; a missing doctrine file is exit 2, an absent craft
section or a no-match stage is empty output.
The surfacing seam. The emitter is derived data with no standing tier of its own; context-kit’s session-context hook is its named consumer, emitting the current stage’s pointer block when the emitter is vendored (context-kit/SPEC.md §The session-context hook, the drift-line seam precedent).
lib/doctrine.sh
The sourced config loader shared by the installer and the gate: it loads
DOCTRINE_KIT_CONFIG_FILE (or the gates-dir doctrine-config.sh when that env
is unset), then a gitignored .local.sh overlay beside it, then fills each
knob’s default — so the installer and the gate read one resolved configuration.
It carries no gate logic: structure stays in the check, values in config,
defaults here.
Layout and configuration
The kit is vendored beside gate-sdk (conventionally at doctrine-kit/); its
gate is registered in the consumer’s gates.list by name and resolves through
gate-sdk’s multi-kit path. Config follows the kit pattern: an optional
doctrine-config.sh in the gates dir (or a DOCTRINE_KIT_CONFIG_FILE
elsewhere) overrides any knob; defaults fill what the consumer left unset. Knobs:
DOCTRINE_KIT_AGENT_FILE— the always-loaded file the installer edits and the gate scans, defaultCLAUDE.md.DOCTRINE_KIT_DOCTRINE_FILE— the link target the installer writes and the gate asserts, defaultdoctrine-kit/DOCTRINE.md.DOCTRINE_KIT_DIGEST_SECTION— the agent-file heading whose bullet list the gate reads as the methodology-rule digest (assertions B and C), default## Delivery doctrine. The default isinstall-doctrine.sh’s installed block heading, so a zero-config consumer that installed via the tool is green out of the box; a consumer that renamed the heading repoints this knob (a rename that leaves it stale exits 2 rather than passing an empty set).DOCTRINE_KIT_CONFIG_FILE— the loader override; when set it must resolve, else the loader exits 2 rather than silently run on defaults. A.local.shsibling of the resolved config sources last for private overlay values.
The defaults are this repo’s own layout, so this repo runs the kit on itself
with no config file: CLAUDE.md is the always-loaded agent file, and its
## Delivery doctrine reference block links doctrine-kit/DOCTRINE.md.
Out of scope
Folding the doctrine into canon-kit — ruled out above. Copy-install of the
doctrine file — a copied doctrine drifts; the reference is the mechanism.
Per-rule enable/disable knobs — the doctrine is one document, and a consumer
that rejects a rule declares a trim marker beside its digest rather than toggling
a knob. A standing consultation step in place of packaging — the always-loaded
anti-pattern this load-triggered kit exists to replace. A point-of-use guard
pushing a craft rule at the triggering action (a Write-seam guard_advise) —
demand-gated and out of scope: guard-kit’s seam is Bash-only, the craft-rule
triggers ride the harness Write/Edit tools the guard never sees, and the
stage-routed surfacing (§stage-rules) already reaches every stage session, so a
new Write-seam mechanism carries one tentative consumer and no live demand. The kit holds no opinion
on which methodology rules a consumer keeps resident versus trims with cause;
it ships the statements and the wiring, and the consumer’s always-loaded budget
rules the digest — but a trim is declared, not silent, so the gate holds the
resident set and the doctrine in name-lockstep modulo those declarations.