Skip to main content

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):

  1. observer / diagnostician / verifier write nothing. A verifier that can write is an actor.
  2. Only a verifier completes with verified_verdict. An executor finishing produces a change_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:

  1. 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.
  2. 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 is reads: "all". Both halves of the scope (reads AND writes) 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.

SiteStatus
assertIndependentVerification (domain)enforced — the ONE decision function (actor + run + session)
Checkpoint persistenceenforced where resolvable — the service assembles both RoleAssignments from the referenced run's executor linkage and calls the domain function; missing linkage abstains
Verifier envelope constructionenforcedverifierEnvelopeFor 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 callenforced — 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 independenceenforced where resolvable, and SAID SOrequestVerification 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 coverageenforced — 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 verdictsgated 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 durabilityenforced — 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 wiredVerifierRunner 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.
  • rollbackRequired reaches the checkpoint boundary through the PRODUCER: envelopes are not persisted, so publishHalt carries the envelope's policy slice ({id, rollbackRequired}) in the checkpoint body and admission calls assertCheckpointForEnvelope — 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 run assertTaskEnvelope at their compose point (the activation's envelopeFor, 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); runChat fills scope.writes with the write-capable tools of the built registry (and reads: "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.budgetUsd is 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:

  1. sideEffect: "external" — the one everdict cannot undo on the caller's behalf.
  2. A workspace mutation whose idempotent was not promised. Absent is unknown, and unknown is not a smaller risk than declared-unsafe.
  3. rollback: { kind: "irreversible", requiresApproval: true } — the author wrote the consent requirement down, and the gate reads it instead of inferring it from a verb.
  4. dataAccess.egress === "external". Orthogonal to sideEffect on purpose: a read tool that can reach an outside network is exfiltration-shaped, and sideEffect: "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:

  • dataAccess is 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 rollback still 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:

  1. 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's trace) 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 carries resolution: "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.
  2. 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 two RoleAssignments, and calls assertIndependentVerification — 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 — no by, 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.