Skip to content

feat: attempt lifecycle on the MCP surface (start_attempt, capture_candidate, record_review) - #447

Merged
TheAmericanMaker merged 4 commits into
mainfrom
feat/attempt-lifecycle
Sep 28, 2026
Merged

TheAmericanMaker merged 4 commits into
mainfrom
feat/attempt-lifecycle

Conversation

@TheAmericanMaker

@TheAmericanMaker TheAmericanMaker commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Refs #409

What

A host session can now run the whole supervised loop through codecarto_change alone: create → plan (with slices) → start_attempt → capture_candidate → record_proof / ingest_observations → record_review → gate → request_acceptance. Before this, the contract named the three lifecycle operations and no surface exposed them; every test seeded attempts through the store. Full write-up: docs/engineering/attempt-lifecycle.md.

Design: create-only vs "capture-candidate binds, repeatable"

store.ts said everything under attempts/ is create-only; the contract says capture-candidate binds candidate_snapshot_id onto a running attempt and is repeatable until a proof references it. The contract's own qualifier in § Ownership and layout — "create-only once finalized" — resolves it, and that is what I implemented (option c):

  • The store allows a write over an existing attempt only while the stored outcome is running; it pins every identity field (id, change_id, slice_id, inputs, baseline_snapshot_id, started_at, created_at, parent_attempt_id, supersedes_attempt_id → invalid-value), checks running → outcome against ATTEMPT_OUTCOME_TRANSITIONS (invalid-transition), and compare-and-swaps on ifCandidate (the candidate the caller last read; stale-revision on mismatch). Once the stored outcome has left running no write reaches the file.
  • Rejected (a) — a revision CAS like change/slice: attempts have no revision (schema amendment), and it would let inputs/slice_id/outcome be rewritten under a still-valid review or approval — exactly the "attempt re-bound to another candidate or slice, or inputs changed" case the contract's rejection table makes a reader catch.
  • Rejected (b) — create-only binding records: attempt.candidate_snapshot_id is a required field for the review-ready outcomes and every reader, fixture and bundle rule binds to it; a side file would give the attempt two candidates and a reader that forgot the helper the stale one, plus a new record kind/path/bundle rule.
  • Kept invariants: proof/review/approval bind to a specific candidate id + digest (snapshots stay immutable); a re-capture after any proof names the bound candidate is invalid-state naming the candidate and the proof; baseline snapshot is written before the attempt.
  • Contract amendment (minimal): one sentence in record-contract.md § Ownership and layout spelling out the running exception. No field, enum, error code or path added.

Actions (snake_case, additive)

Action Args Result
start_attempt change_id, slice_id, inputs{brief_digest,plan_digest,references}, parent_attempt_id? attempt_id, outcome:"running", baseline_snapshot_id, baseline_digest, attested_by:"adapter", stability, input_digest, limitations — activates a planned change / pending slice
capture_candidate change_id, attempt_id candidate_snapshot_id, candidate_digest, attested_by:"adapter", stability, outcome:"running", superseded_candidate_id?, limitations
record_review change_id, attempt_id, review{reviewer,objections,summary} review_id, candidate_snapshot_id, candidate_digest, input_digest, remaining_blockers, outcome, finalized — clean declared-separate review of an adapter-attested stable candidate finalizes to needs-human-acceptance

plan gained an optional write mode (slices + revision + acceptance_scenarios) that stores slices pending, moves draft → planned, writes brief.md/plan.md and returns their digests. Without slices it is the read-only brief it was.

  • Snapshots are captured by the adapter from the server's cwd through the same walker (observe) the acceptance re-read uses — parity by construction. Capture walks twice; disagreement records unstable. repository.dirty is true on git trees (no git is run; the limitation says so).
  • A caller-supplied snapshot / baseline_snapshot / candidate_snapshot / manifest / coverage / repository is refused by name on both capture actions (the adapter can always read its own cwd; refusing rather than silently ignoring tells the caller its bytes did not become the record). The caller-attested path exists in core for an adapter that cannot read the tree; no MCP action reaches it.
  • Scope: coverage = ALWAYS_EXCLUDED + host-declared exclusions. No change/slice field declares exclusions and no host config channel reaches MCP, so captureScope() returns [] — one function to change when a source exists.
  • record_review refuses candidate_snapshot_id / candidate_digest / input_digest (and the envelope) on the body and binds them from the stored attempt/candidate; remaining_blockers is derived.
  • Derived-field refusal on all three (state, decision, attested_by, outcome, stability, digest, candidate_*, input_digest, …).

Readers

lifecycle.ts owns boundCandidate(); gates.ts, acceptance.ts, host-observations.ts, traverse.ts and the MCP re-read resolve through it.

traverse now emits: no candidate → resume-attempt; candidate, no proof names it → record-observations; proof, no review of those bytes → request-review; review with open/deferred blockers → address-objections (naming review + blockers; remedy = new attempt); reviewed clean, not finalized → resume-attempt; needs-human-acceptance → request-human-acceptance; accepted approval for the bound candidate → stop/change-concluded naming the approval. capture-candidate added to TRAVERSE_ACTIONS.

Trust: unchanged

VERIFIED_ACCEPTANCE_INTEGRATIONS stays []; assurance_policy stays verified; cooperative unreachable; no approve/accept path. The only approval in the new test is minted by the existing request_acceptance adapter through the test-only registry seam.

Tests

tests/engineering-lifecycle-mcp.test.mjs (git isolation helper first; added to GUARDED_FILES; real tmp git repo, git init only for the fixture): full loop over tool calls → accepted, exactly one approval, traverse change-concluded; caller snapshot refused on both actions (nothing bound); re-capture repeatable then refused naming candidate + proof; edit-then-revert gives the original digest, ALWAYS_EXCLUDED applied and disclosed; capture then readWorkingTree FRESH untouched / STALE after edit (+ gate proof-stale naming the candidate); forged review digests refused by field, stored review bound to store digests; open blocker doesn't finalize, traverse address-objections; derived fields refused ×9 fields ×3 actions; finalized attempt immutable; store pins identity / CAS on candidate. The one observed proof is written the way E05's protected host entry would (MCP proofs are claimed by contract), and the test says so.

Mutants (each reverted after):

Mutant Result
capture walks a different view than the re-read (drops a file from the entries) 2 fail (loop, parity)
capture declares an exclusion the walker didn't apply (coverage drift) 1 fail (parity)
capture skips ALWAYS_EXCLUDED survived — the engineering namespace is excluded inside collectSnapshot too, so the walker mutation alone is masked; noted in limits
allow re-capture after a proof references the candidate 1 fail
caller snapshot accepted (refusal list emptied) 1 fail
review takes caller candidate_digest 1 fail
gate bypasses boundCandidate 2 fail
store lets a finalized attempt be rewritten 1 fail

Gates

build 0 errors · npm test 1368/1368 ×2 (main 1358; +10) · tarball smoke 12/12 · git diff --check clean · leak grep clean · git status --porcelain .codecarto clean after tests.

Review round 2 (5cf6c8d)

  • D1 judgeApprovals (lifecycle.ts) is the one place an approval file becomes an acceptance: stored request + evaluateApprovalReceipt against the CURRENT bound candidate and the other approvals' nonces. traverse concludes only on a genuine one and stops blocked-needs-operator naming a non-genuine file + codes; request_acceptance counts only genuine approvals as already-accepted and discloses the ignored file (id + codes) in the presentation limitations and the result. Probe: forged approval → traverse blocked-needs-operator (receipt-unknown-request); request_acceptance proceeds.
  • D2 readStoredInputDigests digests stored brief.md/plan.md; plan reports through it, start_attempt derives through it and refuses divergent caller digests (naming both) or a change with no stored artifacts; update regenerates brief.md; the gate refuses input-stale (new blocker code, one row added to record-contract.md § acceptance requirements). Probe: bogus digests → -32602 inputs.brief_digest "sha256:aaaa…" (brief.md digests to …).
  • D3 tests pin M5b/M5c/M8/M9/M14; plan write's change CAS now precedes slice writes so racing planners leave one slice set; captureWorkingTreeWithSeams (@internal, stripped from d.ts) drives the unstable path.
  • D4 refusal list gains tree, capture, candidate, baseline, reread, candidate_reread, working_tree, entries, files; record_review discloses "reviewer separation is declared by the review's author, not authenticated"; traverse's needs-human-acceptance branch consults the gate and stops with its blockers.
  • Mutants (reviewer's 17 + 14 new): 30 killed, 1 survived — M13 (ALWAYS_EXCLUDED masked by collectSnapshot, snapshots.ts). npm test 1374/1374 ×2; build 0 errors; tarball smoke 12/12; d.ts consumer test passes.

Limits

  • repository.dirty always true on git captures (no git run).
  • Scope = ALWAYS_EXCLUDED only; no host exclusion channel on MCP yet.
  • record_review finalizes only to needs-human-acceptance; no action emits ready-for-review, failed or blocked yet (the store accepts them via the same running-attempt path).
  • The ALWAYS_EXCLUDED-only mutant survived because collectSnapshot re-applies the built-in exclusion; walker parity for host-declared exclusions is what the parity test pins.
  • judgeApprovals judges against the CURRENT bound candidate only; a genuine approval for a superseded candidate reads receipt-mismatch (old acceptance is history). A non-genuine file is disclosed and left in place; nothing removes it.
  • plan write: a crash between the change CAS and the slice writes leaves a planned change with no slices, which plan refuses to re-plan — operator matter.
  • The gate's input-stale compare is a limitation, not a refusal, when the change has no stored brief.md/plan.md (store-seeded records); MCP never starts an attempt without them.
  • Reviewer separation is declared, not authenticated.

…ndidate, record_review)

Refs #409

A host session could create, plan and gate a change but could not start an
attempt, capture a candidate or record a review: the contract defined the
three operations and types/validation carried their shapes, but no surface
exposed them, and every test seeded attempts through the store.

Design. The store said everything under attempts/ is create-only; the
contract says capture-candidate binds candidate_snapshot_id onto a RUNNING
attempt and is repeatable until a proof references the candidate. The
contract's own qualifier -- "create-only once finalized" -- resolves it: an
attempt is a projection while `running` and history once it is not. The
store now allows a write over an existing attempt only while the stored
outcome is `running`, pins every identity field (inputs, slice, baseline,
started_at, parent/supersedes), enforces the attempt transition table, and
compare-and-swaps on the bound candidate (`ifCandidate`). A versioned
`revision` on attempts (option a) would let inputs/slice/outcome be
rewritten under a valid-looking review; side-car binding records (option b)
would give the attempt two candidates and a reader that forgot the helper
the wrong one. record-contract.md gains one sentence saying so; no field,
enum, code or path was added.

Surface. start_attempt captures the baseline and capture_candidate the
candidate FROM THE SERVER'S CWD through the same walker the acceptance
re-read uses (working-tree.ts `observe`), so freshness parity holds by
construction; two passes that disagree record `unstable`. A caller-supplied
snapshot/manifest/coverage is refused by name on both. record_review binds
candidate_snapshot_id, candidate_digest and input_digest from the stored
attempt and candidate and refuses them on the body; remaining_blockers is
derived. A clean declared-separate review of an adapter-attested stable
candidate finalizes the attempt to needs-human-acceptance. `plan` gains an
optional write mode (slices + revision) so the inputs an attempt starts
from exist. Derived-field refusal covers all three actions.

Readers. lifecycle.ts owns boundCandidate(); gates, acceptance,
host-observations, traverse and the MCP re-read resolve the attempt's
candidate through it. traverse now advances a running attempt through
resume-attempt -> record-observations -> request-review ->
address-objections from the records, and stops change-concluded once an
accepted approval exists for the bound candidate.

Trust unchanged: the registry stays empty, assurance_policy stays verified,
no approve/accept path; the only approval in the new test is minted by the
existing request_acceptance adapter through the test-only registry seam.

Tests: tests/engineering-lifecycle-mcp.test.mjs drives the whole loop over
tool calls against a real tmp git repo (git isolation helper first, added
to GUARDED_FILES); negatives assert by content. Mutants killed: capture
diverging from the re-read walker, re-capture after proof, caller snapshot
binding, review taking a caller digest, gate bypassing boundCandidate, store
rewriting a finalized attempt.
…uards (review of #447)

D1 (high): a schema-valid approval dropped into approvals/ — no stored
request, a fixture receipt — made traverse emit stop/change-concluded
and made request_acceptance refuse already-accepted. Neither path ran
evaluateApprovalReceipt. `judgeApprovals` (lifecycle.ts) is now the one
place a file becomes "the candidate is accepted": it reads the stored
request the receipt names (readStoredRequest moved here from
acceptance.ts, layer 6 -> 4) and runs evaluateApprovalReceipt against
the attempt, the CURRENT bound candidate, and the other approvals'
nonces. traverse concludes only on a genuine verdict and stops
blocked-needs-operator naming a non-genuine file and its codes;
acceptance.ts counts only genuine approvals as already-accepted, so a
dropped file cannot deny service, and discloses the ignored file (id +
codes) in the presentation's limitations and the request_acceptance
result. Exactly one genuine approval per candidate still holds.

D2 (medium): start_attempt accepted caller-declared brief_digest /
plan_digest. `readStoredInputDigests` digests the stored brief.md /
plan.md bytes; plan reports through it, start_attempt derives through
it and refuses a caller value that differs (naming both) or a change
with no stored artifacts; update regenerates brief.md when one is
stored; the gate re-reads both and refuses `input-stale` (new blocker
code; record-contract.md amended with one row) when the attempt's
inputs drifted. No existing code fit: proof-stale is about the
candidate tree, attempt-not-ready about record states.

D3 (medium): tests pin that a same-context, unstable-candidate, or
caller-attested clean review does not finalize; plan write refuses a
missing/stale revision and a re-plan of a planned/active change; two
racing planners leave one slice set (the change CAS now precedes the
slice writes — before, the loser's slices stayed on disk); the walker's
unstable detection is driven through an @internal between-walks seam
(captureWorkingTreeWithSeams; stripInternal keeps it out of dist d.ts,
no MCP argument reaches it).

D4 (low): refuseSuppliedSnapshot also refuses tree, capture, candidate,
baseline, reread, candidate_reread, working_tree, entries, files;
record_review's text and limitations state that reviewer separation is
declared, not authenticated. traverse's needs-human-acceptance branch
consults the gate and stops blocked-needs-operator with the gate's
blockers instead of telling the host to ask a person for refused work.

Every change only refuses more. VERIFIED_ACCEPTANCE_INTEGRATIONS stays
[], assurance_policy stays verified, no approve path.
test-windows failed on both attempts of the 5cf6c8d run, in
tests/state-store.test.mjs:194 ("one more than the largest ticket on
the floor"). The test started acquireLock, slept a fixed 100 ms, then
read the directory once. On a loaded Windows runner the waiter had not
written its ticket yet, so `mine` was undefined. This PR adds
lifecycle tests that start many stdio servers in parallel with the
other test files, which plausibly adds the delay. The same test passed
on main and on 61397f3.

The test now polls for the ticket (every 20 ms, up to 2.5 s), and the
waiter's timeout rises from 400 ms to 3000 ms so it is still waiting
while the test looks. The assertion is unchanged: the ticket is one
more than the largest on the floor. core/status.ts is untouched.

Not reproduced on Linux: the original and fixed versions both passed
8/8 under 32 busy loops. The fix removes the timing assumption rather
than tuning it.
…447)

The re-review of 5cf6c8d showed that the sentence 'exactly one genuine
approval per candidate still holds' is false when the storage boundary
is unprotected.

An agent that can write approvals/ can copy a genuine approval under a
new id. Both copies then read receipt-replayed, and traverse stops for
an operator. request_acceptance discloses the pair and asks the person
again, and an accept mints a second genuine approval for the same
candidate. Write access to approvals/ already rules out 'verified', so
the paragraph now says this is disclosed, not prevented. The change to
stop instead of asking again is tracked as a follow-up.

Documentation only.
@TheAmericanMaker
TheAmericanMaker merged commit 5233162 into main Sep 28, 2026
6 checks passed
@TheAmericanMaker
TheAmericanMaker deleted the feat/attempt-lifecycle branch September 28, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant