The ownership protocol
Ownership as a verifiable, transferable protocol — not an imitated person. When a workspace hands work to an agent, three questions decide whether the result is worth anything: what was it allowed to touch, when was it supposed to stop, and who says it worked. The protocol answers each with a type, and each type with an enforcement site.
RoleProfile— what a role may touch and what "done" means for it.TaskEnvelope— spoken name AutonomyBoundary: the decision boundary an autonomous task runs inside.HandoffCheckpoint— a resumable state transfer, where facts carry evidence and everything else is a hypothesis that says so.
Two "envelopes", two concepts — keep the names apart. A TaskEnvelope is the AutonomyBoundary
(kernel-enforced, never persisted); RunRecord.envelope is the CausalBudget (capUsd/capRuns — the
delegated spend caused work draws from, enforced at the admission gate, persisted as a spend ledger). On an
agent activation BOTH key on the run id, which is exactly why the language must not blur: an agent run
executes inside an AutonomyBoundary; caused work draws from its CausalBudget. The symbol rename is deferred
to a wire-breaking version — the language is not.
Contracts: packages/contracts/src/records/ownership.ts. Invariants: packages/domain/src/ownership/.
Roles, and the actor behind them
The seven roles (observer · diagnostician · planner · executor · verifier · operator ·
coordinator) separate five things that get conflated into "the agent": role, context, capability, evidence,
and completion. Never which model — a bigger model is not a different accountability.
Two invariants hold inside a profile (assertRoleProfile):
observer/diagnostician/verifierwrite nothing. A verifier that can write is an actor.- Only a verifier completes with
verified_verdict. An executor finishing produces achange_set— a claim, which someone else verifies.
Both are necessary and neither is sufficient, because a profile says nothing about who is wearing it. One process can hold the executor profile, finish, then hold the verifier profile and pronounce its own work good — every rule above satisfied, and the verdict worthless. So identity is its own type:
ActorRef { id, sessionId?, runId? } // member subject, or agent:<agentId>
RoleAssignment { profile, actor } // the unit separation is stated over
assertIndependentVerification(executor, verifier) refuses a verified_verdict when the verifying actor is
the executing actor, or when the two shared one run or one session. The last clause matters as much as the
first: two distinct identities inside one execution context are not independent, because the verifier read the
executor's own reasoning on the way in.
Context separation has no field, deliberately
RoleProfile used to carry contextScopes: string[] — "which provenance scopes this role's context may draw
from". Nothing ever read it, including assertRoleProfile two lines below it. It was removed rather than
wired, on two findings:
- There is no context-assembly point to filter. Knowledge, memory and skills do not arrive as a
pre-built bundle of classes an agent could be given a subset of; the agent pulls each one through a tool
it decides to call (
get_task_context,use_skill,get_file). The only thing injected unasked is the environment block — workspace, model, date, paths. There is no menu for a role to select from. - The job it named is done by the envelope's scope. "What may this role draw on" is which tools it may
call, which is exactly
TaskEnvelope.scope.reads— an evidence-only role gets an explicit read list, the default executor posture isreads: "all". Both halves of the scope (readsANDwrites) are honored by the kernel on every call (authorizeToolInvocation— the one decision function, executed verbatim) and inherited by sub-agents. A second vocabulary for the same concern, read by nothing, is not a weaker guarantee. It is a false one.
The spawn site exists now (CheckpointService.requestVerification), and it fills BOTH halves: scope.reads
gets the read tools, scope.resources gets the evidence. The second field had to be added — putting object
ids in the capability list was the bug, not the design.
Where this is enforced — and where it is not
Stated plainly, because a protocol that overstates its own coverage is the failure it exists to prevent.
| Site | Status |
|---|---|
assertIndependentVerification (domain) | enforced — the ONE decision function (actor + run + session) |
| Checkpoint persistence | enforced where resolvable — the service assembles both RoleAssignments from the referenced run's executor linkage and calls the domain function; missing linkage abstains |
| Verifier envelope construction | enforced — verifierEnvelopeFor builds the only envelope a verifier role may run inside: writes empty, reads = an explicit list of read TOOLS, scope.resources = exactly the evidence |
| Object scope at the tool call | enforced — the kernel calls authorizeResourceAccess on every target a tool declares (ToolDefinition.resourceTargets) whenever the envelope carries a scope.resources. A tool that has NOT declared its resource semantics is refused under such an envelope: the guarantee is "this and nothing else", and we cannot make that claim about a call whose target nobody named |
| Verifier verdict independence | enforced where resolvable, and SAID SO — requestVerification resolves EVERY executor from the evidence's run refs and calls the domain function against each. Coverage is three-valued: enforced (every internal run resolved) · partial (some did not — the ids are recorded) · abstained (none did). A two-valued field collapsed partial knowledge into the optimistic half |
| Verifier evidence coverage | enforced — the kernel reports every object a granted tool actually reached (onResourceAccess), and the decision records offered / reviewed / unreachable. The resource scope proves a verifier could not look OUTSIDE its evidence; this proves it looked INSIDE |
| Affirmative verdicts | gated by both coverages — a verified from the runner is recorded as inconclusive, with the gap named, unless independence is fully enforced AND every offered-and-reachable ref was read. The runner reports what it concluded; the PLATFORM decides what that conclusion is worth given what was checked |
| Verification durability | enforced — the verdict is filed as a VerificationDecision (mig 0151), an append-only aggregate separate from the checkpoint |
| Verifier RUNTIME (the agent that produces the verdict) | not wired — VerifierRunner has no bound implementation. A deployment with no runner REFUSES the request; a missing verifier never becomes an automatic pass |
Two scopes, because they answer two questions (arch-review 10 P1). scope.reads/writes/forbidden are
CAPABILITY names — the strings authorizeToolInvocation compares against tool.name. scope.resources is
the OBJECT list, and authorizeResourceAccess is what enforces it. The verifier envelope needed both and had
only one: the evidence ids (run:r1) were written into scope.reads, where they matched no tool name — so
the spawned verifier could call nothing at all, and "evidence only" was enforced by nothing, because no
guard ever compared a call's target to anything. Two concepts sharing one field is not a weaker guarantee; it
is a false one, and this one happened to fail in the direction that looked like enforcement.
Splitting them was necessary and not sufficient: for one wave authorizeResourceAccess existed and nothing
called it (arch-review 11 P0), which is the same failure one level up — the guarantee lived in the contract
and the runtime never asked. The kernel now consults it on every tool call under an object-scoped envelope,
and a tool reaches its own evidence by DECLARING what it touches (ToolDefinition.resourceTargets, a table
the host attaches for the control-plane surface it owns — declared, never inferred from a tool's spelling).
Undeclared tools are refused under an object scope; third-party MCP servers are therefore unreachable to an
evidence-scoped role, which is the honest answer to "can we promise this call touches nothing else?" for
someone else's tool.
verifierEnvelopeFor (@everdict/domain) is a CONSTRUCTOR, not a validator run afterwards — a verifier's
envelope is not something a caller proposes and we approve. It refuses a non-verifier profile, an empty
evidence set, an empty tool set (a verifier that cannot reach its own evidence is a refusal wearing a
verdict's name), and tools the profile's own read ceiling does not cover. Sub-agents inherit the envelope, so
a verifier cannot delegate its way out of any of the three separations.
CheckpointService.requestVerification is the caller: the evidence is the checkpoint's own refs (what the
executor put forward), the returned verdict is CHECKED against EVERY executor's ActorRef — actor, run and
session — and then FILED as a VerificationDecision. Every executor, because a checkpoint may cite several
runs with different ones: resolving "the first run reference that resolves" left an independence claim with a
hole the size of the second executor, and the hole opened for exactly the verifier with the most reason to
want it — the one whose own work sat in the evidence (arch-review 11). Both halves were previously claimed by this document
and done by neither: the runner's object was returned to the caller untouched, so an agent verifying its own
run was refused by exactly nothing, and a judgment nobody could look up afterwards could not be cited or
audited. VerifierVerdict.actor is an ActorRef for that reason — a bare string can answer only the actor
third of a three-part invariant. The DECISION that spawn must call already exists:
assertEnvelopeForRole(profile, envelope) (@everdict/domain) holds the delegation invariant — a role's
capabilities are the CEILING, the envelope's scope must be a subset (reads: "all" is delegable only by an
unrestricted-read profile; excess writes/reads refuse) — so a "verifier" envelope carrying production writes
stops typechecking its way into a runtime that would enforce exactly what it says.
Completion is a decision, not a declaration: RoleProfile.requiredEvidence was read by nothing — done
stayed whatever the finisher claimed. assertCompletionForRole(profile, checkpoint) now decides at
checkpoint admission (the one seam holding both the role and the evidence refs), with the vocabulary
mapping explicit — trace→a trace ref, scorecard→a scorecard ref, diff→a commit|file ref, report→a
file ref, checkpoint→the checkpoint itself. The synthesized assignment profiles declare no evidence yet,
so the decision arms the moment a real profile does.
Independence reads the executor identity, never attribution: Run.origin.executor records who performed
the work at creation (agent:<id> on activation and chat-turn runs) while createdBy stays the principal
the run acted as — the composition's runActor prefers the executor, which is what lets the same agent
filing from a later activation still be caught by the actor leg. And a VERIFIER checkpoint must declare
by: an anonymous verification used to make the whole independence check abstain — fail-open on the one
field the caller controls — and now refuses at admission.
The envelope
An autonomous task runs inside a TaskEnvelope: a two-halved scope — reads ("all" = the executor posture,
or an explicit evidence-only list) and writes (the effectful capabilities explicitly granted), with
forbidden beating every grant — at least one hard budget (an unbounded autonomous task has no decision
boundary — assertTaskEnvelope refuses one without), and a fixed vocabulary for the two ways a task ends
badly. The scope decision has ONE owner (authorizeToolInvocation, contracts) and the kernel executes its
answer verbatim for both access kinds; kernel cognition tools (todo, plan, spawn, result paging, wait) are
intrinsic — part of how the agent thinks, outside the scope lists, still refusable via forbidden.
stop.onBudgetExhausted: "halt_checkpoint"— stop and leave a resumable checkpoint. Dying silently mid-task is the exact failure the envelope exists to prevent, so "keep going" is not a value the type offers.escalation.onScopeExceeded: "refuse_and_replan"— a refusal is data the runtime acts on, never a warning a loop can log and ignore.rollbackRequiredreaches the checkpoint boundary through the PRODUCER: envelopes are not persisted, sopublishHaltcarries the envelope's policy slice ({id, rollbackRequired}) in the checkpoint body and admission callsassertCheckpointForEnvelope— a rollback-demanding envelope refuses a planless handoff where the checkpoint is minted. Carrying the slice is stricter-only (omitting it is exactly the old behavior), which is why a caller-declared slice is safe. Both envelope AUTHORS runassertTaskEnvelopeat their compose point (the activation'senvelopeFor, the chat turn's scope completion) — the budget invariant fires where an envelope is born, not only in unit tests.
The kernel (packages/agent-runtime/src/kernel/loop.ts) honors the envelope on every tool call and at every
turn boundary, and passes it verbatim to sub-agents — without that line a scoped parent could
spawn_agent its way out of its own scope. A child's effective scope can therefore only shrink: it inherits
the parent's envelope, and the kernel builds its registry from the parent's read-only tools filtered by
the parent's own scope (a read-scoped parent must not hand a child the reads it was itself denied), so a
write the parent held has no door into the child at all.
Who actually gets one
Agent activations (a platform event matching an enabled agent's trigger — apps/agent/src/agent-activation.ts).
That is autonomous work by definition: nobody is watching, and the boundary is the whole safeguard. The
envelope's id is the run id, because an envelope is per-execution and two concurrent activations must not
share a boundary. role stays absent — an agent spec declares no ownership role, and stamping executor on
it would be a claim the record cannot back.
Interactive chat does not. A human is present and asked for the tool call; refusing it would be the gate misfiring, not working.
Resumed legs are bound too. An activation that parks for approval, or dies and is recovered after a
restart, comes back through runContinuationTurn — a new run on the ledger, and for a while an unbounded
one, which is the moment a long-running task is most likely to keep going. The continuation builds its own
envelope keyed on its own run id, so its budget starts fresh rather than inheriting a spent one: the ledger
says this is a new run, and per-run bounding is the same rule sub-agents already follow.
Teammates do not, and that is the remaining gap. spawn_teammate creates a persistent autonomous agent
whose standing task is seeded as its first message; TeammateSupervisor only serializes its turns. There is
no completion condition and no budget, which is precisely what assertTaskEnvelope calls "an unbounded
autonomous task has no decision boundary". A teammate's autonomy boundary today is consent at spawn time.
Binding one needs a decision this protocol has not made — what a teammate's goal and terminal condition
are — so it is named here rather than papered over.
Two seams are worth naming precisely:
- Scope is completed where the tools resolve. The activation states the boundary it owns (goal, budgets,
vocabularies);
runChatfillsscope.writeswith the write-capable tools of the built registry (andreads: "all"— the executor posture), because the agent's granted capabilities only exist as names once tools are constructed. Pinning them there is the point — a server connected mid-run is outside the scope the task was authorized under, not silently inside it. Narrowing writes below "everything granted" is a product decision (teammate bounding) deferred with it. - Budgets are the kernel's units, not dollars.
spec.budgetUsdis a different axis: the delegated slice governing work an activation causes (runs it submits, refused with a 402 at the admission gate, priced on the control plane). The loop measures tokens and wall-clock and knows nothing about money, so the envelope carries generous token/time walls (ACTIVATION_TOKEN_BUDGET/ACTIVATION_TIME_BUDGET_SEC) rather than a dollar figure nothing inside the loop could check.
The halt writes the handoff
On budget_exhausted the host builds the checkpoint, not the agent — the agent is out of budget, and
asking it for one more turn to summarize itself is asking past the boundary that just stopped it. The host
states as fact only what it holds evidence for: the run, which is a resolvable reference on the ledger.
What the work achieved lives in a transcript the host never read, so it goes in hypotheses, where a
successor treats it as something to check rather than something to build on. Publication is best-effort by
contract: a control plane that refuses the checkpoint must not turn a bounded stop into a failed run, and the
halt is already a fact on the event log.
The lifecycle says what the checkpoint says. A halted run settles as suspended — on the session AND
on the universal run ledger — never completed: the checkpoint's own words are "the run halted before
reporting completion", and a status that contradicts them makes "done" and "stopped mid-task" the same claim.
The agent.run.suspended fact carries the handoff's actual fate (published | failed | absent), so
"resumable from a checkpoint" is only ever claimed when one landed. A turn parking on an armed wait
suspends the same way — waiting is not completion; the wake resumes it as a new run.
A scope refusal produces no checkpoint, and that is not an omission: refuse_and_replan returns the
refusal to the model as a tool result and the run continues. There is no halt to hand off from.
Effect contracts, and the gate that reads them
A capability's EffectContract (packages/contracts/src/records/capability.ts) says what invoking it does to
the world outside the sandbox. assertCapabilityEffects has always refused to register a write-capable
capability without one. What was missing is the other half: nothing read it at invocation time. The agent's
permission gate classified risk by name prefix (delete_ / remove_ / revoke_ / unlink_), which is a
guess about a string — sync_inventory looks benign and can bill a customer; remove_label looks alarming and
undoes itself.
effectsRequireConsent (@everdict/domain) is what reading it means. Four independent reasons to keep asking
a human even in auto mode, any one sufficient:
sideEffect: "external"— the one everdict cannot undo on the caller's behalf.- A workspace mutation whose
idempotentwas not promised. Absent is unknown, and unknown is not a smaller risk than declared-unsafe. rollback: { kind: "irreversible", requiresApproval: true }— the author wrote the consent requirement down, and the gate reads it instead of inferring it from a verb.dataAccess.egress === "external". Orthogonal tosideEffecton purpose: a read tool that can reach an outside network is exfiltration-shaped, andsideEffect: "none"is a true statement about the wrong axis.
Provenance travels with the tool, not through a second lookup: the resolved capability stamps effects on
its ToolDefinition, the kernel hands it to the permission hook on the PermissionRequest, and the hook
classifies. The name lists remain authoritative only where nobody made a declaration at all — the built-in
control-plane surface and the kernel's own tools, which are ours and known rather than declared. Conversely a
declaration that says a tool is safe is trusted over the name list: that statement is what the workspace
signed up for when it adopted the thing.
Two deliberate non-additions:
dataAccessis not required by the registration guard. An MCP server's spec cannot honestly answer it for a container it merely names, and demanding a declaration authors cannot make produces invented ones, which is strictly worse than an absent field.- Prose
rollbackstill parses. Every stored contract keeps working; the tagged forms are what a machine can act on, and the guard treats both as declared.
The checkpoint
A successor decides its next action from evidence references, not from the predecessor's prose. So a
confirmedFacts entry requires at least one CheckpointRef — the schema itself refuses a "fact" without
evidence, because a statement with nothing behind it is a hypothesis and the checkpoint has a field for
those. danglingCheckpointRefs resolves every reference against the real stores; a fact whose evidence
cannot be found is not a fact the successor can stand on.
Persistence and the surface
HandoffCheckpointStore (packages/application-control/src/ports, everdict_handoff_checkpoints,
migration 0137) is append-only on purpose: the port offers no update and no delete, so a predecessor
cannot rewrite evidence its successor already acted on. CheckpointService holds the two admission rules,
because both need to read other people's records:
- Dangling evidence is refused (400). Resolvers are bound in the composition root for every ref type
everdict can actually answer for — runs, scorecards, issues and workspace files are records we hold.
A type with no resolver (
commit, a foreign platform'strace) is unverifiable, not false: everdict does not host the tenant's git remote, and refusing a checkpoint for citing a commit would be pretending to a check nobody made. The tenant comparison lives in the resolver, so a checkpoint cannot prove a fact with another workspace's run. What admission checked is stamped on the record: each ref carriesresolution: "verified" | "unverified_external"(set by the service, a producer-supplied value is overwritten) — "evidence-backed" and "evidence-VERIFIED" are different claims, and a successor weighing a fact reads which one it holds. - A verifier does not check its own work (400) — the O3 invariant, decided by the DOMAIN. The service
resolves each referenced run's executor as an
ActorRef(id + run + session context), builds the twoRoleAssignments, and callsassertIndependentVerification— actor AND run AND session independence, never a service-local re-implementation (which is how the check once silently narrowed to actor-id equality: a second agent id verifying from inside the executing session sailed through). Every clause is conditional on the linkage existing — noby, no role, or an unresolvable executor makes the check abstain.
Surface: POST/GET /checkpoints + GET /checkpoints/:id, and the MCP twins publish_checkpoint /
list_checkpoints / get_checkpoint — the transport an agent actually reaches this through, which is the
point. Authz reuses agents:read / agents:write (no new action). Creation emits checkpoint.created on
the E0 same-tx outbox, classified on the agent activity axis; it is deliberately not trigger-matchable,
because an agent waking on another agent's handoff is the runaway vector the agent.run.* family is
excluded for.