drift-kit — drift reporting with pluggable KPIs and lead/lag honesty labels

Gates block what is mechanically decidable per commit; drift is what accumulates between commits, trends no single diff violates. A backlog ages, an always-loaded surface swells, friction recurs, and no stateless session sees the slope. The kit is the trend surface. An advisory report collates KPIs from the other kits’ governed surfaces and groups them under honest weight labels: lead indicators act before drift compounds, lag indicators undercount by construction. A one-line trend summary rides the session-start hook, so every session opens seeing the slope.

Advisory by construction: the report never fails a session, never joins gates.list, and reads trend, not level — a KPI’s absolute value is noise, its direction across sessions is the signal.

The kit carries the report skeleton, the KPI plugin contract, a bundled set of kit-coupled generic KPIs, and the knowledge-friction loop; a consumer’s product- and toolchain-shaped KPIs stay in the consumer repo (§Out of scope).

The report skeleton

--emit drift-report — the collator, a non-gate arm of the gate binary (gate-sdk/SPEC.md §The non-gate arm), reached as bash gate-sdk/bin/run-gates.sh --emit drift-report [--trend]. It owns the frame; every measurement lives in a member:

  1. Read the KPI registry (kpis.list — one member name per line, # comments; the gates.list grammar) and resolve each name through the three tiers of §The extensibility contract.
  2. Derive the iteration-start commit before the plugin export loop and assign it to DRIFT_KIT_ITERATION_START, so the loop carries it to every plugin; the report header reads the same value. The commit is lifecycle-kit’s iteration-start commit (lifecycle-kit/SPEC.md §The state machine), read through that section’s shared crate read on DRIFT_KIT_STATE_FILE. It is never a history search keyed on the iteration’s name: the name is not unique while the unnamed-iteration sentinel stands, and --enter-stage --rename rewrites every stamp’s name. The position is priced: the derivation runs in --trend mode too, so every session start through the context hook pays one state-file read and one git rev-parse. kpi-stage-economics-lag (§Bundled KPIs) adds a read of the state file’s committed history as git log -p, tens of milliseconds over a few hundred iterations, skipped where the stage-economics log is absent.
  3. Run each plugin, collect its rows, and group them into the two labeled sections — the honesty labels are the frame’s contract:
    • header: === Drift KPIs (advisory — trend, not level) ===, plus the iteration-start commit when derivable;
    • --- Lead (weighted high — act before drift compounds) ---;
    • --- Lag (weighted low — undercounts by construction) ---;
    • footer: Read trend across sessions; lag KPIs lower-bound only.
  4. --trend emits a single compact line instead — the fragment each plugin volunteers, joined with · — consumed by context-kit’s session-context hook through its CONTEXT_KIT_DRIFT_REPORT knob.

Degrade discipline: exit is always 0. A member that fails or produces nothing yields a visible <name> n/a (plugin failed) row in the lead section — fail-visible, not fail-closed, because a silently vanishing KPI is itself drift. A member whose surface is missing (no log yet, no timings file) degrades to n/a (<reason>) in its own value, not by dying.

An arm-table member, not a top-level flag: the table row declares the member’s knob roster, which the knob-file derivation, check-reads-couples and check-gate-substrate-parity read, and a hardcoded flag has no row. The collator’s own knobs derive their defaults from GATE_SDK_GATES_DIR and its members read guard-kit’s knobs, so the family is forced rather than chosen.

The collator’s own knobs resolve from drift-kit’s static defaults table (§Layout and configuration), not in the arm: the table is every default’s one producer, so a default restated beside a reader would be a second.

The extensibility contract

A consumer adds its own KPI by dropping a plugin in its own KPI dir. That is drift-kit’s promise to adopters, and no substrate move narrows it.

A registry name resolves through three tiers, consumer-first, and the first tier that answers wins:

  1. each entry of DRIFT_KIT_KPI_DIRS — the adopter’s own dirs, default the consumer gates dir — for a file named <name>.sh;
  2. each vendored kit’s kpis/<name>.sh;
  3. the built-in members the binary carries (§Bundled KPIs).

So a consumer file named kpi-task-split.sh shadows the built-in of that name. The port moved the kit’s own members into tier 3 and touched nothing about how a consumer’s are found.

A tier-1 or tier-2 hit is executed directly ("$path", never bash "$path"), so the execute bit still governs and a non-executable plugin degrades to the fail-visible row rather than running anyway. A consumer plugin reads the exported environment of §The KPI plugin contract and a built-in reads the same resolved values in process, so a consumer plugin is no second-class reader of the contract.

DRIFT_KIT_KPI_DIRS exists for this extension point alone. A consumer-first plugin registry is the check-graph/graph-vocab.knobs consumer-config pattern, so narrowing it would narrow the provenance seam’s own mechanism.

The KPI plugin contract

A plugin is kpi-<name>.sh, resolved through the registry and invoked directly (§The extensibility contract), so it must carry the execute bit or the report degrades that row to its fail-visible read. Two modes:

  • Full (no args) — one or more rows on stdout, each lead|lag<TAB><label><TAB><value>: the section tag, a short human label, and a freeform value that carries its own caveat parenthetical (reading age, undercount note, pointer to the acting close-stage step). Multiple rows are legitimate for one measurement with two axes.
  • --trend — at most one compact <key> <value> fragment, or nothing (a plugin may opt out of the trend line).

Plugins read exported env only: a value in the consumer’s knob file reaches the collator but never a child plugin by itself. So before invoking a plugin the collator exports every scalar of drift-kit’s knob family, and a config override reaches writer and reader alike with no fixed export list to drift out of parity.

The export set is derived. The arm declares the prefix family DRIFT_KIT_* and reads it through the family read. That is every declared scalar’s resolved value, then every consumer scalar the local overlay or the tracked knob file sets (§Layout and configuration, the open family), the environment outranking both. A knob only the consumer’s knob file names therefore reaches the plugin, and one exported in the invoking environment reaches it by inheritance. A transcribed DRIFT_KIT_* roster would lose exactly that knob.

Indexed knobs are not exported: the kit’s own are consumed inside the binary, and a consumer line naming an undeclared knob as indexed or keyed is refused, so no array crosses as a scalar.

It also exports DRIFT_KIT_KIT_ROOTS, the resolved kit-root set newline-separated, which a plugin needing sibling-kit surfaces reads rather than re-deriving the roster. It is the driver’s handoff, recomputed every run, not a consumer knob.

DRIFT_KIT_ITERATION_START is the same class of handoff, reaching every plugin as exported environment and every built-in as the same resolved value. It is an env_only name (gate-sdk/SPEC.md §The knob file), so an adopter’s docs may cite it though no table row backs it. A plugin reading it gets the empty string when there is no iteration-start commit (lifecycle-kit/SPEC.md §The state machine lists the cases) and degrades to n/a on its own rows rather than dying.

Plugins never block and never write outside $DRIFT_KIT_TMP_DIR scratch; a measurement needing state (a baseline, a log) reads a file some other mechanism owns and stamps its reading-age caveat when the file is a past measurement rather than live state (the gate-runtime pattern below).

Bundled KPIs

The generic set — each coupled to a kit-governed surface, each degrading to n/a when the consumer lacks that surface. They are built-in members the binary carries, resolution tier 3 of §The extensibility contract.

Two members read a date and are held to the operator’s civil zone rather than to UTC, as queue-kit’s queue-index cutoff is. The binary resolves the day in-process, because no adopter floor may assume GNU date -d. An ISO day, or an ISO datetime with no written zone, is local time, read through mktime(3) on unix so the offset is the one in force on that day. A written Z or ±HH[:MM] applies as written. Today is the local civil day, and any other shape is no reading. A non-unix build has no zone reader in std and its MSYS userland’s date is GNU, so there the local reading alone stays a date -d subprocess.

Lead:

  • kpi-task-split — the feature↔debt split of the queue’s Done slugs, classified by their commit subjects (feat / fix+refactor); reads the queue file and git. gate-sdk’s check-commit-subject is the parse guarantee: every subject carries a roster type. A slug reaches its subject through git log -1 --grep=<slug>, so the classification rests on the landing commit’s message naming the slug: a Done slug no message names is unclassified, and a later chore commit naming the slug wins over an earlier fix. total counts entries that left the live pool, not deliverables (queue-kit/SPEC.md §The queue format). An exit that shipped nothing (mooted, closed by a ruling, or ruled wontfix) has no feat/fix/refactor commit naming it, so it falls to unclassified by construction and moves only the “of N done” denominator. unclassified is a mixed bucket, not a count of non-shipping exits: a delivered task landed as chore or docs, or followed by a later chore naming it, lands there too.
  • kpi-gate-backlog — proposed-but-absent gates: check-*/scan-* names appearing anywhere in the queue with no file in any gate-resolution dir, over the live gate count. A name with a file on disk is built and drops out of the numerator.
  • kpi-amendment-age — age in days of the oldest amendment on disk (SPEC-*.md, git add-date; fixture and template paths excluded, matching the published-evidence extractor’s amendment-latency harvest); the pressure gauge behind canon-kit’s short-lived-amendment rule.
  • kpi-deferred-age — age of the oldest defer date in the queue’s deferred section, the premise-rot pressure on design-pending work. The defer date is queue-kit/SPEC.md §The queue format’s: the Surfaced mark where an entry carries one, else the Filed provenance date. The member re-implements that read rather than sourcing queue-kit’s lib, which would close a cross-kit cycle; one owner doc with two implementations, each citing it, is the accepted residual. Its unknown-heading reset drops an icebox placed after the deferred section by construction, since an evicted entry’s age is not what it trends. gate-sdk’s check-gate-exemption-tasks carries the same-shaped scan with the opposite behavior, deliberately (gate-sdk/SPEC.md §check-gate-exemption-tasks), and neither is to be “fixed” into agreement with the other.
  • kpi-deprecated-surface — live deprecation-marker lines, the between-major backlog trended so it surfaces gradually instead of at one release. The kit stays deprecation-neutral: the marker spellings and the surface arrive through canon-kit’s two knobs, the consumer-config pattern (gate-sdk/SPEC.md §The provenance seam), and the member holds no literal of either.
    • It reads CANON_KIT_DEPRECATION_MARKERS in process and counts the lines its joined alternation matches over the comment surface canon-kit’s check-deprecation-task scans, through the function that gate calls, so the trend and the gate cannot count different files. An unset surface roster takes that function’s answer, canon-kit’s own default.
    • An unset marker roster degrades to n/a (no CANON_KIT_DEPRECATION_MARKERS roster), so an adopter with none reads one n/a row until they prune the line from their registry. --trend volunteers deprecated-surface <n> above zero. --emit-drift-report declares the canon-kit and gate-sdk knobs the in-process read needs.
    • The counted surface is the gate’s, pruned as the gate prunes it. The release-boundary disposition walk over the same roster is lifecycle-kit’s release-sweep skill template.
  • kpi-queue-net-delta — the design-pending pool at the iteration-start commit against the worktree, as two rows, because one number would be gameable. An icebox move counts as neither filed nor drained: it is compression, not intake and not closure.
    • Entry net delta is filed − drained: filed is a slug now deferred that was in neither section at the baseline, and drained is a slug that has left the pool entirely. Carry weight is the two sections’ line count now against the baseline. Intake moves the first row and compression the second, so a mass eviction that flatters the delta row moves the weight row visibly instead.
    • --trend volunteers the one fragment qnet <±N>, since intake is the axis a filing session can act on inside the session. With no iteration-start commit (no state file, the no-cursor window, or a head this clone cannot resolve) both rows degrade to n/a (no iteration baseline).
    • §The queue-flow arm applies the filed and drained definitions over closed iterations, through the one shared parse. An entry is an entry heading (queue-kit/SPEC.md §The queue format). The baseline is a past revision, so the parse also reads the retired bold-slug bullet lead.
    • Honest limit: carry weight counts lines, so the iteration spanning the conversion that joined each paragraph onto one line reads a one-time drop that is a reformat, not a compression.
  • kpi-prompt-friction — distinct/total prompting calls, read from guard-kit’s ranker as two integers in one process, so the row owes no spawn or parse degrade. Its degrades are n/a (no friction logged) and n/a (guard-kit absent). The numerator is a key count, so a change to how the ranker keys a row steps the trend with no behavior moving, and nothing reds. A trend reader meeting a step looks for the definitional cause first, which guard-kit/SPEC.md §scan-prompts records with its pre-change reading. Guard-kit’s presence is witnessed by templates/guard-config.knobs, never by the surface the measurement reads: a config template is never a port candidate (gate-sdk/SPEC.md §The config-seam port disposition), so no port cut can leave the row reporting a number for a tree with no guard. On a tree without the kit, knob file or friction log or not, --emit drift-report exits 0, every other member runs, this row reads n/a (guard-kit absent) and --trend omits its fragment.
  • kpi-always-loaded — the standing per-session surface from context-kit’s meter, read in process as figures: level, since-baseline delta, and the surfaces’ delta since the report’s iteration-start commit, with the meter’s stale-baseline mark. It hands the meter the report’s own DRIFT_KIT_ITERATION_START rather than letting it resolve CONTEXT_KIT_STATE_FILE, so the row and the header can never name two different starts. Its --trend fragment reads loaded <n>cp, the meter’s code-point total, with stale appended when marked. The interface is the meter’s measurement function, never a parse of its rendered line (context-kit/SPEC.md §The always-loaded meter). Its presence witness is context-kit’s templates/context-config.knobs, on kpi-prompt-friction’s ground (context-kit/SPEC.md §Layout and configuration). --emit-drift-report declares the three CONTEXT_KIT_* knobs the in-process read needs.
  • kpi-settings-local — entry count of the untracked local permission overlay GUARD_KIT_SETTINGS_LOCAL names, parsed in process with no external program: its allow, deny and ask lists summed, a missing or non-list one counting nothing, and an unparseable overlay degrading rather than counting zero. The notice signal for guard-kit’s close-stage prune/promote step.
  • kpi-gate-runtime — full-battery runtime from the runner’s timings file (<tmp-dir>/gate-timings.txt): total, the slowest gates by runtime, and the file’s reading age, a measurement rather than live state, so the age caveat rides the value. Only an unfiltered run writes the file (gate-sdk/SPEC.md §run-gates), so a --only run leaves the reading as the last battery set it. Its TOTAL is the sum of the per-member times, which under the runner’s worker pool does not approximate wall-clock (gate-sdk/SPEC.md §run-gates): a run that got shorter can show a larger TOTAL, because contention lengthens each member while the battery finishes sooner. Read it as dispatch cost, and the slowest-gate rows as the critical path a pool cannot shorten.
  • kpi-overhead — governance and gate-output share over the overhead meter’s log (§The overhead meter): the governance share (pct averaged across the recent window of sessions, carrying the session-count and reading-age caveats) and the gate-output share (gate/total, the axis the deferred economy levers target). --trend emits ovh <pct>%. Degrades fail-visible to a “run –emit overhead-meter” n/a row when the log is absent.
  • kpi-price-table-age — two rows off the consumer price table (DRIFT_KIT_PRICE_TABLE, §The stage-economics meter input 3): the age in days of its priced-as-of: header, and the time to or past its optional prices-valid-through: header. Both, because at the moment that matters they point opposite ways. Age measures when someone last typed the numbers; expiry is when the numbers stop being true. A table retyped the day before an introductory-pricing row lapses reads priced 1d ago, freshest exactly when it is least trustworthy — the moment the expiry row exists to flag.
    • Full mode emits priced <N>d ago (as-of <date>) and either expires in <N>d (through <date>) or EXPIRED <N>d ago — re-verify (through <date>). --trend emits price <N>d, and nothing when the age is n/a; the expiry row volunteers no second fragment (§The KPI plugin contract).
    • Age counts from the wall clock, the kpi-deferred-age idiom. Expiry counts whole calendar days against the through-date: expires in 0d on the last valid day, EXPIRED 1d ago the next.
    • Each row degrades on its own and fail-visible: n/a (no priced-as-of: header), n/a (unparseable priced-as-of date), n/a (no prices-valid-through: header) and n/a (unparseable prices-valid-through date). With no table at all it emits the single n/a (no price table) age row and no expiry row, one row per absent surface.
    • It is advisory and never joins gates.list. A freshness gate is ruled out: prices are a dated literal with no machine-readable feed, so a gate would fetch externally, red on causes no commit produced and break hermeticity (site-kit/SPEC.md §The monitor boundary). The mechanism is kit-generic; no model id, price or roster enters the kit (§The stage-economics meter, the provenance seam).
  • kpi-stage-economics-lag — closes since the newest priced close, off the stage-economics log (DRIFT_KIT_STAGE_ECONOMICS_LOG). The count is keyed on iterations, never on dates, since several closes can stamp on one day. Lead, not lag: the value is exact rather than a lower bound, and it is the signal to act on before the gap widens. It is the visible half of the meter’s feeding obligation (§The stage-economics meter, Feeding).
    • A closed iteration is an iteration name carrying a stamp whose stage is the last member of DRIFT_KIT_STAGES. Closed iterations are ordered by the first appearance of that terminal stamp in the meter’s history ∪ live stamp read: committed history in commit order, then the live file. An iteration is priced when any log line’s iteration field (field 2) names it. The lag is the number of closed iterations after the last priced one in that order.
    • One producer for the stamp read: this KPI and the meter both call the history ∪ live collector in native/src/history.rs, since a second copy of the union would be a second producer of the coupling §The stage-economics meter refuses one for.
    • Full mode emits <N> close(s) unpriced since <iteration> (run --emit stage-economics), or 0 — newest close priced (<iteration>); --trend emits econ <N>.
    • It degrades fail-visible with no trend fragment: n/a (no stage-economics log), n/a (empty stage roster), n/a (no closed iteration) and n/a (no closed iteration priced). The last is deliberately not a count: with nothing priced it would count every close in history, measuring when the log was started rather than lapse.

Lag:

  • kpi-knowledge-friction — re-derivations captured this iteration: the line count of the knowledge-friction log (§The knowledge-friction loop). Lag by construction: only what a session noticed and logged is counted, so the value lower-bounds the real rate. A lower bound of zero bounds nothing, so an empty log is no evidence of zero friction, and the emitter says so on a line of its own (§The knowledge-friction loop owns the three-state contract).
  • kpi-incident-recurrence — re-filings of the same finding, summed over the queue’s [recurrence:] arrays (queue-kit/SPEC.md §The tag algebra owns the grammar), each paired with the entry heading above its tag line, plus the highest-count slug. It re-implements the read rather than sourcing queue-kit’s lib, under kpi-deferred-age’s accepted residual. The lag label is a measurement claim, not a priority one. A recurrence nobody files is uncounted, as for kpi-knowledge-friction, degenerate case included; so is one no session judged, since each date records a judgment. A judged count is comparable only across a fixed judging rule: a series spanning a change to what counts as a recurrence is two series, not one. Actionability rides lifecycle-kit’s pre-emption rule and its LIFECYCLE_KIT_RECURRENCE_THRESHOLD, never this report’s weighting. --trend emits recur <N>; with no tag anywhere it degrades to n/a (no recurrence tag in the queue).

The lag section is expected to be sparse — most lag measurements (review finding rates, detection latency) are manual tallies, and the kit ships no fake automation for them; a consumer with a structured source adds its own plugin.

The knowledge-friction loop

guard-kit’s friction loop catches permission friction, fed by a hook that fires on every command. Knowledge friction has no hook. A session re-derives a fact no doc owns, reading it off an implementation, a gate’s source, a commit message, or a prior or sibling deliverable it consults to shape a new one. It gets the right answer and moves on, nothing logs, and the next session re-pays the same derivation. The loop mirrors guard-kit’s, with capture moved to convention:

  1. Capture (any session) — the moment a session catches itself re-deriving a fact from a non-owning surface, it appends one line to the knowledge-friction log: <date> <fact re-derived> ← <surface it was read from>. The log is .workflow/knowledge-friction.log by default, gitignored per-iteration scratch on the prompt-friction.log pattern. One line, written at the moment of re-derivation — deferred capture is no capture.
    • The affordance is run-gates.sh --emit kfric [--] "<fact>" "<surface>", the --emit-kfric arm (gate-sdk/SPEC.md §The non-gate arm). It stamps that grammar, the date from date +%F, into DRIFT_KIT_KNOWLEDGE_LOG, creating the log’s parent dir if missing. It refuses with a usage message and exit 2 unless both positionals are present and non-empty, in the fixed order fact then surface. Its declared roster is that one knob, a row of drift-kit’s static table (§Layout and configuration).
    • Its argv. Both positionals are free text, so it validates their shape (gate-sdk/SPEC.md §The bin/-tool contract), scanning every positional, since two slots make arity safe in neither; -- ends option processing. --emit kfric --help is a refusal printing the usage block at exit 2, not usage at exit 0 (gate-sdk/SPEC.md §The bin/-tool contract).
    • Why an arm. It makes capture prompt-free. The raw form is a shell redirect (printf … >> <log>) no allowlist glob suppresses safely. The arm takes the fact as an argument, so its invocation is a static prefix under the front-end’s one end-wildcard allowlist entry, and a permission prompt never turns capture into deferred capture. The raw append stays legal as the fallback: the grammar, not the writer, is the log’s contract.
    • Its hook is one always-loaded bullet in the consumer’s instructions file, which must earn its recurring cost by the log actually filling.
    • Seam: kfric is the narrow sensor for a re-derived fact only. A work-shaped mid-iteration finding (a gap, a task, a defect) routes to the consumer’s committed gap channel (lifecycle-kit/SPEC.md §The committed gap inbox), since overloading this log as a backlog inbox dilutes the kpi-knowledge-friction signal. The prior-deliverable cue does not relax that seam: “this artifact’s chrome should be owned or generated rather than copied” is work-shaped, while kfric captures the specific value or structure reconstructed because no doc owns it.
    • The <surface> field is the measured-versus-estimated distinction, so capture needs no other. A kfric line records a fact read off a surface, observed by construction; whether the observation holds is the drain’s fact limb. A figure or mechanism no surface printed (an estimate, a cost reasoned from a manifest, a behaviour inferred from a name) is not knowledge friction and is not captured. A flag or a grammar would buy the same distinction with the capture rate this loop depends on, the ground lifecycle-kit/SPEC.md §The committed gap inbox refuses its own capture-time shapes on.
  2. Triage (close) — templates/close-knowledge.md, spliced into the consumer’s close skill (the close-triage/close-brevity pattern): walk the log and, per entry, re-verify before remediating — name the claim the remediation turns on, name the command that establishes it, run that command, and remediate against what it returned. lifecycle-kit/SPEC.md §The committed gap inbox rules the same obligation for the other frictionless capture channel, and its ground is cited rather than restated. The check sits at the drain, not at capture: a capture-time check is paid on every capture and buys accuracy with the capture rate, while the drain is paid once per entry by a session already reading it. A capture carries two claims, and the second routes the fix. In <date> <fact> ← <surface>, the surface field is an implicit ownership claim: this fact has no owner, or its owner is not the surface I read it off. Both limbs are checked, and the remediation is keyed on which one fell:
    • The surface field names nothing the fact could have been read off — the line is an estimate or an inference, outside this loop’s seam. Drop it, and record in the close commit what it claimed and, where the drain measured it, what the measurement said. If it already reached a governed surface, that is work-shaped and routes to the committed gap channel, as a false fact does.
    • Both hold — a doc-owner edit: give the fact a home under the consumer’s tier contract (canon-kit’s star topology: one owner per fact).
    • The fact is false — no doc-owner edit. The entry is dropped with the correction recorded, and a false fact that already reached a governed surface is a work-shaped finding routing to the consumer’s committed gap channel, never fixed silently inside a triage step.
    • The fact holds and the ownership clause does not — a pointer from where the session looked to where the owner is, never a second home for a fact that already had one; the re-verification selects this shape rather than the filer’s own clause.
    • No cheap command settles either limb — remediate the entry as a claim: say so, and let whatever it produces carry the unverified premise openly, since a drain blocked on an unsettleable claim stalls a cheap loop.

    No remediation is ever a standing session-start instruction: that converts one re-derivation into a permanent per-session tax, which context-kit’s brevity machinery rejects. The rule binds the capture side identically, which is why a per-stage capture prompt is refused below. The outcome is recorded in the close commit that drains the log: which entries were re-verified and what fell. It rides the commit message because the log is gitignored scratch reclaimed in the same motion. Honest limit: this is an authoring obligation attested by a commit message, not a gate, the limit lifecycle-kit states for its own channel. Then remove the drained log, its named reclaim path. Nothing refuses a close that skips the walk, so the log declares itself advisory on the close-surface roster (lifecycle-kit/SPEC.md §The close-surface roster) with that drain as its reclaim command:

    close-surface: .workflow/knowledge-friction.log advisory reclaim=bash gate-sdk/bin/run-gates.sh –emit capture-drain .workflow/knowledge-friction.log

  3. Aggregate (drift) — kpi-knowledge-friction reports the per-iteration capture count, and reading it as a friction count is the error to avoid. It moves with how much friction occurred and how much of it a session stamped, so a fall is attributable to neither, and a zero reading is not evidence of zero friction. The KPI reads best exactly where it is least trustworthy: an iteration whose capture discipline collapsed reads like one whose tier contract is complete. So the emitter carries the non-inference at the point of reading, three log states and three lines:
    • absent — n/a: this tree runs no capture loop;
    • present and empty — a count of nothing, said as such; a log holding only blank lines is empty, since the count is of non-blank lines;
    • present and non-empty — a lower bound.

    --trend’s grammar does not move for the empty state and emits kfric 0, since a series whose grammar changes for one of its values is unreadable across the change (§Bundled KPIs applies the same reasoning to kpi-incident-recurrence); the limit belongs on the human-read line. Detection is the loop; elimination is a tiering edit.

Its surfaces are all in-crate or declared # no-port: — native/src/emit/kfric.rs and its ARMS row, native/src/emit/kpi/knowledge_friction.rs, and smoke/install.sh — so no port cut is owed against this section.

Refused, so an empty log does not re-open a settled call:

  • A corroborating signal, for weakness: a prompt log records that a session read history, never what fact was re-derived, so it can raise a suspicion and never resolve one.
  • A per-stage capture prompt, on step 2’s standing-instruction rule; context-kit/SPEC.md §The consumer footprint has since generalized it to any every-session obligation a kit owns.
  • A capture floor, an independent signal that capture happened: none cheap exists. The KPI stops asserting what it cannot support instead.

The published-evidence extractor

The trajectory emit arm (gate-sdk/SPEC.md §The non-gate arm) publishes the consumer’s own governed trajectory, the evidence behind a committed projection of it. The benefits claim is self-referential by ruling: the extractor emits the governed arm’s real history and states plainly that no controlled ungoverned baseline exists; a synthetic controlled A/B experiment is a separate measurement, not this mechanism.

The extractor is a pure function of closed history, byte-stable across any commit that is not a close.

  • Ranges. Each closed iteration N owns the commit range (close(N-1), close(N)], close(0) being the empty boundary, so the first row runs from the root up to its close commit. No range-scoped column reads HEAD, so an interstitial commit, filed or hotfixed after a close and before the next scope, falls into the next iteration’s range and every published row stays byte-identical until a new close lands. When such a commit may legitimately be made is lifecycle-kit/SPEC.md §Deviation transitions’ interstitial-mitigation rule, which leaves this accounting unchanged. Totals conserve: every commit up to the last close belongs to exactly one range.
  • Stability. It reads no now-relative field (no age-from-today), so re-emission over an unchanged closed history is byte-identical, so a consumer’s freshness gate can byte-compare a committed projection against a re-emission (consumer wiring, below).
  • Closed rows only. One row per closed iteration, one carrying a close stamp: an in-flight iteration’s counts are still moving and would stale the committed projection at every commit.

Per closed iteration it harvests:

  • iteration + stages run — the stamp lines from WORKFLOW-STATE.txt’s git history, since the file truncates at each scope boundary and history keeps every stamp. It renders one slot per configured stage (DRIFT_KIT_STAGES, roster order), each labelled by its shortest roster-unique prefix, so a skipped or non-roster stage reads as a gap.
  • validate attestations — the evidence-manifest lines (validate-evidence.txt history): the per-iteration suite roll-up and any non-clean verdict. This is the primary satisfiable-drift surface — a commitment made an iteration earlier that silently broke a surface shows as a failing suite, not a consistent-looking pass.
  • amendment latency — per amendment file, git add-date to delete-date (merge), the longest lag in the iteration: the commitment-to-merge gauge. An amendment is attributed to the iteration whose range contains its delete (merge) commit; its add-date may precede the range start. Fixture and template amendment paths are excluded (kpi-amendment-age applies the same */gate-tests/*/*/templates/* exclusion).
  • commit shape — the feature/debt split of the iteration’s commit subjects (kpi-task-split’s classification, applied over the same (close(N-1), close(N)] range).
  • gate-roster growth — the gates.list member count at the iteration’s close commit; with the queue’s proposed-gate mentions this bounds the named-but-unbuilt backlog.

Excluded, and stated as a limitation on the framing page: knowledge-friction counts — their log is gitignored per-iteration scratch, not committed history, so the extractor cannot harvest it and that KPI stays a session-local lower bound.

Interface: bash gate-sdk/bin/run-gates.sh --emit trajectory writes the markdown table (one row per closed iteration, stable columns) to stdout — the shape the committed projection pins; --human prepends a human-oriented header. The extractor degrades per surface to an n/a (<reason>) cell and exits 0 — drift-kit’s fail-visible discipline, registering no gate. DRIFT_KIT_TRAJECTORY_SURFACES overrides the harvested state-file paths (§Layout and configuration).

Consumer wiring. The kit ships no gate for the projection. A consumer publishing one commits the emission whole, so every number a reader meets there is emitted and none is hand-copied. It holds the page with a gate of its own that re-emits and byte-compares, the emitter/check-graph freshness pattern, its # graph: manifest coupling the page to the harvested state files.

The freshness gate is blind at the enter-close commit by construction. The extractor emits the closing iteration’s row only once its close stamp is in committed history, so that commit’s own pre-commit run regenerates rowlessly and passes. The consumer contract closes the gap: the close ritual regenerates the projection in the first queue-writing commit after the close stamp lands, the gap-inbox drain’s where it writes the queue (lifecycle-kit/templates/stages/close.md drains before it clears Done), else the Done-clearing commit. To make that commit fire the gate, the gate’s manifest names the queue file in trigger=, a trigger rather than a coupled surface, so couples= is unchanged and the couples⊆trigger parity holds; a close that skipped the regeneration is then red at that commit. CI’s full battery stays the outer backstop (gate-sdk/SPEC.md §Enforcement tiers).

The queue-flow arm

The queue-flow emit arm (gate-sdk/SPEC.md §The non-gate arm) reports how fast the design-pending pool refills, per closed iteration, so a unit set can be weighed against inflow (lifecycle-kit/SPEC.md §templates/stages/).

  • Interface. bash gate-sdk/bin/run-gates.sh --emit queue-flow [<n>], where <n> is a positive integer, default 5, the number of trailing windows. Anything else is a usage error, exit 2.
  • Windows. The iteration-start commits are the <head> of each boundary stamp, the first data line after a truncation, read from the committed history of DRIFT_KIT_STATE_FILE. A window runs from one iteration-start commit to the next. Windows are keyed on those heads, never on an iteration’s name, on §The report skeleton’s ground that a name search lands on the sentinel or a renamed stamp. The row label is the name the iteration’s last committed stamp carries. The in-flight iteration is never a window, because its end has not happened. The history is read newest first and stops at the <n>+1th start, so a long history buys only the blobs of the windows printed.
  • Rows. One per window, oldest first: <iteration> filed <f> drained <d>, where filed and drained are kpi-queue-net-delta’s definitions (§Bundled KPIs) applied to the queue file (DRIFT_KIT_QUEUE_FILE) at the window’s two commits, over the same pool sections (DRIFT_KIT_DEFERRED_SECTION, DRIFT_KIT_ICEBOX_SECTION). Slug sets at the two ends are diffed rather than filing dates counted, since dates at HEAD see only entries still deferred and would lose an entry filed and drained inside the window. The final row is mean-filed <x>, the mean of the rows’ filed to one decimal. The per-window rows are printed, not only the mean, because a session arguing a set at or below inflow reads them.
  • Degrade. Fewer than two iteration-start commits in reach, no committed state file, a window head this clone cannot resolve, or a window commit with no queue file prints one n/a (<reason>) line and exits 0. That is the fail-visible discipline of §The published-evidence extractor, and the arm registers no gate.
  • Shared code. The arm reuses the KPI’s pool parse and its filed/drained computation, so the two readers cannot disagree about what an entry is.

The overhead meter

--emit overhead-meter [transcript.jsonl | session8] measures the methodology’s own cost, so efficiency claims cut both ways: what fraction of a session’s volume is governance (gate output, hook payloads, stage ritual, governed-doc reads) versus task work. The tool is advisory by construction: exit is always 0, it never joins gates.list, and a missing transcript is a 0-exit notice, not a failure.

  • The operand names the transcript by path or by a stamp’s eight-character session id, resolved through the shared inverse lookup.
  • A bare invocation measures only a transcript it can identify. A top-level session measures <sessions-dir>/<harness-id>.jsonl under DRIFT_KIT_SESSIONS_DIR. A delegated session, the shared verdict of lifecycle-kit/SPEC.md §bin/session-id.sh, prints a notice and logs nothing: nothing in its environment names its own transcript, and every descendant’s sits beside it. So a delegated caller passes the operand, usually its own stage stamp’s id.
  • The fallback. A session whose harness exports no id falls back to the newest-candidate scan, and the reading says it did. So does a session whose child flag is set while neither its subagent tier nor a top-level transcript exists for its id, the report’s first line naming which.

The measurement is a byte-proxy at line granularity, honesty first. A fixed marker table classifies each JSONL transcript line whole, and its byte length lands in that category:

  • gate-verdict shapes to gate, the runner’s per-FAIL invariant-line prefix among them (gate-sdk/SPEC.md §run-gates), spelled by the binary’s own constant so the producer and this row spell it once;
  • hook and system-reminder blocks, stage-skill loads and governed-doc reads to the rest of governance;
  • everything unmatched to task work.

The markers are mechanism (kit names, gate-output shapes), never a private vocabulary, so the table crosses no provenance seam. Bytes are not tokens and a line is not a message: the only claim this buys is proportion across sessions of the same shape, and every emitting surface carries that caveat. No transcript content is written out — the meter emits counts and percentages only, and its log lives under the gitignored DRIFT_KIT_METRIC_DIR, the persistent measurement home an append-only trend needs to survive the scratch wipes DRIFT_KIT_TMP_DIR invites.

One line is appended per measured session to DRIFT_KIT_OVERHEAD_LOG, grammar <date> <session8> total=<bytes> gov=<bytes> gate=<bytes> pct=<n> where pct is the governance share. session8 is the dedup key the meter reads on append, so re-measuring a session replaces its line rather than double-counting it. Both meters derive that key one way: strip a leading agent-, scan the subagent tier as well as the flat one, and resolve the session delegation-aware.

A change to what the meter keys or classifies adds no log field: the date field partitions the series at the consumer’s upgrade across it. The nested-tier keys after v0.25.0 (normalize("agent-<hex>"), disjoint from the flat tier’s) and the per-FAIL invariant line moving bytes into gate are two such steps. Honest limit: kpi-overhead summarizes a trailing window, so for about that many sessions after such an upgrade its average blends the two populations.

The one derivation lives in native/src/sessions.rs, and lifecycle-kit/SPEC.md §bin/session-id.sh keeps its contract. Every input arrives on a value struct, so drift-kit still resolves its own knob, DRIFT_KIT_SESSIONS_DIR, and hands the answer in: the sharing happens below the config layer. The module exposes the normalization, the sessions-dir resolution, the delegation verdict the bare invocation branches on, the delegation-aware resolve (which returns the winning path, so a caller needing the transcript does not re-glob for the id it was handed), and their composition key. One private two-tier candidate glob serves resolve and the sibling meter’s find, which keeps the tier layout one glob. key is exposed rather than spelled at each of its three readers, on §lib/gate.sh’s own exactly one place a value is computed.

The per-category breakdown beyond gate= (hook, stage, governed-doc) stays on the meter’s stdout at measurement time; a log field with no reader is a field removed. Field readers: kpi-overhead reads pct, gate, and total (§Bundled KPIs), plus date for the reading-age caveat and the line count for the session-count caveat. gov has no log reader and is kept: it is derivable-but-stated (total - task, and the sum of the four categories), and the field-removal rule reaches a field nothing reads at all.

The producer of the log is the consumer’s close-stage binding, which invokes the arm with the closing session’s stamp id (consumer config, not a lifecycle-kit change), and any session may invoke it ad hoc. All three knobs carry working defaults, so the enabling config ships on. DRIFT_KIT_SESSIONS_DIR defaults empty and the directory is computed in-crate from that emptiness, so the bare no-override invocation needs no config (§Layout and configuration).

Its surfaces are all in-crate or declared # no-port: — native/src/emit/overhead_meter.rs and its ARMS row, the shared native/src/sessions.rs, and smoke/install.sh, the meter’s permanent behavioural oracle (§Testing) — so no port cut is owed against this section.

The economy levers this meter informs stay behind it: commit-first (the pre-commit hook already runs and prints the coupled gates) and failures-only run-gates output (clean lines carry no decision value at battery scale) are evaluation targets, not deliverables. Neither lands, and the consumer’s always-loaded battery wording does not change, until the meter shows gate-output share material over several measured sessions; their design, a gate-sdk output-mode knob, is a future amendment against that data.

Sibling meter: §The stage-economics meter prices the token draw this byte-proxy does not — they share the DRIFT_KIT_METRIC_DIR retention contract and the advisory exit-0 posture.

The stage-economics meter

--emit stage-economics answers the one question no built-in surface prices: real spend by lifecycle stage × model × iteration. The overhead meter measures governance-versus-task bytes and delegation-kit’s usage-trend the rate-window percentage; neither converts a stage’s token draw into money. This meter does, so the operator can judge close-over-close whether the model posture each stage runs on earns its cost.

It is advisory by the overhead meter’s contract (§The overhead meter): exit is always 0, it never joins gates.list, and a missing input is a 0-exit notice, not a failure. It writes only under DRIFT_KIT_METRIC_DIR, the gitignored, account-bearing persistent home, and emits no account identifiers: the trend log carries stage, model, iteration, token counts and priced cost, never the account the tokens billed to.

A log field with no reader is a field removed. Every detail below that no log reader takes stays on stdout at measurement time; that is the field rule the rest of this section cites.

The join. Three inputs, joined on the session:

  1. Stamps — the WORKFLOW-STATE data lines, one per stage-skill invocation, grammar <iteration> <stage> <session-id> <date> <head>, owned by lifecycle-kit/SPEC.md §The state machine; this meter reads that contract and changes nothing there. The <session-id> is lifecycle’s --emit-session-id normalization of a transcript id: a leading agent- stripped, then the first 8 chars (lifecycle-kit/SPEC.md §bin/session-id.sh), called session8 below. The stamp supplies the iteration↔stage↔session8 mapping.

    Stamps are read from DRIFT_KIT_STATE_FILE (§Layout and configuration) as history ∪ live: that path’s committed history, the lines added across its diffs, unioned with its current content, so a /scope boundary truncation of the live file destroys no economics. Union rather than replacement, because replacement blinds the meter to a stage stamped but not yet committed, the in-flight stage being read. Union rather than fallback, because the live file is almost always present and almost always truncated, so a fallback would never fire where it is needed. The iteration/stage/session8 dedup collapses a stamp seen in both arms.

    The git read has one owner and the field parse has two, deliberately. native/src/history.rs owns the -p -U0 invocation and the +-prefix convention, and yields (commit, added_line) pairs with the +++ b/… header excluded there. A second producer of that coupling is what criterion 6 of gate-sdk/SPEC.md §The port-candidate criteria refuses. The field selection is not shared: §The published-evidence extractor wants (iteration, stage) filtered by its stage roster, and this meter wants (iteration, stage, session8) with no roster filter, because a stamp whose stage is outside the roster still carries real spend.

    The read is unbounded and carries no depth knob, as the trajectory extractor’s is: a stamp whose session has aged out of DRIFT_KIT_SESSIONS_DIR takes the unmatched path and costs one skipped row, which already bounds it. Unmatched stamps are counted into one summary line rather than listed, so unbounded history never means unbounded output.

    Diagnosing a stage absent from the trend log. Truncation is never the cause, since a truncated stage still prices off history. A missing (iteration, stage) row means that stamp resolved no transcript (counted in the unmatched summary) or its transcript carried no assistant-turn usage, both reported at measurement time. The union backfills a truncated stage only on the first run that reads far enough back, so a log sampled before that run reads as permanent loss. Re-run the meter before concluding the history arm is lossy.

  2. Transcripts — under DRIFT_KIT_SESSIONS_DIR. A stamp’s session8 selects a transcript by applying the same normalization to each candidate basename, never by a raw filename prefix: a dispatched session’s transcript is agent-<hex>.jsonl, so a raw prefix would match nothing. Candidates are two-tiered: a lead session sits directly under the sessions dir, and the sessions it dispatches under <lead-session-id>/subagents/. The normalization, the sessions-dir default and the two-tier scan are the shared derivation’s (§The overhead meter). This lookup and the under-count bound below walk the resolver’s own candidate globs, so a change to the tier layout moves one glob.

    The meter sums the matched session’s assistant-turn usage into four token categories — input, output, cache_read, cache_creation — per model id seen on those turns. A streaming transcript repeats a message id across lines, so the sum keeps the last usage per message id; summing raw lines would multi-count. The reader is in-crate: a line it cannot parse is skipped rather than fatal, and a transcript with no assistant-turn usage keeps its named skip line. The arm spawns git, date off unix, and no interpreter.

    The join keys on the session, not the stamp — the attribution invariant for stage rows. Under LIFECYCLE_KIT_SESSION_BOUNDARY (lifecycle-kit/SPEC.md §The state machine) a session maps to at most one stage. A stage may draw from several sessions: a batch split stamps once per session, and they sum into the stage’s one row (the fold, under The trend log). Under an iteration-boundary consumer one session may bear several stamps. Its usage is then summed once and attributed to the (iteration, stage) of its last stamp, and every stamp it yielded takes no row and is named in a caveat. Keying on the stamp would resolve one transcript twice and bill its whole burn to both stages. It is an assignment, not a split: apportioning would need an allocation key nothing in the join measures. Last rather than first, because a stamp is its stage’s first step. The yielded stages under-report, and the caveat makes that residue countable.

  3. Price table — a consumer-supplied data file mapping model id → per-token price for each of the four categories, resolved from DRIFT_KIT_PRICE_TABLE (§Layout and configuration). It is consumer config, never a kit literal (the provenance seam, the check-graph/graph-vocab pattern): prices are public facts, but the roster of models a consumer runs is theirs. The kit ships templates/price-table.tsv with placeholder rows and the column schema; the consumer copies it and fills their roster.

    A row’s optional sixth column, effective_from, is an ISO day, so a price change is a second row for the same id with a later date. An empty or absent column is an open start. The row in force on a day has the latest effective_from on or before it, an open start being earliest, and a day before a model’s every row has none. A second row repeating a model and date replaces the first. A row whose sixth column is not an ISO day prices nothing, and the run counts such rows in one stdout line. A cost cell is priced at the row in force on the log row’s own date (The trend log), the run’s date where no dated stamp names the row. So a re-run reprices no row whose rate did not change, and a rate added for a date reaches only rows dated on or after it.

    Two dating headers sit on the same consumer-owned header block, each read as the first line matching ^#[[:space:]]*<field>:[[:space:]]*<YYYY-MM-DD>, trailing prose ignored: priced-as-of:, when the numbers were last transcribed, and the optional prices-valid-through:, the last date every row is still true. Their reader is kpi-price-table-age (§Bundled KPIs), never this meter, so an expired table still prices, loudly rather than silently. An unpriced id is --price-coverage’s (§The price-coverage arm). A scheduled change whose next rate is published is a row dated for the change. prices-valid-through: owns the date the table is vouched for through: a cliff whose next rate is unpublished, or a re-check horizon. The file’s prose cites the header rather than restating the date (de-literalization).

Degradation. An absent live state file is a notice and the run continues to the history arm, since committed history can carry stamps for a file absent from the worktree. The 0-exit “nothing to read” notice fires only when both sources yield no stamps, and a git read that fails yields no history. A price table that is absent, or that has no row in force for a model on its row’s date, degrades that cost cell to n/a and the token counts still report — the degrade DRIFT_KIT_TRAJECTORY_SURFACES applies to an unreadable surface (§Layout and configuration). A cell whose four token counts are all zero prices 0.0000 at any rate, table or no table: a harness writes placeholder-id turns with no usage, and such a cell never raises the caveat below. Cost is additive over priced cells only, and the output carries an “incomplete pricing” caveat when any contributing cell degraded.

The under-count bound. The unmatched counter sees the stamp with no transcript and is blind to its inverse, the transcript with no stamp. A stage that continues in a new session (a credential swap mid-stage, any resume) leaves that session unstamped, so its burn is invisible rather than wrong. The meter counts the transcripts under DRIFT_KIT_SESSIONS_DIR that no row claimed and reports the count. The fan-out row below places a dispatched transcript through its anchor, so what stays counted resolved no anchor: an upper bound, never an attribution, since such a transcript descends from no stamped session. Most of it is ordinary non-lifecycle sessions. The one lifecycle case left is the unstamped continuation, a resumption with no parent edge to walk. Attributing it would need the stamp side to record it, which is lifecycle-kit’s contract, so drift-kit only sizes it.

The port-owed set is empty, the kit’s with it. Every surface declaring this section is in-crate — native/src/emit/stage_economics.rs, its ARMS row, the shared native/src/sessions.rs and native/src/history.rs — or declared # no-port:: smoke/install.sh, the meter’s behavioural oracle across six fixture sets. So no port cut is sequenced against this section, and with §The overhead meter’s, drift-kit holds no owed file and no bin/ directory.

The trend log. One line is appended per (iteration, stage, model) triple to DRIFT_KIT_STAGE_ECONOMICS_LOG, a sum over that key’s sessions, grammar:

<date> <iteration> <stage> <model> in=<tok> out=<tok> cr=<tok> cw=<tok> cost=<usd|n/a>

<stage> is the stage-or-role column: a lifecycle stage, a cost-bearing role, or either with the fan-out suffix appended (the reserved supervision value and the fan-out row, below). cr is cache-read and cw cache-creation. cr is the headline field: cache-read of accumulated context, not model choice, is the dominant burn, and the field keeps that lever visible close-over-close.

The dedup key is the triple, and a replace is in place. Re-measuring a triple rewrites the key’s first line where it stands, drops any later duplicate of that key, and appends only a key new to the log. With the stamp-date rule below, a second run over unchanged stamps, transcripts and price table writes a byte-identical log, which is a fixture check rather than an inference. A run that emitted no row neither creates nor rewrites the log. The key also makes the history ∪ live read safe: a history arm re-derives rows already logged, and re-derivation replaces rather than double-counts.

The fold happens before the write. A stage run as several sessions in one iteration stamps once per session, so one triple draws from several transcripts. The pass accumulates in memory across a row key’s sessions and emits once per key, so the writer runs once per key and its replace can never annihilate a sibling. The key is a tuple joined by a separator no field can carry, so no two keys alias. Writing once per session would lose all but one session’s row inside a single invocation, because the run seeds its retained set from the log and writes it back whole. An accumulating write is refused: the collector re-derives every historical row on every run, and the replace is what keeps the history ∪ live union from duplicating them. The fold moves the summation earlier; it does not move it into the log.

The row’s stdout discriminator degrades to a session count. The stdout report keeps one line per emitted row, and its who column reads <n> sessions where a key drew from several sessions, as the fan-out row’s reads <n> anchors; for one session it is that session’s id. This is the fold’s honesty half: without it a fold replaces a silent loss with a silent sum, and the /economics narrative is where the caveat is read. No log field is added, under the field rule: the narrative reads stdout and no log reader reads a count.

Two alternatives are refused. Widening the dedup key with the session id: the id is not written to the log, so this adds a column the grammar never had and moves every surface quoting the grammar. A folded fan-out row or a supervision row would have no id to write. Having the writer recompute the row as an aggregate is this fold with more machinery, and it buys no completeness, since the finder resolves at most one file per short session id.

What the fold costs. For a split stage neither the log nor stdout answers what each session cost: the breakdown collapses into the summed row and its count, the narrowing the fan-out row already carries. A sum still names its contributors’ number, where a replace named nothing. The narrowing is accepted on the log’s purpose — the trend, not the audit. Its readers are a close-over-close comparison, a benchmark consuming this log, and the tiering watch below, and each reads a per-stage series.

Per-model row order is deterministic, first-appearance order, so a session touching two models emits its rows in the same order every run. No row’s identity depends on the order, the dedup key being the triple. Any per-model sub-breakdown beyond these fields stays on stdout. Field readers:

  • the /economics narrative reads cost and the four token fields, cr the headline;
  • the operator reads cost close-over-close;
  • a controlled A/B benchmark’s measurement half would consume this log rather than rebuild it;
  • kpi-stage-economics-lag reads the iteration field (§Bundled KPIs);
  • the consumer’s lead binding orders the bare align rows by date to read a cr trend.

date is the stage’s stamp date, never the day of the run. The collector re-derives history on every run, so a run-dated column would put most of the history on the last run’s day and order the series by when the meter ran. The date comes from stamps alone, never from transcripts. From the history ∪ live stamps it already reads, the run collects the latest date per (iteration, stage) and per iteration (ISO dates, so the latest is the greatest string). A row’s date is:

  • a stage row — the latest date among the stamps naming its (iteration, stage);
  • a fan-out row — the same lookup on its stage with DRIFT_KIT_FANOUT_SUFFIX removed, the anchor’s date being its subtree’s; a supervision anchor’s fan-out takes the supervision row’s date;
  • a supervision row — the latest date among all the iteration’s stamps, since a lead carries no stamp of its own and supervises the whole iteration.

Latest, not first: a split stage’s row is a sum, and its latest session is the one that makes the sum final. The rule applies to every line the run writes back, not only the lines it re-measures. Before the one write, every retained line whose (iteration, stage) derives a date takes it. A row whose transcript has aged out is re-dated from its surviving stamps, and a run-dated log heals on its next run with no migration step.

Degradation is visible and counted. A live-file stamp whose fourth field is not an ISO date contributes no date, and a stamp whose iteration field falls outside the history arm’s grammar, such as an unnamed-iteration placeholder, dates nothing from history. A row whose key no dated stamp names keeps the date it has, or takes the run’s date when new. Every such line written adds to one stdout count, <n> row(s) dated by measurement — no dated stamp names their (iteration, stage), printed only when non-zero. Dropping such a row is refused, since it would destroy spend the meter measured correctly. The measurement date lives on stdout only, the run header’s stage-economics: <today>: a logged row is either re-measured every run or final, so a reading age on it has no reader.

The reserved supervision value — the lead’s burn is its own row. Under a split-lead posture the lead session dispatches, verifies and runs batteries while carrying no stamp, so without this row every per-stage total understates the iteration by the whole supervision line item. The meter emits it as a distinct row labelled by DRIFT_KIT_SUPERVISION_LABEL (§Layout and configuration). A row rather than an apportionment across stages: the lead’s burn is proportional to no stage’s tokens, so spreading it would fold a fabricated number into figures a tier decision is read off. A distinct row is a line item the operator can accept, discount or ignore. The meter’s contract is that a degraded or absent measurement is visible, never silently folded (§The report skeleton).

  • The column’s widened meaning. <stage> reads stage or cost-bearing role. A supervision row is a role that carries cost and no stamp, never a lifecycle stage that dropped out of the roster; a reader assuming the column is roster-closed would misdiagnose the row as roster drift. The fan-out row below widens it again, admitting either member with a suffix appended.
  • The producer — derivation from the transcript path, not from a stamp. The two-tier scan resolves a dispatched stage session at <sessions-dir>/<lead-session-id>/subagents/<agent>.jsonl. For every stamp whose transcript resolved on that nested tier, the <lead-session-id> component names the supervisor and the stamp supplies the iteration. The meter collects (lead-session, iteration) → dispatch count, resolves each lead’s own transcript on the flat tier, and sums its usage per model with the same reader, no second parser. No lifecycle change: the lead still stamps nothing and moves no cursor, so this stays a read-only consumption of lifecycle-kit/SPEC.md §The state machine.
  • The attribution invariant. A transcript’s usage is attributed to exactly one row key, an (iteration, stage-or-role) pair or that pair’s fan-out value. It is why the session-keyed join above holds for stage rows, why a lead whose own transcript carried a stage row yields no supervision row, and why a dispatched transcript resolves exactly one anchor. One guard serves all three: a transcript already claimed by a row is never a candidate for another. A lead supervising one iteration lands its whole usage on that iteration’s supervision row, with no key.
  • Apportioning a lead across iterations. A lead spanning two or more iterations apportions in proportion to the number of stamped stage sessions it dispatched per iteration. The split is integer, with the remainder to the iteration holding the most dispatches and ties broken by iteration name, so it is deterministic and the parts re-sum to the whole. The run’s caveat names that key: a disclosed key is honest and a silent one is not.
  • Collision rule. If any stamp the run reads names a stage equal to the label, the meter emits a visible notice naming DRIFT_KIT_SUPERVISION_LABEL and no supervision rows that run. The check reads only the meter’s own stamps, so it adds no roster dependency. A suppressed anchor suppresses its fan-out: a lead with no supervision row is no anchor, so its direct fan-out emits nothing rather than an orphan row the reader cannot place.
  • Degradation. A run with no nested-tier match, a stage run without a live lead, emits no supervision row and no notice: zero supervision burn is the honest reading, not a missing measurement. A lead transcript aged out of the sessions dir takes the unmatched-summary path.
  • No new field. The four token fields, cost and date carry a supervision row as they carry a stage row, its date the latest stamp date of its iteration (The trend log). The dedup key, the in-place replace and every existing reader work unchanged: supervision is a value in an existing column. The apportionment key and the collision notice are stdout caveats, under the field rule. The row’s named reader is the /economics narrative’s supervision line item (§The /economics skill).
  • Readers of the widened column. Two production readers parse the log: this meter’s own dedup match, and kpi-stage-economics-lag, which reads the iteration field and never the stage column. smoke/install.sh counts its lines. No gate reads it, since it lives under the gitignored DRIFT_KIT_METRIC_DIR, and the /economics narrative reads it as prose. The trajectory arm never reads this log: it reads stamps and its own DRIFT_KIT_STAGES roster, so a non-stage value in the column cannot fall out of a roster there.
  • A fourth reader parses the column. The consumer’s lead binding judges an align-tier decision on the cr field of the bare align rows, never mixed with the align+fanout family. It is prose-tier and hand-read, so the claim that holds is “no reader in production code parses the column”, never “no reader parses it”. The fold keeps this reader’s contract — the stage token, the bare-versus-suffixed discrimination, one row per (iteration, align, model) — but moves its conclusions. A split stage’s cr rises to its true value, so a tier judged cheap on a truncated series may not be, and re-judging it belongs to whoever owns the tiering judgment.

The fan-out row — a stage’s dispatched subtree is its own line item. A stage session that dispatches agents pays for their whole subtree, yet a dispatched transcript carries no stamp, so without this row each per-stage figure understates its stage by its fan-out. The meter bills the subtree to a row whose <stage> value is the anchor’s stage-or-role with DRIFT_KIT_FANOUT_SUFFIX appended (§Layout and configuration) — build+fanout, close+fanout, supervision+fanout.

  • A suffixed value rather than a bare reserved label. A bare fanout label, the shape supervision takes, would lose which stage’s fan-out it was. The suffix keeps the (iteration, stage) join intact and sorts the subtree row beside its own stage row.
  • A separate row rather than a fold into the stage row. Separability is the deliverable: a folded total would leave the close-over-close series blind to the split it was extended to show. It would also silently redefine a value already logged, so one close row name would mean two things across the change.
  • No dispatch-type dimension. A fork’s spend and a typed dispatch’s spend fold into one anchor total, so the row is an aggregate proxy for what a stage’s delegation cost, never an isolation of fork cost. A fork is distinguishable at the meta layer: its record carries isFork beside an agentType naming the fork type, and no model. Fork cost is re-derived by walking parentAgentId and pricing the fork transcripts directly, the cost of leaving the dimension out until a reader for it exists.
  • The anchor set. An anchor is a transcript already holding a row of its own. Every stamped stage session whose transcript resolved anchors its subtree to that stamp’s (iteration, stage). Every lead that emitted a supervision row anchors its own direct fan-out to (iteration, DRIFT_KIT_SUPERVISION_LABEL), since a lead’s audit sweeps are as unbilled as a stage’s. A stage anchors on its stamp resolving, because a stamped stage whose transcript carries no usage is still a placeable (iteration, stage); a supervision role that emitted no row is no role at all. A lead that carried a stage row anchors its fan-out to that stage row, with no special case.
  • The producer — the parent walk, nearest anchor wins. A grandchild sits flat in the same <root>/subagents/ directory as a child, so the path cannot carry the parent edge. The harness writes it in a .meta.json sibling of each nested transcript, whose parentAgentId names the parent as a bare id. That id resolves against agent-<id>.jsonl in the same directory, with <id>.jsonl accepted as a fallback, so neither spelling is guessed. A record with no parentAgentId is a direct child of the root session. For each subagents/ transcript that is neither an anchor nor already holding a row, the meter walks parents until an anchor is reached and gives the nearest anchor its usage. Intermediate agents are transparent, so a deep fork lands under the stage session that caused it. A walk reaching the root with no anchor emits no row and leaves the transcript in the under-count bound.
  • The walk’s bounds. The walk is bounded by the transcript population and by a visited set, so a cycle or a dangling parentAgentId costs a counted notice rather than a loop. The parent forest is read in one pass per subagents/ directory rather than one per hop, and usage through the stage rows’ reader, so each attributed subtree member costs one transcript parse.
  • Reading a harness-private artifact. The meta sibling is under no contract, and the meter reads it because it is the same coupling class the meter already runs on: one file over from the transcript usage schema and the <root>/subagents/ layout. A shape change fails as a visible no-op rather than a wrong figure. That is why coupling to a private shape is admissible for an enrichment and would not be for the pricing arithmetic. A dispatcher-minted key is refused: a forgotten convention under-counts silently in the tier this row exists for. It would also relocate the harness coupling into parsing a child’s first prompt, and cannot reach a fork, which inherits context rather than receiving an authored prompt.
  • Apportionment. A lead spanning several iterations apportions its fan-out by the same dispatch-count key, with the same integer split, as its supervision row. One key over one lead is what keeps the two rows agreeing about which iteration the lead belonged to.
  • Several anchors, one row. A batch split gives one (iteration, stage) several anchors, one per session, and their subtrees sum into the single fan-out row. The attribution unit is the row key, not the anchor, so this is The trend log’s fold, stated there for both row families. What stays the fan-out’s own is the order: apportionment first, the fold second, since the dispatch-count split is a property of the anchor and not of the row. The stdout caveat names the contributing anchor count where it exceeds one, so a folded row is never read as one session’s.
  • Collision rule. The default suffix is collision-proof by construction: the stamp reader’s stage-field alphabet is lowercase alphanumerics and hyphens, so no stamped stage carries a +. An override can break that, so the check mirrors the supervision label’s. A stamp whose stage ends in the suffix raises a visible notice naming DRIFT_KIT_FANOUT_SUFFIX, and the run emits no fan-out rows.
  • Degradation — the contract that bounds the coupling. Every arm degrades to a visible absence, never to a wrong figure, and never to a non-zero exit:

    what is missing behavior
    the meta layer entirely no fan-out rows; one notice; every existing row unchanged — the meter’s behavior before this row existed
    one transcript’s meta record that transcript takes no fan-out row and stays in the under-count bound; counted in the unresolved notice
    parentAgentId naming an agent with no transcript same — counted, never guessed
    a cycle or an over-long chain same — the walk is bounded and the transcript counted
    the price table absent or missing a row in force for a model the row’s cost degrades to n/a and raises the existing incomplete-pricing caveat, exactly as a stage row’s does
  • No new field. The four token fields, cost and date carry a fan-out row as they carry a supervision row, its date its anchor’s (The trend log). The dedup key stays the <iteration> <stage> <model> triple, and a re-measure replaces the row’s line like any other. The split, the transcript count and every degradation stay stdout caveats, under the field rule. The row’s named readers are the /economics narrative’s fan-out line item (§The /economics skill), the operator reading the log close-over-close, and a controlled A/B benchmark’s measurement half, which inherits the row with no change.
  • No lifecycle change. No stamp is added, no cursor moves, no stage-skill template changes. The fan-out edge is derived, as the supervision edge is, which keeps this a read-only consumption of lifecycle-kit/SPEC.md §The state machine.

Feeding — a consumer whose decisions read the log binds a feeding point. The meter is on-demand, so a log nobody runs it into goes stale with nothing to say so. A consumer that reads the log, for a tier decision or a close-over-close cost comparison, binds a run into its terminal stage’s housekeeping. By then every stamp of the iteration exists, and that stage’s transcript and its lead’s are still on disk. A row is dated by its stamps (The trend log), so re-running re-dates nothing and the run is safe at every close. kpi-stage-economics-lag (§Bundled KPIs) makes a skipped feed visible. The kit names no stage and no binding: where the feed runs is consumer config.

Refused: a freshness gate, and on-demand only. A freshness gate is ruled out by the advisory contract: the log lives under the gitignored, account-bearing DRIFT_KIT_METRIC_DIR, absent in CI, so a gate could only pass vacuously or red on a cause no commit produced. That is the ground §Bundled KPIs gives for the price table, and why the signal is a KPI. On-demand only would cut the log’s decision reader, a lead binding’s tier-revert signal, and leave that tier with no way to be re-judged.

The manual-operation meter

--emit manual-ops [iteration] ranks the operations an iteration’s sessions repeated by hand, so the close can see where a tool would remove work. No other channel sees them: the stage-economics meter prices tokens, the overhead meter governance bytes, the prompt-friction log only the shell calls the guard did not decide (guard-kit/SPEC.md §scan-prompts), and the knowledge-friction log only what a session reports. It is advisory by the overhead meter’s contract: exit is always 0 bar a usage error at exit 2, it never joins gates.list, a missing input is a 0-exit notice, and it writes only under DRIFT_KIT_METRIC_DIR.

  • The sessions. The operand names an iteration, by default the one the last data line of the live DRIFT_KIT_STATE_FILE names. The transcripts read are those §The stage-economics meter attributes to that iteration’s row keys — its stage rows, its supervision row and their fan-out — through the meter’s own attribution run without a price table or a log, never a second derivation. So a transcript counts once, and a lead’s and a dispatched agent’s work are both in scope. A transcript is one session, and a lead apportioned across iterations counts whole in each.
  • The operations. Each tool_use block on an assistant line is one call, and a block id a streaming transcript repeats counts once. A shell call, from the harness’s shell tools, is keyed by the prompt-friction ranking key, a PowerShell call by its PowerShell key (guard-kit/SPEC.md §scan-prompts), so the two channels name a shape alike. An edit call, from the harness’s file-writing tools, is keyed by its path relative to the line’s cwd, else to the repository’s toplevel. A path under neither keys as (outside), so no path off the repository is printed. Any other tool is not counted. A line the reader cannot parse is skipped, as the stage-economics reader skips it.
  • The ranking. Keys in DRIFT_KIT_MANUAL_OPS_IGNORE, the shapes a consumer counts as ordinary work, are dropped. The rest rank by call count, then session count, both descending, then key, and the first DRIFT_KIT_MANUAL_OPS_TOP print, one line each: <calls> <sessions> <iterations> <kind> <key> [<rows>]. <rows> names the stage-or-role values of the sessions that made the calls, and <iterations> counts the logged iterations in which the key ranked, this one included.
  • The log. The printed keys are written to DRIFT_KIT_MANUAL_OPS_LOG, one line each, <iteration> <kind> <key>. A re-run replaces the iteration’s lines where they stand and appends a new iteration’s, so a second run over unchanged inputs writes a byte-identical log, and a run printing no key writes nothing. Its one reader is this arm’s <iterations> column: a shape that ranks iteration after iteration is the strongest tooling candidate, and transcripts age out of the sessions dir while the log persists. Counts, sessions and rows stay on stdout, under §The stage-economics meter’s field rule.
  • What it never writes. No command text and no transcript content: a key is a shell shape or a repository-relative path. The log carries no account identifier.
  • A candidate is never a verdict. A high count can be the work itself, as an amendment’s edits are; the reader of the ranking judges which a tool would remove (§The /economics skill).

The tool names are the harness’s transcript vocabulary, the coupling class §The stage-economics meter’s fan-out row already reads; a renamed tool fails as a visible zero, never a wrong count.

The price-coverage arm

--price-coverage answers, before a session tiers anything, whether the price table can price the models the harness is running. A new id is also the signal that the harness’s model roster moved, so its first reader is the lead’s first step (lifecycle-kit/templates/lead.md). It is not a KPI row: kpi-price-table-age stays header-only, because a coverage row would scan hundreds of megabytes of transcripts inside every drift report for a reading the lead already takes before its first dispatch.

The roster half. It reads every transcript under DRIFT_KIT_SESSIONS_DIR modified within the last DRIFT_KIT_PRICE_COVERAGE_DAYS days, through the shared two-tier scan (§The overhead meter). It sums usage per model id with the meter’s own reader, so the arm and the meter agree on what a model id is. An id counts only when its four summed counts are not all zero, the test by which the meter prices a cell at 0.0000 (§The stage-economics meter). An id is priced when DRIFT_KIT_PRICE_TABLE holds a row for it in force today (§The stage-economics meter, input 3). It prints one head line, price-coverage: <m> model id(s) with usage in <n> transcript(s) of the last <d>d. An indented line follows: all priced, or unpriced: <id>[, <id>…] — no row in force today in <table> with the ids in first-seen order. An absent table reads every id unpriced and says no price table in place of the row clause. An empty window prints zeros and all priced. A sessions dir that is not a directory prints no sessions dir <dir> — set DRIFT_KIT_SESSIONS_DIR in place of that line, so a misresolved dir never reads as a clean window.

The page half runs only where DRIFT_KIT_PRICE_PAGE_CMD is non-empty, and ships off: it is network at session start (Policy-as-choice, and gate-sdk/SPEC.md §The adopter constraints). The fetch is the consumer’s own command, so no URL, provider or fetch program enters the kit or an adopter’s floor. Its argv is spawned directly, with no shell, and its stdout is the pricing page. It runs under a wall-clock bound of DRIFT_KIT_PRICE_PAGE_TIMEOUT seconds, and a command outliving it is killed and reads as a failed fetch, so a hung fetch cannot wedge the step running the arm. The section is the page’s lines from the first line equal to DRIFT_KIT_PRICE_PAGE_SECTION up to the line before the next heading of the same or a higher level (as many or fewer leading #, then a space or tab), with CRLF read as LF. Its hash is git hash-object --stdin over those bytes, the floor program rather than a new dependency. The stored hash is the table’s # price-page-hash: header, read as the dating headers are, its value the first whitespace-delimited token after the colon. The arm never writes it: the session re-verifying the rows transcribes the printed hash with priced-as-of:. It prints one line, price-page: followed by one of:

  • off
  • unchanged since priced-as-of <date>
  • CHANGED since priced-as-of <date> — re-verify the rows, then record: # price-page-hash: <hash>
  • n/a (no price-page-hash: header) — record: # price-page-hash: <hash>
  • n/a (no section heading configured)
  • n/a (section heading not found)
  • n/a (fetch failed: <status>), <status> being exit <n>, timed out after <n>s or the spawn error

A changed hash says something in the section moved, whether a wording edit, a new model or a price, and never which, so the answer is always a re-read. A restructured page reads as changed or as not found, which is the same instruction.

It is advisory and exits 0, and exits 2 on any operand or a knob outside its shape. Its exit grammar is the --emit- collapse, yet it is an Arm::Run: that family joins the fence-safe set by derivation, and the page half spawns a consumer’s fetch, which may reach the network (gate-sdk/SPEC.md §The non-gate arm). Its knob roster is DRIFT_KIT_PRICE_TABLE, DRIFT_KIT_SESSIONS_DIR, DRIFT_KIT_PRICE_COVERAGE_DAYS, DRIFT_KIT_PRICE_PAGE_CMD, DRIFT_KIT_PRICE_PAGE_SECTION and DRIFT_KIT_PRICE_PAGE_TIMEOUT. The arm, its knobs and the section rule are kit mechanism; the rows, the fetch command, the heading and the stored hash are consumer config, and no kit literal names a model id, a provider or a URL.

Named callers: the lead at its first step, before its first dispatch is tiered, and any session judging a cost=n/a cell. Honest limit: the window is by modification time, so an id last used before it reads as absent rather than priced. And “in force today” passes an id whose only row starts after sessions that already ran on it, which the meter prices n/a for those sessions.

The install-observation record

A gate’s fixture pair proves it does what its author specified. It cannot distinguish a gate that is right from a gate whose author agrees with it, because in the authoring tree the party that writes a red and the party that dispositions it are the same party. The distinguishing evidence is a red hit by someone who did not write it, and that evidence is unrecoverable: a red nobody wrote down was never observed. So the channel’s field set is fixed before the first observation, not grown to fit the observations that arrive.

The record is two files, one per seat, each resolving under DRIFT_KIT_METRIC_DIR and inheriting that dir’s retention and privacy contract in full (§Layout and configuration): append-only, surviving a scratch wipe, gitignored, never committed. DRIFT_KIT_INSTALL_RECORD holds what a non-author hit. DRIFT_KIT_AUTHOR_INSTALL_RECORD holds the author seat’s observations: the party that wrote or ships the gates under observation, and whoever installs on its behalf, such as a rehearsal on a machine the author controls or a CI smoke. Both take the grammar, key rules and refusals below. The records key to people, so both files are private. §The install-evidence projection is the only thing about the channel ever published, and it reads the non-author record alone. The filer declares the seat and nothing verifies it: an author install filed without the flag reads as external evidence.

The seat is a second record rather than a field on the line. A seat field on install would leave red and checkin lines to take their seat by joining on <id>, and a red whose <id> has no install line would have none. It would also change the arity of a shipped grammar. A separate file needs no join, and the projection’s single input keeps author evidence out of every published count. The author record’s reader is the observer at the write-up, the reader <profile> and <floor> already have, so it takes the same grammar and no projection of its own.

One line per observation event, whitespace-separated positional fields, the date first and the event kind second:

<date> install <id> <profile> <floor> <ttfg>
<date> red     <id> <gate> <verdict> <disposition> <behaviour>
<date> checkin <id> <day> <kit> <retained>

Fields split into two classes, and the split is the seam rather than a convenience. A field the projection aggregates into a published count takes a closed value set, because an open value there produces a column no projection can render honestly:

  • <verdict> — true-positive false-positive unclear. Whether the red named a real defect.
  • <disposition> — fixed worked-around bypassed abandoned. What the person hit by it did.
  • <behaviour> — changed unchanged. Whether the red changed what the tree does, which is what separates a gate that taught something from one that was merely satisfied.
  • <day> — 7 30. The check-in horizon.
  • <retained> — yes no.
  • <ttfg> — a non-negative integer, minutes from the first install command to the first green gate run, or - where no green was reached. - is a value, not a gap: an install that never went green is the observation this channel most needs and the one a missing line would erase.

Every other field — <id>, <profile>, <floor>, <gate>, <kit> — is free text validated for shape alone, because each names a consumer’s vocabulary. A kit literal enumerating one consumer’s profile names, host floors or gate roster would publish that vocabulary and be false for every other consumer (gate-sdk/SPEC.md §The provenance seam). The projection reads the consumer’s own DRIFT_KIT_GATES_FILE as a classifier of <gate>, never as a validator. An adopter’s red may name a gate this roster does not carry, and refusing it would discard the observation over a naming disagreement.

<id> is an opaque install key the observer mints. It is the record’s privacy hinge: it joins an install’s rows to each other without carrying anything about who ran it, so the private file can be read by a human and the public projection can aggregate over it without either needing a name.

One key rule per kind, because assuming a single rule would be wrong for two of them. The sibling meters replace a measured line; this channel mostly does not.

  • red is pure append. Two identical red lines are two reds. A red event has no natural key — the same person can hit the same gate twice, and collapsing that would erase the recurrence the channel exists to measure.
  • install replaces on <id>. One install has one first-green time; a re-measure corrects it rather than adding a second install.
  • checkin replaces on the <id> <day> <kit> triple, the shape §The stage-economics meter already uses for the same reason: a re-run of a check-in must not double-count retention.

A replace is in place, the shape §The stage-economics meter’s trend log fixes. The key’s first line is rewritten where it stands, any later duplicate of that key is dropped, and only a key new to the record appends. So a re-file corrects rather than reorders, and a re-file of unchanged fields leaves the record byte-identical.

What is not stored, because it is derived. First useful red is the first red line for an install whose verdict is true-positive; per-gate true/false-positive history is a group-by over red lines. Storing either would be maintaining a fact the record already determines, and the two would then be able to disagree.

Interface. The arm is bash gate-sdk/bin/run-gates.sh --emit file-install [--author] [--] <kind> <field>.... It appends one line, stamping the date from date +%F and creating the record’s parent dir if missing. Its confirmation is file-install: <line>, followed by (replaced) where a key rule below replaced a line. --author as the first token files the line into DRIFT_KIT_AUTHOR_INSTALL_RECORD instead. Anywhere else before a -- it is a flag in a positional slot and is refused like any other, so a misplaced flag never lands a line in the wrong seat. After a -- it is a value, as every token there is. The flag takes no part in any kind’s arity. Its arities are the grammars above, less the date:

--emit file-install install <id> <profile> <floor> <ttfg>
--emit file-install red     <id> <gate> <verdict> <disposition> <behaviour>
--emit file-install checkin <id> <day> <kit> <retained>

It refuses with a usage message at exit 2, writing nothing, on:

  • an unknown kind, or the wrong arity for the kind;
  • an empty positional;
  • a closed-set field whose value is outside its set;
  • a free-text positional failing the shape scan (gate-sdk/SPEC.md §The bin/-tool contract), every positional scanned, since several slots make arity safe in none.

-- ends option processing, which is also how a - <ttfg> is filed. --help is a refusal at exit 2, not usage: a non-gate arm’s usage lives in bin/run-gates.sh’s own help and in this kit’s README, the spelling --emit kfric already carries.

Its declared knob roster is DRIFT_KIT_INSTALL_RECORD and DRIFT_KIT_AUTHOR_INSTALL_RECORD.

The refusals are the protocol. An observer who runs the arm is told what the record wants, which is why the closed sets are enforced at capture rather than at projection. That is the whole observation protocol this channel needs. A separate protocol doc would be a second home for a contract the arm already states. An always-loaded instruction bullet would turn a bounded campaign into a permanent per-session tax, and §The knowledge-friction loop’s standing-instruction rule binds capture here too.

Named caller and transition, which gate-sdk/SPEC.md §The non-gate arm requires of every arm: the caller is a human observer at an observed install or a check-in, filing with --author for the author seat’s own. The non-author record is read at the projection’s next emission, and the author record at the observer’s write-up.

The install-evidence projection

bash gate-sdk/bin/run-gates.sh --emit install-evidence writes a markdown table set to stdout — the shape a consumer’s committed projection pins; --human prepends a human-oriented header, the spelling §The published-evidence extractor’s arm carries so a comparator’s default is the document rather than a banner. It degrades per block to an n/a (<reason>) cell and exits 0, this kit’s fail-visible discipline, registering no gate of its own. A degraded row keeps its block’s column count, so the table’s shape never moves with its content.

It is a pure function of the non-author record, DRIFT_KIT_INSTALL_RECORD; the author record is not an input, so every block’s population is non-author by construction. A record line the parser cannot read, a short one included, is skipped rather than fatal. The projection is byte-stable over an unchanged record: it reads no now-relative field, so nothing ages between emissions. Every group-by renders in a fixed order — gate and kit names sorted, <day> ascending — so two emissions over one record cannot differ by iteration order alone. A byte-comparing freshness gate rests on that property; without it the gate would flap rather than assert.

An absent record and a record of zero observations are one reading, the counted zero, and the projection says so rather than degrading: every block renders, its denominators read 0, and n/a (<reason>) appears only where a statistic has no population to be taken over. A published zero is the candid form of the same gap a missing page would leave unstated.

Every published figure carries its denominator. The population this measures is small by construction, and a rate published without its n is the dishonest form of exactly the claim this channel exists to make honest. Blocks:

  • Installs — observed; how many reached a first green; the min, median and max time-to-first-green over those that did. An install whose <ttfg> is the no-green sentinel is outside that population rather than a zero in it.
  • Reds hit by a non-author — the total, split by <verdict>.
  • Per gate — one row per gate a red line names: reds, false positives, reds that changed behaviour. A gate name absent from DRIFT_KIT_GATES_FILE is rendered in its own unrostered count rather than dropped, so an adopter running a roster this consumer does not carry still contributes. It is a count and not a named row for the same reason <gate> is publishable at all — a rostered name is the publisher’s own vocabulary and an unrostered one is the adopter’s, which the rule below never publishes. The aggregate row sorts last whatever the roster holds, so its position is the block’s rule rather than its spelling’s.
  • Dispositions — the <disposition> split over the same total.
  • Retention — per <day>, per <kit>.
  • First useful red — installs reaching one, and the median days from the install line to it. The population is the install lines, so an <id> carrying a red and no install line contributes no span.

A median over an even population is the lower of its two middle values, stated as a rule because an even count needs one and an average would publish a figure no observation took.

Three fields never reach the projection, and the rule is stated as a rule rather than left to each block: <id>, <profile> and <floor>. <id> keys to a person. The other two describe an adopter’s machine and choices, which at the population sizes this channel is for are near-identifying in combination with a date. What is published is the fields whose vocabulary is the publisher’s own — <gate>, <kit> — plus counts and statistics derived from the rest. <profile> and <floor> are recorded and unpublished deliberately: their reader is the observer, at the write-up transition, the same human-reader-at-a-named-transition shape queue-kit’s [observed-by:] value already takes.

No row keyed to a single install is ever emitted.

Named caller and transition: the caller is the consumer’s regeneration step before committing its projection, and the gate that byte-compares it at the commit. That is the shape §The published-evidence extractor already carries, and what gate-sdk/SPEC.md §The non-gate arm requires of an arm.

Consumer wiring is §The published-evidence extractor’s, its gate’s # graph: manifest coupling the page to the record through knob:DRIFT_KIT_INSTALL_RECORD.

The gate is inert — clean on a counted zero, and says so — when the record does not resolve to a file: the record is gitignored, so CI, a fresh clone and every adopter’s tree hold nothing to re-emit from. An armed gate there would red on every commit. Counted inertness is the shape check-action-pinning carries for a tree holding none of its subject. It keeps the gate armed on the one machine that holds the record, where a hand-edited or stale page is caught at commit. Honest limit: the gate’s coverage is one machine’s, so it guards against the authoring session’s own drift and attests nothing to anyone else.

The /economics skill

/economics is the customer-facing post-iteration narrative. Run at close, it chains --emit overhead-meter → --emit stage-economics → --emit manual-ops into one report answering “what did this iteration cost, where, and was the model posture worth it”. stage-economics is the sole cost-attribution surface, pricing per-transcript, per-stage, per-model token draw (the token SSOT); overhead-meter contributes the governance share, not a cost figure, and manual-ops the tooling candidates. The narrative excludes delegation-kit’s usage-trend budget-%: that rate-window footprint is account-wide, confounded by overlapping sessions and by a second operator on the same account, so beside the per-transcript token SSOT it is a confounded number a reader could over-trust as this iteration’s cost.

It ships as the skill template templates/economics.md, materialized as a copy in the consumer’s agent-skill directory, the template↔consumer-copy split the guard/hook skills use; its one bound slot is the consumer’s model posture. It is not a lifecycle stage (it moves no cursor, stamps nothing), so it is outside check-stage-skill-coverage’s stage roster; it is a reporting ritual the close skill may invoke, never a gate.

Layout and configuration

drift-kit/
  templates/drift-config.knobs   # comment-only knob-file template, a pointer to the roster
  templates/kpis.list            # the shipped registry: every bundled KPI (consumer copies + prunes)
  templates/close-knowledge.md
  templates/price-table.tsv      # placeholder + schema for the stage-economics price table (consumer fills the roster)
  templates/economics.md         # the /economics close-cadence skill template
  smoke/install.sh
  smoke/overhead-fixture.jsonl   # synthetic transcript driving the classifier smoke
  smoke/manual-ops-fixture.jsonl # synthetic transcript driving the manual-operation meter smoke

Registers no gates (advisory), so no checks/ or smoke/violation.sh, and ships no bespoke unit tests, so no gate-tests/ (a gateless kit may carry one, gate-sdk/SPEC.md §run-gate-tests). So every assertion this kit makes runs from smoke/install.sh, reached by --run-consumer-smoke with every kit vendored. Two invocations a session reaches for first do not work:

  • --run-gate-tests drift-kit/gate-tests errors no fixture tree, since there is none;
  • naming a subset of kits fails, because a smoke in the named set asserts over the whole vendored tree a subset scratch cannot supply; the failing install is whichever kit holds that assertion, so read the runner’s own <kit> failed line.

templates/kpis.list names every bundled member, never a starter subset: it is the kit’s claim about what it bundles, which the roster above, this SPEC and the smoke’s per-member row assertion are all stated over. Pruning is the consumer’s act on its own copy, and the template’s header says so. gate-sdk/SPEC.md §check-template-registry-parity holds the two in parity both ways. The kit ships no kpis/ directory, so the binary’s own native-dispatch declaration puts this template in that gate’s population, a predicate the gate takes beside a sibling directory.

Config is a knob file: copy templates/drift-config.knobs into the gates dir as drift-config.knobs (or point DRIFT_KIT_KNOB_FILE elsewhere) and set any knob below; defaults fill what the file leaves unset. drift-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 one with its shape and rendered default. A gitignored drift-config.local.knobs in the gates dir is the home for a private value a tracked file cannot carry. gate-sdk/SPEC.md §The knob file owns the grammar, that .local overlay, the environment-over-file precedence for a scalar, the knob reference, and the refusals: a set DRIFT_KIT_KNOB_FILE that does not exist, a left-behind drift-config.sh or drift-config.local.sh, a non-empty file named by the retired DRIFT_KIT_CONFIG_FILE. The kit’s table validator refuses at exit 2 for the rule DRIFT_KIT_KPIS_FILE states below and for a value outside the integer shape DRIFT_KIT_PRICE_COVERAGE_DAYS, DRIFT_KIT_PRICE_PAGE_TIMEOUT or DRIFT_KIT_MANUAL_OPS_TOP states. A derived default below names the knob it reads as ${NAME}, or as ${NAME:-<default>} so the roster’s rendered literal reads as agreement.

drift-kit’s family is open to consumer scalars, for §The KPI plugin contract: in its knob files a scalar line naming an undeclared DRIFT_KIT_ name is a consumer knob. It resolves through the same precedence as a declared scalar, with no default and no validator, and only the family read that exports it to plugins reads it. An undeclared name written as an indexed or keyed line is still refused, and so are the locators DRIFT_KIT_KNOB_FILE, DRIFT_KIT_LOCAL_KNOB_FILE and DRIFT_KIT_CONFIG_FILE set in a file. Honest limit: a misspelled declared name in drift-kit’s file reads as a consumer knob rather than a refusal.

A sibling kit’s fact keeps drift-kit’s own knob. DRIFT_KIT_SESSIONS_DIR, DRIFT_KIT_STATE_FILE and DRIFT_KIT_STAGES re-derive what lifecycle-kit also derives rather than importing a sibling kit’s bin contract. Where the derivation below the knob is shared in-crate (§The overhead meter, §The stage-economics meter), the sharing happens after the knob has resolved to a value. A metric-dir log (DRIFT_KIT_OVERHEAD_LOG, DRIFT_KIT_STAGE_ECONOMICS_LOG, DRIFT_KIT_MANUAL_OPS_LOG and the two install records) defaults under DRIFT_KIT_METRIC_DIR, so a set metric dir moves it. It is gitignored with that dir, its writer creates the dirname, and the table is the one resolver of its default. Knobs:

  • DRIFT_KIT_KPIS_FILE — the KPI registry; default ${GATE_SDK_GATES_DIR}/kpis.list when that file exists, else empty. The roster renders the path unprobed. The two adoption modes are the ones the enforcement map states for every registry it reads — adopted-but-broken refuses where not-adopted degrades:
    • Set explicitly — environment, local overlay or tracked file — to a path that is not a file is adopted-but-broken. The table validator, which reads the value’s origin, refuses at exit 2 naming the knob, so a misconfigured registry gets a failure rather than a quietly shorter page.
    • Unset with the default path absent, or set empty, is not-adopted: the knob resolves to the empty string, which a reader takes as no registry, drop the section.

    Emptiness carries the not-adopted signal, so no reader carries the default path: a reader that had to recognise it would be a second home for it.

  • DRIFT_KIT_KPI_DIRS — array, extra resolution roots searched before the vendored kits’ kpis/ dirs and before the binary’s built-in members (§The extensibility contract, tier 1); default (${GATE_SDK_GATES_DIR}), the consumer gates dir.
  • DRIFT_KIT_QUEUE_FILE — default ${GATE_SDK_QUEUE_FILE:-TASK-QUEUE.md}.
  • DRIFT_KIT_KNOWLEDGE_LOG — default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/knowledge-friction.log.
  • DRIFT_KIT_TIMINGS_FILE — default ${GATE_SDK_TMP_DIR:-.tmp}/gate-timings.txt.
  • DRIFT_KIT_TMP_DIR — plugin scratch root; default ${GATE_SDK_TMP_DIR:-.tmp}. Members are regenerated on every run, so a scratch wipe is harmless.
  • DRIFT_KIT_METRIC_DIR — the persistent measurement home, distinct from DRIFT_KIT_TMP_DIR by retention contract: its members are append-only trend logs that survive scratch wipes. Default .metric. The dir must be gitignored and never committed, since trend samples carry account identifiers and per-session refs; the retention and privacy contract is kit-generic, and a consumer’s provenance seam makes the gitignore load-bearing.
  • DRIFT_KIT_SESSIONS_DIR — the agent transcript directory the meters read; default <config-home>/projects/<cwd-slug>, the config home and the working directory’s <cwd-slug> both derived as lifecycle-kit/SPEC.md §bin/session-id.sh states. The table row’s default is empty, and the computed fallback lives in native/src/sessions.rs: empty means derive it, never no value.
  • DRIFT_KIT_OVERHEAD_LOG — the overhead meter’s append log; default .metric/overhead-log.txt, derived as ${DRIFT_KIT_METRIC_DIR}/overhead-log.txt. The collator, kpi-overhead and the meter all read it there, and the smoke’s writer/reader assertion holds that pair together (§Testing).
  • DRIFT_KIT_DONE_SECTION / DRIFT_KIT_DEFERRED_SECTION — queue section headings the task-split and deferred-age KPIs scan; defaults Done / Deferred (queue-kit’s).
  • DRIFT_KIT_ICEBOX_SECTION — the design-pending pool’s second section, queue-kit’s optional icebox tier; default empty, meaning the pool is the deferred section alone. Read by kpi-queue-net-delta and §The queue-flow arm so an eviction reads as compression rather than as closure. queue-kit and canon-kit carry the same independent knob: a consumer enabling the tier sets each, and one left unset degrades that kit to “no icebox”.
  • DRIFT_KIT_TRAJECTORY_SURFACES — the state-file paths the trajectory extractor harvests, one scalar of two whitespace-separated fields, <state-file> <evidence-file>, never an array; default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/WORKFLOW-STATE.txt ${GATE_SDK_WORKFLOW_DIR:-.workflow}/validate-evidence.txt. A surface it cannot read degrades that iteration’s cell to n/a.
  • DRIFT_KIT_GATES_FILE — the registry two readers take; default ${GATE_SDK_GATES_DIR}/gates.list. The trajectory extractor reads its member count at each close commit (gate-roster growth), and the install-evidence projection reads its membership as the classifier sorting a red’s gate name into a named row or the unrostered count (§The install-evidence projection).
  • DRIFT_KIT_STAGES — array, the ordered stage roster the trajectory extractor renders (one slot per stage, labelled by its shortest roster-unique prefix); default (scope align build validate close), which reduces to the frozen single-letter header. A consumer running a wider roster derives this from its sole roster owner with a knob reference in its knob file, DRIFT_KIT_STAGES[] <- LIFECYCLE_KIT_STAGES (gate-sdk/SPEC.md §The knob file). The reference also carries into the trigger of a # graph: manifest coupling this knob, reaching lifecycle-kit’s tracked knob file (gate-sdk/SPEC.md §The # graph: manifest). kpi-stage-economics-lag reads it too: its last member is the terminal stage whose stamp closes an iteration (§Bundled KPIs).
  • DRIFT_KIT_STAGE_ECONOMICS_LOG — the stage-economics append trend log; default .metric/stage-economics-log.txt, derived as ${DRIFT_KIT_METRIC_DIR}/stage-economics-log.txt. Written by the meter and read by kpi-stage-economics-lag (§Bundled KPIs), both through this one resolved value.
  • DRIFT_KIT_PRICE_TABLE — the consumer-owned model→price roster the stage-economics meter prices through and kpi-price-table-age ages; default ${GATE_SDK_GATES_DIR}/price-table.tsv (beside graph-vocab.knobs, the consumer-config precedent). Absent, cost degrades to n/a and tokens still report. The table is the one site computing this default, and the KPI, the meter and --price-coverage read it there.
  • DRIFT_KIT_PRICE_COVERAGE_DAYS — non-negative integer, default 7; the modification-time window --price-coverage reads transcripts over, 0 reading every transcript (§The price-coverage arm).
  • DRIFT_KIT_PRICE_PAGE_CMD — a command knob, one DRIFT_KIT_PRICE_PAGE_CMD[] = word line per argv element, spawned with no shell; default empty, the page half off. Its stdout is the pricing page.
  • DRIFT_KIT_PRICE_PAGE_SECTION — the heading line whose section is hashed; default empty.
  • DRIFT_KIT_PRICE_PAGE_TIMEOUT — positive integer seconds bounding the page fetch, default 10; a fetch outliving it is killed with every process it started (installer/SPEC.md §The update notice) and reads as failed.
  • DRIFT_KIT_SUPERVISION_LABEL — the reserved value the stage-economics meter writes into the trend log’s <stage> column for a lead’s own burn (§The stage-economics meter, the reserved supervision value); default supervision. A consumer whose lifecycle roster already carries that word renames it here; a stamp naming the label collides and suppresses the rows for that run rather than blending two meanings into one column value.
  • DRIFT_KIT_FANOUT_SUFFIX — the suffix the stage-economics meter appends to an anchor’s stage-or-role value to name its dispatched subtree’s row (§The stage-economics meter, the fan-out row); default +fanout. The default is collision-proof by construction (+ is outside the stamp’s stage alphabet); an override that a stamped stage name ends in collides and suppresses the fan-out rows for that run. It is the fan-out row’s only knob: a meta-filename or field-name knob would imply a portability the mechanism does not have, and the degradation contract already gives a consumer what an opt-out would buy.
  • DRIFT_KIT_MANUAL_OPS_LOG — the manual-operation meter’s log (§The manual-operation meter); default .metric/manual-ops-log.txt, derived under DRIFT_KIT_METRIC_DIR.
  • DRIFT_KIT_MANUAL_OPS_IGNORE — array of keys the meter drops as ordinary work, matched whole against a shell shape or an edit path; default empty, ranking every key.
  • DRIFT_KIT_MANUAL_OPS_TOP — positive integer, default 10: how many keys the meter prints and logs.
  • DRIFT_KIT_INSTALL_RECORD — the install-observation record (§The install-observation record); default .metric/install-observations.log, derived as ${DRIFT_KIT_METRIC_DIR}/install-observations.log. The capture arm and the projection both read it there.
  • DRIFT_KIT_AUTHOR_INSTALL_RECORD — the author seat’s install-observation record (§The install-observation record); default .metric/author-install-observations.log, beside the non-author record. The capture arm is its one reader and writer.
  • DRIFT_KIT_STATE_FILE — the WORKFLOW-STATE path whose committed history and live content the stage-economics join reads for stamps (§The stage-economics meter, history ∪ live). The report skeleton reads its first stamp for the iteration-start commit (§The report skeleton). Default ${GATE_SDK_WORKFLOW_DIR:-.workflow}/WORKFLOW-STATE.txt, the default the trajectory extractor’s surface list computes. kpi-stage-economics-lag reads its stamps through the meter’s own history ∪ live read (§Bundled KPIs), and §The queue-flow arm reads the committed history alone for its windows.

Per-KPI couplings (which meter, which log, which scan flag) are the plugins’ own headers, not knobs — a consumer retargeting one edits its copy in the consumer dir (registry shadowing), the guard/hook consumer-copy precedent.

Testing

The report and every bundled plugin are advisory plain text over live git state — not fixture-stable, so no expected-output corpus (the gate contracts do not fit; context-kit’s reasoning). smoke/install.sh builds the throwaway consumer, registers the bundled set, and asserts:

  • exit 0 with both section headers and at least one row per registered KPI;
  • a registry naming a missing plugin yields its visible n/a row without failing;
  • --trend emits exactly one line;
  • per registered name, the member still emits a row when it is the registry’s only entry. A solo run writes one name to a throwaway registry and collates it through the real resolution and rendering path, since the kit ships no bypass that would invoke a member on its own.

A floor, not equality, because a member owns one or more rows (kpi-queue-net-delta emits two by design). The solo probe carries the bite the floor cannot: the report substitutes its own n/a (plugin failed) row for a member that fails or emits nothing, so the row count holds even where every member is silent. Contribution is the property, not health — an n/a row is a row — and a name resolving to no member is skipped, that row being the report’s own and asserted separately. The probe asserts it probed something, so a resolution change cannot make it vacuous. Residual limit: neither assertion pins which member emitted a given row, so a member emitting the wrong number of rows is not caught.

The failing-member control and the extension-point assertions (§The extensibility contract) configure the collator through a throwaway knob file named by DRIFT_KIT_KNOB_FILE, never the environment, because DRIFT_KIT_KPI_DIRS is indexed and takes no environment override. That file’s DRIFT_KIT_KPI_DIRS[] = line names the consumer plugin dir, and its scalar line DRIFT_KIT_SMOKE_CUSTOM = reached, a name no table declares, is the open-family knob the shadowing plugin is asserted to receive (§The KPI plugin contract).

The arms and the fixture-stable members, each over purpose-built inputs:

  • The trajectory extractor needs committed history the throwaway consumer lacks, so it runs against a hermetic fake-history repo, one closed, range-bounded iteration: the table parses, that iteration’s row is emitted, and the in-flight iteration’s is not.
  • The queue-flow arm runs against its own hermetic repo, three iteration starts over a queue whose pool moves between them. It asserts the two closed windows’ rows and mean, the in-flight third no window, <n> trimming to the trailing windows and 0 refused, and the one-start and no-history degrades.
  • The overhead meter has a fixed classifier, so smoke/overhead-fixture.jsonl carries known category bytes and the smoke drives the meter through its arm over it. It asserts the log-line grammar, that the task line is excluded from governance, that gate is a proper subset of gov, that pct is the rounded governance share, and that a re-measure replaces the session’s line rather than doubling it. kpi-overhead is exercised over that log (its two lead rows and the ovh trend fragment) and in its log-absent degradation.
  • The writer/reader-divergence assertion runs meter and KPI under one DRIFT_KIT_METRIC_DIR override with no explicit DRIFT_KIT_OVERHEAD_LOG, and asserts the reader finds the log the writer wrote. Writer and reader computing defaults independently is the divergence the namespace export cannot guard.
  • The stage-economics join runs through its arm over a synthetic fixture set: a small WORKFLOW-STATE stamp file, a synthetic transcript carrying usage records for the stamped session8, and a placeholder price table.
    • The trend line’s fields: the <iteration> <stage> <model> grouping and the in/out/cr/cw/cost values, with the row’s date pinned to the fixture stamp’s own date.
    • A re-measure replaces the triple’s line rather than doubling it and leaves the log byte-identical.
    • The heal: a pre-seeded line for the fixture key carrying another date, behind an unrelated line, comes back with the stamp’s date in its original position, while the unrelated line stands and is counted as dated by measurement.
    • The undated fallback: a live stamp with no date field raises the dated by measurement count and takes the run’s date.
    • The n/a cost cell plus the incomplete-pricing caveat when the priced model has no row (the price-table-absent degradation).
    • A dated fixture on its own sessions dir, state and log carries a two-row model, one open start and one dated on the stamp’s day, and its logged cost is the dated rate’s. Beside it, a zero-usage model with no row reads 0.0000 and raises no incomplete-pricing caveat.
  • --price-coverage runs over its own sessions dir and table. An id with usage and no row, and one whose only row starts tomorrow, read unpriced in first-seen order; an open-start row prices its id, and a zero-usage id is not counted. An absent table reads every id unpriced, a sessions dir that is not a directory prints its no sessions dir line, and price-page: off holds with the command knob empty. A throwaway knob file then sets the knob to a bash -c argv printing a fixture page. The page half reads unchanged against a hash the smoke computes from the expected section, and CHANGED after one price in the section is edited. It reads section heading not found under a heading the page lacks, and fetch failed: timed out for an argv sleeping past a one-second DRIFT_KIT_PRICE_PAGE_TIMEOUT.
  • History ∪ live runs against the trajectory extractor’s fake-history repo, whose live WORKFLOW-STATE was overwritten with the in-flight iteration’s stamp, so the closed iteration’s stamps survive only in committed history. One run asserts both arms: the history-only stamp prices (replacement would lose it) and the live-only stamp prices (a history-only read would lose the uncommitted tail).
  • kpi-stage-economics-lag runs as a solo member over that repo with a second close stamped live. One closed iteration priced in a purpose-built log and one not reads 1 close(s) unpriced with trend econ 1, and pricing the newest reads 0 — newest close priced. Each degradation — no log, an empty stage roster (a throwaway knob file emptying DRIFT_KIT_STAGES), no terminal stamp, and a log naming no closed iteration — is asserted by its own n/a row.
  • Three further stage-economics fixtures each get their own sessions dir, state file, and log, because the flat set’s log is asserted to hold exactly one line, and a second row there would red that assertion rather than the behavior under test.
    • (i) A two-stamp fixture, one session stamped into a further stage. The log holds exactly one line, the row names the last stamp’s stage, and the yielded stamp is named in the collapsed caveat. A transcript matching no stamp in the same dir is reported by the unstamped-transcript bound.
    • (ii) A nested-tier fixture, a synthetic <lead>/subagents/<agent>.jsonl beside a flat <lead>.jsonl. Exactly one supervision row is emitted for the iteration, carrying the lead transcript’s own usage, and the dispatched session keeps its own stage row. The row’s label is DRIFT_KIT_SUPERVISION_LABEL’s value rather than a literal, and a stamp naming the label suppresses the row with the collision notice.
    • (iii) A fan-out fixture, a synthetic three-level tree: a flat <lead>.jsonl, and under <lead>/subagents/ a stamped stage transcript with a spawnDepth 1 meta record, a child at spawnDepth 2 naming it as parentAgentId, and a grandchild at spawnDepth 3 naming the child. A second session stamped into the same (iteration, stage), carrying usage and a child of its own, is the batch-split witness for both folds.
      • Exactly one fan-out row, its tokens the sum over the whole subtree, so a walk stopping at depth 2 and two anchors racing under the dedup key both red; the contributing anchor count is named where it exceeds one.
      • Exactly one stage row carrying the sum over both same-stage sessions, with its session count on stdout.
      • The suffix read from DRIFT_KIT_FANOUT_SUFFIX, and a stamp ending in it raising the collision notice and suppressing the row.
      • The under-count bound excluding every transcript the pass attributed.
    • A fold asserts a value, never a count. A file-global line count is monotone in duplication and blind to replacement, so a violation that replaces rather than duplicates leaves it green; the flat set’s count and idempotence checks stay, since both are about duplication.
    • The fan-out fixture’s two degradation assertions make the coupling’s bounded failure testable, and neither may be dropped for brevity. Deleting the grandchild’s meta record moves it back into the under-count bound and raises the counted unresolved notice, every row the pass does not own byte-identical; deleting the whole meta layer emits zero fan-out rows plus the single notice, every other row again byte-identical.
    • No fixture is conditioned on an interpreter being installed, since the meter spawns none.
  • kpi-price-table-age reads two dates out of a file the fixture writes, so the smoke drives it over purpose-built tables: the age row, the price <N>d trend fragment, and each header’s absence degrading its own row independently. It asserts the inversion the KPI exists for: a table whose priced-as-of: is today and whose prices-valid-through: has passed reads a fresh age row and an EXPIRED expiry row together. A fixture pinning only the age row has not tested the feature, since the age row is reassuring in exactly that case. The no-table degradation emits its single n/a (no price table) row.
  • kpi-incident-recurrence reads declarations out of a queue file the fixture writes. The smoke asserts the single lag row, the count summing dates across declarations rather than counting declarations, the highest-count slug and the recur <N> trend fragment. Both degradations, a queue with no declaration and no queue file at all, are asserted, with the trend’s silence under the first.
  • The install-observation pair is a pure function of the record, so the smoke drives both arms through the front-end over a throwaway record named by DRIFT_KIT_INSTALL_RECORD.
    • The capture arm: the line grammar of each of the three kinds, and the per-kind key rules, a re-filed install and a re-filed checkin each replacing and a re-filed red appending. The --author routing into a throwaway DRIFT_KIT_AUTHOR_INSTALL_RECORD, the projection’s blindness to it, and a misplaced flag refused. One refusal per class — an unknown kind, the wrong arity, an empty positional, a closed-set field outside its set, and a flag in a positional slot — each leaves the record unchanged. A refusal that wrote is the failure the exit code alone would not show.
    • The projection: each block’s denominator over that record, the sorted group-by order, the byte-identity of two emissions over one unchanged record, and the unrostered classification against a throwaway gates file. The record-absent degradation is asserted to be the counted zero rather than a missing block, the reading the committed page rests on.
  • The manual-operation meter runs through its arm over its own sessions dir, state file and log, with smoke/manual-ops-fixture.jsonl as one stamped session’s transcript, carrying a repeated block id, another tool and an unparseable line, beside a second stamped session. The smoke asserts the printed lines’ fields and order where two keys tie on calls and differ on sessions. A key a throwaway knob file’s DRIFT_KIT_MANUAL_OPS_IGNORE names is absent, and DRIFT_KIT_MANUAL_OPS_TOP bounds the ranking. The log keeps its grammar, a re-run leaves it byte-identical, and a pre-seeded line for a key under another iteration raises its <iterations> to 2. An edit off the repository keys as (outside), and no command text reaches the output or the log. With no stamp or no sessions dir, the run prints a 0-exit notice and writes no log.

Gate-sdk’s check-shellcheck lints all kit sources as usual.

smoke/install.sh stays on the shell substrate permanently and carries # no-port: saying so. The disposition is the class ruling at gate-sdk/SPEC.md §Consumer smoke, The port disposition, which reaches this file by its ground rather than by its scope: legs 2 and 3 hold of it identically. A smoke/install.sh is an executable install recipe an oracle reads as text, and no adopter path executes it.

Out of scope

Toolchain-shaped KPIs are consumer content: orphan-crate and bare-#[allow] scans (Rust-specific dead-surface detection) and a TODO(spec-ambiguity) marker count (a marker convention is consumer vocabulary; generalize the convention first if it ever ships). So are product-workflow KPIs: gate exemptions (a scan-exceptions disposition split) and backlog-aging finding counts, both reading consumer gates.

A narration-marker by-eye count is superseded by canon-kit’s check-manifest-temporal, which gates the same axis instead of trending it. The always-loaded baseline mechanics are context-kit’s surface; drift-kit ships only the kpi-always-loaded built-in member that reads its meter.