agent-harness GitHub

Harness Specs

Last Updated: 2026-10-03 · Concrete schemas and drafts referenced from BLUEPRINT.md.

§1 Global laws — pointer, not a mirror

The global law set lives in the claude-config repository, and it is the only copy. Claude loads rules/laws.md, deployed as ~/.claude/rules/laws.md; Codex reads the same contract as codex/AGENTS.md, and claude-config’s tests/check-law-parity.ps1 keeps those two in step. This section used to hold a dated verbatim mirror (ratified 2026-07-26, issue #92). The mirror drifted from global laws 2f/2g, and the drift misled a review (issue #358), so global law 8 pruned it. A global law N or CLAUDE.md law N pointer means law N of that canonical file. BLUEPRINT §0 uses the separate design-principle namespace P1 through P12. Operational references must name the namespace; there is no default meaning for a bare law N. Historical BLUEPRINT law N citations mean the correspondingly numbered PN. Review and termination remain canonical in global laws 2 and 11; P11 points there rather than defining another review-round count.

Transition note (issue #429). Operational citations were written before the P1-P12 / global-law split, so an unconverted bare law N must never be read as whichever namespace is convenient. Read it by where it lives:

doctor --config-root <claude-config> compares each canonical guidance file with its deployed copy byte for byte, as separate checks: source CLAUDE.md against ~/.claude/CLAUDE.md, source rules/laws.md against ~/.claude/rules/laws.md (the law set Claude actually loads, #366), and source codex/AGENTS.md against ~/.codex/AGENTS.md. The supplied directory must be the clean, published main checkout of the harness origin’s claude-config sibling, with all three guidance paths tracked and visible to Git; otherwise the checks are UNPROVEN. A readable byte mismatch fails.

This repository’s own deltas from the global merge rules:

§2 .agent-harness/tier.json schema

{
  "tier": 3,
  "name": "workshop",
  "authority": { "push": "free", "merge": "gated" },
  "flags": { "sensitive_data": false, "wave_mode": false, "dormant_production": false,
             "relaxed_work_loss_guards": false },
  "floor_posture": "guide",
  "public_synthetic_publication": {
    "remote": "origin", "repository": "OWNER/REPOSITORY"
  },
  "budgets": { "standing_context_tokens": 6000, "session_baseline_tokens": null },
  "human_todo": "HUMAN_TODO.md",
  "status_doc": "plans/ACTIVE.md",
  "last_reviewed": "2026-07-06"
}

§2.1 Logical repo root selection (issue #139)

A declaration binds to a LOGICAL repo, which is usually — but not always — the Git checkout root. One checkout can carry several products, each declaring its own posture, while the checkout root itself deliberately declares none. harness audit <path> and harness doctor --repo <path> resolve the logical root of the requested path with this rule, in order (harness.logical_repo_root):

  1. Nearest declared ancestor wins. Walking from the requested directory up to the Git checkout root inclusive, the first directory that carries BOTH AGENTS.md AND a tier declaration (.agent-harness/tier.json or legacy .claude/tier.json) is the logical root. Containment breaks the tie, so a product’s own declaration binds for a path inside it even when an outer umbrella also declares one.
  2. A fully undeclared directory may resolve downward. If the requested directory carries NEITHER half, declared roots below it are searched (breadth-first, max depth 4, skipping dot-directories, vendor directories, anything reached through a symlink or junction — the junction-aware path_is_alias predicate decides, not is_dir — and any child that carries its own .git, which is another checkout’s repository or submodule; a declared root ends the descent on its branch). Exactly one match is selected. Two or more competing roots are ambiguous and raise — the operator re-runs against one of them — and the tool never picks a product silently.
  3. Otherwise the Git checkout root is the repo, exactly as before. A HALF-declared directory (one of the two files) is a repo missing the other half, never an umbrella: it takes this branch so the missing declaration is still reported.

Consequences, all of which the tests pin:

§3 Budget table (enforced by check-budgets.mjs, ~80 lines)

Artifact Cap On overflow
repo CLAUDE.md T1 ≤40 / T2 ≤100 / T3+ ≤150 lines rotate detail to linked docs
AGENTS.md (rulebook, T3+) ≤80 lines one home per policy; link out
“now”/STATUS doc head (the declared status_doc; enforced by harness.py audit) ≤150 lines rotate to docs/archive/status-YYYY-MM.md
MEMORY.md index ≤30 lines / ≤3KB fold + prune (Gardener)
SKILL.md ≤80 lines (target 60) split or demote to doc
AGENT_MAP.md ≤100 lines split into docs/regions/*.md
FLOOR_LIMITATIONS.md (deny-floor ledger) ≤120 lines rotate to archive/floor-limitations-<year>.md
directory CLAUDE.md ≤30 lines move detail to region map
global CLAUDE.md ≤130 lines it’s the law layer, not a manual — rotate working-style detail into skills
total standing harness (T3) ≤500 lines demote something

Failure message contract: emit a ROTATE instruction naming the archive target — never “trim to pass”. Enforcement points: CI job (T3+) and a PostToolUse(Edit) nudge (“over budget: split/rotate, don’t append”).

§4 Skeletons

Skill anatomy (every SKILL.md):

---
name: <kebab>
description: <1-2 sentence "Use when..." trigger>
---
# <Name>
## Use when / Do NOT use when   <- anti-trigger section is mandatory (routing disambiguator)
## Guardrails                    <- verbatim guard phrases, e.g. "Do not claim a path is
                                    verified if you only reasoned about it"
## Workflow                      <- numbered, atomic; never pauses mid-pipeline
## Read first                    <- ONLY region map + STATUS head; never >200-line docs;
                                    never auto-injected files

AGENT_MAP.md row: | domain | entry points | invariants | verify command | do-not-read | plus the Minimum Handoff Shape: Changed / Verified / NOT verified / Failures+workarounds / Docs sync / Next safe slice.

Directory CLAUDE.md (≤30 lines): what this region is, its invariants, its verification command, Region map: docs/regions/<domain>.md, and any region-local rules. Nothing global.

HUMAN_TODO.md: IDs (H-1, H-2…), one line each + link; ## Changelog at bottom; rules header: read at session start, surface in every summary, check off per global law 5 (direct evidence; a human decision is never inferred).

Tombstone CLAUDE.md (3 lines): Tier: T0 TOMBSTONE / FROZEN <date> — do not develop here. / Live successor: <path or "none">.

REVIVAL.md (≤20 lines): how to run, known hazards, data locations/backups, re-seed tier.

§5 Dispatcher hook wiring

The shared dispatcher owns exactly one event: the PreToolUse(Bash) deny floor. Claude wires it at user scope on hosts that keep one (none on the primary workstation; a repo may declare floor_wiring: none). Each active Codex repo wires exactly one project .codex/hooks.json adapter that pins the shared ~/.claude/hooks/dispatch.py; Codex has no global floor matcher. A repository’s owner may declare it floorless (agent-harness itself and claude-config, owner decision 2026-09-07): it then wires no adapter and no global Claude hook, and no floor runs there — declared as tier.json "floor_wiring": "none" (§2 schema) and recorded in the repo’s instruction files, not drift for a sync or a doctor run to repair. Repo-tier lifecycle hooks (PostToolUse, PostToolUseFailure, SessionStart, and Stop) are separate, repo-owned executables when a tier actually implements them. Never route those events through the floor dispatcher or stack global and project floor matchers.

Claude global adapter schematic (Codex project adapters must use the stricter contract below):

{
  "hooks": {
    "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command",
      "command": "python \"$HOME/.claude/hooks/dispatch.py\" --event pre", "timeout": 5 }] }]
  }
}

Codex project-hook trust bootstrap

doctor can diagnose only the static adapter and inspectable activation blockers. Its exact-CWD /hooks remediation message is deliberately not proof that Codex has accepted a project or handler for a particular session. Use this supported bootstrap procedure instead:

  1. Start the normal interactive Codex TUI from the exact project CWD that will run the hook. If Codex asks for project trust, accept trust for that exact project; do not substitute a parent, sibling, or different worktree CWD. When that CWD is a linked worktree, stay there while reviewing the authoritative root-checkout adapter identified by doctor.
  2. In that same TUI and CWD, open /hooks. Before trusting anything, review the intended handler’s current source path, matcher, command (including --event pre --runtime codex), timeout, current definition hash, and any expected=<sha256> audit marker. Trust only that reviewed handler, not every listed handler.
  3. Confirm in the same exact-CWD /hooks view that the intended handler is enabled and trusted with no relevant warning or error. The app-server hooks/list result may corroborate the source, command, enabled state, hash, warnings, errors, and trust status, but is inspection only. Noninteractive codex exec can exercise an already trusted handler; neither interface grants project or per-hook trust.
  4. Immediately collect runtime evidence: run a declared harmless allow canary such as git status --short --branch, then an inert deny canary that cannot perform a real mutation. For example, the force-push-shaped probe git push --dry-run --no-verify --force . HEAD:refs/heads/codex-h2-deny-canary must be denied before Git executes; its dry run and local . destination leave no remote update if the hook is unavailable. Capture the intended floor’s distinctive denial banner/version and verify in /hooks that no other enabled matching handler could account for it; an unattributed deny is not proof. Record the exact CWD, reviewed definition/hash, handler attribution, and both results.

Never hand-edit Codex trust state or definition hashes, use a blanket “trust all” action without reviewing every handler, or pass --dangerously-bypass-hook-trust. These shortcuts erase the review boundary rather than repairing it. This procedure was established on Codex CLI 0.145.0; if the normal TUI, /hooks, or hooks/list contract changes in a later version, record that version-specific difference and re-establish the supported path before relying on it.

Candidate validation from linked worktrees

A linked-worktree dispatcher or adapter-marker change is a source candidate, not a live hook candidate. Its candidate-local .codex/hooks.json copy is inactive: Codex uses the matching adapter under the checkout that owns the common Git directory. The expected doctor --repo diagnostic therefore names the root-checkout source and rejects a worktree-only or differing local copy. Keep that fail-closed result as evidence of the topology; do not make it green by copying the candidate into the root checkout, hand-editing trust state or hashes, or passing a hook-trust bypass flag.

Validate an unmerged candidate in separate evidence lanes:

  1. Record the candidate’s full git rev-parse HEAD SHA, then run the source gates that read its dispatcher and adapter seam in the candidate worktree: py -3 -m unittest discover -s tests -v and py -3 templates\hooks\smoke_test.py. These prove source behavior only.
  2. Make a standalone full clone at that exact SHA; do not make another linked worktree. For a local source, git clone --no-local <source-repository> <scratch> forces an independent object copy. Run git -C <scratch> checkout --detach <candidate-sha>, then prove that git -C <scratch> rev-parse HEAD is that SHA, git -C <scratch> status --porcelain is empty, git -C <scratch> rev-parse --path-format=absolute --git-common-dir is inside the scratch clone, and git -C <scratch> worktree list --porcelain names only the scratch checkout. The selected source must contain the exact committed candidate; an uncommitted working-tree copy is not a substitute.
  3. From the scratch root run the named static audit, py -3 .\harness.py doctor --repo . --json, and record Codex project root markers, Codex hook source, Codex adapter contract, Codex project hook activation, and project Codex floor separately. A detached or feature-branch candidate is not published main, and its template can differ from the installed global copy, so the overall Doctor result may also contain an expected UNPROVEN canonical-reference leg or a shared-floor mismatch. Record every result; never recast one as green or bypass it. A successful named adapter check proves only its stated static scope. It neither grants trust nor executes the dispatcher.
  4. Do not start a normal session in that pre-merge scratch clone as a shortcut to candidate runtime evidence. The normal project adapter invokes the installed ~/.claude/hooks/dispatch.py bytes, so a canary there can execute the installed dispatcher rather than the candidate templates/hooks/dispatch.py. Do not deploy dirty, in-review, or unmerged bytes globally to change that fact.

Only after the producer change is merged, review a clean-main sync-global dry run and separately authorize installation of the reviewed clean-main bytes. In a new normal TUI from the producer’s exact CWD, use the project-hook trust bootstrap to review and re-trust the installed handler. Then, and only then, collect runtime evidence with both a harmless allow canary (git status --short --branch) and the inert local dry-run deny probe git push --dry-run --no-verify --force . HEAD:refs/heads/codex-h2-deny-canary. The deny must occur before Git executes and show the intended floor banner/version; the local . destination and --dry-run leave no remote mutation. This exact-CWD step proves runtime attribution, not merely the static marker.

The rollout order is fixed: producer merge → reviewed clean-main install → producer exact-CWD re-trust and canaries → consumer marker refresh → each consumer’s exact-CWD re-trust and canaries. Consumer marker updates and their runtime validation are separate reviewed rollout work; neither a producer PR nor a standalone scratch audit performs them.

§5.4 Posture and FLOOR_ACK (owner decisions 2026-09-02 and 2026-09-27; #21 slices #26 / #62; #356)

The analyzer’s verdict (check(): allow / ask / deny) is computed exactly as before and then RENDERED by the effective posture (floor_posture, resolved by dispatch.floor_posture):

Analyzer verdict wall guide core
allow allow allow allow
deny whose reason is pure opacity — cannot be inspected, cannot safely, opaque, malformed, nesting/depth exceeds, comment inside a scriptblock, [push-config-unverifiable] — but never one naming a secret-looking target or an unresolved delete/removal/pathspec operand (rm -rf $dir, a splatted Remove-Item, find -delete, git rm --pathspec-from-file): those are #62’s GUARDED opacity and take the row below. And only when the command text, quotes included, carries no charter hint (dispatch._CHARTER_HINT: force spellings or any git push, deletion verbs, sudo/doas/su, program text piped or substituted into an interpreter, nested program text such as -c/eval/-x/foreach/bisect run, brace expansion, secret-looking names, copy/move/write verbs) — the analyzer returns its FIRST deny, so git push --force origin $BRANCH is denied as a dynamic refspec before the force check, and the hint keeps it a double-check deny allow — the parser’s uncertainty is not the agent’s fault (#21) allow, unless the command carries a CORE hint (dispatch._CORE_HINT: the charter hint’s deletion verbs, privilege heads, downloaded program text piped or substituted into an interpreter, and secret-looking names, plus — since 1.8.0 — a tag or mirror spelling inside a git … push segment (refs/tags/, tags/, --mirror in any abbreviation, .mirror), so an earlier verdict in the same push cannot hide those spellings — a bare local-tag name or tag <name> behind an earlier non-core verdict is not covered (FLOOR_LIMITATIONS); never other git push/force spellings, gh, nested program text or work-loss spellings) or a later segment earns a core verdict: then double-check
a CORE deny (dispatch.verdict_is_core): rm -rf/Remove-Item outside the project or at a root, an unresolved delete operand, find -delete, pipe-to-Remove-Item, a secret-looking target, downloaded program text run directly, privilege elevation, the tag guard ([tag-guard], 1.8.0: a forced or deleting push of a tag, --mirror) (a sensitive_data repository never runs core; see below) deny double-check: deny once with a key; allow when the identical command carries # FLOOR_ACK=<key> double-check, same mechanism
any other deny — git config/environment execution, launchers, T4 work-loss spellings, and the git pathspec-file opacity guide keeps as guarded (git rm --pathspec-from-file). Branch force, +refspec and branch deletion are no longer analyzer denies since 1.8.0 deny double-check allow, unless the command carries a core hint or a later segment earns a core verdict: then double-check
ask (T3 work-loss guards) ask (Codex: deny) double-check, same mechanism, both runtimes allow, with the same core-hint and later-segment exception
dispatcher error (fail-closed) deny deny — never scaled, never acknowledgeable deny — never scaled

§6 Deny-floor bypass test matrix (must-block / must-allow)

This matrix pins the ANALYZER (every fixture declares wall). How a verdict renders below T4/wave is §5.4’s posture table.

Branch history is not in this matrix since floor 1.8.0 (owner decision 2026-10-04, #356 step 2): the default branch of every floored repository is protected server-side by a ruleset, so git push -f, git push --force, git push origin +main, --force-with-lease, branch deletion and --prune over branches are MUST ALLOW at every tier, sensitive_data included (whose public-remote privacy guard still judges the destination).

MUST BLOCK (all tiers): the tag guard — a forced or deleting push whose destination may be a tag (git push --force origin v1.0 where v1.0 is a local tag, +refs/tags/*, :refs/tags/v1, --delete origin tags/v1, git push --force origin tag v1, --force/--prune with --tags, a pattern destination outside an explicit non-tag namespace such as refs/*), git push --mirror in any abbreviation, and a refspec-less push that inherits a configured mirror or a forced or deleting tag refspec; rm -rf /, rm -rf ~, rm -rf outside repo/scratch, ... | Remove-Item, ... | del, curl … | sh, wget … | sh, sudo …, write to .env/*credentials*/*secret* files; with sensitive_data: git push <public-remote>, gh repo create --public — with the two ratified BLUEPRINT §1 exceptions: issue #48 for a push ATTRIBUTABLE to a non-sensitive repository, and the 2026-08-03 exact-route public-synthetic declaration. Both are narrow and every condition is enforced, so these stay MUST BLOCK under sensitive_data even though the destination is that repository’s own remote: any repository-redirecting git global (--work-tree, --git-dir, -c core.worktree, in any spelling git accepts), a repository that does not EXPLICITLY declare sensitive_data: false, a repository whose checkout or primary checkout sits inside a directory declaring sensitive_data, a destination that does not resolve to one of the repository’s configured remotes, a refspec-less push inheriting a configured remote.*.push, and any multi-ref, tag-publishing or deletion selector (--all, --tags, --follow-tags, --mirror, --delete, -d, and their abbreviations). A self-sensitive repository additionally stays MUST BLOCK unless every co-located declaration grants the same valid public_synthetic_publication, the command explicitly names its declared remote plus one local branch/HEAD, that remote has exactly one push URL matching the declared GitHub repository, no configured core.gitProxy/core.sshCommand or declared-remote receivepack/vcs overrides its transport/receiver, and no command-line --receive-pack/--exec or force-with-lease is requested. The declaration never relaxes public repo/gist creation, visibility changes, or the gh api exfiltration surfaces (since 1.6.28 the sensitive_data gh api guard is narrowed to those surfaces plus non-branch DELETEs; routine PR/issue/comment mutations pass).

Secret-file scope, measured not asserted: `.env`/`*credentials*`/`*secret*` above is implemented as a NAME test, not an extension test. Its one authoritative coverage statement is the measured table in tests/test_secret_path_coverage.py (#244); FLOOR_LIMITATIONS.md records the divergences. Whether *secret* was ever meant as a suffix glob is an open owner reading, so this records the divergence without changing it (issue #130).

MUST BLOCK only at T4 / wave_mode (warn at T3, allow T1–T2): git reset --hard, git clean -fd, git checkout -- ., git worktree remove --force — and the LAUNDERED force spellings of a worktree removal, which ride the identical ladder because an opaque spelling must never score better than the literal form it might be: a runtime-computed action word (git worktree $ACT …, issue #117 — [worktree-action-opaque]), a dynamic option or separator-free operand token in a removal (-$X, bare $A — [worktree-remove-opaque]; global law 7’s $WT_PROJECT_DIR/<name> compounds keep the plain score — braced and quoted spellings included, but NOT the Windows $VAR\<name> one, whose backslash a POSIX lexer eats, costing the token the separator that pins it out of option space, so it lands on the ask/deny rung instead: issue #128), and argv-visible config that blinds git’s clean check (-c status.showUntrackedFiles=no and its --config-env/opaque twins, issue #123 — [worktree-remove-config]; literal normal/all values stay plain).

Plain git worktree remove is allowed at EVERY tier, wave_mode included (owner ruling 2026-07-27, issues #41/#117): git refuses a worktree holding tracked modifications or untracked files, and removal leaves a checked-out branch behind — not because it is harmless. Git’s clean check (git status --porcelain --ignore-submodules=none) does not consider gitignored content: it reports a worktree holding .env, local.db, vendor.cfg and node_modules/ as clean, and removal then deletes all of it. The branch guarantee is scoped to a worktree that has one: a clean detached worktree passes git’s pre-removal check and its commits — held only by that worktree’s HEAD — leave git log --all with the removal, which is why global law 7 mandates git switch -c before committing (issue #122; the floor cannot see detached-ness in argv). All measured on git 2.45.1 and pinned by ignored_worktree_removal_is_destructive in smoke_test.py, including the status.showUntrackedFiles=no blinding of the untracked-file refusal. Keep no .env that must outlive its worktree.

MUST ALLOW (false-positive regression tests): commit/PR bodies describing dangerous commands (git commit -m "block rm -rf in hook"), gh pr create --body-file …, branch force, lease, +refspec, deletion and --prune at every tier (1.8.0), a non-forced push of a tag (git refuses to move an existing tag without force), and compound commands where the dangerous-looking text is inside quotes.

Parsing notes: tokenize argv (shlex for POSIX; separate lightweight matcher for PowerShell pipe forms — shlex won’t parse | Remove-Item); split on ;, &&, | and check each segment; NEVER match against -m/--body string arguments.

§6.1 Cross-product gate — where the tripwire ends (issue #63)

The matrix above tests each command in canonical form. tests/test_prefix_wrapper_crossproduct.py crosses it with the shapes real command lines carry — 25 prefix spellings (leading redirections, VAR=value, separators, and combinations) and 74 wrapper spellings (launchers, container/remote exec, nested interpreters, scriptblock/evaluator forms) — and asserts BOTH directions: charter denies stay denied, curated benign commands stay allowed.

A shape the floor does not cover is recorded in that module’s baselines with the issue it belongs to, so the repo states where the tripwire ends instead of leaving it unstated: DOCUMENTED_BYPASSES (whole shapes: #56, #37, #9, #67), DOCUMENTED_CASE_BYPASSES (individual rules disarmed by an otherwise-covered wrapper: #68, #69, #79, #80), DOCUMENTED_OVER_BLOCKS (shapes that deny EVERY benign payload: #21 plus the charter’s own privilege-transition denials) and DOCUMENTED_CASE_OVER_BLOCKS (its payload-granular mirror). A baseline entry that starts behaving correctly fails the gate as UNEXPECTEDLY FIXED, so a fix has to be promoted into the enforced set rather than left un-guarded against a later re-break. That promotion is the mechanism, not a formality: every executable #46 prefix shape and 3 of the #68 case entries were retired this way once main closed them. Command-leading --% rows were removed instead of being credited as executable coverage.

UNEXPECTEDLY FIXED and the corpus sweep are reported TOGETHER. They were sequential self.fail calls, and fail raises, so a recorded entry that started behaving correctly suppressed every live bypass in the same run — three case-level entries were hiding 81 corpus failures that no run had ever printed.

Two properties keep a baseline from meaning less than it looks. A SHAPE-level entry must record which probes it lets through or denies — an empty evidence list would exempt a shape from ~955 corpus checks while asserting nothing — and a shape’s composed line must be a command that actually runs: a payload embedded inside an interpreter’s own quoted program (perl -e, python -c, node -e, awk, expect -c) is a string literal of that LANGUAGE, never a second layer of shell quoting, which would close the template’s span and compose a syntax error. Both are asserted, not conventions.

§7 Stop-hook verification (T3 warn / T4 block)

Fire ONLY on narrowly detectable states; never on research-only sessions:

No new stop-hooks. The states above are the grandfathered set (BLUEPRINT P12 — the meta-gate cap, issue #92 P5): a stop-hook is a gate, and a new gate about process compliance arrives only as a ratified mission, never as a session detour. A proposed addition must displace one of the states above or start life as a tracked issue.

§8 Model & effort routing (full table)

Model tiers (top / default / cheap) are ROUTING tiers — unrelated to the T0–T4 blast-radius ladder.

Task class Model tier Effort Walls vs tripwires
Deny floor / dispatcher changes, promotion audits top xhigh wall: agent model: pins + review requirement
Region maps, skills, ADRs, global laws top xhigh convention
Merge decisions; second lens on high-risk work top high (xhigh only if irreversible / wide blast radius) wall at T4 (gate), tripwire below
Adversarial review gate default high convention
Code implementation, debugging, feature slices in mapped regions default high convention
Gardener triage, tombstone classification, promotion routing default low wall: ~/.claude/agents/gardener.md pin + PR-only output
Judgment-bearing subagent work (a lens, a call, a triage), lookups, conversation default low convention
Bulk mechanical sweeps, doc rotation, formatting, test running — INCLUDING when fanned out across subagents cheap medium–high (never low) convention

Effort is the first dial (cheaper than a model swap). Default-up when unsure. Interactive sessions: pick per the table at session start; don’t leave xhigh pinned globally for maintenance work.

Triage and classification are judgment, not mechanics — deciding what matters is a call, so they sit on the default tier even though they run on a schedule. A cheap tier only earns work that is genuinely simple, well-specified, and hard to get wrong; and when it does, it runs at medium/high effort, because a cheap model at low effort compounds two handicaps.

Delegation is not a task class. A subagent is routed by the work it does, not by the fact that it was delegated: wide mechanical fan-out follows the cheap row above and BLUEPRINT §3 (medium/high effort, never low); a subagent asked for an independent lens or a call follows the default row. If a task matches both rows, the DEFAULT row wins: the ladder routes up when the class is unclear, because a cheap model on judgment work is the expensive mistake.

The tiers above are deliberately unnamed, and this table does not restate the ladder. Which model fills top / default / cheap, and the fan-out fleet caps (≤3–5, ≤8–12 for a sweep), live in the model-effort-routing global skill — the single home. A named model written in two files is how a stale routing row survives repeated prose bans; if this table and the skill ever disagree, the skill wins and the local copy is the bug. Model bans and admissions are declared in the model-effort-routing skill and enforced by the config repo’s tests/check-agent-models.ps1; they are deliberately not restated here. This table is the durable judgment-vs-mechanical shape and changes only when that shape changes.

§9 Bootstrapper CLI + ESTATE.md

Home: this repo. Implemented in the dependency-free harness.py (one implementation, no .sh/.ps1 twins):

The owner lease is coordination, not authentication or an OS lock. It proves nothing about a process that does not follow the protocol. A non-cooperating process or watcher may still write after revalidation, including an ignored file that plain removal would delete; text and JSON output carry that limitation, and the command does not claim arbitrary external-process occupancy detection.

Deferred until earned by repeated use: tier-up, estate-wide mutation, and Gardener scheduling.

Template layout: templates/tier1..tier4/ overlays + templates/hooks/ + templates/skills/.

~/.claude/ESTATE.md schema (one row per repo, ALL roots — source/, Desktop/, …): | repo | root | live path | tier | flags | status | human-todo alias | last reviewed | notes (wrapper warnings, vendor decision, plugin-vs-skill choice) |

§10 Gardener spec

§11 Skill-forge pipeline (automated skill creation, human-ratified)

  1. Inputs: ≥3 clustered failure-ledger entries with the same signature, or a second-occurrence memory line tagged promote-candidate.
  2. Draft from the §4 anatomy template; must include the anti-trigger section and at least one verbatim guard phrase; ≤60 lines.
  3. Validate: frontmatter parses, caps hold, no read-first ladder, no restatement of a policy that has a home (BLUEPRINT P2).
  4. Output: branch + PR (part of the Gardener PR or standalone). Agents NEVER self-install skills; the human merge is the trust gate.
  5. Decay twin: skills with zero invocations in a quarter are archived by the same pipeline.

§12 Session concurrency rules

§13 Disposition of existing global assets

§14 The bounded review pipeline (review-and-ship)

BLUEPRINT §1’s T3 “bounded review pipeline” slot is filled by the review-and-ship skill, shipped from claude-config for both runtimes. Reference, don’t restate: the skill file is the single home for the step-by-step, and global law 2 (claude-config rules/laws.md) plus BLUEPRINT P11 are the contract it executes — one review round, one severity-bar triage (confirmed CRITICAL/HIGH fix commits only; the rest tracked or declined on the thread), one fix round verified against the fix diff (a fresh-context pass is owed only when the fixes changed logic or the risk boundary, global law 2g; a manual @codex review is never a per-fix step, global law 2f) — then ship or park. Tier changes WHO reviews and how many eyes the single round gets (T1-T3 one independent pass, with a second distinct lens at T3 only for genuinely high-risk work; T4 two adversarial reviews), never how many rounds run.

§15 Learning-plane contracts (L1 lab; draft 2026-10-08)

The learning plane (experience -> candidate -> independent eval -> canary -> maturity -> promote or revert) shares five record contracts across the estate. agent-harness is their single writer: another lane asks on the bus for a field and never forks a schema. The JSON Schemas (draft 2020-12) under schemas/learning/ are the contract; this section names the rules around them and scripts/learning_contracts.py enforces both (standard library only; it refuses a schema that uses a keyword it does not interpret). Synthetic examples: schemas/learning/examples/.

py -3 scripts\learning_contracts.py validate <file.json|file.jsonl>...   # exit 0 valid, 1 invalid, 2 refused
py -3 scripts\learning_contracts.py fold --candidate <lc.json> --records <promotion records.jsonl>
py -3 scripts\learning_contracts.py experience-id --kind <source kind> --key <source key>

Storage. Records reference private work, so they live outside every repository under %USERPROFILE%\.estate\learning\ (beside outcome-ledger\) and are never committed. Records carry pointers (ref: scheme:locator), never transcripts; prose is bounded (claim 2,000 characters, reason 280) and experience feedback carries no free text at all. Every record has schema, id, at (UTC, Z) and producer (lane, runtime, model, session), plus an optional ext object keyed by lane slug for producer-private fields consumers ignore.

Record Fields Rules beyond the schema
estate-experience/v1 (exp_) source, observed_at, started_at, repo, task_kind, recipe, base_sha, result_sha, trajectory_ref, joins, outcome, feedback, failure_keys, memory_used, skills_used, cost, variant, split_key id = exp_ + sha256(source.kind|source.key)[:16]; re-observed as the outcome advances (same id, later observed_at; readers keep the latest, fold_experiences); only a merged run matures; joins (ledger:, pr:, …) joins outcome-ledger/v1 rather than replacing it
outcome-event/v1 (oev_) observed_at, run, experience, version, supersedes, repo, task_kind, recipe, runtime, model, variant, outcome, success, verdicts, corrections, failure_keys id = oev_ + sha256(run.kind|run.key|version)[:16]; experience matches the run’s deterministic experience id; version 1 alone has null supersedes; success uses experience_succeeded; observed_at is no earlier than at
memory-use/v1 (mu_) observed_at, experience, memories, skills, method id = mu_ + the experience id’s suffix; a memory cited was supplied or read; effect (helped, harmed, neutral) comes from an evaluator, never self or the run’s own session
learning-candidate/v1 (lc_) kind, trigger, claim, evidence, future_decision, confidence, context, scope, destination, promotion_class, protected, consequential, supersedes, contradicts, genome, valid_from, valid_until admission fails without a concrete future_decision (four or more words, not a placeholder, not the claim restated); promotion_class is at least the kind’s class and is never lowered; only an episodic candidate has a null destination; immutable once written
candidate-genome/v1 (gen_) parent, parent_genome, changes, candidates, training_evidence, evaluation, objectives parent_genome makes the variant archive a tree; training_evidence is excluded from the variant’s evaluation; most variants change one layer
promotion-record/v1 (prom_) candidate, prev, from, to, promotion_class, effect, authority, gates, reason, veto, landed, genome, merged_into, superseded_by, revert one record per lifecycle move in either direction, chained by prev; see the fold below
decision-resolution/v1 decision, source, status, option, option_label, answered_at, created, expires, subject, measures what an injected resolver returns for one agent-hq decision read at origin/main (source: agent-hq@<sha>); an answered or defaulted decision names its option and time; subject binds a per-promotion approval or veto window to one candidate; measures carries the exit bars and graduation the resolver measured
learning-would-apply/v1 (wa_) candidate, class, class_reasons, destination, op, base, bytes, sha256, authority, eligibility, verdict, reasons the applier’s account of one candidate: the whole file it would write (UTF-8, LF, at most 256 KiB) and its sha256, which the validator recomputes; base names the replaced blob (null for an add) so a revert can guard on it; would_apply needs an eligible candidate with no problems, its required authority answered, and a class no lower than the destination’s
promotion-receipt/v1 (rcpt_) candidate, record, kind, installed, observed, matured, reverted written by the applier or the store, never the learner; id = rcpt_ + sha256(candidate|record|kind|canonical evidence)[:16]; carries exactly its kind’s evidence: installed the destination repo, merge commit, path and blob sha256 on origin/main, observed an attributed experience, matured a window (since, through) of at least maturity_days, issued after it, reverted the revert commit; receipt_status counts one only against a live record of the folded chain that entered an installed state
eval-case/v1 (case_) suite, experience, split_key, layer, category, input_ref, oracle one task built from history with its grader; split_key decides its side like an experience’s and must equal its anchoring experience’s; category is one of the five LongMemEval-V2 memory categories or task; layer is extraction, retrieval, behavioural, procedural or system
eval-outputs/v1 suite, variant, variant_ref, outputs what one variant (baseline or candidate) produced per case, recorded by whoever ran it; a missing output fails that case
eval-labels/v1 suite, variant, evaluator, labels pass/fail grades for judge cases from an evaluator that is never self; a run that uses any label is labelled with that evaluator’s kind, never oracle
system-run/v1 (sys_) candidate, baseline_variant, candidate_variant, excluded_training, arms, policy, verdict, reasons, gate; current reports also evidence_scope an observational comparison over the experience ledger’s two labelled arms; current reports declare evidence_scope: observational and always gate: null. Legacy reports remain readable but cannot supply canary promotion evidence
eval-run/v1 (run_) suite, split, gate_name, candidate, genome, baseline_ref, candidate_ref, tier, evaluator, training, cases, holdout_digest, anchors, policy, results, verdict, gate the replay evaluator’s report; on the hold-out its gate is a ready promotion-record/v1 gate result whose digest and anchors match the run’s

Lifecycle (schemas/learning/lifecycle.json). A candidate is born in candidate (the plan’s “Observed” stage is the experience ledger itself; a refused admission is never a candidate). Every later move is a promotion record whose (from, to) is an edge of that file. A gate result may only be recorded on a record leaving one of its gate_sources states: evaluation gates leave evaluating, canary leaves canary, maturity leaves probation, owner and independent_review leave any live-path state. That placement is what forces a canary-class candidate through canary, and since every class above P0 needs maturity, every one of them through the seven-day probation; only P0 (episodic) goes candidate -> active directly. A record moving forward carries no failed gate, and no record carries two results for one gate (array order is not time order).

Promotion classes (schemas/learning/promotion-classes.json, P0-P8 as data: kind_class, per-class activation_gates, conditional_gates for protected and consequential, owner_on_activation). The fold of a candidate’s records (fold(candidate, records, as_of)) is the promotion-gate decision logic. Byte-identical retries count once; the fold stops at the first record that fails any of:

Any fold error forces the returned effect to shadow: a consumer stops applying a candidate whose history it cannot fold, so a malformed or contested revert fails closed.

Class is the blast radius (K2). A candidate’s class is max(kind, destination, content) (effective_class), and its declared promotion_class may be no lower; the reasons travel with the refusal. destination_class takes the highest class whose allowlist (promotion-classes.json destinations.allow) admits the repository and path, so an overlapping entry can only raise it: P1 and P2 write one curated memory note, projects/<real project directory>/memory/<name>.md in claude-config; P3 a skills/<s>/SKILL.md; P4 a Muse recipe or coordinator prompt; P5 muse/agent-routes.json; P6 the scheduler; P7 one tool or harness script, never gate, merge, token, tier, policy, hook, trust or learning-contract code. A destination on no allowlist is P8, and so is any path with a protected segment (destinations.protected_segments: MEMORY.md, CLAUDE.md, AGENTS.md and the other auto-loaded instruction files, and Windows device names), in any case. An episodic candidate has destination: null and writes nothing. content_class is a tripwire, not a proof: it escalates to P8 on authority, permission or injection language (content.patterns) in the claim, the future decision, the destination path, and every string of context, scope and ext, after NFKC normalisation, removal of format characters, collapsing whitespace and casefolding. A broken pattern fails closed to P8. Paraphrases can still evade it; the owner’s per-promotion approval of P8 and the reviewed PR of P1 notes are the real gate. relpath refuses .git segments, segments ending in a dot or space (Windows strips them) and ~ (8.3 short names). A would_apply report is checked the same way: its class is at least the destination’s and its bytes’ content class, its bytes are at most 262144 bytes of UTF-8, and its authority is what the class requires (required from the class’s mode, the class’s own decision with a live option for P1, P6 and P7, a per-promotion approval rather than a class decision for P8, answered before the report, with its ref and source), never what the report asserts. Paths are classed case-exactly, so the applier refuses a path whose on-disk spelling differs from the classed one (a case or short-name alias), a symlink or junction on any segment, and any write whose bytes do not hash to what the reviewer saw.

Authority is resolved, never asserted (K1). effect says whether the candidate’s artifact is on a live surface after the move: shadow means evaluated and shown, nothing written live. live is legal only moving into canary, probation, active or reinforced, and above P0 the fold checks it against the owner’s answer: fold(candidate, records, as_of, resolve) takes an injected resolver that maps a decision: ref to a decision-resolution/v1 read from agent-hq origin/main (an answer only in a working copy does not count). With no resolver nothing above P0 goes live and no owner gate counts. promotion-classes.json holds the map as data: authority names each class’s decision (P1: lp-p1-memory-autopromote, which decides P1 notes only; P6 and P7: lp-p6-p7-owner-approval; P2 to P5: none, so never live; P8: the owner’s approval of that one candidate, always), and decisions gives each answer option its meaning: live or shadow, the exit bar (exit_bars, each target a floor or a ceiling: P1 needs 28 days in shadow, 50 non-protected candidates judged, 0.90 precision and at most a 5% contradiction-or-revert rate), approval (none, per_promotion, or until_graduated per graduations), whether a protected candidate still needs the owner (protected_approval: yes for P1 options b and c, no for d), the veto window in days, and the landing channel.

The owner’s conditions hold on every live record, not only on activation. Each must carry authority that resolves, is answered (an approval is never defaulted) no later than the record, decides the candidate’s class, and whose option permits live for it, with its exit bar in force at that instant (at <= record < until). Whichever record turns the candidate live, and the activating record, carry what the class requires: an owner gate whose ref resolves to an answer meaning approve (per-candidate decisions answer keys a/b, so the meaning is option_label: Approve or Decline, Allow or Veto) bound to this candidate (subject.candidate, and subject.digest when the owner approved one exact version), or, once the class has graduated, a veto window (veto: decision:<id>) bound to the candidate, opened during the stay it closes, running its full veto_days (a window that defaulted early does not count), and not answered veto; a window that expired unanswered after its days allows. Every installed live record (probation, active, reinforced) names where it landed: landed: pr:https://github.com/<owner>/<repo>/pull/<n> in the destination’s repository for a PR channel. A class with no canary stage is never live in canary. Any owner gate, on any record, counts only as such a resolved approval, and an evaluator that calls itself the owner on an evaluation gate (owner-graded labels) counts as a grader but vouches for independence only when its ref resolves the same way. The resolver measures exit bars with exit_bar_status(bar, measured) and graduation with graduation_status(rule, approved_at, reverted_at, as_of) (events after as_of never change it); resolver_from(resolutions) adapts records a caller already trusts, which makes it exactly as trustworthy as whoever wrote them: until the store’s reader of agent-hq origin/main builds the resolutions, --resolutions is a test and audit seam, not authority.

Hold-out. An experience’s split is derived, never stored: split_of(split_key), a salted sha256 (estate-experience/v1/split) with a 20% hold-out, where split_key defaults to source.kind|source.key; producers set it to group runs that must land on one side (one PR, one finding cluster, one correction key). The first observation fixes an experience’s source.kind, source.key and split key: fold_experiences refuses later changes to these identity fields, so an existing run never moves across the split. Optional source.account is producer-supplied attribution (a 1-64 character string), not identity or authority; it may be added or corrected on later observations without changing the experience ID or default split key. Outcome events retain only source kind/key in run; consumers join experience for account attribution. Offline evaluation and replay gates carry holdout_digest (sha256 of the evaluated case ids, sorted and newline-joined), training_excluded: true and anchors (the experiences the evaluated cases came from), which the fold checks against the candidate’s evidence. The replay evaluator computes all three; excluding a genome’s training_evidence and every case sharing a split key with the training evidence is its job, not the fold’s.

Replay evaluator (scripts/learning_eval.py, W1). It never runs a variant: whoever runs the baseline and the candidate records eval-outputs/v1, and the evaluator selects, grades and compares. Selection is the hold-out separation: a case is on the side split_of(case.split_key) gives; an anchored case’s experience must be in the ledger and its split key must match; every case built from the candidate’s evidence or its genome’s training_evidence, and every case sharing a split key with that evidence, is dropped; the genome is exactly the one the candidate names or the candidate outputs ran as (variant_ref: gen:<id>); and evidence missing from the ledger refuses the run (exclusion that cannot be proven is not claimed). A re-worded copy of a training run is caught only if it keeps the run’s split key. Deterministic oracles grade first (exact, contains_all, contains_none, regex, set_match, ranked_recall, abstain, numeric); an output without the field an oracle reads fails rather than passing vacuously. set_match may also name forbidden_items the output must not contain (with or without expected_items). A judge case takes its grade from eval-labels/v1, whose evaluator is never self, never oracle and never the candidate’s or genome producer’s session, and demotes the run’s tier and gate evaluator from oracle to that evaluator’s kind. Pairs are compared case by case by case (wins, losses, ties, an exact two-sided sign test reported in results; the verdict and the gate use the one-sided sign_p) with breakdowns by memory category and layer, retrieval recall@k and MRR, and mean cost. The verdict is pass when at least min_cases distinct inputs (input_ref and oracle; clones count once) were evaluated, the pass-rate delta is at least min_delta and the one-sided exact sign test on the discordant pairs is under max_sign_p; fewer cases is insufficient, which emits no gate. The defaults are each gate’s pinned eval_minima in promotion-classes.json (20, 0, 0.05, from the 2026-10-08 statistics audit: max_losses = 0 failed real improvements more often as n grew), and the fold re-checks a passing gate’s own metrics (cases, delta, wins, losses, anchored) against them, computing the sign test itself, so a loosened policy never moves a candidate. The policy is part of the run id and of the gate’s metrics and summary, so a loosened gate is visible as one. Only a hold-out run emits a gate (offline_eval, replay or retrieval_regression), validated against eval-run/v1 before it is returned. schemas/learning/examples/memory-eval/ is a synthetic suite covering the five LongMemEval-V2 categories across the extraction, retrieval and behavioural layers, with a training leak and a cluster leak the evaluator must drop.

Regex compilation and matching run in an isolated Python worker (-I -S) with a two-second deadline and a one-second cleanup grace. Its stdout reply and stderr diagnostics each have a 4 KiB capture quota, enforced while reading; output overflow, timeout, startup failure and malformed or duplicate-member JSON refuse the evaluation without writing a report. The run ID binds the worker source SHA-256 and all four execution budgets as well as the selected case contracts and role-labelled baseline/candidate evidence. Changing that execution contract therefore changes the run ID even when the hold-out membership and measured results agree.

Receipts, not claims (K3). The path to live carries promotion-receipt/v1 receipts, written by the applier or the store, never by the learner: installed (the destination repository, merge commit, path and blob sha256 on origin/main), observed (an attributed use), matured (a window of at least maturity_days with no attributed regression) and reverted. receipt_status counts a receipt only against a live record of the folded chain that entered an installed state, at the candidate’s exact destination repository and path, issued by someone other than the learner (its producer’s session and lane differ from the candidate’s); observed, matured and reverted count only after that install, and a revert inside a maturity window voids it. The fold sees records, not git: that the commit is on the destination’s origin/main and the blob hashes to blob_sha256 is the store’s verification (L3) before it admits a receipt. learning_metrics resolves authority from the dataset’s decision-resolution/v1 records, and promoted (with revert rate and time to learn) counts verified installs, never entry into active; activated counts entry into active, shadow included. The maturity and owner gates are producible: learning_promote.py --gates takes a matured receipt as the maturity gate (ref: receipt:) and an answered per-candidate decision as the owner gate (pass on Approve, fail on Decline), and --mature produces a shadow candidate’s maturity gate from the clock once probation has run its window (--regressed fails it).

Commit, then draw (K3, attack E). split_of’s default salt is a public constant, so a producer who chooses split keys can grind them into the hold-out (46 of 200 in the red-team run), and a single-user host cannot hide a salt from its own agents. So the order is reversed: the candidate (with its evidence and genome) is committed first, and the split is drawn afterwards with a public beacon fixed after that commit, such as a later agent-hq main commit (learning_eval.py --salt-source agent-hq@<sha> --salt-at <when it was fixed>). The beacon string is the salt, so anyone can recompute the split. A beaconed hold-out admits only cases anchored to an experience whose at is no later than the draw (an unanchored key, or one created after the beacon was published, could have been ground against it), and one case per distinct input, so the gate’s cases, wins, losses and delta are over distinct inputs and clones cannot inflate the sign test. The fold also requires the gate’s counts to be integers in range and coherent (wins + losses <= cases, anchored <= cases, distinct anchors <= anchored, and delta = (wins - losses) / cases). A hold-out gate, passing or failing (a split ground to force a fail is a denial lever), names its salt_draw (source, at), and the fold refuses a gate with none or one drawn at or before the candidate’s at. A candidate built from a genome names it in genome (the genome lists the candidate back); the fold refuses its anchored gates without that genome, so allocate the genome id before staging the candidate. The fold checks the order only: that the source is the first beacon after the commit (no shopping among later commits), and that the candidate’s own commit time is the store’s import time rather than the self-reported at, are the store’s checks (L3). The draw must also follow the genome’s at and precede the gate’s. Until the store binds those times, the fold closes attack E against reusing the public salt, not against a writer who back-dates its own records.

py -3 scripts\learning_eval.py --cases <cases.jsonl> --baseline <outputs.json> --candidate-outputs <outputs.json> --candidate <lc.json> --experiences <experiences.jsonl> [--genome <gen.json>] [--labels <labels.json>...] [--split holdout|dev] [--gate offline_eval|replay|retrieval_regression]

Exit 0 pass, 1 fail, 3 insufficient, 2 refused.

Promotion-record generation (scripts/learning_promote.py, W2). Given a candidate, its promotion records and the gate results of its current stay (raw gate results or eval-run/v1 reports), it writes the record that moves the candidate to the next state on its class’s path: candidate to evaluating (P0 straight to active); evaluating to canary or probation once every evaluation gate is present; canary to probation on its canary result; probation to active on maturity (and the owner’s pass where the class needs it). Only passes the fold would count move a candidate forward (never the learner’s, self or an LLM judge alone), since an evaluation gate recorded too early would strand the candidate in probation. A failed gate before activation (as a candidate, while evaluating, in canary or in probation) moves to rejected. Every generated move is shadow; a live candidate, reverts, reinforcement, supersession and decay are never generated, since they need blame, judgment or the owner. Run reports are accepted only for this candidate (and the genome it names). Gates are stored sorted and the id is derived from the move, so a retry with the same --at, producer and reason is byte-identical and the fold counts it once. The generator refuses times after now and returns a record only after folding the chain with it appended.

py -3 scripts\learning_promote.py --candidate <lc.json> --records <promotion records.jsonl> --gates <gate or eval-run json>... --lane <lane> --session <session>

Variant archive (scripts/learning_archive.py, W3). Every candidate-genome/v1 stays in the archive, linked by parent_genome into a tree (orphans become roots and cycles are reported, never followed), so a dominated variant can still parent a better one. Nothing is reduced to one number: schemas/learning/objectives.json gives each objective its direction (correctness and reliability up; cost, latency, owner intervention and reverts down), a genome is compared only when it measured every objective asked for, and the frontier is the set no other such genome dominates. Parents for the next generation are frontier members ranked by NSGA-II crowding distance, so the extremes and the sparse regions come first. The learning-archive/v1 report lists each genome (parent, depth, children, layers, objectives, frontier, evaluation ref), the roots, the frontier, the unmeasured genomes, the best genome per objective and the chosen parents.

py -3 scripts\learning_archive.py <genomes.json|jsonl>... [--objectives correctness,cost] [--parents 4]

Procedural and system evals (W2). A procedural case (layer: procedural, oracle procedure) replays a proposed skill on a historical task it was not generated from: the recorded output carries completed, turns and recovered, and the case passes when the run completed, within max_turns when set, and recovered from its injected failure when require_recovery is set. The run’s results.procedural reports completion rate (an errored run did not complete), mean turns over completed runs, recovery rate over the cases that require recovery, and mean cost per variant. A system comparison (scripts/learning_system.py) compares runs labelled with the candidate (variant: gen:<id>, which must be the genome the candidate names) and the baseline, leaving out runs whose ids are the candidate’s or genome’s training evidence. A run’s started_at (its end at when no start is recorded) and observed_at must be at or after the later of the candidate’s and supplied genome’s at (the candidate’s alone without a genome). Historical runs do not meet arm minima; a historical-only comparison is insufficient and emits no gate. The report ID binds the evaluator revision and normalized UTC cutoff. These are checks on claimed source times in the folded latest observation, not immutable experiment assignment or execution receipts. The cutoff is artifact creation time, not canary-entry time. Re-observations can alter source timestamps, and neither timestamps nor matching labels authenticate cohort membership. Until trusted assignment and execution resolution exists (#520/#517), every report declares evidence_scope: observational and emits gate: null, including pass and fail. Local assignment labels, joins and claimed receipts cannot upgrade that scope. Legacy reports without the scope field remain readable for archival compatibility, but the promotion generator refuses all system-run/v1 reports, and both the generator and lifecycle fold refuse extracted canary gates with a system-run: reference. Field omission cannot restore their promotion eligibility. A run’s variant is fixed by its first observation, like its split key. Each arm reports success (experience_succeeded, the one success definition), maturity, clean and revert rates, regressions, triage precision, owner-correction rate and cost. The verdict is an observational non-regression comparison: with at least min_runs (20) runs and min_matured (10) matured runs per arm (reverts are only known once runs mature, so an unmatured arm is insufficient, never a silent pass), the candidate passes when success has not fallen, and reverts and owner corrections have not risen, beyond the policy’s tolerances (0 by default). A pass is neither proof of improvement nor canary authorization. CLI exit codes remain 0 (observational pass), 1 (observational regression), 2 (refused input) and 3 (insufficient comparison). See docs/LEARNING_SYSTEM_EVIDENCE.md for the compatibility boundary and the remaining trusted-store work.

py -3 scripts\learning_system.py --experiences <experiences.jsonl>... --candidate <lc.json> --baseline-variant <ref> --candidate-variant gen:<id> [--min-runs 20]

Suite conventions. A recipe-replay suite (suite: recipe-replay-<recipe>, layer: procedural, category: task) has one case per historical lens job. Its experience is that job’s experience (so split_key is the job’s), and its input_ref is the job spec to re-run (muse-job:<lane>/<wave>/<job>, carrying repo, base SHA and lens variables). Its oracle is set_match, with expected_items holding the location keys of findings triage confirmed (which the variant must still report) and forbidden_items holding those it refuted (which it must no longer report). A location key is <path>#L<line // 40> with the path normalised as the ledger does (lower case, forward slashes); keys are compared verbatim, and a key that lands in both lists makes the case uninformative, so the builder drops it. Both arms re-run the job at the same base, so runtime drift is shared; neither replays historical output. A skill-procedure suite (suite: skill-procedure-<skill>) has one case per historical run of the procedure. Its input_ref is that run’s task, and its oracle is procedure, with max_turns and, where the run hit a failure, require_recovery. In both, the cases come from the hold-out side of the experience split, and the candidate’s evidence (the dev-side runs the recipe or skill was written from) is excluded by the evaluator.

One outcome stream (K4). scripts/learning_outcomes.py projects experience observations in observed_at order into immutable outcome-event/v1 versions per run, starting at 1 and chained by supersedes. Only changes to outcome, triage verdicts, owner corrections, failure_keys, variant or routing metadata (repo, task_kind, recipe, runtime, model) emit a version; source, split-key and variant drift are skipped and reported using the experience fold’s refusal rules. read_since reads strictly after a durable cursor (observed_at, id), ordered by UTC instant then id, and returns the advanced cursor for replay. All three reducers consume the latest version per run: posterior gives the Muse coordinator’s Beta(1 + confirmed + successful clean merges, 1 + refuted + reverted-or-regressed runs) per repo and recipe. A merge adds positive outcome evidence only after its 7-day maturity is known and success remains true. An explicit regression adds one negative outcome contribution immediately; a run that is both regressed and reverted still contributes only one. Within each run, duplicate triage signals are counted once per (kind, ref, signal), including copies with different optional metadata; distinct refs or signals remain separate. Observational system-comparison precision uses the same deduplication. lesson_eligible selects matured runs or runs with owner corrections; recurrences finds correction or failure keys shared by more than one run. A late reversion replaces the earlier clean merge contribution in every consumer, rather than counting the run twice. project <experiences...> [--out FILE] writes JSONL (stdout by default), and posterior --events FILE --repo R --recipe X reads it; a refusal exits 2 with a JSON error on stderr. Projection is whole-history, and observed_at is the producer’s clock, not an ingestion offset: an observation that arrives later with an earlier observed_at renumbers the run’s later versions on the next projection, and an incremental reader whose cursor is past it never sees it. Until the stream gains a log offset (#511), a consumer re-reads the whole stream (the reducers are cheap and deterministic) rather than trusting a cursor across a re-projection. After changing the projection’s deduplication policy, rebuild from the complete experience history and replace the derived stream; old events contain aggregate verdict counts and cannot be deduplicated without those observations.

Retiring a consolidated single (cc#835). probation -> merged joins evaluating -> merged and candidate -> merged, so a single (typically a protected P1 owner correction) that reached probation before its consolidation was admitted can still retire into it. merged is not live-capable, so a merge out of a live probation folds to effect: shadow and the applier removes the installed note in that move, guarded by the expected hash. merged_into names the superseder; that it is admitted, of a class no lower than the single’s, and (for a protected single) carries a counted owner pass is L2’s and the store’s check, since the fold sees one candidate.

The contract bundle (K5). scripts/learning_bundle.py build writes the versioned schemas/learning/BUNDLE.json: every top-level schemas/learning/*.json file except the manifest itself, plus scripts/learning_contracts.py, in sorted repository-relative POSIX order. Each file is SHA-256 hashed after normalising CRLF to LF; the bundle digest is SHA-256 of the UTF-8 bytes of the sorted path<TAB>sha256<LF> lines. Consumers pin the value printed by digest and use verify --vendored DIR (with repeated --map REL=VENDORED_REL for renamed paths) to compare their copies against the manifest and report drift. check guards the committed manifest against contract edits, additions and removals. The synthetic schemas/learning/compat/accept/ and reject/ fixtures pin valid records for every schema and invalid records with expected errors; a fixture whose expected validation changes is a breaking contract change: run build --bump and announce it on the learning-plane bus. Never edit a fixture to make a red test green without that version bump and announcement.

Trust limits, stated rather than implied. Records are written by agents, so an evaluation gate is an attestation the fold checks for consistency, not proof: evaluator identity is still self-reported (the session rule stops accidental self-evaluation, not a dishonest writer), and a hold-out split is only as unaimable as its beacon: the fold checks that it was drawn after the candidate, the store that it was the first beacon after it. Authority is resolved rather than asserted, so its strength is the resolver’s: once the store builds resolutions from agent-hq origin/main, a record can no longer claim an answer the owner did not give. What makes an evaluation gate checkable is its ref: an eval-run: report can be re-run. Memory refs use the journal’s spelling, memory:<project>/<name>.md, so every lane joins on one string.

Changing a contract. Additive optional fields keep /v1; renaming, removing or tightening a field is /v2. Every change is announced on the learning-plane bus thread, and tests/test_learning_contracts.py pins the data files to the shared enums and this section’s field lists to the schemas.