@adlc/context-handoff
v1.11.0
Published
Context-rot handoff — absolute band signals, session-terminal deny (D1–D3), and operator CLI for write/resume/bypass/repair/unlock (F3 continuity).
Maintainers
Readme
@adlc/context-handoff
ADLC phase: P4 continuity (F3) — absolute context bands, session-terminal
mutation deny (D1–D3), and the operator CLI for
write/resume/bypass/repair/unlock/continue. Binding design:
docs/specs/context-rot-handoff.md.
adlc handoff write --session <id> [--ticket <id>] [--write] [--json]
adlc handoff resume --session <consumer> --deny-session <denier> [--write]
adlc handoff bypass --session <id> [--unbound-reason <text>] [--write]
adlc handoff repair --session <id> --ticket <id> --content-hash <h> [--write]
adlc handoff unlock --session <id> --pid <n> --started-at <iso> --host <h> --nonce <n> [--write]
adlc handoff continue --deny-session <denier> [--session <new>] [--capture-from <transcript>] [--write]
adlc handoff supervise [--dir .adlc] -- <command> [args...]Mutating --write requires ADLC_MANIFEST_KEY (never silent success).
--dir names the ledger directory and its final path segment must be .adlc:
artifacts and manifest evidence share that tree, and any other name is refused
rather than splitting them. repair binds a deny that already exists and is
still open — it never creates one. unlock reclaims only a lock minted on this
host, so a dead-looking PID from another machine cannot evict a live session.
A bypass grant on stdout is scoped to the calling adapter invocation; the
durable proof is the context-handoff-bypass manifest entry; an explicitly
empty --unbound-reason is refused rather than degraded to a bound grant.
write, resume, and repair all read-modify-write a deny marker, so each
holds that session's .adlc/handoffs/<id>.lock (O_EXCL, released on exit) and
exits 2 when a live session on this host holds it. write rebinds the marker
onto the final it writes — ensureDenyMarker is idempotent, so without that a
refreshed hash would wedge every later resume — and refuses to unbind or to
refresh a consumed deny. When the manifest append fails, the run's file
mutations are rolled back so no bind survives that nothing attests.
Continuation
continue is the sanctioned recovery from a handoff deny: it captures the
denied session, binds the final to that capture, and consumes the deny for ONE
successor. The denier is never un-denied — D2 stays sticky and the work moves to
a new session. It composes capture → write → resume in a single run under the
denier's lock, records context-handoff-continue, and rolls back every file it
touched when that evidence append fails.
The capture body lives at .adlc/handoffs/content/<session_id>.md and is
written only by host-privileged code; isProtectedHandoffPath denies agent
writes to .adlc/handoffs/content/**, and continue joins the mutating
subcommands an agent's shell must not run under deny. content_hash is sha256
over the canonicalized capture body (LF line endings, no trailing whitespace),
so re-deriving it from disk catches an edited capture that a valid signature
cannot.
Degrades with exit 2 and nothing consumed: an unbound deny (bind it with
repair first), a consumed deny, a missing or corrupt --capture-from source,
a successor id that already holds a resume-auth, an id that cannot be safely
quoted in the prompt, or an active ticket that disagrees with the deny's bind.
The successor id comes from --session or is minted by the command — never from
agent input.
A --capture-from transcript older than HANDOFF_MAX_AGE_HOURS contributes no
model narrative: the brief still ships, with the omission stated in it. Age is
read from the transcript's own newest timestamp, falling back to the file mtime
only when no entry carries one.
The bind is enforced, not advisory. A capture-backed record carries
content_kind: 'capture' on both the final and the deny marker. Every enforcing
adapter re-derives the capture's sha256 from disk on each mutation check, so an
edited, oversized, or deleted capture denies with a capture_tamper:<session>
reason — including for the successor that already consumed the deny. It is plain
sha256, so a keyless hook enforces exactly what the keyed CLI does. Records
written before this field exist keep their previous semantics untouched: their
hash was never re-derivable, so there is nothing to check. repair clears
content_kind when it rebinds a hash, which is the documented way out of a lost
capture.
Reading a capture back. Use readVerifiedCapture(root, sessionId, expectedHash).
It returns the body only when the bytes on disk still hash to the value the
deny record and resume-auth are bound to; missing, oversize, and altered all
fail closed with no body. Supervisors and session-start injectors must go
through it rather than reading the file — a signature proves the hash was
authorized, never that the file still matches it.
A keyless injector (a harness hook, which scrubs the manifest key before it imports anything) gets the content bind from this function and nothing more: it cannot verify the resume-auth's HMAC, so it must surface a capture as advisory context and say so, never as proof that the reading session is an authorized continuation. Authorization is decided where a key exists — the supervisor, and the mutation gate that re-derives the same hash on every mutation.
import { readVerifiedCapture } from '@adlc/context-handoff/lib/capture.mjs';
const got = readVerifiedCapture(root, denySessionId, denyRecord.content_hash);
if (!got.ok) return; // absent, oversize, or edited — inject nothingCapture content is redacted. Credentials are stripped as part of composing
the brief, not as a step a caller remembers — the capture is persisted AND
pasted into the successor's prompt on one path. The shapes mirror the findings
ledger's (packages/core/lib/ledger.mjs), which refuses to commit a finding
containing one; test/capture-redact.test.mjs pins the two behaviourally.
Removed spans leave an explicit [adlc: redacted <kind>] so a reader knows.
Pure-hex runs are exempt at any length — a brief's job includes quoting the
sha256 content_hash the successor verifies against. Best-effort, not a proof
of secret-freedom.
One successor id, one authorization. The resume-auth is created with
O_EXCL, so two continuations of different denies naming the same successor
cannot both believe they authorized it — the denier's lock cannot serialize
that, since they hold different locks. The loser degrades with exit 2 and the
winner's grant is untouched.
Rollback never overwrites a stranger. Every undo is a compare-and-swap on the bytes this run wrote: an artifact another writer has since taken is left alone and named in the error, because the failure that triggers a rollback is often that writer.
Capture content is fenced. Everything the brief carries is attacker-reachable
— a branch name, a filename in git status, a ticket title, the previous
session's own words — so each section is wrapped in <<<UNTRUSTED-CAPTURE-DATA
/ END-UNTRUSTED>>> markers, with those delimiters stripped from the content so
the fence cannot be closed from inside. bootstrap_prompt repeats, before and
after the body, that fenced content is recorded data rather than instructions.
Supervision
supervise is the zero-touch path: it wraps a harness command, and when that
session hits a handoff deny it performs the whole recovery the operator would
otherwise perform by hand.
ADLC_MANIFEST_KEY=$KEY adlc handoff supervise -- claude --model opusIt mints the first session id, polls that session's deny marker (~2 s; fs.watch
is not trusted), waits for the transcript to stop growing (5 s of stability, or
the child exiting) so the handoff summary is captured whole, runs
handoff continue --write, terminates the superseded child (SIGTERM, then
SIGKILL after 10 s), and respawns the harness with the successor id and the
bootstrap prompt. Successor ids come from continue, never from the wrapper.
The key stays with the supervisor. ADLC_MANIFEST_KEY is required up front
and reaches only the continue step; superviseChildEnv strips it — along with
ADLC_ADMIN_KEY — from every harness child.
Contract item 24. Every spawn also drops CLAUDECODE,
CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID and
CLAUDE_CODE_ENTRYPOINT. A claude process that inherits them treats itself as
a nested child and silently stops writing its transcript — no error, no warning,
just a continuation with nothing to capture. The scrub is applied per spawn, not
once at startup.
Degrade hands the session back. An unbound deny, a corrupt transcript, a
payload that cannot be trusted, or a capture that no longer matches its
content_hash all stop the loop with exit 2, a single warning, and the
copy-pasteable handoff continue one-liner. The child is left running and
nothing is consumed — a degrade is a decision for the operator, and killing
their session first would take it away from them.
A failing harness is a failing supervisor. The wrapper is what a script waits on, so it never reports its own success for a session that failed: exit 3 when the supervised command exits non-zero or is killed, exit 4 when it could not be started at all. The harness's own code is carried in the message and the JSON payload rather than passed through, because 1 and 2 already mean something about the supervision itself. A clean exit and an operator's Ctrl-C are both 0.
A crashed intermediate is reported, not failed. A session that writes its
deny and then dies on its own — non-zero, or a signal the supervisor never sends
— still hands off: the deny already said it was being replaced, and the
supervisor terminates it on the ordinary path anyway, so failing the run would
fail every successful handoff. It is named on stderr and carried in the JSON as
abnormalExits, because a handoff written by a session that crashed came from
different circumstances than one that stopped when asked.
A missing transcript is not a failed continuation. The narrative is optional — the deterministic brief still carries ticket, evidence and git state — so a session that dies before its transcript appears is continued without one rather than degraded. It is reported loudly all the same, because a missing transcript is what the item-24 environment scrub failing looks like from the outside.
The capture is re-verified at the injection point. The mutation gate already
re-derives the hash on every evaluation, but that defends mutation; the bootstrap
prompt is read by a model before it touches a tool, so the supervisor calls
readVerifiedCapture again before spawning the successor.
import { WARN_PCT, HANDOFF_PCT, HARD_PCT } from '@adlc/context-handoff/lib/thresholds.mjs';
import { evaluateBands } from '@adlc/context-handoff/lib/bands.mjs';
import { evaluateMutationGate } from '@adlc/context-handoff/lib/mutation-gate.mjs';node --test packages/context-handoff/test/*.test.mjsDeny-store expectation
loadDenyRecords treats a missing denies/ as unavailable only when
.adlc/.deny-store exists (JSON {schema,sessions} written by ensureDenyMarker
after a verified marker; sessions[] makes selective marker delete fail closed). The sentinel is a sibling of handoffs/ so deleting handoffs/ alone
cannot clear expectation; full signed per-deny ledger is still deferred.
Ticket-store presence alone does not expect denies. A legacy
.adlc/handoffs/.deny-store is treated as expected and self-healed to the new
path. evaluateMarkerOnReentry does not use the global sentinel for
per-session marker_vanished — callers thread denyEverWritten. Unbound operator bypass may clear D0:deny_store_unavailable and
D3:invalid_record.
Advisory nags (nagSuppression) are suppressed when remaining-to-hard is below
MIN_REMAINING_TO_HARD (near-hard / handoff zone) so deny/handoff owns the
signal; this never affects mutation deny.
