INVARIANTS.md

58e6347eeb72 · 375 KB

OpenAgents invariant ledger

These contracts define the OpenAgents application. Every entry is explicitly Current or Proposed. A current contract must name an executable test or a concrete manual proof whose file exists in this repository. A proposed contract describes a release gate, not current behavior, and cannot be used as evidence that a feature is safe or enabled.

A change to a listed contract must update this ledger and its named proof in the same commit. ops/ci/docs-check.exs verifies unique IDs and evidence-file paths.

A proof must be capable of failing for the claim it names. docs/taxonomy.md naming rule 7 says a contract is not true until its proof runs green; this is its companion, because a green proof of the wrong population says nothing. A contract that quantifies — "no route", "every surface", "nothing", "only" — needs a proof that derives the population from something the code cannot lie about: the router, OpenAgentsWeb.RouteAuthority, OpenAgentsWeb.ApiRouteAuthority, a compiled BEAM import table, a database constraint, a configuration list, or the repository tree. A test of the surfaces someone thought of cannot fail on the surface they did not, so it is green exactly when it would be most useful. Where enumeration is impractical, narrow the claim to what its proof covers: a smaller true statement beats a larger unprovable one. docs/2026-08-23-invariant-proof-audit.md classifies every contract here against that rule and records what remains.

OpenAgents identity and canon

CANON-001 — Persona sources are immutable and status-labeled

Status: Current

Every historical source admitted to author OpenAgents's persona is pinned by repository revision, path, content SHA-256, source status, admitted uses, and explicit exclusions. The complete manifest has a canonical digest admitted by the application and is validated before the supervision tree starts. Founder direction, retired product material, unscheduled drafts, and scoped performances cannot silently become runtime authority or ordinary voice.

Episode numbers are not source identity. The conflicting Episode 263 file is quarantined, and the final Omega Alpha transcript is pinned by its actual path.

Evidence: OpenAgents.Persona.SourceManifest, priv/sarah/persona/sarah.v1.sources.json, and OpenAgents.Persona.SourceManifestTest.

CANON-002 — Retained machine names are enumerated, not assumed

Status: Current

The product says computer. A machine name is retained only where something outside this release can observe or replay it: a row an earlier release wrote, a client this repository does not ship, another node during a hot upgrade, or a sealed ciphertext. Everywhere else the name is computer.

The durable half of that exemption is enumerated, not described. OpenAgents.Vocabulary lists every machine-named table, column, constraint, and index, and the proof derives the live population from information_schema and pg_catalog rather than from a list maintained beside it, so a migration that adds a machine-named durable surface fails until someone records it and says why, and a ledger entry the database does not have fails as well. The wire half has no query that enumerates it and is listed in docs/taxonomy.md instead, held by the contracts that own each surface.

A retained name owes a population, and the population must be established from something that cannot lie rather than from a reading of the call sites. The retired AAD sarah.machine_token.v1 had none — nothing seals a version-1 blob and a sealed pairing token cannot outlive its bounded window, which IDENTITY-011 now enforces rather than assumes — so the token vault carries one version and one AAD. The audit actor kind machine looked the same and is not: a paired computer authenticating to the Git plane pushes under {:machine, id}, from a variable that no source scan for a literal finds. It stays, and the proof asserts that every principal kind OpenAgents.Forge.GitHTTP.audit_actor_kinds/0 admits is an actor kind OpenAgents.Audit accepts, so narrowing one without the other fails here instead of raising after a pack is already written.

Evidence: OpenAgents.Vocabulary, OpenAgents.Audit, OpenAgents.Forge.GitHTTP, OpenAgents.Machines.TokenVault, test/openagents/vocabulary_test.exs, test/openagents/audit_test.exs, test/openagents/machines/token_vault_test.exs, and test/openagents/machines_test.exs.

PERSONA-001 — Each inference uses one immutable persona artifact

Status: Current

OpenAgents's core identity, voice, and first-conversation greeting come from one versioned artifact admitted by its exact content SHA-256. The artifact and its source-manifest identity are validated and installed before the supervision tree starts.

Every request built for an OpenAgents inference receives instructions composed from that installed artifact. Two requests are outside that clause and are named rather than assumed. The delegated-probe inference proxy, OpenAgentsWeb.InferenceProxyController, relays the probe's own system messages: the model in that call answers as the probe, not as OpenAgents, and the proxy composes no OpenAgents persona at all. The persona evaluation runner composes the candidate artifact under evaluation rather than the installed one.

Provider adapters contain no independent OpenAgents persona. No adapter reaches the persona, the composer, the role catalog, or the Blueprint projection, so none can obtain or recompose the identity. An adapter that builds a request body sends the composed instructions byte for byte and adds no text of its own anywhere in that body; an adapter that relays host-composed frames reaches no session configuration and names no instruction field; an adapter that never leaves the VM reaches no egress at all.

Both populations are derived rather than remembered. The three provider behaviours record their implementors in each module's BEAM attribute chunk, and every configured provider must be one of them, so an adapter selected by configuration that declared no behaviour fails as well. Every module that names OpenAgents.Providers.Request is read from its atom table and classified by where the instructions on that request come from, and every module classified as composing the installed artifact must carry a compiled call into the composer. The wire probes read the outbound body, not request headers or a second request an adapter might make, and the socket adapter is bounded by what it can name rather than by a captured frame.

Evidence: OpenAgents.Persona, priv/sarah/persona/sarah.v1.md, OpenAgents.Context.Composer, OpenAgents.Turns.TurnServer, OpenAgents.PersonaTest, and test/openagents/providers/persona_boundary_test.exs.

PERSONA-002 — One core OpenAgents identity composes with an admitted role

Status: Current

Protected identity and host safety precede surface truths, the selected role, Blueprint expression facts, captured capabilities, and recalled evidence in a deterministic composition. The public application selects only general_collaborator.v1. Role selection accepts only typed host surface, authority, request, and capability inputs; its canonical input/catalog receipt is persisted immutably for text turns and voice sessions. User text, Blueprint, profile memory, and bounded recalled evidence cannot select a role or replace protected layers. Inactive coding, sales, company-operations, and public- broadcast registers fail closed to the compatible general baseline or no role.

Evidence: OpenAgents.Context.Composer, OpenAgents.Roles, OpenAgents.Roles.Catalog, OpenAgents.Roles.GeneralCollaborator, test/openagents/roles_test.exs, OpenAgents.Context.ComposerTest, and OpenAgents.RolesTest.

PERSONA-003 — Persona promotion requires revision-bound regression evidence

Status: Current

Every persona candidate is evaluated against the committed, source-labeled behavior corpus. Promotion requires a complete passing report bound to the exact persona, source manifest, corpus, and model revisions. All cases must pass, so military, founder-voice, sales, false-recognition, or generic-assistant containment failures cannot be averaged away.

Evidence: OpenAgents.Persona.Evaluation.Runner, priv/sarah/evals/persona/corpus.v1.json, OpenAgents.Persona.EvaluationTest, and mix openagents.persona.verify_promotion.

BLUEPRINT-001 — Platform facts are source-linked immutable revisions

Status: Current

Every admitted OpenAgents Blueprint revision is a complete canonical snapshot of typed platform facts. Facts carry stable IDs, source refs/status/time/digests, compatibility, introduction/retirement revisions, and admission provenance. Appending or retiring creates a revision; PostgreSQL rejects edits or deletion of admitted rows. Compilation is deterministic and refuses conflict, stale compatibility, missing provenance, private-memory sources, and prose that claims data, pricing, tool, or action authority.

Every inference immutably pins the verified Blueprint revision and digest, or pins the explicit-none state when no revision exists. Blueprint informs expression below protected host layers and cannot attach a capability or authorize execution.

Evidence: OpenAgents.Blueprint, the OpenAgents Blueprint schemas and migration, OpenAgents.Context.Composer, OpenAgents.Conversations.begin_inference/5, test/openagents/blueprint_test.exs, and OpenAgents.BlueprintTest.

PROGRAM-001 — Model programs are immutable typed data, never effect authority

Status: Current

Every admitted model-program artifact has one full canonical digest covering its signature, compiler/model/decoding identity, compatibility, Prompt IR, parameters, independent datasets, optimizer budget, evaluator, metrics and uncertainty, provenance, approval, predecessor, and activation status. Training or validation cannot alias true holdout. Boot admits only pinned digests with known predecessors.

The OTP reader returns only typed artifact data. Program outputs are proposals, selections, or scores and cannot execute a tool, add a catalog entry, mutate policy or protected identity, write memory, or promote/activate themselves. Each applicable turn captures one immutable artifact identity before provider work; an in-flight capture cannot observe another catalog.

Evidence: OpenAgents.ProgramArtifacts, OpenAgents.ProgramArtifacts.Reader, priv/sarah/programs/, test/openagents/program_artifacts_test.exs, OpenAgents.Conversations.begin_inference/5, and OpenAgents.ProgramArtifactsTest.

DEGRADE-001 — Missing program artifacts degrade explicitly to a baseline

Status: Current

If an applicable signature has no admitted compatible artifact, capture returns the deterministic baseline with no artifact ID/digest and a bounded degraded receipt. Invalid or unapproved artifacts never enter the boot catalog. The turn receipt immutably records either the exact artifact and catalog identity or the explicit baseline reason before provider work.

Evidence: OpenAgents.ProgramArtifacts.capture/1, turn_receipts.program_artifact_receipt, its database constraint/trigger, OpenAgents.ProgramArtifactsTest, and degraded capture tests in OpenAgents.TurnProvenanceTest.

PROGRAM-002 — Shadow programs have no live effect or private report payload

Status: Current

Shadow signatures validate bounded typed input/output and run under a separate task supervisor. The live turn never consumes their result. Memory and collective outputs remain proposals; routing can name only a capability in its captured input catalog. Malformed, late, failed, incompatible, and missing- artifact paths degrade explicitly without writing memory, tools, policy, Blueprint, collective data, or the user-visible answer.

PostgreSQL stores immutable terminal comparison metadata, digests, latency, usage, and withheld output shape, never raw inputs or candidate values. Replay data is explicitly synthetic or must enter through a future consented fixture path; production conversations/profile memory are not datasets.

Evidence: OpenAgents.ShadowPrograms, OpenAgents.ShadowPrograms.Signatures, OpenAgents.ShadowPrograms.OpenAI, shadow_program_runs, priv/sarah/evals/shadow/corpus.v1.json, test/openagents/shadow_programs_test.exs, and OpenAgents.ShadowProgramsTest.

PROGRAM-003 — Promotion is offline, human-approved, and rollback-capable

Status: Current

A compiled candidate, pinned independent evaluation, human approval, and human activation are separate append-only artifacts/events. Evaluation is bound to distinct train/validation/true-holdout manifests, exact evaluator identity, complete bounded cost, metrics/uncertainty, and passing safety/privacy gates. Datasets are synthetic or carry an explicit consent receipt; private production data has no implicit admission path.

Only a human actor receipt can approve, activate, or roll back. Activation atomically advances a PostgreSQL pointer generation for later turn captures; an in-flight capture remains unchanged. Rollback selects the pinned predecessor and preserves the complete prior trail.

Evidence: OpenAgents.ProgramLifecycle, its artifact/event/activation schemas and database guards, test/openagents/program_lifecycle_test.exs, the synthetic sample promotion report, and OpenAgents.ProgramLifecycleTest.

Identity and authorization

IDENTITY-001 — Human browser and session identity

Status: Current

Every human browser interaction requires an active local user established through the GitHub OAuth authorization-code flow. The immutable external key is GitHub's numeric user ID; login and avatar URL are refreshable projections and never authority. OAuth start uses high-entropy state plus PKCE S256. A short-lived PostgreSQL attempt receipt makes state one-time even if an old encrypted cookie is replayed. Phoenix exchanges the code and rereads /user server-side, then stores the delegated GitHub token as encrypted server-side ciphertext for GitHub repository tools. The token never enters the browser. The browser session contains only OpenAgents's local user ID and is encrypted, signed, HTTP-only, same-site, and secure in production.

Agent credentials use the separate agent identity contract below; they do not create rows in users or require GitHub.

The first sentence quantifies over routes, so it is proven by enumerating them: OpenAgentsWeb.AuthenticatedRouteGateTest dispatches every route OpenAgentsWeb.RouteAuthority classifies :authenticated_browser without a session and requires each to refuse. The two OAuth entries are in that class and refuse the same way — an anonymous request reaches them and leaves with an auth_error and no session.

Amended 2026-08-26 (issue #129): a refusal at /device remembers the terminal code in the browser session and returns the reader to the approval after the OAuth round trip, so one intent costs one sign-in rather than two. The refusal itself is unchanged — no session, no approval, and the code alone grants nothing — but two of its outputs now depend on a value the browser sent: where the redirect points, and what the landing page prints.

That value is admitted by exactly one function. OpenAgents.DeviceAuthorizations.cast_user_code/1 returns {:ok, code} only for the eight-character alphabet this application mints, anchored so no second line can follow, and every place that puts the value into a URL, a session, or a page casts it rather than trusting an earlier cast. Anything else — a host, a scheme-relative URL, a path, a CRLF, markup, a query-string list — leaves the reader on the bare public root with nothing remembered, which is where the refusal put them before this existed. The proof is adversarial and enumerates the crafted forms rather than asserting the happy path.

What this does not do: name the computer that is asking. Nothing in the device authorization records one, so the approval page names the client and the scopes, which are what the application actually knows.

Evidence: OpenAgents.GitHubOAuth, OpenAgents.Accounts, OpenAgentsWeb.AuthController, OpenAgentsWeb.UserAuth.require_authenticated_user/2, OpenAgents.DeviceAuthorizations.cast_user_code/1, OpenAgentsWeb.Endpoint.session_options/0, OpenAgents.GitHubOAuthTest, OpenAgents.AccountsTest, OpenAgentsWeb.AuthControllerTest, OpenAgentsWeb.DeviceSignInReturnTest, and OpenAgentsWeb.AuthenticatedRouteGateTest.

IDENTITY-002 — Conversation lookup never accepts a client database ID

Status: Current

The browser supplies only its encrypted OpenAgents session. Server code loads the active local user and resolves that user's internal storage owner and canonical conversation. A route parameter, form value, mutable GitHub login, or LiveView event must not select another user, owner, or conversation. All typed, memory, data, voice, and telemetry routes fail before mutation without an active user; health endpoints remain public and create no identity state.

The route half of that sentence is enumerated rather than sampled. OpenAgentsWeb.AuthGateTest asks eleven hand-written paths whether they redirect, which cannot fail for a route nobody added to the list, so OpenAgentsWeb.AuthenticatedRouteGateTest derives the population from OpenAgentsWeb.RouteAuthority instead: every route it classifies :authenticated_browser must refuse an anonymous request with a redirect to the public root or a 401. A route added outside the :authenticated pipeline fails there until it is reclassified.

Amended 2026-08-23 (issue #174): the LiveView event half used to rest on each surface's own test, which cannot fail for a handler nobody wrote a test for. Two mechanisms carry it, and OpenAgentsWeb.LiveViewScopeTest enumerates both from the router and from compiled import tables.

  • Who is acting is re-resolved before every event. OpenAgentsWeb.UserAuth.on_mount/4 attaches a :handle_event hook in the :ensure_authenticated and :ensure_admin stages; each re-reads the account through OpenAgents.Accounts.get_active_user/1, and the operator hook rechecks OpenAgents.Accounts.admin?/1. A session banned or demoted between mount and click is halted, not served. Every LiveView route OpenAgentsWeb.RouteAuthority classifies :authenticated_browser or :operator must sit in a live session that mounts one of those stages, and the live sessions themselves are an exact declared set, so a new session is classified before it can carry a surface.
  • What is being resolved goes through a context. No LiveView reaches OpenAgents.Repo. A view that writes its own query is a surface where the scope rule is restated from memory, which is how a handler comes to select a record its caller never had. OpenAgentsWeb.AdminForumLinksLive did exactly that — Repo.get!(Forum.ActorLink, id) straight from the event params — and now resolves through OpenAgents.Forum.fetch_actor_link/1.

What is still not enumerated: a context function that itself takes no acting principal, called from a handler with a caller-supplied identifier, passes both tests. OpenAgents.DeviceAuthorizations.get_pending_by_user_code/1 is the deliberate instance — the device code is the terminal's own bearer secret, and the deciding half (approve/2, deny/2) binds the acting account. docs/2026-08-23-invariant-proof-audit.md records the residue.

Evidence: OpenAgentsWeb.UserAuth, OpenAgentsWeb.Router, OpenAgentsWeb.ChatLive.mount/3, OpenAgents.Conversations.get_conversation_for_user/1, OpenAgentsWeb.AuthGateTest, OpenAgentsWeb.AuthenticatedRouteGateTest, and OpenAgentsWeb.LiveViewScopeTest.

IDENTITY-003 — Account continuity supersedes browser portability

Status: Current

One GitHub-authenticated user resolves the same canonical OpenAgents owner across browsers and devices; no export/import ceremony is required for that account. The disabled encrypted portability adapter remains only an explicit transfer mechanism into the currently authenticated destination account. It does not link accounts, change GitHub identity, grant authorization, or create automatic sync between distinct users.

Each export derives a fresh AES-256-GCM key from a person-held passphrase using the pinned PBKDF2 envelope and persists no key, passphrase, envelope, ciphertext, payload, or claim in the portability plane. Decrypted claims may persist only by passing the existing destination profile-memory admission contract. Portability receipts may retain the opaque ciphertext-envelope digest for replay plus bounded lifecycle metadata; they may not retain or index plaintext or a content-derived claim derivative.

Destination replay/sequence, conflict, tombstone, and revocation receipts are explicit. Rotation cannot revoke offline old bundles, tombstones propagate only when a newer bundle is imported, and a lost bundle/passphrase is unrecoverable by OpenAgents. Adapter failure leaves account-local operation unchanged.

Evidence: ADR 0002 (which supersedes ADR 0001's identity decision), OpenAgents.Memory.Portability, its envelope and receipt schemas, test/openagents/memory_portability_test.exs, and OpenAgents.MemoryPortabilityTest.

IDENTITY-004 — Agent participation identity is bounded and durable

Status: Current

An agent can self-register without GitHub and receives a durable credential whose only scope is agent:participate. The credential can participate on public forum and issue surfaces but has no operator, promotion, deployment, or membership authority. A human link is optional, and at most one active human link is enforced by a database uniqueness constraint. The link delegates only explicitly bounded authority. The agent remains the author recorded at creation time, so linking or unlinking never rewrites attribution; an unlinked agent has no owner.

Evidence: OpenAgents.Agents, OpenAgentsWeb.Plugs.DualPrincipalAuth, test/openagents/agents_test.exs, and test/openagents_web/controllers/agent_controller_test.exs.

IDENTITY-005 — Box control is account-scoped and target-specific

Status: Current

The box:control API scope is independent from forge, deployment, chat, agent participation, and computer:control scopes. A request can use it only with a human account token or an agent whose linked human granted exactly the Box target kind. Every request names a conversation owned by the effective human account, and a foreign conversation or Box is indistinguishable from a missing one.

Evidence: OpenAgentsWeb.Plugs.AssignmentControlAuth, OpenAgents.Box, OpenAgentsWeb.BoxController, and test/openagents_web/controllers/box_controller_test.exs.

IDENTITY-006 — Assignment credentials are repository and branch scoped

Status: Current

An assignment credential identifies one durable assignment and can access only its repository. Git receive-pack authorizes every requested ref update before any ref moves; the credential can update only its assignment branch. It cannot write a default or protected branch, close an issue, authenticate as an operator, or access non-Git API routes. The credential stores only a digest, expires with the assignment deadline, and is revoked when the assignment reaches a terminal state.

No assignment is in a non-terminal state while its credential is revoked. Revocation and the terminal state are written in one transaction by finish/4, and the transition into running is one conditional statement: Assignments.start_running/2 writes only where the state is not already terminal, so Postgres evaluates the guard while it holds the row's write lock. A starter that arrives after the finalizer matches no row, changes nothing, and returns {:already_finished, assignment}, which is how its caller tells starting work from finding the work already over.

Evidence: OpenAgents.Forge.Assignments, OpenAgentsWeb.Plugs.ForgeGitAuth, OpenAgents.Forge.GitHTTP, test/openagents/forge/assignment_test.exs, and test/openagents/forge/assignment_start_race_test.exs.

(Amended 2026-08-25, issue #257: start_target/7 read the state and then wrote running back, with nothing holding the row between them. A run that finalized inside that window had its credential revoked and its state overwritten, which left an attempt that looked live and could not authenticate. The guard and the write are now one statement.)

IDENTITY-007 — Delegated Box control is explicit and revocable

Status: Current

An agent can control a Box only when its linked human grants the box:control scope. The grant records the granter and lifecycle timestamps. Unlinking or revoking the grant prevents new Box-control requests, while historical agent authorship remains unchanged.

Evidence: OpenAgents.Agents, OpenAgentsWeb.Plugs.AssignmentControlAuth, and test/openagents/agents_test.exs.

IDENTITY-008 — Computer control is independent and computer-bound

Status: Current

The computer:control API scope is independent from box:control in both directions. A human account token requires the Computer scope, and a delegated agent requires an active target_kind: computer grant from a linked human. Computer routes do not accept a Box grant, and Box routes do not accept a Computer grant. The local computer controller remains authoritative over declared roots, presence, probe-reported ACP agents, prompt bounds, and execution; the API cannot widen a tier, add a root, or request an unadvertised capability. Computer projections never expose a computer token, token digest, or raw probe document. They do expose revoked_at: machines.revoked_at was written by both revocation paths and read by nothing, so the computers API and the account export could say a computer was revoked and never when, alone among this release's credentials. It is the owner's own record of their own computer and is published with status.

Amended 2026-08-24 (issue #183): revoking a computer ends the inference authority it holds, and cannot be raced.

An inference_grants row names the computer it was minted for. A grant is not a provider credential, but it is authority to spend the owner's account at OpenAgentsWeb.InferenceProxyController, and that controller authenticates the grant token alone — it never asks which computer presented it, and the plaintext was delivered to the computer at delegation start. So revoking the computer used to close its channel and finish its assignments while every grant it held stayed active until its own budget or expires_at closed it, up to inference_grant_ttl_seconds later. machine_id was written by OpenAgents.Work.DelegationServer and read by nothing; the absence was the gap.

OpenAgents.Inference.revoke_active_for_machine/1 now runs inside the same transaction that writes the revoked computer row, on both paths that revoke one — OpenAgents.Machines.revoke_machine/2 and the pairing-expiry branch of OpenAgents.Machines.claim_pairing/2. It moves status and the terminal stamp and nothing else, so revocation is not a way to acquire, exchange, or shed the fence THREAD-001 requires; sarah_guard_inference_grant_update refuses that independently.

The window between the decision and its commit is closed in PostgreSQL rather than at the call site. inference_grants_refuse_revoked_computer fires before every insert that names a computer, reads that computer's row FOR SHARE, and refuses unless it is active. FOR SHARE conflicts with the FOR NO KEY UPDATE an ordinary UPDATE machines SET status = 'revoked' takes, so a mint and a revocation cannot overlap on that row: either the mint commits first and the sweep finds its grant, or the revocation commits first and the mint re-reads revoked and raises. The foreign key's own FOR KEY SHARE does not conflict with an ordinary update and would not have served. OpenAgents.Inference.mint/1 performs the same read first so an ordinary caller gets {:error, :machine_revoked} rather than a Postgrex.Error, and a mint failure degrades a delegation to a grant-less one rather than blocking it.

The guard is in the database because a source scan finds call sites and not values: machine_id reaches mint/1 from a variable, and a future writer that never appears in a grep is fenced the day it lands. What the proof does not show is contention across two connections — the sandbox holds a test in one transaction — so the tests establish the refusal inside the revocation's open window at both layers, and assert the lock itself from pg_get_functiondef in the live database.

A computer's token expiry is a separate clock and is left alone: an active computer whose token_expires_at has passed can no longer authenticate, and its grants close on their own expires_at.

Evidence: OpenAgentsWeb.Plugs.AssignmentControlAuth, OpenAgents.ComputerAgentJobs, OpenAgentsWeb.ComputersController, OpenAgents.Machines.revoke_machine/2, OpenAgents.Inference.revoke_active_for_machine/1, priv/repo/migrations/20260824032226_refuse_inference_grants_for_revoked_computers.exs, test/openagents_web/controllers/computer_control_api_test.exs, and test/openagents/inference/computer_revocation_test.exs.

IDENTITY-012 — The computer tables' constraints are unreachable from a request

Status: Current

Five constraints on machines and machine_pairings have no check_constraint/2 or unique_constraint/2 beside them, so a violation arrives as a Postgrex.Error and not an invalid changeset. That is a defect where a user-supplied value can reach the constraint: a form should have refused the input, and instead the request answers 500. None of these five can be reached that way, so each is recorded rather than mapped. Mapping a constraint to a changeset error claims a population, and an empty population is a claim nothing tests.

Constraint Why a request cannot reach it
machines_status_check status is not cast; every writer passes a literal
machine_pairings_status_check the same
machines_token_expiry_after_creation token_expires_at is not cast, and its TTL is admitted at boot
machines_token_digest_index 256 bits from :crypto.strong_rand_bytes/1
machine_pairings_code_digest_index a code from the same generator

The last row is the weakest of the five and still holds: the code space is 30^8, rows are never deleted, so a collision is a real event at a scale this release will not see, and it costs the caller one 500 and a retry that succeeds. It is recorded, not mapped, because the caller supplied nothing to correct.

machines_token_expiry_after_creation was reported as reachable through a non-positive :machine_token_ttl_seconds, which would have made every pairing approval raise. It is not: OpenAgents.RuntimeConfig admits that setting only in 300..2_592_000 and install!/0 raises before the endpoint starts, so the node cannot boot into the state that would reach the constraint.

The reason each is unreachable is a property of the code and not a fact about it, so it is asserted rather than described. The proof feeds the whole hostile parameter set — every guarded column, on both write paths, including the unauthenticated one — through both changesets and fails if any guarded field becomes castable. It then reaches each constraint directly with raw SQL and requires it to refuse, so a constraint that is dropped or weakened fails here as well as in CANON-002's ledger.

Evidence: OpenAgents.Machines.Machine, OpenAgents.Machines.Pairing, OpenAgents.RuntimeConfig, and test/openagents/machines/constraint_reach_test.exs.

IDENTITY-011 — A pairing window closes on its own clock

Status: Current

A computer pairing is a bounded window: the CLI registers it, the account owner approves it in the browser, and the CLI claims the computer token exactly once. OpenAgents.Machines seals that token at rest for the claim window alone, and OpenAgents.Machines.TokenVault carries one version and one AAD on the strength of that bound — CANON-002 states it as settled.

Nothing enforced it. claim_pairing/2 expired an elapsed pairing, and that was the only thing that ever did, so the window closed only when someone knocked on it. A pairing the CLI stopped polling — killed, disconnected, or abandoned after approval — stayed approved indefinitely: token_ciphertext held a sealed, openable smct_ token long past the window, and the computer the approval created stayed active, counting against the owner's capacity and issuing operator-approval receipts for a computer that never connected. machine_pairings.expires_at recorded the deadline and nothing read it, which is why machine_pairings_expires_at_index had no reader.

OpenAgents.Machines.expire_elapsed_pairings/0 is that reader, and OpenAgents.Machines.PairingExpiry runs it on the same interval and shape as OpenAgents.Forge.AssignmentExpiry. It applies claim_pairing/2's own expiry rather than a second, weaker one: each row is re-read under FOR UPDATE, the status moves to expired, token_ciphertext is nulled, and any computer the pairing created is revoked together with the inference grants it holds. That last step inherits IDENTITY-008 unchanged, because it is the same call inside the same lock. A sweep and a late claim racing the same row therefore have one winner, and the loser finds a status it no longer acts on. The revocation is announced on the computer's topic, which this path had never done because until now nothing ran it without a bearer.

The selection is proved separately from the outcomes, and the split is the point. expire_elapsed_pairing/1 refuses anything terminal or still fresh on its own re-read, so widening the outer predicate changes no row and no return value — every outcome test stays green while the sweep silently stops using the index and starts scanning the table each minute, which is the defect this entry closes. OpenAgents.Machines.elapsed_pairing_query/1 is therefore public and asserted twice: on exactly which rows it names, and on EXPLAIN of that same query naming machine_pairings_expires_at_index with sequential scans priced out.

Evidence: OpenAgents.Machines.expire_elapsed_pairings/0, OpenAgents.Machines.elapsed_pairing_query/1, OpenAgents.Machines.PairingExpiry, OpenAgents.Machines.TokenVault, and test/openagents/machines/pairing_expiry_test.exs.

IDENTITY-009 — Unified delegation preserves substrate authority

Status: Current

The unified delegation surface never widens the reach available through the Box or Computer substrate. It resolves the target kind before enforcing the matching human scope or delegated grant, then forwards to the existing substrate authority without storing a mirrored delegation record. Box and Computer identifiers are opaque, kind-prefixed references to their durable substrate records; malformed, unknown, and foreign references are indistinguishable from missing references. The projection is bounded and redacted, and does not expose provider URLs, computer credentials, probe documents, prompts, or subprocess environments.

Evidence: OpenAgents.Delegations, OpenAgentsWeb.Plugs.DelegationAuth, OpenAgentsWeb.DelegationsController, OpenAgents.BoxRuns, OpenAgents.ComputerAgentJobs, and test/openagents_web/controllers/delegations_controller_test.exs.

IDENTITY-010 — Computer assignment credentials are opt-in and delegation-scoped

Status: Current

An issue assignment has one target in the shared assignment ledger. A Computer target receives its repository-and-branch-scoped forge credential only when its owner explicitly enables scoped forge credentials for that Computer. The plaintext credential exists only in memory while the delegation starts and is delivered in the server-to-controller agent frame, under the assignment_credential key and under no other key in that frame, for injection into that delegated process environment.

It reaches no durable row, no log line, and no API response. That is proven by scanning rather than by listing: a delegation runs end to end with a real minted credential, and every base table PostgreSQL's own catalog reports is asked whether any row of it renders the plaintext, both while the delegation is running and once it finishes. The catalog cannot forget a table or a column, so a job, a journal, an activity row, or a projection added later is scanned the day it lands rather than the day someone remembers it. The same run captures every log record the delegation emits, at every level, and reads the created assignment's own response.

The modules that can hold a plaintext credential are derived from compiled import tables rather than named from memory. The plaintext comes into existence in one place and leaves OpenAgents.Forge.Assignments through create/1 and through OpenAgents.Forge.AssignmentCredentialVault, so the modules that receive one, the modules that write the vault, and the modules that read it are exact sets, and a new holder fails until this contract accounts for it.

The shell environment and the global Git configuration this credential must stay out of are on the delegated Computer, past this application's boundary. What is proven here is that the server puts the credential in one frame field and nowhere else; local controller refusal remains authoritative for what happens after that.

The credential expires with the assignment and is revoked when the assignment completes, is cancelled, expires, or loses its computer or controller. Computer validation and local controller refusal remain authoritative.

Evidence: OpenAgents.Forge.Assignments, OpenAgents.Forge.AssignmentCredentialVault, OpenAgents.ComputerAgentJobs, OpenAgents.Work.DelegationServer, test/openagents/forge/assignment_test.exs, test/openagents/forge/assignment_credential_reach_test.exs, and test/openagents_web/controllers/computer_control_api_test.exs.

CAPACITY-002 — Box fan-out admission is bounded and durable

Status: Current

Fan-out admission records one durable plan and one logical item per requested Box. PostgreSQL-backed admission locks enforce conversation, owner, and global active limits before provider creation. Queued items retain their labels and request order, hold no provider resource, and promote when a Box stops. Generated labels are sequential within a conversation and remain stable for the Box lifetime. Admission records an estimated hourly burn rate, and conversation and owner burn-rate ceilings are separate from accumulated usage. Later settlement must use its own durable usage quantity rather than treating the current active burn rate as historical spend.

Evidence: OpenAgents.Box, OpenAgents.Box.Fanout, and test/openagents/box_fanout_test.exs.

CAPACITY-003 — Box lifecycle reconciliation is one-way and observable

Status: Current

The supervised Box reconciler runs every 60 seconds. It refreshes mutable or unsettled ledger rows against the provider, gives each provider request a 15-second receive timeout, reclaims Boxes after a 3,600-second TTL or 1,800 seconds of inactivity, and records the stop reason, lifetime, and settled cost. A live non-terminal run prevents idle reclamation. Provider-terminal and provider-missing responses release capacity and promote queued work. Provider transport failures and rate limits record retry backoff without changing lifecycle state. Provider Boxes without a ledger claim are reported durably; only Boxes carrying this deployment's ownership marker in the provider name are stopped. Reconciliation never resumes or recreates a Box.

Evidence: OpenAgents.Box.Reconciler, OpenAgents.Box.Usage, and test/openagents/box_reconciler_test.exs.

WORK-002 — Detached Box runs reconcile from durable evidence

Status: Current

Long-running Box work is admitted before dispatch and executes from a run-specific directory containing its script, combined log, process ID, and exit sentinel. The application polls that directory, stores bounded redacted output, and treats a missing process without an exit sentinel as lost. Dispatch ambiguity receives one probe and never an automatic second dispatch. Cancellation and timeout record requested and effective timestamps separately.

Evidence: OpenAgents.BoxRuns, OpenAgents.BoxRunServer, OpenAgents.Box.Client, and test/openagents/box_runs_test.exs.

Data authority and synchronization

DATA-001 — PostgreSQL is authoritative

Status: Current

Visitors, conversations, messages, and turns are persisted before their state is presented as accepted. PubSub and LiveView streams are projections; losing either must not lose accepted data.

Evidence: OpenAgents.Conversations.create_turn/2 and OpenAgentsWeb.ChatLiveTest durable-turn test.

DATA-002 — One canonical conversation per authenticated user

Status: Current

Database uniqueness enforces one internal storage owner per local user and one conversation per owner. Initial greeting creation is coupled to first conversation creation. Distinct users remain isolated; multiple sessions for one user converge on the same conversation and therefore the same text and voice limits. Pre-authentication browser rows remain inaccessible legacy data and are never silently claimed during login.

A thread is not a conversation and does not count against this uniqueness: an account holds as many threads as it has pieces of work, each with its own objective, transcript, and model authority (THREAD-001).

Evidence: unique indexes and identity-source constraint in create_github_users, OpenAgents.Conversations.ensure_conversation/1, and account continuity/isolation/rate tests in OpenAgents.AccountsTest.

DATA-003 — History is bounded and stable

Status: Current

The newest page and every older page contain at most the configured page size. Ordering uses persisted timestamp plus UUID, and rendered rows use stable message IDs.

Evidence: OpenAgents.Conversations.list_messages/2 and bounded-history test.

Memory and recall

MEMORY-001 — Recall is confined to the current account conversation

Status: Current

Every recall snapshot and search is bound to one canonical conversation resolved from the authenticated local user. A snapshot from another conversation or user is refused, and no API offers a cross-conversation or unscoped fallback. GitHub identity establishes account continuity but does not turn conversation evidence into verified facts about a person.

One exception, and it is named here rather than discovered later: the system memory bucket. An admitted system memory (MEMORY-011) is written by one account and read into every account's turn. That is cross-account recall by construction, it cannot be reconciled with the rule above, and it is the point of the bucket rather than a side effect of one, so it is admitted as an amendment to this invariant instead of being smuggled past it. Four bounds make it an exception rather than a hole:

  • It lives in one module. OpenAgents.Memories.SystemRecall and the one shared ranking query in OpenAgents.Memories.Retrieval.Lexical are the only reads in either memory plane that name no account. Both are enumerated by name, so a second unscoped query fails the enumeration until somebody adds it there on purpose.
  • It is off unless an operator turns it on. OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL sets :memory_recall, :system_bucket_enabled, which is false in config/config.exs and declared false in both the production (ops/deploy/fleet-startup.template.sh) and staging (ops/staging/gate-5-profile.sh) profiles. With it off no query in that module runs, and user and learned recall is byte for byte what it was before the bucket had a recall path at all.
  • An eligibility filter replaces the scope predicate, and it is a predicate. MEMORY-004's discipline holds through the exception rather than around it: what stands in for user_id is bucket = 'system', superseded_by_id IS NULL, tier IN ('ledger','glass'), and membership of the id set OpenAgents.Memories.Admissions.statuses/0 derived as admitted. A candidate, a rejected row, a suspended row, and a sub-ledger row are absent from the read rather than filtered out of its result, and a row's own admission column — the author's claim about their own row — is read by nothing (system-memory specification section 7.1).
  • Per-source caps bound it at ranking time. At most 25% of one message's ranked candidate pool comes from a single account, by a stable round-robin over accounts in rank order; at most one memory per account and two in total attach to a note; and system rows spend the same per-turn character budget as the account's own, so a note that quotes the network is no longer than one that does not (section 7.2). What the caps exclude is not counted into the note's dropped line, because that count would tell every account how large the shared store is.

Nothing else gains an unscoped read from this. Every other query in both planes still names conversation_id or user_id, and the enumerations below are what prove it.

The recall APIs are enumerable rather than remembered. Recall reaches PostgreSQL only through the backend :recall_search_backend names in config/config.exs, so the backends are the population, and each backend's public API is its own __info__(:functions). Every function in it is named here with how its scope is closed — capture_ref/3 by the conversation it is given, load_snapshot/2, search/3,4, search_page/3,4, and read/3,4 by refusing a snapshot from another conversation — and every refusing entry point is driven across a conversation boundary. A recall function added beside them fails until this contract accounts for it.

Evidence: OpenAgents.Memory.RecallSnapshot, OpenAgents.Memory.LexicalRecall, OpenAgents.Memory.HybridRecall, cross-scope tests in OpenAgents.Memory.LexicalRecallTest, the entry-point enumeration in OpenAgents.Memory.ScopeBoundaryTest, and, for the amendment, OpenAgents.Memories.SystemRecall, test/openagents/memories/system_recall_test.exs, and the unscoped-query enumeration in test/openagents/memories_test.exs.

MEMORY-002 — Recalled history is classified evidence, not current profile truth

Status: Current

Every materially used read source is represented by a host-built openagents.memory_evidence.v1 value with validated source/scope, observed and recalled times, bounded claim and relevance, host classification, and validated corroboration/conflict refs. The model cannot supply scope or classification, fabricate a source that passes the scoped database read, or promote weak, stale, conflicting, or irrelevant evidence. Current user correction outranks older history. Conversation evidence never enters the separate profile-memory plane through repetition, model confidence, or recall classification.

Historical source content remains untrusted quoted data. It cannot change OpenAgents's identity, safety rules, captured catalog, execution scope, authority, or host limits. Terminal receipts immutably retain each materially used source ref and its host classification.

Evidence: OpenAgents.Memory.Evidence, OpenAgents.Tools.ConversationRead, OpenAgents.Context.Composer, turn_receipts.used_memory_evidence, its terminal database trigger, OpenAgents.Memory.EvidenceTest, and OpenAgents.TurnMemoryEvidenceJourneysTest.

MEMORY-003 — Profile memory is explicit, inspectable, correctable, and forgettable

Status: Current

Durable profile claims live in a separate account-owner plane. A record is only active with a same-owner complete user-message source or a host-recorded explicit owner assertion. Candidates never activate through repetition or model confidence. Claims and their identity/provenance fields are immutable; correction atomically supersedes the old record with a source-linked new one. Lifecycle transitions are explicit and optimistic-generation checked.

Every record and source is available to bounded owner export. Forget and expiry are terminal states retained for audit; explicit purge is allowed only after a terminal state and removes the record plus every derived source link without deleting the independent conversation source message.

Evidence: OpenAgents.ProfileMemory, the profile-memory schemas and database constraint triggers, OpenAgentsWeb.ChatLive, OpenAgentsWeb.MemoryExportController, test/openagents/profile_memory_test.exs, test/openagents/profile_memory_test.exs, OpenAgents.ProfileMemoryTest, and memory-control journeys in OpenAgentsWeb.ChatLiveTest.

MEMORY-004 — Scope and snapshot boundaries are database predicates

Status: Current

Recall scope is enforced by messages.conversation_id in every PostgreSQL query, never by prompt instructions. Each inference immutably records a message:<uuid> high-water ref for the last eligible message before the current user turn. Searches apply its ordered timestamp/UUID cursor and admit only complete user/assistant rows, so streaming or failed assistant text and normal later inserts cannot enter the turn's recall view.

The queries are enumerable rather than remembered. Every Ecto query in a recall backend rooted at the recall corpus (messages, turn_tool_steps, voice_tool_steps) names conversation_id, and every query in a profile-memory reader rooted at the profile plane names owner_visitor_id — or, for the owner's own message and conversation reads, visitor_id. Both populations are read from the modules' own source, so a query added beside the enforced ones fails until it carries its scope, and a query rooted at a table this contract gives no scope column fails until the column is named. What that establishes is that the predicate is written; that it refuses is the behaviour the recall and profile-memory tests drive.

Evidence: OpenAgents.Conversations.begin_inference/5, the generated search_vector and partial GIN migration, OpenAgents.Memory.LexicalRecall, immutable turn receipt triggers, snapshot/status/index tests in OpenAgents.Memory.LexicalRecallTest, and the query enumeration in OpenAgents.Memory.ScopeBoundaryTest.

Search is discovery only. conversation_search.v1 returns bounded excerpts; conversation_read.v1 must reread an exact source and bounded neighborhood under the same host-supplied conversation/snapshot before historical wording is treated as grounded. Foreign and unknown source UUIDs share one not_found outcome, while materially returned refs are recorded on the turn receipt.

Evidence: OpenAgents.Tools.ConversationSearch, OpenAgents.Tools.ConversationRead, OpenAgents.Tools.RecallContext, OpenAgents.Tools.ConversationRecallToolsTest, and the end-to-end recall loop in OpenAgents.TurnToolLoopTest.

Recall's source universe covers durable tool activity as well as messages. conversation_search.v1 lexically matches terminal tool steps from both surfaces (turn_tool_steps, voice_tool_steps) over tool name, status, and bounded result/error text, returning typed turn-tool-step:<uuid> / voice-tool-step:<uuid> refs classified as tool_activity with the same excerpt bound and deterministic score/timestamp/id ordering as messages. A tool step is observable at a snapshot only when it completed at or before the watermark message's insertion instant, or when the assistant message that concluded its work unit (turn or voice response) is itself admitted by the message fence — both predicates compare immutable persisted values with the immutable snapshot, so work later than the frozen turn never enters its recall view. conversation_read.v1 resolves those refs within the same conversation/snapshot scope into a bounded step context (tool name, status, bounded result/error, timestamps, executor disclosure) plus the nearest admitted neighboring messages; foreign and unknown step refs share the messages' single not_found outcome.

Evidence: tool-step queries and step reads in OpenAgents.Memory.LexicalRecall, step-ref usage items in OpenAgents.Memory.Evidence, and the tool-step recall, scope-refusal, and snapshot-fencing tests in OpenAgents.Tools.ConversationRecallToolsTest.

Profile-memory queries likewise require owner_visitor_id in every predicate. An opaque persisted snapshot pins both the owner's monotonic scope generation and capture time. Activation and terminal generations reconstruct the active set after concurrent correction, forgetting, or expiry; another account cannot load or use the snapshot. Multiple authenticated sessions for the same account intentionally share it. Unknown scope has no global fallback.

Evidence: profile_memory_scopes, profile_memory_snapshots, generation columns and indexes in the profile-memory migration, OpenAgents.ProfileMemory, and cross-browser/concurrent-snapshot tests in OpenAgents.ProfileMemoryTest.

MEMORY-005 — Memory writes require exact current consent

Status: Current

A sampled tool call is a proposal, not write authority. memory_remember.v1 accepts only an exact bounded claim directly authorized by the current complete user message, an exact host-recorded confirmation, or an exact first-party UI action in the same account scope. Repetition, recalled text, classifier confidence, and model arguments cannot substitute. memory_forget.v1 applies the same rule to one record, one category, or the whole account scope and is idempotent without revealing foreign-record existence.

Each turn captures a persisted profile-memory snapshot before provider work. List/search use only that frozen generation, while remember/forget return a bounded reversible receipt after PostgreSQL commits. OpenAgents may acknowledge only the exact successful result returned in the durable tool outcome.

Evidence: OpenAgents.Memory.Consent, the four OpenAgents.Tools.Memory* tools, turn_receipts.profile_memory_snapshot_ref, test/openagents/tools/profile_memory_tools_test.exs, and OpenAgents.Tools.ProfileMemoryToolsTest.

MEMORY-006 — Semantic recall is scoped, disposable, and lexically degradable

Status: Current

Authoritative durable conversation rows — messages, and for lexical tool-activity recall the terminal tool steps — remain the sole recall authority; semantic embeddings exist only for messages. Embeddings are asynchronous derivatives bound to the source message, exact conversation, content digest, model/version manifest, and active generation. Hybrid queries repeat the conversation and frozen snapshot predicates in PostgreSQL and admit only ready rows whose digest still matches the authoritative complete message. Each asynchronous embedding claim has a bounded lease and attempt number. A provider task must finish before the lease, and a replacement worker may reclaim only an expired running claim. Attempt fencing prevents a stale worker from publishing a late derivative. Correction, deletion, and rebuild invalidate derivative rows and produce append-only receipts; a new manifest generation cannot read an older one. Receipts cannot be edited or deleted while their authoritative conversation exists. Exact authenticated-owner product-data deletion first removes that conversation and may then delete its now-unlinked content-free receipts in the same transaction.

The pinned hybrid rank is deterministic reciprocal-rank fusion with stable timestamp/UUID ties. A provider, manifest, shape, query, or incomplete-index failure is explicitly semantic_degraded and returns the unchanged lexical page. Hybrid recall is disabled by default and cannot be activated merely by creating vectors; its committed comparison must preserve lexical fallback and scope isolation while improving synonym recall.

Evidence: OpenAgents.Memory.SemanticIndex, OpenAgents.Memory.HybridRecall, semantic derivative tables and triggers, test/openagents/semantic_recall_test.exs, priv/sarah/evals/recall/hybrid-comparison.v1.json, and OpenAgents.SemanticRecallTest.

MEMORY-007 — Learned preferences require confirmation and never confer authority

Status: Current

Behavior preferences occupy an account-owner plane separate from conversation evidence, profile facts, roles, tool catalogs, routing authority, and collective artifacts. An observation or model confidence can create only a candidate. The only admitted effects are finite presentation/interaction choices; every candidate must pass policy review, an exact owner confirmation receipt, and a separate durable activation receipt before it can affect composition.

Every turn freezes a monotonic preference snapshot. Applied and current-instruction-overridden preferences are immutably recorded with exact preference, effect-digest, and activation-receipt refs. A current user request wins for that turn without rewriting the stored preference. Suspension, correction, and deletion end the effect for later snapshot generations while preserving historical turn provenance. Outcomes can be recorded only for a preference actually applied to that exact owner turn.

Preference evidence and receipts remain append-only while their owner exists. Exact authenticated-owner product-data deletion is the only exception: PostgreSQL cascades the complete preference graph after the visitor root is gone, while rejecting direct or foreign-scope receipt deletion.

Evidence: OpenAgents.Preferences, the preference schemas and database guards, OpenAgents.Context.Composer, turn_receipts.used_preferences, test/openagents/preferences_test.exs, the committed preference comparison, and OpenAgents.PreferencesTest.

MEMORY-008 — Experience is private, terminal, evidenced, and advisory

Status: Current

Work experience occupies an exact account-owner and conversation-work-scope plane separate from profile facts, learned preferences, roles, tool authority, and collective artifacts. Requested and running cases never enter recall. Success requires a target receipt emitted by a succeeded governed tool step in the same owner conversation; failure remains explicitly labeled failure. A single success is one scoped observation, never a universal pattern.

Each turn either has no experience capture or freezes one deterministic, bounded bank at a monotonic scope generation. Every projection names its applicability and evidence; storage and projection both pass redaction. Corrections create a new record, inspection/export remain bounded, and deletion removes evidence, pattern, and bank derivatives while invalidating affected banks. Crossing into collective work requires the independent exact-consent candidate workflow and cannot directly publish or write a global pattern. Default activation remains off until the committed memory-on/off evaluation shows measured task benefit without scope or provenance regression.

Experience deletion receipts remain append-only while their owner exists. Exact authenticated-owner product-data deletion is the only exception: PostgreSQL cascades the scoped experience graph, including frozen banks linked to deleted turns, then removes standalone deletion receipts only after the visitor root is gone. Direct and foreign-scope receipt deletion remain rejected.

Evidence: OpenAgents.ExperienceMemory, the experience schemas and database guards, OpenAgents.Context.Composer, turn_receipts.used_experiences, test/openagents/experience_memory_test.exs, the committed benefit comparison, and OpenAgents.ExperienceMemoryTest.

MEMORY-009 — Graph memory is a disposable, generation-atomic projection

Status: Current

The relationship graph is never memory authority. Every node and edge belongs to one account owner, one conversation work scope, one immutable manifest generation, and at least one exact authoritative experience-record or pattern membership. Deterministic identities preserve explicit entity, version, and conflict fields without collapsing differing outcomes into asserted truth.

Source mutations emit an outbox row in the same PostgreSQL transaction. A pending mutation makes the current graph unavailable; rebuild locks the source scope, pins and digests one source snapshot, constructs a complete building generation, and atomically retires the old generation and exposes the new one. Dropping every graph table loses no authoritative memory, and replaying the same source snapshot produces the same build digest.

Traversal requires the exact owner/work scope, current source policy eligibility, finite depth and result bounds, and source provenance on every returned artifact. Deletion uses an inspectable exact-generation cascade plan and append-only completion receipt. Graph use remains disabled by default until its committed paired evaluation demonstrates material relationship benefit with no scope or membership regressions.

Graph operation receipts remain append-only while their owner exists. Exact authenticated-owner product-data deletion is the only exception: manifest-owned projections cascade with the visitor, and the same transaction removes standalone memberships, mutation events, cascade plans, and operation receipts after the visitor root is gone. Direct and foreign-scope receipt deletion remain rejected.

Evidence: OpenAgents.GraphMemory, graph manifests/artifacts/memberships/outbox and database guards, test/openagents/graph_memory_test.exs, the committed graph comparison, and OpenAgents.GraphMemoryTest.

MEMORY-010 — Cloud memories are account-scoped, explicit, and bounded at recall

Status: Current

Cloud memories (OpenAgents.Memories) are a plane of their own, distinct from the visitor-scoped memory planes MEMORY-001 through MEMORY-009 govern. They are account-scoped rather than conversation-scoped, thread-sourced rather than message-sourced, and authoritative rather than derived: dropping the table loses what an account asked to have remembered, which is the property that separates this store from every projection beside it. It is a separate plane because the existing ones cannot hold it — profile-memory records admit only a same-owner conversation-message source or host assertion (MEMORY-003), a thread is not a conversation (THREAD-001), and MEMORY-002 forbids consolidation-derived material from entering the profile plane at all.

Scope is a database predicate, never an application filter. Every Ecto query rooted at OpenAgents.Memories.Memory names user_id, in the context and in every retrieval backend that reads PostgreSQL, and the queries are read from each module's own source AST rather than remembered, so a query added beside the scoped ones fails until it carries its scope. A memory of another account is refused as absent rather than as forbidden.

The system bucket is the one exception, amended into MEMORY-001 rather than assumed here. Its two queries — the eligibility read in OpenAgents.Memories.SystemRecall and the shared ranking query in OpenAgents.Memories.Retrieval.Lexical — name no account, and the enumeration admits exactly those two by module and by count: each must name the system bucket in place of user_id, and a third unscoped query fails until somebody declares it. The user and learned buckets keep the unamended rule, and they keep it whether the system flag is on or off.

Writes are explicit. Nothing infers a memory from what a turn contained: a row exists because a caller asked for it through POST /api/v1/memories. Corrections supersede rather than edit — the replacement is a new row, the old row points at it, and recall reads live rows only — so a wrong memory is traced and replaced rather than overwritten. Bodies are immutable for the life of a row.

Recall is bounded three ways and reports what it excluded. The store caps live memories per account at write; each turn caps how many memories attach and how many characters they spend; and the [From memory: …] note states the count that did not fit rather than truncating. A user memory attaches whenever the account holds one, because the reader asked for it and it need not share vocabulary with the turn; a learned memory must clear the retrieval backend's floor. Recall never fails a turn: an empty store, an unavailable embedding provider, and an unreadable backend each recall nothing.

Retrieval is a swappable interface with two backends. The embedding backend is the target; the PostgreSQL full-text backend is a marked stand-in, and it is what a deployment runs unless OPENAGENTS_FEATURE_MEMORY_EMBEDDINGS and an embedding credential are both configured. No pgvector index exists for this plane — message_semantic_embeddings is bound to message_id — so the embedding backend compares vectors stored on the memory rows in process, the way the tool catalog does.

Recognition on POST /api/v1/responses grants no authority. The ambient plug refuses nobody, so an anonymous caller, an unreadable credential, and a credential scoped elsewhere all reach the route exactly as before recall existed, and the account it recognizes only widens the context of the answer.

Product-data deletion removes them. Memories key on the retained account row rather than the visitor root, so the DATA-004 visitor cascade does not reach them and OpenAgents.DataRights.delete/3 removes them explicitly in the same transaction.

Evidence: OpenAgents.Memories, OpenAgents.Memories.Retrieval, OpenAgentsWeb.MemoryController, OpenAgentsWeb.Plugs.AmbientApiTokenAuth, the memories table's shape constraint and partial indexes, test/openagents/memories_test.exs, test/openagents_web/controllers/memory_controller_test.exs, and test/openagents_web/controllers/responses_controller_test.exs.

MEMORY-011 — A system memory is evidenced, admitted by receipt, contested by record, and surfaced to nobody

Status: Current

The system bucket of OpenAgents.Memories holds what the network as a whole has learned rather than what one account asked to have remembered. A wrong user memory misleads one session; a wrong system memory would reach every session, so the row carries what makes a claim answerable and the store refuses one that does not.

A system row carries fields the other two buckets carry none of: a sys: slug, a transparency tier of ledger or glass and never lower, an as_of date distinct from the insert time, an admission the author claims, and a non-empty evidence_refs list whose every entry names a kind of receipt, memory, or url, a ref, and a digest. Both halves are the memories_system_shape constraint as well as the changeset: an evidence-free or sub-ledger candidate is unrepresentable at the table, not merely unwritten by the code that exists today, and a user or learned row carrying a tier is refused the same way. Every column is asserted IS NOT NULL before it is compared, because a check constraint passes when it evaluates to NULL and an absent evidence list is exactly the hole the constraint exists to close.

Admission is a receipt, never a field. A verdict is a row in memory_admissions — attributed through author_id to the account that wrote it, dated, and append-only, with no updated_at and no path that updates one — and OpenAgents.Memories.Admissions.status/1 derives the effective status from those records. The admission column on the candidate is the author's claim and nothing more, so a row that says admitted with no steward record behind it reads as a candidate. A steward who changes their mind writes a second record; both stay readable.

Only a steward admits, and the steward set is ADMIN-001's operator allowlist, bootstrapped to the owner's account. Only the original author or a steward writes a superseding row on a system slug; anyone else is refused, and supersession is the only correction path there is.

Disagreement is a record too. Any account may write a challenge against a system memory — slug chl:<memory>, role challenge, a ground, and optional evidence of its own — and a challenge that carries evidence suspends an admitted target until it is resolved. That is the fail-safe direction: a contested claim silently absent is cheaper than a poisoned claim silently present. An unevidenced challenge is recorded and changes nothing, so an empty evidence list is refused at the table rather than read as absence. Two resolutions, both steward-only: a refutation of one challenge (slug ref:<challenge>, role refutation), or a superseding row on the target's slug, which records a refutation of each open challenge in the same transaction so the resolution is a receipt rather than an inference. A refutation from a non-steward account is refused on the role, before the challenge is read. A reversal is a further challenge; nothing is ever edited.

The derived status — candidate, admitted, rejected, or suspended — follows from the set of records and not from the order they arrived in. Every ordering the derivation uses is over {inserted_at, id}, which is data on the records, and resolution is set membership rather than a comparison of dates, so a refutation backfilled earlier than its challenge still resolves it and every permutation of one record set derives one answer.

One account's open evidenced challenges suspend at most 25% of the admitted store, rounded up; the earliest by {inserted_at, id} take effect and the rest queue with no recall effect. Stated without overstatement: with one server and no anonymous publisher there is no forged attribution, so this bounds a prolific or systematically wrong challenger rather than defending against an attack, and it is a bound on the store rather than on the per-message recall pool of specification section 7.2, which does not exist until recall reads this bucket.

None of these paths reads another account's memory. An admission or challenge record proves its target is a system row through the composite foreign key (memory_id, memory_bucket) -> memories (id, bucket) rather than through a lookup; a refutation is held to a challenge against the memory it restores by the composite foreign key (challenge_id, memory_id, challenge_role) -> (id, memory_id, role), which is why challenge_role carries the literal the check constraint pins; and a correction authorizes inside the UPDATE predicate — the actor's own user_id or the steward role — so a caller with no standing is refused without learning that the row exists. MEMORY-010's rule that every query rooted at OpenAgents.Memories.Memory names user_id holds unchanged, and the AST proof reads this module too.

Surfacing is a decision an operator makes, not a state the store drifts into. OpenAgents.Memories.recall/3 reads the user and learned buckets, named as a predicate in the query rather than filtered out of its result, and it reads the system bucket only when :memory_recall, :system_bucket_enabled is on. That switch is false in config/config.exs and declared false in the production and staging profiles, so on every deployment that has not made the decision no session sees a system memory — not another account's, not its own author's, and not one of any derived status. This is the decision MEMORY-001's amendment records, and the reason it is a decision is that an admitted row read into every account's turn is cross-account recall by construction rather than a ranking change.

When the switch is on, OpenAgents.Memories.SystemRecall is the whole of what surfaces. Only rows the admission records derived as admitted are eligible: candidates, rejected rows, and rows suspended by an open evidenced challenge reach no turn, and neither does a superseded row or one below the ledger tier. The filter is the query rather than a pass over its result. Per-source caps bound the rest — 25% of one message's ranked pool per account by stable round-robin, one memory per account and two in total per note — so an account that wrote most of the admitted store still does not own a note. Every step is deterministic: a total read order, the ranking module's stable tie-breaks, and a round-robin that consults nothing else, so equal inputs give equal notes.

A note names what it is quoting. A system line reads [From memory: (system, as of <date>, <status>)] <body>: the bucket, the as_of date that dates the claim rather than the insert time that orders the chain, and the status the admission records derived — never the admission column, which is what the author claimed about their own row. A reader can tell a network claim from their own, and can see how old it is.

Recall degrades to silence, never to an error. An empty store, an unreadable one, and an unavailable ranking backend each surface nothing and fail no turn.

Evidence: OpenAgents.Memories.Admissions, OpenAgents.Memories.Admission, OpenAgents.Memories.Memory, OpenAgents.Memories.SystemRecall, OpenAgents.Memories.Note, OpenAgents.Memories.Evidence, the memories_system_shape and memory_admissions_shape constraints and the composite foreign keys memory_admissions_memory_fkey and memory_admissions_challenge_fkey, test/openagents/memories/system_memory_test.exs, test/openagents/memories/challenge_test.exs, and test/openagents/memories/system_recall_test.exs.

MEMORY-012 — A promoted claim has one live home, and the boundary is drawn where it crosses

Status: Current

The knowledge base owns what the project has reviewed and decided; system memory owns what the network has observed and can evidence. A stance is editorial and a memory row is evidentiary, and specification section 8 keeps the two from becoming rival stores of one claim with two rules: promotion drains memory into the knowledge base, and the knowledge base wins a recall collision.

Promotion is where the boundary is enforced, and it is the only place it can be. The knowledge base is retrieved in the client from a corpus compiled into a WebAssembly plugin; memory recall runs on this server inside POST /api/v1/responses, and the two notes reach the model through different fields of the same request. No process holds both. A precedence rule between them needs a decidable test for "the same claim", and two rails that retrieve by different methods over different corpora share no identifier unless somebody records one. A memory and a stance are the same claim exactly when a promotion tombstone on that memory names that stance — a steward's recorded judgment, never an overlap of prose. Nothing else is a collision here, so a memory that shares a stance's vocabulary, or quotes its id, is a different claim and keeps its place in recall.

Given that link, precedence needs no second mechanism. promote/3 writes a superseding row on the claim's slug — a promotion tombstone — and points the old row at it, so the claim is no longer live and the chain is kept rather than deleted. The tombstone carries the stance id, a body this server composes rather than the caller, and admission: "candidate", which is all it can ever hold: no admission, challenge, or refutation may name a promotion tombstone, refused by the composite foreign key (memory_id, memory_promoted) -> memories (id, promoted) with the literal false pinned by memory_admissions_shape, the same device challenge_role uses. promoted is generated from stance, so it cannot disagree with it. Recall surfaces live rows whose derived status is admitted (specification 7.1), so neither half of a promoted claim can reach a note by any route, and the reviewed stance is its one live home.

"A tombstone whose body names the stance" is a shape rather than a habit: position(stance in body) > 0 is part of memories_system_shape, so a tombstone pointing nowhere is unrepresentable rather than merely unwritten by the code that exists today. A stance id is lowercase words joined by hyphens, as the corpus writes them, and a user or learned row carrying one is refused in both directions the way the other system columns are.

Only a steward promotes. Promotion records the outcome of a review, and the review is the knowledge base's authority; an author draining their own claim into the corpus would assert the review rather than record it. A promotion is a steward's correction on the target's slug, so it resolves the target's open challenges in the same transaction, as any steward correction does (MEMORY-011).

Two things are deliberately not enforced, and calling them enforced would be worse than leaving them open. An unlinked coincidence — a stance and an admitted memory a reader would call one claim, with no promotion between them — is not suppressed, because no decidable test separates it from two claims that merely share words; a similarity threshold shipped as "the knowledge base wins" would read as a guarantee while dropping true memories on a false positive, and duplication is the benign failure where suppression is the destructive one. The honest answer to a real duplicate is to promote it. A fresh claim written on a promoted slug is not refused either: draining by slug would take a read of memories across accounts, which is the predicate MEMORY-010 exists to keep out of this store.

Evidence: OpenAgents.Memories.Promotions, OpenAgents.Memories.Memory, the memories_system_shape stance clauses, the generated memories.promoted column, the composite foreign key memory_admissions_promotion_fkey, docs/memory/knowledge-base-boundary.md, and test/openagents/memories/promotion_test.exs.

PRIVACY-001 — Secret-bearing profile memory is rejected, never scrub-stored

Status: Current

Before candidate storage, the host applies the pinned sarah.memory.policy.v1 policy to the claim, provenance/artifact metadata, and same-owner source content. Credential, API/auth token, wallet/seed/payment, encoded-secret, and local-path material rejects the whole candidate. The rejected value, a hash of it, or a partially scrubbed shell is never persisted. Only owner scope, fixed policy version, bounded reason/category, size bucket, and time enter the rejection audit.

Every export or future model/UI projection re-applies sarah.memory.redaction.v1. A value that fails revalidation is withheld as a whole field. Stored policy identities are immutable, so later policy changes cannot silently relabel old records or rejection evidence, which is why the two identifiers above are the strings the code emits rather than tidier ones.

The projections are enumerable rather than remembered. The modules that name a profile-memory schema are an exact set, and each is accounted for by what it does with a stored claim: OpenAgents.ProfileMemory projects claims, OpenAgents.DataRights erases the owner's plane by id, OpenAgents.Memory.Portability compares claims for import admission and exports through OpenAgents.ProfileMemory, and the four memory tools read only the category list. OpenAgents.ProfileMemory is the one projector, so it is the one place the redaction policy is applied, and a module that gains a dependency on the profile plane fails until this contract says what it does with a claim.

Evidence: OpenAgents.Memory.Policy, OpenAgents.Memory.Redaction, profile_memory_policy_events, immutable policy-version trigger, test/openagents/memory/policy_and_redaction_test.exs, OpenAgents.Memory.PolicyAndRedactionTest, and the projection enumeration in OpenAgents.Memory.ScopeBoundaryTest.

Turn and provider lifecycle

TURN-001 — At most one active turn per conversation

Status: Current

An active turn is queued or streaming. A partial unique PostgreSQL index is the final arbiter; the UI's disabled composer is only feedback.

Evidence: turns_one_active_per_conversation_index and active-turn test.

TURN-002 — Every accepted turn has durable paired messages

Status: Current

The user message, empty streaming assistant message, and turn record are inserted in one transaction. Completion, failure, and cancellation update both assistant-message and turn terminal state. PostgreSQL applies a one-megabyte hard ceiling to every message, and the configured, lower assistant-message ceiling applies to the complete accumulated stream rather than each delta.

Evidence: OpenAgents.Conversations.create_turn/2, finish_turn/5, and turn tests.

TURN-003 — Provider work never blocks the LiveView

Status: Current

Each response executes in a temporary TurnServer under a dynamic supervisor; the outbound provider call executes in a supervised task. Text deltas cross a typed provider callback and are persisted before broadcast.

Evidence: OpenAgents.Turns.TurnServer, its named supervised provider task, and OpenAgentsWeb.ChatLiveTest streaming test.

TURN-004 — Interrupted work becomes explicit failure

Status: Current

Active records left by a runtime restart are marked failed during application startup. A response is never left permanently presented as in progress without an executing turn process. Recovery records the bounded provider-neutral runtime_restarted code, fails every active provider and tool step in the same transaction, and is idempotent.

Evidence: OpenAgents.Conversations.recover_interrupted_turns/0, the OpenAgents.TurnRecovery application child, and the process-death recovery test in OpenAgents.TurnProvenanceTest.

TURN-005 — Tool continuations are serial, bounded, and commit-first

Status: Current

One turn may request a bounded number of tool calls and provider continuations (sixteen of each today). Calls execute one at a time; parallel calls fail the turn. Hitting an execution bound refuses the over-limit call with a durable typed step outcome and drives one final tool-free report response, so the person receives partial findings instead of a failed turn; only runaway behavior past the report path fails the turn. Each continuation uses the exact provider call ID and previous response ID only after rereading the matching committed outcome. Cancellation reaches provider and tool tasks, while prior text, receipts, provider steps, and tool outcomes remain durable on every terminal path.

Evidence: OpenAgents.Turns.TurnServer, OpenAgents.Providers.OpenAI.request_payload/1, OpenAgents.TurnToolLoopTest, and OpenAgents.Providers.OpenAI.RequestPayloadTest.

PROVENANCE-001 — Every new inference has an immutable receipt

Status: Current

Before provider work starts, OpenAgents durably captures the exact model, persona, role, instruction digest, canonical input digest, optional runtime artifact identities, and first provider step. Identity fields never change; terminal receipts and provider steps cannot be rewritten. Failures, cancellation, and restart recovery preserve the captured chain. Turns created before this contract remain explicitly legacy rather than receiving invented provenance.

Evidence: OpenAgents.Conversations.begin_inference/4, PostgreSQL provenance triggers, OpenAgents.Provenance.Canonical, and OpenAgents.TurnProvenanceTest.

PROVIDER-001 — Model providers are replaceable

Status: Current

Conversation and web code depend on OpenAgents.Providers.Provider, not OpenAI event shapes. Adapters emit typed OpenAgents-domain lifecycle, text, reasoning, tool-call, usage, completion, failure, and cancellation events. A response ID is persisted when announced, and matching explicit completion is required; stream closure alone cannot produce a completed turn. Provider-specific events, credentials, and raw errors never reach the receipt or browser. Response creation is a non-idempotent mutation and the adapter sends it exactly once; continuation is an explicit host decision backed by a committed tool outcome.

Replaceability is a claim about every module, so it is proven over every module. The adapter is selected by configuration and reached through the behaviour, so nothing outside its own namespace holds a compile-time dependency on it. OpenAgents.DependencyBoundaryTest reads each compiled module's import table and fails when one gains that dependency, which is what the earlier adapter-behavior tests could not do: they exercised the adapter rather than the code that must not know about it.

A grant names one model and the proxy routes it. OpenAgents.Inference.Models is the list of models a grant may name, and it keeps the id a client asks for (ox-alpha) apart from the string the provider is called with (stealth/ox-alpha), so a routed vendor string can change without invalidating grants that already name the model. The adapter for each model is read from configuration, so no caller holds a compile-time dependency on one.

A third lane, OpenAgents.Providers.VercelGateway, reaches models through Vercel's AI Gateway rather than a vendor directly. It speaks the same chat completions the OpenRouter adapter does, so it reuses that adapter's request building and stream decoding and differs only in endpoint, credential, and the provider it tries first. Gemini is served there by both google and vertex, and vertex is where this account's Google credits are spent, so vercel_gateway_providers names the provider order and vercel_gateway_fallback_models names the model fallback list Vercel tries if the primary model fails.

Evidence: OpenAgents.Providers.ProviderEvent, OpenAgents.Providers.OpenAI, OpenAgents.Providers.OpenAI.StreamDecoderTest, OpenAgents.Providers.OpenRouter, OpenAgents.Providers.OpenRouter.StreamDecoderTest, OpenAgents.Providers.VercelGateway, OpenAgents.Providers.VercelGatewayTest, OpenAgents.Inference.ModelsTest, OpenAgents.Providers.Test, OpenAgents.TurnProviderEventsTest, and OpenAgents.DependencyBoundaryTest.

PROVIDER-002 — Model selection is honest: no silent substitution

Status: Current

A turn is never answered by a different model than the one requested without the caller being told. OpenAgents.Inference.Models is the one config-driven catalog (config :openagents, :model_catalog) of the models this deployment serves, GET /api/v1/models publishes it — id, provider lane, context and output ceilings, availability — and every admission checks the same list, so the offered set and the refused-against set cannot drift.

Concretely:

  • A model outside the catalog is refused with a typed error naming the served set — a field-level 422 at POST /api/v1/threads, model_not_served at the inference proxy — never replaced by a default.
  • A model in the catalog whose provider credential is not configured is listed as unavailable rather than omitted, and selecting it is refused with model_unavailable at thread admission and at the proxy, before any provider is called. Availability is the adapter's own report (configured?/0); it says only that a credential is present, never what it is.
  • A proxy body naming a served model other than the grant's is refused with model_mismatch naming both, rather than silently answered by the grant's.
  • Every successful proxy response attributes the effective model — the x-openagents-model header and each SSE chunk's model field — so a client renders what answered rather than what it assumed. Nothing in this application substitutes a named model: a mismatch is refused, so a caller that named one gets that one or an error.
  • Amended 2026-08-25 (#250): one lane can be substituted for by its provider, and the response says so rather than the host pretending otherwise. Where config :openagents, :vercel_gateway_fallback_models is set, Vercel answers a failed primary with another model and returns 200. The proxy reads the serving model back off the response and attributes that, so the header and the chunks name what actually ran. Where such a lane discloses no model, the attribution is the word unresolved — not the requested model, which the deployment cannot claim served. An adapter says whether it can be substituted for (OpenAgents.Providers.Provider.substitutable?/0, false where it is not exported), so silence from a lane that cannot substitute still means the model that was asked for.
  • Amended 2026-08-25 (#199): where nothing named a model — neither the mint nor the call — the server selects, preferring a configured lane that is not degraded in catalog order and falling back to the catalog default when every configured lane is degraded. This is not substitution: there was no request to substitute for, and a caller that said "whatever you serve" is better served by a lane that answers than by a fixed one that does not. It is bounded to exactly that case — a model named in the body, or a grant minted for a model other than the default, is routed as it always was — and the effective model is attributed on the response as always, so the answer still says what produced it.

The thread API's local lane is not an exception to this law, because no selection happens there. Amended 2026-08-25 (issue #243): POST /api/v1/threads with "lane": "local" records a bounded free-form vendor string as the thread's model without catalog admission — and mints nothing. No grant ever carries that string, the proxy never pins it, and no provider is ever asked to serve it; OpenAgents.Threads.mint_grant/1 refuses the thread outright (THREAD-001). Every path that mints authority still admits only the catalog, so a non-catalog model can be recorded but never granted, and no turn is ever answered by it.

The chat lane keeps the same law through OpenAgents.Chat.Backends: an unsupported model on POST /api/v1/chat/turns is a typed 422, and GET /api/v1 publishes the supported enum from the same list.

Evidence: OpenAgents.Inference.Models, OpenAgentsWeb.ModelCatalogController, OpenAgentsWeb.InferenceProxyController, OpenAgentsWeb.ThreadController, OpenAgents.Inference.ModelsTest, OpenAgentsWeb.ModelCatalogControllerTest, OpenAgentsWeb.InferenceProxyControllerTest, OpenAgentsWeb.InferenceProxyFallbackTest, and OpenAgentsWeb.ThreadControllerTest.

METER-001 — A cost is reported only where a price exists, and never as zero

Status: Current

Metering that prices some lanes at zero is worse than no metering: it reports a number, and the number is wrong. gpt-5.6-luna is the lane the coder actually runs on and this deployment has never been given its rates, so a surface that read a missing cost as zero would have shown $0.00 beside the account's largest real spend — in the same typeface as a figure somebody measured.

So every metered usage record says on whose authority it was priced. OpenAgents.Inference.Pricing stamps pricing_id on every record OpenAgents.Inference.record_usage/2 writes, and the id resolves to one of three bases:

  • declared — the operator entered the provider's published rates. This is the only basis anything may bill from (Pricing.billable?/1).
  • provisional — the deployment carries rates that were written to make the system run, or rates whose table it can no longer dereference. A cost is computed and labelled; nothing may bill from it.
  • unpriced — no rates at all. No estimated_cost_microusd is written, Pricing.cost/1 answers nil, and no reader substitutes a zero.

Concretely:

  • GET /api/v1/models publishes pricing_basis on every entry beside availability, so a caller reads what a lane will cost and whether that figure can be trusted before it spends. An unpriced model publishes no pricing block at all.
  • OpenAgents.Threads.spend/1 refuses to total a session that touched an unpriced lane: cost.microusd is nil, cost.unpriced_models names the lanes that made it nil, and cost.priced_microusd still reports what was measured so nothing is discarded. GET /api/v1/threads/{id} publishes the same shape, nulls included.
  • The thread page shows the word Unpriced rather than a dollar figure, and labels a provisional figure as a working number rather than a bill.
  • /models renders that same catalog for a person, because the contract asks that pricing be readable by the CLI and by the web before spend. An unpriced lane's rate cells carry the word rather than $0.00, every lane shows its basis and the id of the table it is priced against, and a deployment where no lane is declared says at the top that nothing on it is billable — derived from the catalog, so it cannot outlive the fact.
  • OpenAgents.Inference.Credit.spent/1 is a floor, not a total, while unpriced_calls/1 is above zero; balance/1 publishes complete? so no reader shows a balance as whole when it is not.

Amended 2026-08-25 (#250): a record is priced against the model that served the call, never the one that was asked for. The two are not always the same name. config :openagents, :vercel_gateway_fallback_models instructs the Vercel AI Gateway to answer a failed google/gemini-3.7-flash call with openai/gpt-5.6-luna and return 200, and the adapter did not read back which model answered — so a Luna call was priced at Gemini's rates and produced $2.25 of measured-looking cost from a lane this deployment has no rates for.

So every metered record also names its lane. served_model is read off the response's own model field, carried out of the chat-completions decoder as {:model_served, name}, and written as the catalog id where the catalog serves that model. Three values are not model names and none of them resolves to a rate table, so all three price at nothing:

  • unresolved — the lane may substitute (Provider.substitutable?/0, true for the gateway exactly while a fallback list is configured) and the response disclosed no model. Naming the requested model here would be a claim the deployment cannot support.
  • mixed — one grant's calls were served by more than one model. A single accumulated total cannot be charged at two rate tables, and picking one would be a guess.
  • any model outside the catalog — a fallback the operator never priced.

OpenAgents.Threads.spend/1 reports the serving lane in cost.unpriced_models rather than the requested one, so an operator is sent to price the lane that actually ran. A lane that cannot be substituted for needs no disclosure: it gets the model it asked for or an error, so its silence still means the grant's model.

An unpriced lane is not a free lane and not an error. It is the deployment saying it does not know what a call cost, which is a different fact from the call having cost nothing, and the distinction survives to every read surface. A max_cost_microusd ceiling therefore cannot bound an unpriced grant — it can only stop spend it can measure — so such a grant is bounded by its call and token ceilings, by the account's admission cap, and by revocation. Turning an unpriced lane into a number is an owner action: enter the provider's real rates in config :openagents, :model_catalog and set source: :declared in the same edit. No code here may guess one.

Evidence: OpenAgents.Inference.Pricing, OpenAgents.Inference.PricingTest, OpenAgents.Inference.CreditTest, OpenAgents.ThreadsTest, OpenAgentsWeb.ModelCatalogControllerTest, OpenAgentsWeb.ThreadControllerTest, OpenAgentsWeb.ThreadShowLiveTest, OpenAgentsWeb.ModelCatalogLiveTest, OpenAgentsWeb.InferenceProxyFallbackTest, and OpenAgents.Providers.OpenRouter.StreamDecoderTest.

Durable effects

EFFECT-001 — An effect commits with the intent that asked for it, and is delivered under lease

Status: Current

An intent that asks for something outside its own transaction — launch a worker, call a provider, start a delegation — records the asking in effects inside the transaction that writes the intent. OpenAgents.Effects.enqueue/2 performs one insert and participates in the caller's ambient transaction, so a rollback takes the effect with it. Either the intent and its effect are both durable or neither is; there is no window in which the system has promised work it holds no record of owing.

This closes a specific failure, not a hypothetical one. OpenAgents.Work used to commit the work_jobs row and then ask Horde for a worker, from the same process on the same node. A crash between those two steps left a committed queued job that nothing was executing, and nothing noticed: OpenAgents.Work.recover_interrupted_jobs/0 sweeps at boot and never after, so the job sat until that node restarted. The teardown corpus names this class — "best-effort live reactor" loss of committed work — in docs/2026-08-24-coder-first-cloud-complements.md section 3.

  • Delivery is leased, and a lease is a pair. claim_batch/2 takes candidates and updates them conditionally, so two workers racing over one batch take disjoint sets and no effect is handed to two workers to run twice. effects_lease_pair_check refuses an owner without an expiry and an expiry without an owner, so there is no lease nobody can reclaim.
  • A dead worker loses nothing. reclaim_expired/1 returns every effect whose lease ran out to pending, on this node or any other. The attempt it spent is not refunded, so a handler that reliably kills its worker still reaches maximum_attempts and stops rather than looping forever.
  • Redelivery is safe by construction. The idempotency key is derived from the effect's kind and its source, not its payload, so it is the same string on every retry, every node, and after every restart; a unique index on it makes a repeated enqueue one row. complete/1 is idempotent, so the worker whose lease expired mid-flight reports success without writing a second completion or contradicting the record.
  • A reused key with different content is refused. payload_digest fingerprints the content separately from the key that identifies the effect. Enqueuing a known key with a different payload returns :payload_conflict rather than silently answering the second caller with the first caller's effect — the fix docs/2026-08-24-coder-first-cloud-complements.md section 3 names as the one T3 skipped.
  • An unknown kind is a refusal, not a no-op. OpenAgents.Effects.Registry is the admitted map of kind to handler; the claim query offers only kinds in it, and dispatching an unregistered kind fails the effect. Nothing turns a payload string into a module or an atom at runtime. An outbox that quietly drops what it does not recognize is the behaviour this table replaces.

The converted call site is the work-job launch: OpenAgents.Work.start_job/1 and its delegation, scv, and continual_learning siblings commit the job row and its work.launch_worker effect in one transaction, then try the launch inline and retire their own effect on success. A launch that did not happen is delivered by OpenAgents.Effects.Handlers.WorkLaunch, which is idempotent three times over: the worker is a Horde cluster singleton, a terminal job needs no worker, and a job that no longer exists owes nothing. Other post-commit effects on this plane — turn starts, account-run provider launches, thread event broadcasts, terminal workspace cleanup — are not yet on the outbox and remain best-effort.

Evidence: OpenAgents.Effects, OpenAgents.Effects.Effect, OpenAgents.Effects.Worker, OpenAgents.Effects.Registry, OpenAgents.Effects.Handlers.WorkLaunch, OpenAgents.Work, priv/repo/migrations/20260824204740_create_effects.exs, test/openagents/effects_test.exs, and test/openagents/effects/work_launch_test.exs.

EFFECT-002 — Six acknowledgment milestones stay distinct

Status: Current

A single sequence number never stands for more than one of these facts:

  1. Command admitted — the caller's intent passed admission.
  2. Event committed — the intent row is durable, and any effect it asked for is durable with it.
  3. Effect claimed — a worker holds a lease and said it would try.
  4. Effect completed — the handler returned successfully.
  5. Turn quiesced — the work the effect started has stopped.
  6. Work verified — someone accepted the result.

A thread_events sequence is a transcript position. It is not an execution claim and it is not a completion claim, and nothing reads it as either. The effects table keeps milestones three and four apart in the schema rather than by convention: effects_status_shape_check requires a claimed row to hold a lease and an owner with no completed_at, and a terminal row to hold a completed_at and no lease, so "a worker took this" and "this ran" cannot collapse into one column. source_sequence records the transcript position the effect came from, beside the status and never in place of it.

Milestones one, five, and six are owned elsewhere and are named here so that nothing later borrows an effect status to mean them: admission belongs to each intent's own path, quiescence to the thread and turn plane (OpenAgents.Conversations terminal turn state), and verification to receipts.

Evidence: OpenAgents.Effects, priv/repo/migrations/20260824204740_create_effects.exs, test/openagents/effects_test.exs, and test/openagents/effects/work_launch_test.exs.

Tool authority and execution

TOOL-001 — A turn uses one immutable tool catalog

Status: Current

The registry validates configured tool specifications at boot. Before provider work, each turn captures one catalog snapshot and writes its canonical digest to the immutable receipt. Every call must match the exact tool name and version in that snapshot; later registry builds or deployments affect only later turns.

Evidence: OpenAgents.Tools.Registry, OpenAgents.Tools.Snapshot, OpenAgents.Turns.TurnServer, and OpenAgents.Tools.RegistryAndRunnerTest.

COLLECTIVE-001 — Private material crosses scope only through exact consent

Status: Current

A collective candidate can be created only in the same transaction as an active collective_contribution receipt from the owning person. The receipt binds the exact browser-owned source refs and their content digest, source-scope digest, category, intended use, attribution and compensation disclosures, policy version/digest, confirmation nonce/digest, and grant time. Model suggestions, profile-memory consent, product terms, and tool approval are different authority types and cannot satisfy this gate.

The candidate remains keyed and queried by its private visitor owner. It stores only the scope digest, opaque per-source provenance refs, redaction-policy identity, generalized-kind placeholder, evaluator/status, and bounded review/publication refs; it does not copy source refs, quotes, or identifying context into candidate fields and has no registry/discovery/execution path. Owner withdrawal atomically marks consent withdrawn and the unpublished candidate terminal. If publication refs later exist, withdrawal instead creates revocation_pending propagation state. Consent never publishes or admits a module; those require separate independent operator review.

Evidence: OpenAgents.Collective, OpenAgents.Collective.ConsentReceipt, OpenAgents.Collective.Candidate, database scope/state/transition constraints, and the consent, isolation, raw-copy, and withdrawal cases in OpenAgents.CollectiveTest.

COLLECTIVE-002 — Generalization is bounded, content-free, and reproducible

Status: Current

Only an authenticated privacy reviewer in the candidate owner's scope can run generalization or inspect opaque lineage. The versioned fixed-vocabulary generalizer recognizes a bounded supported signal and emits one admitted schema for evaluation cases, prompt examples, module patterns, or reusable work patterns. Output is scanned again for secrets, contacts, identifiers, paths, exact source fragments, authority-bearing fields, and size before storage. It cannot copy private sources or manufacture executable capability/authority.

The append-only generalization receipt contains candidate/source/policy/ generalizer/output digests, kind-support signal, source count, risk/utility, and bounded reason codes—never rejected content. Unsupported/high-risk material is terminally rejected with no generalized payload. A successful payload becomes immutable, and identical kind/signal/policy inputs produce the same output digest. This is conservative de-identification, not a claim of mathematically irreversible anonymity, and it still grants no publication/module authority.

Evidence: OpenAgents.Collective.Generalizer, OpenAgents.Collective.GeneralizationReceipt, generalized-candidate database constraints/triggers, and adversarial schema, reproducibility, lineage, and leak scans in OpenAgents.CollectiveGeneralizerTest.

COLLECTIVE-003 — Publication requires independent evidence and operator authority

Status: Current

A generalized candidate reaches the cross-user collective catalog only when its contribution consent remains active, its privacy generalizer is bound to an authenticated reviewer, and a different authenticated evaluator records a pinned artifact, pinned dataset, versioned policy, and passing privacy, safety, regression, novelty, utility, compatibility, and no-authority-expansion result. A separately authenticated operator, distinct from both reviewers, must then write the publication receipt. Self-review, self-approval, missing digests, failed dimensions, withdrawn consent, and legacy unbound generalization receipts fail closed.

Publication creates a new immutable openagents.module_artifact.v1 through the same artifact validation and registry dependency admission used by first-party modules. The artifact contains only the reviewed generalized payload and opaque attribution lineage. It begins disabled in a staged collective catalog and has no installed executable, discovery, routing, or invocation path. Review, operator-decision, and publication receipts are append-only and bind all actor, policy, evaluation, artifact, predecessor, attribution, and derived-data-plan evidence. An operator rejection is terminal for that immutable candidate and publishes nothing.

Consent withdrawal creates revocation propagation. Revocation or staged regression rollback writes a successor receipt and revoked artifact digest, excludes the module from all new catalog projections, and marks the bounded delete/rebuild plan required while preserving content-free historical evidence. A privacy revocation cannot be rolled back, and an already published module version is never overwritten.

Evidence: OpenAgents.Collective.Reviewer, OpenAgents.Collective.Publisher, OpenAgents.Collective.ReviewReceipt, OpenAgents.Collective.OperatorDecisionReceipt, OpenAgents.Collective.PublicationReceipt, OpenAgents.Modules.Artifact.from_collective/1, database transition/append-only guards, and OpenAgents.CollectivePublicationTest.

COMPENSATION-001 — Attribution accounting never creates payout authority

Status: Current

Technical invocation cost, contributor attribution, compensation eligibility, and payment are distinct facts. An event is compensation-eligible only when an operator-admitted policy with payout_authority: false binds an exact immutable module artifact to contribution allocations totaling one million parts, the invocation is uniquely persisted and billable, its terminal outcome is accepted by an authenticated outcome reviewer, the artifact is not revoked, and the invocation/outcome has not already been classified. A model proposal, module publication, invocation, or successful tool result alone cannot make an event eligible or payable.

Eligible units are allocated deterministically by contribution reference with integer remainder handling. Unique invocation and outcome keys prevent double counting. Revocation blocks future eligibility but never rewrites historical events. Refund, chargeback, fraud/dispute, and policy-migration handling occurs only through append-only signed adjustments; statements deterministically reconcile gross, adjustment, and net units for one policy and contributor.

Every policy, module allocation, outcome decision, event, share, adjustment, and statement is append-only. Contributor/operator projections expose opaque lineage, digests, counts, units, and reconciliation state but never arguments, results, conversation content, user identity, payment instructions, custody, or a payout operation.

Evidence: OpenAgents.Compensation, its seven typed receipt schemas and database constraints, test/openagents/compensation_test.exs, and duplicate, revocation, allocation, adjustment, reconciliation, privacy, and no-payout cases in OpenAgents.CompensationTest.

REPUTATION-001 — An attestation is scoped signed evidence, never a score

Status: Current

A reputation attestation is one Ed25519-signed canonical claim binding an issuer key, a subject, an accepted outcome, a repository, an issue, a revision, an artifact digest, an admitted verifier policy version and digest, a confidence in parts per million, evidence references, a timestamp, and a nonce. The six event types — completion, verification, review, payment, reversal, and revocation — stay distinct facts.

Issuance requires an accepted-outcome receipt that already reached its admitted terminal state, so an invocation, a presence signal, token volume, online time, or unverifiable narration can never produce an attestation. The verifier policy rules carry global_score: false, no function returns a ranking, and subject evidence is counted inside one repository with a score of nil.

Verification is independent of the interface. A client recomputes the claim digest, checks the signature against the admitted public key, compares the policy digest and version, resolves each evidence reference, and reads the revocation state. Because the claim covers the issue, the revision, the subject, the outcome, and the verifier, a valid attestation presented for another issue, revision, verifier, or actor fails its binding. A reversed or invalidated outcome produces a linked invalidating attestation, and the revoked claim and signature stay readable. Retiring a key never invalidates the history it signed, and that is enforced rather than assumed: because the key's active window is half-open at retired_at, a retirement at or before the newest attestation the key signed is refused with the conflicting attestation time named, a key that signed nothing retires no earlier than its activated_at, and a retired_at beyond a small clock-skew allowance in the future is refused. There is no override — disowning a signed claim is a linked revocation, never a rewrite of the key's validity window. A private key never enters the database.

Disclosure follows repository authority: an attestation is public only where the repository is public or its transparency level admits ledger disclosure (TRANSPARENCY-001), evidence must stay inside the repository, and a private attestation withholds the outcome reference and every evidence reference from the signed claim while remaining verifiable.

A subject is a bare string inside the signed claim, and it stays that way. What resolves one to an account is a separate binding, reputation_subject_claims: a claim the account makes and an operator decides, where only a linked claim resolves and a unique index on subject_id means one string never resolves to two accounts. The binding carries the kind — account, forum_actor, or agent — with one CHECK constraint per kind, because putting the kind on the attestation would either leave it outside the signature or invalidate every signature already published. Nothing in issuance or verification reads the binding: an attestation is exactly as valid, and exactly as checkable, for a subject no account has claimed.

Evidence: OpenAgents.Reputation, OpenAgents.Reputation.Claim, OpenAgents.Reputation.Attestation, OpenAgents.Reputation.SigningKey, OpenAgents.Reputation.PolicyReceipt, OpenAgents.Reputation.SubjectClaim, the append-only and uniqueness constraints on reputation_attestations, the per-kind reference constraint on reputation_subject_claims, OpenAgentsWeb.ReputationController, test/openagents/reputation_test.exs, test/openagents/forge/key_rotation_test.exs, test/openagents/reputation/subject_claim_test.exs, test/openagents_web/controllers/reputation_subject_claim_controller_test.exs, and test/openagents_web/controllers/reputation_controller_test.exs.

SETTLEMENT-001 — A bounty pays once, against fingerprinted evidence

Status: Current

Bounty settlement is a separate authority from attribution accounting. A payment leaves the treasury only when an operator-admitted treasury policy bounds the amount, the daily budget, the attempt count, and the admitted self-custodial destination kinds; the priced specification carries a named buyer, a sats amount, acceptance criteria, a verification policy, an expiry, and a fingerprint over all of them; the claim pins that fingerprint and the claimant's own destination; a verification under the specification's own verifier policy digest accepts the exact commit the claim delivered; and the settlement request carries an approval reference and an idempotency key.

A repriced specification, a moved fingerprint, a rejected verifier, a commit without its own verification, a missing approval, an expired claim, a dispute, an exhausted budget, or an exhausted attempt bound each stop the payment. One idempotency key names one payment intent, one intent per claim can reach paid, one receipt exists per intent, and a payment hash is unique, so a duplicate request, a retry, and a lost acknowledgement all resolve to the first receipt instead of a second payment. Expiry, dispute, and refund are append-only adjustments that never rewrite a receipt.

The treasury never holds the claimant's keys and never provisions a wallet for them: the domain hands an authorized request to the configured gateway and records the returned evidence, and an unconfigured gateway fails closed. Public projections publish only the amount, the status, reference kinds, and the evidence the repository's disclosure level admits, never a destination, a claimant or buyer reference, an operator identity, an approval reference, or a gateway reference. The claimant can export the full receipt, including their own destination, without a hosted wallet.

A simulated rail is admissible, and it cannot be mistaken for a real one. OpenAgents.Settlement.PaymentGateway.Simulated exists so the loop can be driven end to end where no treasury is attached, and it is bounded three ways: it refuses outright when runtime_environment is :production, so pointing a live treasury at it fails the attempt rather than faking a payment; every gateway_ref it mints starts with simulated:, so a row it wrote is distinguishable from a real transfer in the database and in every export; and its payment hash is derived from the idempotency key alone, so a replay produces the identical hash and the unique payment_hash constraint refuses a second receipt. It keeps no ledger and vouches for no key it was not handed. It moves no sats: outbound payout stays on the self-custodial MoneyDevKit treasury bridge, which this repository does not implement.

Sats are integers everywhere on this path — the treasury rules, the priced amount, the intent, the receipt amount, and the fee — and the rail itself refuses an amount that is not a positive integer rather than rounding one into a receipt.

Evidence: OpenAgents.Settlement, OpenAgents.Settlement.PaymentGateway, the settlement schemas and tables, with their uniqueness and partial-uniqueness constraints, and the pricing, claim, verification, duplicate, stale-commit, approval, budget, retry, reconciliation, expiry, dispute, refund, privacy, and receipt-export cases in test/openagents/settlement_test.exs. One bounty is driven the whole way on real material — a resolvable commit in a forge-hosted repository, a bounty-labelled issue, and an accepted OUTCOME-001 completion claim closed at that revision — in test/openagents/settlement/simulated_bounty_proof_test.exs.

Two things this invariant deliberately does not claim. Settlement checks the shape of a commit sha and never asks the forge whether that commit exists, and nothing requires a verification's evidence digest to be taken over an accepted completion claim; the end-to-end proof does both by hand, so the binding is shown rather than enforced. Making either one policy is #207's successor work, not this invariant.

MODULE-001 — Every invocation pins one immutable admitted module

Status: Current

The captured turn registry contains provider-neutral module artifacts whose canonical digest covers typed input/output, lifecycle state, side-effect and approval classes, capability/data scopes, policy facets, executor identity, publisher/maintainer, provenance, compatibility/dependencies, predecessor, deprecation, rollback, and attribution policy. The artifact also binds the loaded BEAM executor identity. Immediately before invocation the runner verifies that loaded identity; missing or changed bytes fail closed. Durable tool-step identity records the registry, route receipt, artifact, executor, module version, attribution-policy version/digest, side-effect class, arguments, invocation key, and explicit billing identity.

Only admitted or explicitly deprecated modules are projected into discovery and provider schemas. Disabled and revoked artifacts remain visible to provenance inspection but are unavailable to new turns. A registry replacement can affect only a later turn; predecessor and rollback metadata never mutate an already captured snapshot. First-party recall remains subject to TOOL-001 through TOOL-004 and its existing conversation/profile scope predicates.

Evidence: OpenAgents.Modules.Artifact, OpenAgents.Modules.Registry, OpenAgents.Tools.Registry, the module identity columns and database transition constraint on turn_tool_steps, OpenAgents.Modules.RegistryTest, and module invocation reconciliation tests in OpenAgents.ToolStepPersistenceTest.

MODULE-002 — Discovery and lifecycle never grant model authority

Status: Current

The model-facing discovery tool receives the exact registry already captured by its turn and returns at most twenty bounded public metadata references. It cannot register a module, reveal executable/provider schemas or private configuration, or grant execution, scope, approval, or authority. Every later use must revalidate both the registry and artifact digests against the same capture; stale references fail closed. Deprecated modules are excluded from default new selection but remain available to an explicit historical projection.

Stage, admit, deprecate, disable, revoke, and predecessor rollback are host-only operator operations requiring an authenticated operator identity and a unique approval receipt. Artifact, provenance, policy, compatibility, and dependent impact checks run before an append-only PostgreSQL receipt is committed. Text and voice surfaces apply the latest receipts only when capturing a new registry; an in-flight turn/session keeps its prior snapshot. Revoked modules cannot be restaged or admitted, and active dependents block disable/revoke.

Evidence: OpenAgents.Modules.Discovery, OpenAgents.Tools.ModuleDiscover, OpenAgents.Modules.Lifecycle, OpenAgents.Modules.LifecycleReceipt, the append-only database trigger, and the discovery/lifecycle tests.

MODULE-003 — Routing proposals cannot weaken explicit policy

Status: Current

Module routing receives a captured registry, a versioned/digested host policy, an intent digest, required capability/effect/data scope, and application-created authorities. Publisher, cost class and numeric budget, quality, privacy, residency, jurisdiction, censorship-resistance, approval, side effect, runtime, scope, and authority are hard filters applied before deterministic ranking. A model/program proposal is only a reference to revalidate. It cannot add a candidate, change policy, infer away a stricter constraint from casual language, or trigger an unauthorized fallback.

The exact decision is persisted before tool-step dispatch without raw prompt content. Immediately before execution the host revalidates registry, artifact, policy, scope, and authority; the tool runner then independently repeats its scope/authority/effect checks. No eligible module produces a typed unavailable or refused outcome. An unadmitted routing-program identity is rejected, and an absent/degraded optional program uses the reproducible deterministic baseline.

Evidence: OpenAgents.Modules.RoutingPolicy, OpenAgents.Modules.Router, OpenAgents.Modules.RouteReceipt, OpenAgents.Modules.RoutingReceipts, the append-only route-receipt trigger, OpenAgents.Turns.TurnServer, and router/tool-loop tests.

MODULE-004 — Every capability surface preserves the same authority boundary

Status: Current

Every route and invocation names exactly one admitted surface from text, voice, search, computer, repository, mcp, or agent. The selected artifact must admit that surface, kind, and effect; revalidation refuses a surface change. Voice and text share the admitted persona, role, Blueprint, memory authorities, registry, and outcome envelope rather than forking OpenAgents's identity.

Read-only work requires exact application-created scope and authority. Reversible writes require exact current-user consent, and external effects require an external or operator approval receipt bound to module, version, and scope. Every successful non-read effect returns a target receipt, and every outcome discloses the actual executor. Large catalogs are bounded by schema count and encoded size; above either ceiling only discovery is exposed, and its proposals pass ordinary revalidation. Missing executors fail honestly.

Evidence: OpenAgents.Modules.SurfacePolicy, the surface field on module route decisions/receipts, OpenAgents.Tools.Registry.prompt_catalog/1, OpenAgents.Tools.Runner, test/openagents/surface_eval_test.exs, and the surface, catalog, approval, receipt, voice-interruption, and degradation tests.

TOOL-002 — Model requests never widen host authority

Status: Current

Tool name, arguments, recalled text, and prompt content grant no scope or authority. The runner checks the application-created execution context against the captured specification before implementation code runs. It admits read-only tools, explicitly scoped reversible writes, and external effects only with the surface-specific approval and target-receipt contract. Reversible memory writes still pass their current-consent, owner, policy, conflict, and optimistic-generation gates.

Evidence: OpenAgents.Tools.ExecutionContext, OpenAgents.Tools.Runner, and the scope, authority, schema, and side-effect runner tests.

TOOL-003 — Tool outcomes are durable before provider continuation

Status: Current

Every provider call ID maps to one ordered immutable request row. A worker must atomically claim requested -> running; duplicate requests return the existing row and duplicate claims cannot execute it again. Provider continuation output can be constructed only by rereading a committed terminal outcome. Active steps block normal turn completion and become explicit cancelled, failed, or interrupted outcomes with the containing turn.

Evidence: OpenAgents.Conversations.ToolStep, the turn_tool_steps constraints and transition trigger, OpenAgents.Conversations.tool_continuation_output/1, and OpenAgents.ToolStepPersistenceTest.

TOOL-004 — Every outcome identifies the actual executor

Status: Current

Every success, failure, refusal, cancellation, timeout, or unavailable result is a bounded openagents.tool_outcome.v1 envelope naming the captured module version and actual executor/disclosure. Attribution and target receipt refs are bounded and validated; one OpenAgents interface never implies OpenAgents performed hidden work. The terminal invocation adds an immutable normalized outcome receipt and bounded usage/cost projection before provider continuation. OpenAgents's continuation and UI activity disclose the executor; UI activity renders at most the bounded durable outcome projection UI-002 sanctions, never an unbounded payload. Success may rely on the normalized outcome receipt for local/read-only work; target-system refs remain separately preserved whenever an effect or source produces them.

Evidence: OpenAgents.Tools.Tool, OpenAgents.Tools.ExecutionResult, OpenAgents.Tools.Runner, OpenAgents.Conversations.ToolStep, the invocation-ledger database constraints, normalized-outcome tests, and executor-disclosure UI tests.

TOOL-005 — The offered set names only tools this caller can reach

Status: Current

A tool the caller cannot use is not offered to the model. Every surface that builds a model-facing catalog resolves the caller once for the turn — OpenAgents.Tools.Reach.caller/1 from the execution context, or caller_for_user_id/1 where the surface already holds the visitor — and the selector drops every tool whose declared reach: the caller does not hold before ranking, so an unreachable tool takes neither a top-K slot nor an always-include slot.

Three requirements exist, and each tool's specification declares which apply:

  • :signed_in_owner — the conversation resolves to an active account through OpenAgents.Tools.OwnerContext. computer_list, computer_probe, computer_run, computer_devin, computer_agent, deep_work, and incident_lookup declare it.
  • :paired_computer — that account has an active paired Computer. computer_probe, computer_run, computer_devin, and computer_agent declare it. computer_list deliberately does not: listing zero Computers is how the model learns to say "pair one first".
  • :operator — that account holds operator authority. scv_deploy declares it, because SCV-001 spends OpenAgents capacity rather than the caller's.

Repository tools declare no reach: their gate is per-repository membership, which depends on an argument the catalog has not seen. Box tools declare none either: their gate is deployment configuration, not who is asking.

The narrowing decides what is offered and never what is allowed. Each tool re-resolves its own owner and re-checks its own gate at execution (TOOL-002), so a stale or wrong catalog cannot widen authority. An unknown requirement refuses the whole registry at boot rather than narrowing nothing.

A caller's identity reaches the catalog as a visitor id, never an account id. The two are separate identifier spaces and no surface substitutes one for the other; a context that cannot name its owning visitor builds the unbound context instead.

"Every surface" is proven by enumerating surfaces. OpenAgents.Tools.Selector.reachable/2 narrows nothing when it is given no caller, so a new catalog builder that omits :reach offers the whole catalog and every existing test stays green. OpenAgents.DependencyBoundaryTest compares the set of modules whose compiled import table names OpenAgents.Tools.Selector against the two this contract names, and requires each of them to name OpenAgents.Tools.Reach as well. test/openagents/tools/reach_test.exs enumerates the other axis, the tools and their declared requirements.

Evidence: OpenAgents.Tools.Reach, OpenAgents.Tools.Selector, OpenAgents.Tools.AdmittedCatalog, test/openagents/tools/reach_test.exs, test/openagents/chat/open_router/tool_runtime_test.exs, and OpenAgents.DependencyBoundaryTest.

TOOL-006 — The shipped tool catalog is a closed set, read-only but for consent

Status: Current

The catalog the product installs at boot is enumerated, not accumulated. Every module in config/config.exs under :tools requires an authority every conversation caller already holds, is admitted by name, and is either read-only, or gated on a current consent receipt. A tool that ships must work for every caller that can see it; offering a tool that always refuses trains the model and the person to ignore refusals. TOOL-005 narrows the offer to what this caller can reach; this invariant narrows what exists to be offered at all. Neither substitutes for the other.

"Gated on a current consent receipt" is not a softening, and it is the only admitted exception to read-only. It means the module declares a non-read-only side_effect together with metadata that sends OpenAgents.Modules.SurfacePolicy.authorize_execution/2 down its receipt path — an explicit, person-signed receipt bound to that exact module, version, and scope_ref. For an external_effect that is the external_confirmation or explicit_operator_approval class the surface contract already requires. The reversible_write plus executor_consent combination, which runs without asking anyone, does not qualify. Because OpenAgents.Tools.AdmittedCatalog applies the same check when it builds the catalog, such a tool is not offered on a turn where the person has not consented, rather than offered and refused: the standing prompt cost is zero and the "works for every caller that can see it" rule above still holds, since the only callers who can see it are the ones who consented. There is no standing grant to record and no "always" to accumulate — the receipt is scoped to one conversation and re-checked on every call, which is the ask-every-time rung of the approval ladder in docs/2026-08-23-openagents-coder-cli-spec.md, section 7.3.

Filing an issue from chat (OpenAgents.Tools.IssueCapture) is the first and currently the only module admitted under that exception. It writes to a public tracker, so it is :external_effect; it files under the requesting account's own repository membership, so it mints no authority of its own; and a caller holding no writing role is refused with a typed error that names the missing role rather than a silent fallback to a repository they did not choose.

Modules that are not admitted stay in lib/openagents/tools/ and stay under test through the broader fixture catalog in config/test.exs, which must remain a superset of the shipped set. Unregistering is not deleting, and being under test is not admission. Re-admitting a module is a policy change subject to the criteria in docs/2026-08-23-agent-tools-zero-base.md.

Evidence: OpenAgents.Tools.Registry, OpenAgents.Tools.ConversationExecutionContext, and test/openagents/tools/shipped_catalog_test.exs.

DEGRADE-002 — Tool degradation is explicit and deterministic

Status: Current

Unknown versions, invalid schemas or arguments, scope/authority refusal, unsupported effects, cancellation, timeout, crashes, and oversized/invalid output become typed bounded outcomes. They never silently execute a substitute tool, widen scope, expose raw exceptions, or fabricate success.

Evidence: OpenAgents.Tools.Runner and its failure-path tests.

Lexical recall unavailability is the typed lexical_unavailable tool failure. The failed step remains in the receipt and the provider may only describe the available path honestly; it may not guess at history or silently substitute a different authority plane.

Evidence: the committed recall evaluation, OpenAgents.TurnToolLoopTest, and degradation tests in OpenAgents.Tools.ConversationRecallToolsTest.

Delegated work

WORK-001 — Delegated jobs are durable, budgeted, governed, and never die silently

Status: Current

A deep_work.v1 call is only delegation, never execution: it starts one durable work_jobs row scoped to the caller's conversation and owner and returns immediately with a job reference, so the requesting turn or voice response acknowledges in one sentence while the work runs server-side. The worker drives the same configured text provider under the same composed persona instructions, the same captured tool-catalog snapshot, and the same governed OpenAgents.Tools.Runner, with each tool call committed as an ordered work_job_steps row before execution and continued only from its committed terminal outcome. A job's authorities never include work.delegate or memory.write, its provider request never advertises deep_work, and an arriving recursion call is refused with a durable typed outcome, so delegation depth stays at one and a job cannot widen the caller's authority.

Every job is bounded — thirty-two tool calls, thirty-two continuations, a ten-minute wall clock — and a tripped bound refuses the over-limit call with a durable typed step outcome, forces one final tool-free report response, and ends the job as explicit budget_exhausted. Every terminal path (completed, failed, interrupted, budget_exhausted) stores a non-empty report: streamed report text is persisted as it arrives, and a job that dies before a narrative gets an honest host summary of its committed step evidence. PostgreSQL constrains status transitions and makes terminal jobs and terminal steps immutable. A delegation additionally binds one account-owned computer, its admission-time authority snapshot, its bounded execution budget, and its immutable request. Only the generation-fenced ACP session ID may change after admission; workers read computer, agent, working directory, and wall-clock authority from the immutable fields. Startup recovery RESUMES orphaned active jobs (#97): it restarts each job's supervised worker, which re-claims through the generation fence and continues — a delegation by its durably checkpointed ACP session id, deep work from its committed evidence. A job whose cluster singleton is still alive (a fleet survivor) answers already_started and is never disturbed or double-adopted; the restart is recorded as a degraded runtime_restarted incident; and only a job whose worker cannot start at all is finalized interrupted with its partial report intact. On terminal state the bounded report becomes a durable assistant conversation message linked to the job, so it enters ordinary recall and later provider context; a live voice session receives the report through the existing typed-message injection as a best-effort projection that can never rewrite the committed terminal state.

Evidence: OpenAgents.Work, OpenAgents.Work.Job, OpenAgents.Work.JobStep, OpenAgents.Work.JobServer, OpenAgents.WorkRecovery, OpenAgents.Tools.DeepWork, the work-job migration triggers, OpenAgents.WorkJobTest, and OpenAgents.DeepWorkToolLoopTest.

SELF-EDIT-001 — Every behavior change is anchored to a pushed commit (2026-08-19)

Status: Current

OpenAgents may edit her own source only through governed repository tools acting on a per-job clone of her own forge, and nothing she writes becomes running behavior except through the receipted pipeline. Concretely:

  • The pushed commit is the artifact. Every behavior change to the running system is anchored to a commit pushed to the forge; the WAL entry that acked that push is its durable digest. Hot-loaded code is a projection of a pushed commit, never authority: a node restart that converges to the promoted fleet target (or, absent one, to the image) is always correct and loses nothing that was ever authority.
  • Mutation stays inside the job's clone. Repository write tools operate only under that job's workspace clone whose origin is the local forge — never GitHub, never the baked source, never another job's clone. Pushes go only to that job's own openagents/job-<id> branch; a push to any other ref is refused with a typed outcome. The clone is removed when the job ends.
  • Promotion is an operator action. No OpenAgents tool can promote, deploy, or hot-load. The job's report links the pushed SHA; the /admin/forge Promote click (enumerated by ADMIN-001) is the human approval receipt, and the allowlist of hot-loadable modules remains operator-owned data. The operator API under FLEETPROMOTE-001 is the same approval by a scripted operator, not a way around one.
  • Receipts reconstruct what ran. Tool outcome receipts carry the commit SHA of every push; push, build, and deploy receipts chain from that SHA; together they let an operator reconstruct exactly which code was live when, with no step inferred.

"No OpenAgents tool can promote, deploy, or hot-load" quantifies over every tool module, not over the six the product ships, so it is proven that way. OpenAgents.DependencyBoundaryTest reads the compiled import table of every module in lib/openagents/tools/ and fails when one gains a dependency on OpenAgents.Forge.Promotion, OpenAgents.Forge.Targets, OpenAgents.Forge.HotLoader, OpenAgents.Forge.Deployment, OpenAgents.Forge.RelupDeployment, or OpenAgents.Forge.RollingReplacement — including a module that is written and tested but not yet admitted, which is where the next tool comes from. sarah.tool.scv_deploy.v1 reaches OpenAgents.SCV.Deployments rather than any of these; SCV-001 governs it, and it starts a coding agent rather than changing what the fleet runs.

Evidence: OpenAgents.Tools.Repository (clone confinement, branch discipline, typed refusals), OpenAgents.Work.Coding, OpenAgents.Forge.Pushes / OpenAgents.Forge.Targets / OpenAgents.Forge.HotLoader receipts, ADMIN-001, OpenAgents.CodingJobTest, the repository tool tests in test/openagents/tools/repository_mutation_tools_test.exs, and OpenAgents.DependencyBoundaryTest.

SCV-001 — An SCV spends our capacity only under operator authority and fixed bounds

Status: Current

An SCV deployment is the one lane where OpenAgents runs a coding agent on hardware we own and pay for, rather than on a computer the person paired and powers. Every other execution path is bounded by something outside our control; this one is not, so its ceiling is written down and enforced rather than assumed.

  • One entry point, and it is operator-only. Every surface that starts an SCV enters OpenAgents.SCV.Deployments.start/2, which refuses any account that is not an OpenAgents operator with :operator_required before a row is written or a process is spawned. The refusal lives in the code that starts the run, not in whatever advertised it, so a model that calls the tool on behalf of a signed-in non-operator is refused exactly as an unauthenticated caller is. sarah.tool.scv_deploy.v1 declares external_effect under the explicit_operator_approval class, and the matching receipt is minted only for operators, so OpenAgents.Modules.SurfacePolicy refuses the same call a second time and independently.
  • It is a work job, not a second job system. The durable unit is a work_jobs row of kind scv, so an SCV inherits the seven statuses, the PostgreSQL transition triggers, the Horde cluster singleton, the owner_node/generation fence, the startup recovery sweep, cancellation, and the bounded report that lands in the conversation as a durable assistant message. An interrupted SCV is finished honestly rather than resumed: a killed coding-agent process has no session to re-attach, so a worker that adopts a row at a bumped generation ends it interrupted instead of paying for the same objective twice.
  • Four bounds, fixed at admission. The objective is capped at 2,000 bytes; the wall clock and the captured-output ceiling are snapshotted onto the row when the run is admitted, so a configuration change mid-run cannot widen a run already in flight; the executor enforces the wall clock and this application independently backstops it; and the number of SCVs queued or running across the whole application is capped by configuration. A tripped concurrency ceiling refuses the call with :scv_capacity_reached rather than queueing unbounded work.
  • It reads; it does not write. The run is admitted only under the read_only permission profile in the opencode-core environment, against a disposable clone of a forge repository at an exact 40-character revision resolved by the application. The caller names a repository the operator may read as owner/name; a filesystem path from a caller never reaches an SCV. The workspace is removed on every terminal path, including the one that runs when the worker died. In staging and production that workspace must sit on a durable path: a container's System.tmp_dir!() is the writable image layer on the boot disk, which the node already shares with Docker and the import workspace, so a repository cloned there is how a node runs out of room while its durable volume idles. Configuration names the root, and a node refuses to boot with an SCV lane enabled and a clone root under /tmp.
  • No job may deploy one. scv.deploy is a turn authority only. Job authorities never include it, so neither a deep-work job, a delegation, a coding job, nor an SCV can start another SCV.
  • It is metered and visible. Token usage is recorded into the shared inference_grants ledger, the same one the coding kind uses, so "how much did an SCV spend" is a query. Each run's lifecycle events reach the content-free public projection on the status page through the existing [:openagents, :scv, :event] telemetry, and every non-completed terminal is recorded as a typed incident.
  • It is off by default. The lane is admitted only when the scv_deploy feature is enabled, which OpenAgents.RuntimeConfig accepts only alongside the work lane and tools, only with an admitted model slug and bounds, and only above the staging gate that admits advanced product features.

The one-entry-point clause quantifies over surfaces, and test/openagents/scv/deployments_test.exs proves the refusal at the gate rather than that every caller passes through it. OpenAgents.Work.start_scv/1 is what an admitted deployment calls to write the row and start the worker, and it is public, so a second caller would be an SCV that skipped admission. OpenAgents.DependencyBoundaryTest compares the set of modules whose compiled import table names start_scv/1 against the one this contract names, so that caller fails the proof before it can exist.

Evidence: OpenAgents.SCV.Deployments, OpenAgents.Work.Scv, OpenAgents.Work.ScvServer, OpenAgents.Tools.ScvDeploy, OpenAgents.Work.Job (the scv kind), OpenAgents.RuntimeConfig, test/openagents/scv/deployments_test.exs, and OpenAgents.DependencyBoundaryTest.

OUTCOME-001 — An agent-authored claim is accepted only against the accepted-outcome contract

Status: Current

When an agent claims that work is complete, the claim counts as an accepted outcome only when every part the contract names holds; anything less is a typed non-accepted result, and human-only work stays outside the gate entirely.

  • The issue is the canonical record, and it must be scoped. A claim anchors to an issue that states its problem, scope, acceptance criteria, and success metrics. A claim against an issue missing any of those sections is incomplete, never accepted.
  • The attempt is bound, not implied. Each execution attempt records the issue number, repository, authority, budget, and exact revision it produced. An attempt bound to a different issue or repository, an unadmitted verifier, or a violated producer-verifier separation policy is unauthorized.
  • Green must have been able to be red. The claim records an admitted verifier, a falsifier, and a terminal result. A failed terminal result is failed, and a result carrying any of the five named false-green classes — false_green_fixture_assert, false_green_api_mirror, false_green_mocked_seam, false_green_coverage_theater, false_green_round_up — is failed even when the verifier reported green.
  • Every criterion names its evidence. An accepted outcome explains which receipt satisfied each acceptance criterion, so the issue page can show the mapping; a criterion with no evidence makes the claim incomplete.
  • The public projection is content-free about private material. A projection of an evaluation carries the result state, typed reasons, criterion names, and public receipt references only — never prompts, logs, private repository names, or private receipt references.
  • Human-only work is not gated. Work by a human actor and repositories with agents disabled evaluate to not_applicable and remain fully usable.

The committed contract is priv/api-contracts/accepted-outcome-v1.json, and OpenAgents.AcceptedOutcome.validate/1 refuses a contract whose required sections, attempt fields, false-green classes, or result states drift from the code that enforces them.

The claim is durable, and only the caller's judgment comes from the caller. issue_completion_claims stores one graded verdict per {issue, attempt, revision}: the state, the typed reasons, and which evidence satisfied which acceptance criterion. A caller supplies the criterion-to-evidence mapping and, optionally, false-green classes against its own result — an assertion that can only make the verdict worse. Everything else OpenAgents.Issues.CompletionClaims grades is read from records: the issue's four sections from its body, the attempt's five binding fields from forge_assignments and the budget_snapshot on its work_jobs row, the verifier from the published check result the evidence resolves to, and the falsifier as that check's own identity reporting failed. Producer-verifier separation is always required on this path, so an attempt whose requester also published the check is unauthorized.

Only a qualification receipt can close an issue, and only under an opt-in. An acceptance criterion is satisfied only by an issue_evidence edge that is a qualification receipt, for that issue, at that exact revision, with the status succeeded. deployment_check_results is the one family whose row is a verdict about named bytes — identity is {repository, name, commit, artifact digest} — and whose publisher is an authorized principal that is not the attempt. A push records receipt of bytes and carries no result at all; a build records that a tree compiles, which is necessary for anything to work and sufficient for nothing to be done; a deployment records that an artifact reached an environment, which is placement rather than behavior. All four still record on the evidence chain; only one qualifies. On top of that, closing requires the repository to have opted in twice — repository_closure_policies.agents_enabled and verified_closing_enabled, both false by default, with an absent row meaning the same as both false — and requires that no evidence edge for that issue at that revision carries its family's word for failure.

A later receipt contradicts; it never reopens. A failing edge arriving for an issue and revision an accepted claim rested on stamps contradicted_at and names the edge that disagreed. The issue stays closed, because reopening on a later signal is a separate policy with its own failure modes (ISSUE-001), and rule four stops any further close on that revision.

There is one automatic closer, and an automatic close is attributable. OpenAgents.Issues.ClosingReferences remains the trailer path and is unchanged: a person wrote Closes #N, their write authority was checked, and the commit was reachable from the default branch. This path closes only issues that are still open, so a trailer close that already happened is recorded as a claim and moves nothing. Neither path reads commit prose; OpenAgents.Forge.CommitReferences is still the only reader of it. A reader tells the two apart by the record: a person's close leaves an issue_closing_references row with a closed_by_user_id, and a verified close leaves an issue_completion_claims row whose closed_by_actor is system:accepted-outcome and never a user id. PostgreSQL refuses any row that claims a close on a verdict other than accepted (issue_completion_claims_close_requires_accepted).

Evidence: OpenAgents.AcceptedOutcome, OpenAgents.Issues.CompletionClaims, OpenAgents.Issues.CompletionClaim, OpenAgents.Issues.ClosurePolicy, priv/api-contracts/accepted-outcome-v1.json, docs/accepted-outcome-contract.md, test/openagents/accepted_outcome_test.exs, test/openagents/issues/completion_claims_test.exs, and test/openagents_web/controllers/issue_completion_claim_controller_test.exs.

THREAD-001 — A thread owns its own model authority, and names it exactly once

Status: Current

A thread is the unit of agent work: one objective, its turns, its transcript, and its budget (docs/taxonomy.md). It is account-scoped, plural, and disposable. A thread belongs to the account's owner visitor and requires no conversation, so DATA-002 is unchanged — the account still has exactly one conversation, and a thread is not one.

  • A grant names exactly one fence. inference_grants.conversation_id is nullable and inference_grants.thread_id is nullable, and inference_grant_exactly_one_fence refuses any row that names both or neither. A grant is the only way a client reaches a model without holding a provider key, so an unfenced grant would be unattributable spend and a doubly-fenced one would be spend attributed twice. PostgreSQL refuses both, and OpenAgents.Inference.Grant.mint_changeset/1 refuses the same rows earlier.

  • A fence, once set, cannot be acquired or exchanged. The immutability trigger compares both fence columns with IS DISTINCT FROM, so a thread-scoped grant cannot later acquire a conversation and a conversation-scoped grant cannot later acquire a thread. Comparing a NULL column with <> yields NULL and raises nothing, which is exactly how a machine-less grant could once have acquired a machine (priv/repo/migrations/20260819080000_allow_machineless_inference_grants.exs).

  • A thread's authority is singular. At most one active grant may name a thread, enforced by inference_grants_one_active_thread_index. OpenAgents.Threads.mint_grant/1 revokes the thread's active grants and advances threads.generation in the same transaction as the mint, so an earlier generation's token is provably stale rather than merely old.

  • Authority does not outlive the thread. Every terminal transition (OpenAgents.Threads.finish/2, OpenAgents.Threads.cancel/2) revokes the thread's active grants inside the transaction that writes the terminal row, so no terminal thread ever holds a live grant. Deleting a thread — or the account, under the DATA-004 cascade — deletes its grants with it.

    Amended 2026-08-26 (issue #106): a thread that reported can be granted again, and being granted again is what reopens it. OpenAgents.Threads.mint_grant/1 used to refuse every thread that was not open, which made the two halves of the contract contradict each other the moment a client started ending its threads honestly: every honest end is terminal, so a session could keep a thread resumable only by never saying what it did. oa coder --resume was refused before it reached the transcript it exists to replay. The mint now reopens a reported thread inside its own transaction — the report is written to the transcript as thread.reopened, the terminal columns clear, the admission cap is taken again under the same owner lock, and the generation advances — so the thread holding authority is an open thread at every instant, and reopening loses nothing that was recorded. A cancelled thread stays refused :thread_terminal: DELETE is a disposal, and a caller that used it asked for the thread to be over. A local-lane thread stays refused :thread_local_lane in every state.

  • A terminal thread says what happened, and cannot say two things. Added 2026-08-26 (issue #106). POST /api/v1/threads/{id}/report routes finish/2, which had no route and no caller in lib/ — so DELETE was the only way to end a thread, and 31 of one account's 50 most recent threads read cancelled with "The thread was cancelled before it reported." for sessions that answered correctly and exited 0. The record said the opposite of what happened. The mirror of that bug is worse, so the outcome is stated rather than inferred and the statement has to be internally consistent: the route requires an explicit status from the terminal three and never defaults to succeeded, and succeeded carries no error_code while failed and cancelled must each name one. OpenAgents.Threads.Thread.terminal_changeset/2 refuses a pair that disagrees and threads_terminal_outcome_check refuses it again in the database, so a run that failed, was interrupted, or exhausted its steps cannot be filed as a success by any writer. Reporting revokes exactly as cancelling does. A resent identical report is answered rather than refused, because a client retrying a call it never saw the answer to is not reporting twice; a different second report is refused thread_terminal, because a thread reports once and the standing report is not overwritten by a later claim.

  • A local-lane thread holds no authority, ever. Added 2026-08-25 (issue #243). threads.lane admits thread and local by check constraint, defaulting to thread — the granted lane every earlier thread came through. POST /api/v1/threads with "lane": "local" opens a transcript-only thread: its model is the bounded vendor string a local runtime serves (ollama:...), admitted against no catalog and no provider, and the open mints nothing — the response carries no grant and no token, and the fence never moves off generation 0. OpenAgents.Threads.mint_grant/1 refuses a local-lane thread with :thread_local_lane — rendered as the stable code thread_lane_local — the way it refuses a terminal one, so the no-provider-key and metering invariants hold by construction: a thread that can never be granted can never spend the account's credit or reach a provider. Everything else about it is an ordinary thread: the transcript appends and broadcasts, the transparency tier governs its readers, the admission cap counts it, and DELETE ends it. Its thread.opened event records "lane": "local", so the transcript itself says no authority backs it. test/openagents/threads/local_lane_test.exs proves both halves.

  • A thread is bounded where a bound means something. The objective is capped at 32 KB and the terminal report at 32 KB, both by check constraint; every transcript entry is pinned to openagents.thread.event.v1 with no updated_at; and a thread is open with no report or terminal with one, never both and never neither. A transcript entry carries no size ceiling, only the requirement that it says something (thread_events_payload_present_check). The 16 KB ceiling it used to carry was inherited from scv_run_events, whose payloads are a deliberately minimal projection of work stored elsewhere; this table is the work, and has to reproduce a session as a full ATIF trajectory. A single observed reasoning block is 38,791 characters, so the ceiling bought chunking and reassembly on every read rather than any real bound. What a client sends to a model is bounded by the client.

  • Authority is capped at admission. OpenAgents.Threads.open/3 refuses an account already holding maximum_open_threads_per_account open threads with :thread_quota_reached, and POST /api/v1/threads renders that as a 429 naming the limit. The cap is taken before any row is written, so a refused caller leaves nothing behind. Because a thread has at most one live grant, capping open threads caps the account's concurrent thread-scoped authority by the same number.

    Amended 2026-08-24 (issue #195): the cap holds under concurrency. The count is taken under the owner visitor row's FOR UPDATE lock, inside the transaction that inserts the thread, so two simultaneous opens at the boundary serialize and admit one thread, not two. The cap is what bounds the account's joint credit exposure (below), so a cap that a race could pass would make that bound a fiction. test/openagents/threads/credit_race_test.exs races the boundary.

  • A thread's budget is its own, and its money is the account's. OpenAgents.Threads.ceilings/0 reads the thread_grant_* settings and passes them to OpenAgents.Inference.mint/1, which otherwise applies the delegation ceilings. A delegation is one probe run the server admitted before minting anything; a thread is authority a caller asked for. The two budgets are stated separately, and neither moves the other. The cost figure is the exception: OpenAgents.Threads.ceilings/1 lowers it to what OpenAgents.Inference.Credit.remaining/1 says the account has left, so opening a second thread hands out no second allowance and an account with nothing left is refused :credit_exhausted rather than minted a grant it cannot spend. GET /api/v1 publishes both allowances, because a client that read a fixed per-thread cost cap would be reading a budget nobody is given.

    Amended 2026-08-24 (issue #195): the remainder is metered spend, and mints serialize. OpenAgents.Threads.mint_grant/1 locks the owner visitor row (after the thread row, always in that order) before reading Credit.remaining/1 and minting, so concurrent mints for one account each read the remainder at their own serialization point rather than from a shared snapshot. What the remainder deliberately does not subtract is a live grant's unspent ceiling: a parent thread holds the whole remaining balance as its ceiling, and a delegated child thread opened while it runs must still be granted usable authority, so reserving headroom would refuse every such child with :credit_exhausted and break delegation. Overlapping live threads can therefore each be ceiled at the same remainder — concurrently or in sequence, the schedules mint identical figures — and the account's joint exposure is bounded by the admission cap times the remainder, which is why the cap above is serialized. Actual spend stays honest where money moves: every call is metered into the remainder, and an exhausted account cannot mint at all. test/openagents/threads/credit_race_test.exs proves the serialized figures, the exhausted refusal under race, and the unchanged serial ceilings.

  • A thread's authority has no clock and no ceiling of its own, and spent authority releases the slot without being asked. inference_grants.expires_at, max_calls, max_total_tokens, and max_cost_microusd are all nullable, and nil is unbounded on each. A thread's grant is minted with no deadline and no call or token ceiling: it is bounded by revocation and by the account's credit, which is what max_cost_microusd carries, and by nothing else. The database still refuses a ceiling that is present and non-positive (inference_grant_positive_ceilings), because a grant that could never buy a call is a different thing from a grant with no limit. It used to carry thread_grant_ttl_seconds, 256 calls, a million tokens, and two dollars, and the reaper closed the open thread it fenced as authority_expired — each of which ended a coding session mid-work in an afternoon, with nothing wrong and nothing finished. The ceiling still clears itself: OpenAgents.Threads.reap_expired/1 runs at admission and on every read of a thread, and an open thread that has minted authority and holds none — its budget spent, or its grant revoked — becomes failed with authority_spent, so it cannot hold an account's admission slot forever. A grant that does carry a deadline, where the deadline is a security bound rather than a convenience, is still retired past it, and a lapsed token stops being live in the ledger rather than merely being refused on presentation.

  • Authority reaches only the account that opened the thread. OpenAgents.Threads.get_for_user/2 joins through the owner visitor, so another account's thread id resolves to nil and the route refuses it with the same not_found an absent id gets. The web thread viewer (OpenAgentsWeb.ThreadShowLive) resolves through the same lookup and renders nil as the same plain 404 an unknown id gets, so the browser confirms no more existence than the API, and it reads the transcript and the grant meter without ever holding a plaintext token. No route returns a grant token for a thread the caller did not open, and the token is returned exactly once, at the mint.

    Amended 2026-08-23 (issue #174): that sentence quantifies over routes, so it is enumerated rather than sampled. A plaintext grant token comes into existence in one place, OpenAgents.Inference.mint/1, and leaves OpenAgents.Threads through mint_grant/1 and open_and_mint/2,3, so every module that can hold one carries a compiled import edge to one of them. OpenAgents.Threads.GrantTokenReachTest reads those edges from each module's BEAM import table and asserts four exact sets: the modules that mint a token (OpenAgents.Threads, OpenAgents.Work.Coding, OpenAgents.Work.DelegationServer, OpenAgents.Work.Scv), the modules that receive one from a thread (OpenAgentsWeb.ThreadController alone), the routed handlers among them (the same one controller), and OpenAgents.Threads's own export table, so a new function that hands a caller a token is classified before anything can call it. It then dispatches every route the router gives that controller and requires a token in the body only at the mint. A second route that renders a grant fails there.

    Amended 2026-08-24 (issue #205): "the account that opened the thread" is now the rule for every write and for the mint, and the default rule for reads. A thread carries a transparency tier, and a thread its owner opened at a wider tier is readable by the audience that tier names — see THREAD-002, which states what widens and what does not. The token clause is untouched: fetch_readable/2 returns no grant, the reader's view of a thread carries "grant": null, and the mint still resolves through get_for_user/2.

    Amended 2026-08-25 (issue #241): the owner-scoped lookup gained a second caller. OpenAgents.Gym.record_trial/3 resolves a claimed trial thread through get_for_user/2 before linking it to a benchmark trial, so the Gym admits only threads the bearer's account owns and confirms nothing about anybody else's — ADMIN-001 names the same check at the route. It resolves and links; it never writes to the thread, mints for it, or returns it.

    Amended 2026-08-25 (issue #242): the stored linkage gained a reader. OpenAgents.Gym.fetch_trial_thread/1 resolves a thread only through a stored trial row's thread_id — a link record_trial/3 admitted through get_for_user/2 at ingest — and serves the operator-gated /gym/runs/:id transcript view (OpenAgentsWeb.GymRunLive, recheck on mount and on every event). The trial row is the only key, so an arbitrary thread id has no path in; the function reads, and never writes to the thread, mints for it, or returns a grant. This is an operator read of another account's transcript — exactly the transcripts a bearer deliberately linked to a benchmark trial, and nothing else — and ADMIN-001 names it beside the ingest check. test/openagents/gym_test.exs proves the refusals (unknown trial, unlinked trial, deleted thread) and test/openagents_web/live/gym_run_live_test.exs proves the gate.

Evidence: OpenAgents.Threads, OpenAgents.Threads.Thread, OpenAgents.Threads.Event, OpenAgents.Inference.mint/1, OpenAgents.Inference.expire_elapsed_for_owner/1, OpenAgentsWeb.ThreadController, priv/repo/migrations/20260823221415_create_threads_and_thread_events.exs, priv/repo/migrations/20260823221416_allow_thread_scoped_inference_grants.exs, priv/repo/migrations/20260825120000_add_lane_to_threads.exs, test/openagents/threads/grant_fence_test.exs, test/openagents/threads/grant_token_reach_test.exs, test/openagents/threads/local_lane_test.exs, test/openagents/threads_test.exs, test/openagents_web/controllers/thread_controller_test.exs, and test/openagents_web/live/thread_show_live_test.exs.

THREAD-002 — A thread is private until its owner widens it, and widening is a recorded act

Status: Current

A thread's transcript is the substrate the registry is meant to learn from (docs/2026-08-24-registry-network-strategy.md, §5). The consent gate lands before the collection volume, not after, so a transcript discloses nothing until the account that opened it says so.

  • The vocabulary is the one this application already has. A thread's visibility is a tier of the dark/pulse/ledger/glass ladder that OpenAgents.Transparency and OpenAgents.Forge.Visibility already use (docs/taxonomy.md). No parallel public/private enum is introduced, and OpenAgents.Threads.VisibilityTest holds Thread.visibilities/0 to a subset of Transparency.tier_atoms/0, so a fifth word cannot enter through this door.
  • Only a tier with a read path behind it is offered. threads.visibility admits dark and ledger and nothing else, by check constraint and by Thread.open_changeset/3. pulse would need a metadata-only projection of a transcript and glass a capability beyond reading one; neither exists, so neither is stored. POST /api/v1/threads refuses any other value — a tier of the vocabulary this surface cannot enforce, or a word that is not a tier — with the stable code thread_visibility_unsupported, because a client that meant to publish and did not must learn it from the code it branches on rather than from prose.
  • The default is owner-only. The column defaults to dark in PostgreSQL and in the schema, so a thread opened by a caller that names no tier — and every thread written before the column existed — is readable by its account and nobody else.
  • Widening is explicit and recorded. A wider tier reaches a thread only by being named at open/3; nothing derives one, and no other caller's action raises it. When one is named, OpenAgents.Threads.open/3 appends thread.visibility_set to the transcript in the same transaction as the insert, carrying the tier and the default it replaced. The consent decision is therefore a fact in the same append-only log as the work it governs, not a column somebody can find changed with no account of when or why.
  • A tier widens reading and nothing else. fetch_readable/2 is the only lookup a tier reaches, and it serves exactly two surfaces: GET /api/v1/threads/{thread_id} with its /events, and /threads/:id. Every write — the transcript append, the cancel, the re-mint — resolves through the owner-scoped get_for_user/2, so a published transcript is never a thread a stranger may append to, end, or spend. OpenAgents.Threads.GrantTokenReachTest asserts the caller set of each lookup from the compiled import tables, so a write that starts resolving through the tier-scoped lookup fails there.
  • A tier does not disclose the owner's money. A reader admitted by the thread's tier is served "grant": null by the API and no budget card in the viewer. The ladder is about the transcript; what the account is spending is not on it.
  • A refused reader learns nothing. A thread at dark answers a stranger with the same plain not_found — and the same PublicNotFoundError in the browser — that an unknown id gets, so the tier withholds the thread's existence and not merely its contents.
  • The audience is the one the surface can enforce. ledger admits any signed-in account holding the thread's id. Both read surfaces require an authenticated principal — the API route is bearer-authenticated and /threads/:id sits in the :authenticated live session — so no anonymous audience is claimed. A genuinely public thread would need a route outside that session, and until one exists the tier does not promise one.
  • The consent record leaves with the data. GET /data/export/account carries each thread's visibility beside its objective, its repository, and its events, so a recipient reading the document offline has the terms alongside the transcript rather than only the transcript (DATA-004).

Plugin usage inherits this and adds nothing: a plugin run is recorded as a tool.ran event on the thread that ran it, so the owning thread's tier already governs it through the same read paths. No aggregate over tool.ran exists today. When the registry's usage counter lands (issue #206), it must aggregate by joining threads.visibility and counting only rows at a tier that permits it; a counter that read thread_events without that join would republish, in aggregate, transcripts their owners kept dark.

Amended 2026-08-25 (issue #218): the tier now gates a second consumer, the benchmark workbench's trace corpus. OpenAgents.Threads.WekaExport turns a consenting thread's transcript into a WEKA v1 trace — the block-hash format AgentX replays (docs/2026-08-24-benchmark-workbench-agentx.md, section 5) — and refuses a dark thread with :consent_required. The gate is applied per thread rather than per subtree, because a consenting parent may spawn a narrower child (THREAD-003): a dark child produces no sub-agent entry, no request, no block count, and no record that it existed. corpus/2 takes a recorded thread-id set rather than running a query — a query is how a consent gate gets widened by accident — and records every refusal by id and reason beside the code revision that built the document, so a corpus is reproducible and honest about what it does not contain. mix openagents.weka.export is the only surface that writes one; no route publishes a corpus, and publication stays a separate, explicit decision. What leaves is structure and timing: prompts are cut into 64-token blocks replaced by session-salted chained hashes, remapped to session-local integers, so prefix reuse survives and content does not.

Evidence: OpenAgents.Threads.fetch_readable/2, OpenAgents.Threads.Thread.visibilities/0, OpenAgentsWeb.ThreadController, OpenAgentsWeb.ThreadShowLive, OpenAgents.DataRights.AccountExport, OpenAgents.Threads.WekaExport, priv/repo/migrations/20260824210500_add_visibility_to_threads.exs, test/openagents/threads/visibility_test.exs, test/openagents_web/thread_visibility_test.exs, test/openagents/threads/weka_export_test.exs, test/mix/tasks/openagents_weka_export_test.exs, and test/openagents/threads/grant_token_reach_test.exs.

THREAD-003 — Child threads are nested, budgeted, and typed

Status: Current

A child thread is a normal thread that names an open parent from the same account. Delegation therefore uses a nested transcript ledger: each child gets its own grant, its own budget, and its own terminal report, while the parent gains a durable thread.spawn record that links to it.

  • A child names an open, same-owner parent. OpenAgents.Threads.open/3 accepts parent_thread_id and validates it. A parent that is missing, malformed, terminal, or owned by another account is refused with a parent_thread_id changeset error, and a visibility wider than the parent is refused with a visibility changeset error.
  • A child counts toward the same admission cap. The cap already counts every open thread, and a child is a normal open thread. Counting it keeps a parent from spawning an unbounded number of concurrent children and exceeding the account's joint credit-exposure bound.
  • A child inherits its parent's visibility unless the caller narrows it. A child opened without a visibility takes the parent's visibility, and a caller that names one cannot choose a tier wider than the parent's. The parent's consent gate governs the whole subtree.
  • A child budget is bounded by the parent's remaining allowance. A child grant is minted with ceilings no larger than the parent's active grant has left in calls, total tokens, and cost, where nil means unbounded for that dimension. If the parent has no active grant or no remaining allowance in a bounded dimension, OpenAgents.Threads.mint_grant/1 returns :parent_authority_exhausted.
  • Spawning records a thread.spawn event on the parent. The parent and child events are inserted in the same transaction as the child thread, and the parent's event_count advances with it. No child event is mirrored on the parent beyond this lifecycle record.
  • Completion carries a typed report. A terminal thread now stores report_type alongside report. OpenAgents.Threads.finish/2 records the caller's report_type or defaults to outcome; cancel/2 records cancelled; the authority-spent reaper records failure. A terminal thread always has both a report and its type, and neither is inferred from silence.

Evidence: OpenAgents.Threads, OpenAgents.Threads.Thread, priv/repo/migrations/20260825054906_add_parent_and_report_type_to_threads.exs, test/openagents/threads_test.exs, and test/openagents/threads/visibility_test.exs.

Tenant deployment control plane

DEPLOYPLANE-001 — A deployment intent carries no authority

Status: Current

A deployment request states what a tenant wants deployed. Every authority decision comes from durable records instead: repository membership rechecked at each sensitive transition, the environment's protection policy, and published evidence. A request names a full 40-character commit SHA and a sha256: artifact digest; a branch or tag is provenance, never something resolved later.

The plane authenticates a human holding deployments:write or a short-lived workflow grant. forge:write is not deployment authority, and no route in this plane reaches the operator fleet-promotion surface behind deployments:promote, which FLEETPROMOTE-001 governs. A private repository is readable only by a member, and cross-repository reads, approvals, cancellations, and provider bindings are denied.

Evidence: OpenAgents.Deployments, OpenAgents.Deployments.Authority, OpenAgents.Deployments.Principal, OpenAgentsWeb.DeploymentController, OpenAgentsWeb.ApiRouteAuthority, test/openagents/deployments_test.exs, and test/openagents_web/controllers/deployment_controller_test.exs.

DEPLOYPLANE-002 — A workflow grant binds to exactly one context

Status: Current

A grant issued to a workflow is single-context and short-lived: it binds one repository, one environment where applicable, one source ref, one source workflow, and one workflow run ID, with a clamped lifetime. Presenting a grant cannot widen repository, environment, commit, artifact, or audience authority, and a workflow principal can never approve a request. Revocation takes effect before the next sensitive transition.

Evidence: OpenAgents.Deployments.WorkflowGrant, OpenAgents.Deployments.Authority, and test/openagents/deployments_test.exs.

DEPLOYPLANE-003 — Policy admits a run on exact bytes, with a durable explanation

Status: Current

OpenAgents.Deployments.Policy evaluates allowed branches, allowed tags, allowed source workflows, freeze, deployment window, artifact age, required checks, and required approvals, and persists an explanation for every rule it evaluated. A required check satisfies a requirement only when it names the same commit SHA and the same artifact digest and is younger than the environment's validity limit, so a green result cannot be replayed onto different bytes. A missing required check leaves the request pending rather than admitting it. Approvals honor separation of duties: a requester cannot approve its own request. Preview environments may supersede an in-flight request; production never supersedes implicitly.

Evidence: OpenAgents.Deployments.Policy, OpenAgents.Deployments.Protection, test/openagents/deployments/policy_test.exs, and test/openagents/deployments_test.exs.

DEPLOYPLANE-004 — One lifecycle defines every legal transition

Status: Current

OpenAgents.Deployments.Lifecycle is the only definition of legal deployment states and transitions, and transitions are enforced transactionally against the durable row. Terminal states have no successors, a run cannot skip deploying on its way to succeeded, and a run already deploying cannot be superseded. Every transition appends a sequenced deployment event whose payload is bounded and redacted, so event polling, subscriptions, and receipts cannot disclose a secret.

Evidence: OpenAgents.Deployments.Lifecycle, OpenAgents.Deployments.Event, test/openagents/deployments/lifecycle_test.exs, and test/openagents/deployments_test.exs.

DEPLOYPLANE-005 — Only an admitted, immutable execution reaches a provider, and uncertainty fails

Status: Current

A provider receives an immutable execution object built after admission. It never receives caller credentials, its idempotency is keyed by run ID, and secrets are resolved at execution time only for its own bound environment as references rather than stored values. A provider failure, exception, exit, timeout, or unknown result terminalizes the run as failed; only an explicit provider success produces a success receipt.

OpenAgents.Deployments.Worker claims queued runs under renewable leases, re-evaluates policy and membership before handing work to the provider, observes cancellation during execution, and reconciles runs whose lease expired after a crash. The worker starts only when the deployment_control_plane feature is enabled, validated by OpenAgents.RuntimeConfig; the API surface records and evaluates runs regardless.

Evidence: OpenAgents.Deployments.Execution, OpenAgents.Deployments.Provider, OpenAgents.Deployments.Providers.Fake, OpenAgents.Deployments.SecretResolver, OpenAgents.Deployments.Worker, OpenAgents.RuntimeSupervisor, test/openagents/deployments_test.exs, and test/openagents/runtime_config_test.exs.

Fleet release authority

FLEETPROMOTE-001 — Fleet promotion needs the operator scope and live operator standing

Status: Current

Promoting a commit as the OpenAgents fleet target is release authority over this system, not a tenant action. DEPLOYPLANE-001 governs a repository deploying its own code under deployments:write; this governs the OpenAgents release itself, and no route in that plane reaches it.

Two conditions authorize every promotion, and holding one is never enough: the credential carries the exact deployments:promote scope, and OpenAgents.Accounts.admin?/1 is true for the promoting account at request time. The second check is what makes operator removal effective immediately, including for an unexpired privileged token. Authority is never inferred from a login, a repository membership, a Git push credential, or forge:write. Only a current operator can be issued the scope at all, and a privileged credential's maximum lifetime is shorter than an ordinary one's.

Identity is exact. A promotion names one full 40-character commit SHA that the WAL-backed repository already contains, so a push never promotes itself and the API cannot ask for "whatever is newest". A caller-generated idempotency key names one promotion: the same key with the same bytes returns the original target, and the same key with different bytes is refused. An optional expected-current-target ID is a compare-and-set precondition, so two concurrent operators cannot unknowingly supersede each other.

The /admin/forge Promote button and POST /api/v1/admin/forge/targets are one authority path, not two implementations of one policy. Both call OpenAgents.Forge.Promotion, which calls OpenAgents.Forge.Targets.promote/4, so both write the same append-only forge_fleet_targets receipt carrying the promoting operator's identity in promoted_by, and both broadcast the same lifecycle event. The API states an intent only: the builder, classifier, direct-load, relup, and rolling-replacement lanes still own execution. A promotion publishes no image identity and admits no node; RELEASE-006 governs what a booting node may run, and this surface never widens it.

Every attempt — granted or refused — records bounded audit evidence naming the operator, the repository, the environment, the source channel, the request ID, and a digest of the idempotency key. Neither the plaintext credential nor the plaintext key is ever stored, and a status response discloses no node identity, filesystem path, or unrestricted failure detail.

Refusals carry the one /api/v1 envelope, OpenAgentsWeb.ApiError, with a stable code per refusal reason so a release client can tell "you may not do this" from "someone promoted first" from "those bytes are not in the forge".

"One authority path, not two implementations" is a claim about every caller, so the callers are the proof. OpenAgents.DependencyBoundaryTest asserts two exact sets from the compiled import tables: OpenAgents.Forge.Promotion is the only module that calls OpenAgents.Forge.Targets.promote/4, and OpenAgentsWeb.AdminForgeLive and OpenAgentsWeb.FleetTargetController are the only modules that call OpenAgents.Forge.Promotion. A third surface, or a second writer that skips the scope and standing checks, fails there.

Evidence: OpenAgents.Forge.Promotion, OpenAgents.Forge.Targets, OpenAgentsWeb.Plugs.OperatorApiTokenAuth, OpenAgentsWeb.FleetTargetController, OpenAgentsWeb.AdminForgeLive, OpenAgents.ApiTokens, OpenAgentsWeb.RouteAuthority, OpenAgentsWeb.ApiRouteAuthority, OpenAgentsWeb.ApiError, OpenAgents.DependencyBoundaryTest, test/openagents/forge/promotion_test.exs, test/openagents_web/controllers/fleet_target_controller_test.exs, and test/openagents_web/route_authority_test.exs.

Interface and release

VOICE-001 — Spoken identity is admitted before media

Status: Current

Standalone OpenAgents's first voice artifact is openagents.voice.openai.marin.v1 using native OpenAI Realtime gpt-realtime-2.1 at low reasoning effort. It is a deliberate repository-local revision of the earlier Leda direction, not a silent fallback or a change to One. Boot refuses unadmitted architecture, provider, model, voice, reasoning, or duration values. Any future custom voice, Leda cascade, or built-in replacement requires a reviewed artifact revision and regression evidence.

Evidence: OpenAgents.Voice.Config, test/openagents/voice/config_test.exs, and OpenAgents.Voice.ConfigTest.

VOICE-002 — Browser media admission cannot acquire server authority

Status: Current

Voice is default-disabled. When enabled, an active authenticated user may send only a bounded SDP offer through the same-origin, CSRF-protected endpoint. Phoenix supplies the OpenAI credential, session configuration, and stable privacy-preserving safety identifier derived from OpenAgents's local user ID. The browser receives only a bounded SDP answer; provider credentials, call identity, protected configuration, and future tool control remain server-side. Invalid identity, configuration, SDP, provider location, and provider failure all fail closed without raw provider details.

Evidence: OpenAgentsWeb.VoiceCallController, OpenAgents.Voice.OpenAI.CallClient, OpenAgents.Voice.OpenAI.CallClientTest, and OpenAgentsWeb.VoiceCallControllerTest.

VOICE-003 — Durable voice history is generation-fenced

Status: Current

PostgreSQL owns every admitted voice generation and permits at most one active generation per conversation. Provider events, response receipts, and transcript items repeat the admitted generation; both runtime matching and database constraints reject stale work. Ordered normalized events commit under a locked session row, exact provider-event retries are idempotent, immutable session provenance cannot be rewritten, and a terminal generation rejects late events.

The supervised runtime owns only live connection state. Startup or process loss makes the affected generation explicitly failed; it never invents a successful resume or transcript.

Evidence: OpenAgents.Voice, the create_voice_runtime migration, OpenAgents.VoiceSessions.SessionServer, OpenAgents.VoiceRecovery, test/openagents/voice_sessions_test.exs, OpenAgents.VoiceTest, and OpenAgents.VoiceSessionsTest.

VOICE-004 — Only bounded provider-neutral voice evidence becomes durable

Status: Current

OpenAI wire events are decoded behind the provider adapter. Audio deltas, partial transcript deltas, credentials, SDP, provider error text, and unbounded wire payloads never enter OpenAgents's durable voice domain. Final user speech may be stored; OpenAgents speech is bound to a started response receipt and is marked interrupted when barge-in supersedes it. Completion without a started receipt rolls back. Provider-reported usage remains absent when the provider did not report it rather than being invented as zero.

Call audio is the single deliberate exception, and it enters through a different door: not the provider event path but a browser upload, under the separate bounds of VOICE-012. The distinction is the point — a bounded recorded artifact is not the same as letting the provider wire into the database.

Evidence: OpenAgents.Voice.ProviderEvent, OpenAgents.Voice.OpenAI.EventDecoder, OpenAgents.Voice.ResponseReceipt, OpenAgents.Voice.TranscriptItem, and their voice runtime tests.

VOICE-005 — Microphone capture is explicit, fenced, visible, and finite

Status: Current

Only a direct START VOICE action may request microphone access. New tracks begin disabled and may transmit only while the browser peer and control channel are open, playback is usable, the user has not muted, and LiveView projects a ready state from the current server generation. Reconnect, failure, end, navigation, or hook destruction disables and stops every media track, closes the data channel and peer connection, clears remote audio, stops and finalizes any recording, and best-effort closes the server generation. Typed chat never asks for microphone access.

Recording is part of what capture means, so it is disclosed on the same terms. While recording is on, the surface that carries START VOICE states before the microphone opens that calls are recorded, stored, and readable by an operator, and names the retention window; a visible marker announces capture while it is running, and appears only while it is actually running. Recording off makes no such claim anywhere.

Evidence: assets/js/voice_controller.js, assets/js/voice_state.mjs, assets/js/voice_recording.mjs, their Node tests, the disclosure tests in OpenAgentsWeb.ChatLiveTest and OpenAgentsWeb.DataControllerTest, and assets/test/voice_state_test.mjs.

VOICE-006 — Voice controls project server truth and preserve typed OpenAgents

Status: Current

Browser peer events cannot claim a durable listening, responding, interrupted, ended, or failed state. The visible lifecycle is derived from the browser-scoped, generation-fenced PostgreSQL session projected by LiveView. Explicit interruption commits before provider cancellation. Voice admission is refused during a text turn; sending typed input while voice is active ends the voice generation first, so two OpenAgents responses cannot run in parallel. Voice failure leaves the typed conversation intact and available.

Evidence: OpenAgentsWeb.ChatLive, OpenAgentsWeb.VoiceCallController, OpenAgents.VoiceSessions, their tests, and assets/test/voice_state_test.mjs.

VOICE-007 — Every live response freezes one governed OpenAgents context

Status: Current

Automatic provider response creation is disabled. A final user transcript first becomes a complete conversation message, then Phoenix captures the exact persona/role, Blueprint, program-or-explicit-baseline receipt, tool catalog, conversation high-water mark, profile-memory snapshot, selected evidence, and composed spoken instructions. Only then may Phoenix request a response. A provider response without that immutable context fails the generation.

Voice presentation may make delivery brief and speech-appropriate, but cannot override protected identity, evidence grammar, tool authority, or completion honesty. A session refuses changed persona, Blueprint, or tool-catalog identity rather than silently mixing revisions.

Long calls stay governed rather than unbounded: when a completed response's provider-reported input size crosses the configured compaction threshold — and only at a quiet boundary with no active tool chain, no queued input, and no compaction already in flight — the host drives one text-only, tool-free maintenance response under the same frozen context, asking for a progress summary that preserves exact values and the next action. That summary is byte-bounded and durable on the session row (compaction_summary, compaction_count) before the runtime deletes old provider items it knows by id and injects one bounded system summary item. The summary is continuity evidence only: it never rewrites or displaces voice_transcript_items / messages transcript authority, the compaction response cannot re-trigger itself, pruning never happens without a persisted summary, and new person input always supersedes an in-flight compaction.

Evidence: OpenAgents.Voice.ContextCapture, voice_response_contexts, OpenAgents.Context.Composer, OpenAgents.VoiceSessions.SessionServer, OpenAgents.Voice.record_compaction_summary/3, and governed context and compaction tests in OpenAgents.VoiceSessionsTest.

VOICE-008 — Realtime function calls use the governed tool runner

Status: Current

Realtime exposes the same captured typed registry used by text turns. Model function arguments remain proposals: Phoenix validates call identity, schema, scope, authority, consent, generation, and module version through OpenAgents.Tools.Runner. Each request and normalized terminal outcome is durable before any function_call_output or continuation is sent to OpenAI. Unknown, malformed, unauthorized, stale, interrupted, and failed calls cannot become an effect claim. Durable tool steps retain the raw model arguments as user-owned, deletable conversation evidence alongside the canonical argument digest; durable provider events and operational telemetry remain digest-only and content-free.

Host bounds refuse rather than kill: a call past the per-cycle tool budget is refused with a tool_call_limit_reached continuation and one tool-free report response, never a session failure, and the composed instructions disclose that the budget exists. Every provider function_call item receives a terminal function_call_output — including cancelled calls after a barge-in — so the provider conversation never holds an orphan call the model must narrate around. Continuation payloads are byte-bounded; the durable tool step keeps the full result.

Evidence: OpenAgents.Tools.Registry.realtime_catalog/1, OpenAgents.Voice.ToolStep, voice_tool_steps, OpenAgents.VoiceSessions.SessionServer, and voice tool tests in OpenAgents.VoiceTest and OpenAgents.VoiceSessionsTest.

VOICE-009 — Text and voice share one append-only conversation authority

Status: Current

Final user transcriptions and completed OpenAgents transcripts project into the same ordered messages table as text. Provider item identity makes retries idempotent. Interrupted OpenAgents speech is cancelled evidence and never enters provider history as a complete answer; when it re-enters composed context it is explicitly labeled interrupted and non-complete, and a barge-in truncates the provider-side assistant item to the approximate playback position so the model's conversation cannot retain speech the person never heard. PostgreSQL forbids rewriting voice message content or provenance; a correction is a later message, not an in-place edit. Starting a text turn serially ends the live voice generation, so text and voice cannot produce parallel OpenAgents answers.

Evidence: messages voice provenance and transition constraints, OpenAgents.Voice.persist_transcript/3, OpenAgents.Conversations.provider_messages/1, OpenAgentsWeb.ChatLive, cross-modal chronology tests in OpenAgents.VoiceTest, and priv/sarah/evals/voice/corpus.v1.json.

VOICE-010 — New voice admission is live-governed, globally bounded, and attributable

Status: Current

The deploy flag and append-only PostgreSQL release control are independent. Only the latest open control admits a new call; draining and disabled refuse it without ending active calls or typed OpenAgents. Every session pins the exact control row. Admission serializes on a database advisory lock before counting active sessions, so simultaneous authenticated requests cannot exceed the global budget. Per-conversation attempt, one-active-generation, duration, response-token, session-token, estimated-cost, tool, and payload limits remain separate, explicit ceilings. Session budget ceilings are abuse backstops, not working limits, and their enforcement is disclosed: one host notice at 80% lets the model wrap up, and a budget-ended session records and surfaces usage_budget_reached rather than presenting as a silent disconnect.

Evidence: OpenAgents.Voice.ReleaseControl, OpenAgents.Voice.admit_session/2, voice_release_controls, OpenAgents.Voice.Usage, OpenAgents.Voice.ReleaseOperationsTest, and test/openagents/voice/release_operations_test.exs.

VOICE-011 — Voice operations are measurable without becoming a content sink

Status: Current

Operational telemetry is built from strict fields and may never contain credentials, browser/conversation identity, SDP, provider call identity, raw audio, transcript content, composed instructions, raw tool arguments/results, or provider error prose. Recording adds bytes to the product but not to this path: chunk payloads and content digests never enter telemetry, aggregate reports, or logs, while byte counts and durations remain operational fields. Browser observations accept only a finite event enum, derive a reduced browser family/major on the server, and stop at 64 events per session. Aggregate reports read only timing, state, normalized kind, browser, usage, and cost fields. Missing samples remain missing and block the canary; health cannot substitute for media, quality, browser, load, or rollback proof.

Evidence: OpenAgents.Voice.OperationalTelemetry, OpenAgents.Voice.Operations.Report, OpenAgents.Voice.ClientEvent, OpenAgentsWeb.VoiceTelemetryController, their redaction and controller tests, and test/openagents/voice/release_operations_test.exs.

VOICE-012 — Call audio is bounded, sealed, fenced evidence — never authority

Status: Current

Voice media never reaches OpenAgents: it flows browser-to-OpenAI over WebRTC while the server holds only a lifecycle sideband. A recording is therefore what one browser uploaded, not what was said. It can be withheld, truncated, or stopped at will, so it never displaces voice_transcript_items as the conversation record and never becomes evidence for a OpenAgents response.

Audio may become durable only under every one of these at once: recording is enabled and a recording key is configured, so audio is sealed at rest or not stored at all; each slice commits under the locked session row against the admitted generation, in strict sequence, with a repeat of a stored sequence idempotent and a gap refused; chunk size, chunk count, and total bytes are explicit ceilings, past which the recording becomes truncated and further slices are refused; and uploads are accepted only within a bounded grace window after the call ends, because the tail slice arrives after the recorder stops. The session is resolved from the encrypted session cookie, never from a client-supplied identifier.

A recording is stored, read, and deleted as a whole ordered concatenation; a single slice is not media. Statuses distinguish a clean upload from a truncated, abandoned, or failed one, so partial audio is never presented as a whole call. Audio carries its own retention window, shorter than the operational voice window, and is deleted by that sweep and by DATA-004 cascade.

Recording failure never affects the call: an unsupported browser, a blocked audio graph, a refused upload, or a missing key all yield an unrecorded conversation rather than a failed one.

Recording is a property of the voice surface, not a per-account setting. While it is enabled there is no opt-out flag and none may be added without changing this contract; the disclosure states the situation rather than offering a choice, and typed chat remains available and is never recorded. No account route returns stored audio and account export carries recording metadata rather than its sound, but the operator route named in ADMIN-001 unseals and streams it; the seal here defends against a stolen database, not against the person who holds the key.

Amended 2026-08-25 (issue #193). The asymmetry this contract carried is closed. voice_transcript_items.content used to rest as plaintext beside the sealed audio, so a stolen database got the words either way and the seal on the audio moved nothing. The transcript is now sealed too, under OpenAgents.ContentVault and its own key, along with voice_sessions.compaction_summary. Both are read through the schema — OpenAgents.Voice.TranscriptItem.text/1 and OpenAgents.Voice.Session.compaction_summary/1 — so no reader reaches the words by touching a field. What is unchanged is the boundary: these seals are under a key the operator holds, so they defend against a stolen database and not against the operator, exactly as the paragraph above says of the audio. The same words still rest in messages.content, which carries a generated search_vector and cannot be sealed without ending lexical recall; that is recorded in EXIT-006 and in docs/2026-08-25-encryption-at-rest.md rather than implied here.

Evidence: OpenAgents.Voice.Recordings, OpenAgents.Voice.Recording, OpenAgents.Voice.RecordingChunk, OpenAgents.Voice.RecordingVault, OpenAgents.ContentVault, OpenAgents.Voice.TranscriptItem, the create_voice_recordings migration, OpenAgentsWeb.VoiceRecordingController, assets/js/voice_recording.mjs, OpenAgents.Voice.RecordingsTest, OpenAgentsWeb.VoiceRecordingControllerTest, assets/test/voice_recording_test.mjs, and test/openagents/voice/recordings_test.exs.

ADMIN-001 — One operator reads across accounts, including call audio, and writes on an enumerated set of surfaces

Status: Current

IDENTITY-002 confines every ordinary server path to the active user's own data. /admin is the second deliberate exception after LEADERBOARD-001, and it is the opposite kind: the leaderboard publishes a narrow projection to the internet, while this reads a wide one for exactly one person.

Operator access is an allowlist of GitHub's immutable numeric IDs, never of logins — a login can be renamed and the freed name claimed by someone else, and users.github_id is already the identity root. A banned account is never an operator. The check runs on mount and again on every event, so removal from the allowlist takes effect on the connected socket rather than at the next reconnect. A non-operator receives exactly what an unauthenticated visitor receives, with no flash and no distinct status, so the surface never announces that it exists. No product surface links to it.

This contract states what that authority is, not what it ought to be. It constrains nobody; it makes the surface countable, and the proof fails when the count changes.

What the operator reads. The /admin panel shows the bounded fields of OpenAgents.Admin.Call: account display identity, call lifecycle, model, token total, transcript-item count, and recording completeness metadata. Calls with no uploaded recording are listed with the reason rather than hidden, so the panel cannot present an incomplete history as a complete one. No routed controller returns transcript content, composed instructions, tool catalogs, provider call identity, or recall material.

Decrypted call audio is the exception, and it is deliberate. OpenAgentsWeb.AdminRecordingController answers GET /admin/recordings/:id/audio for any account's recording, resolving it through OpenAgents.Admin.get_recording/1 and unsealing each chunk from OpenAgents.Voice.RecordingVault on the way out. The sealing under VOICE-012 defends against a stolen database, not against the operator, and the product says so rather than implying otherwise: the voice disclosure in OpenAgentsWeb.MemoryLive tells every account that call audio is readable by an operator. A route that hands one person another person's voice is worth naming exactly, so it is named here and enumerated in the proof.

That read is not audited, and this ledger records the absence rather than covering it. An access log written by the operator's application into the operator's database is evidence to the operator and to nobody else, so it would read as a control while constraining nothing — the failure mode docs/taxonomy.md naming rule 7 exists to prevent. Making the read accountable needs the signature or external anchor that docs/forge-operator-independence.md already names as missing from the WAL, and until that exists the honest statement is that the operator can listen and no record of it survives.

What the operator writes. The operator path is not read-only. Every write it holds is enumerated below, and the proof asserts the enumeration rather than the sentence:

  • Promoting an already-pushed commit as the fleet deploy target, from /admin/forge and from POST /api/v1/admin/forge/targets (OpenAgents.Forge.Promotion into OpenAgents.Forge.Targets), receipted with the promoting operator's identity in the append-only forge_fleet_targets ledger. Only SHAs present in the WAL-backed repository are promotable, so the surface cannot introduce code — it can only approve code that already survived the push path. Under FLEETPROMOTE-001 the token path is stricter than a browser session rather than looser.
  • Connecting and disconnecting Codex accounts and starting SCV deployments from /admin/scv/accounts (OpenAgents.SCV.CodexAccounts, OpenAgents.SCV.Deployments).
  • Forum moderation: closing, reopening, and pinning a topic and hiding or deleting a post, from OpenAgentsWeb.ForumTopicLive and from PATCH /api/v1/forum/topics/:id and PATCH /api/v1/forum/posts/:id; and approving or rejecting an identity claim from /admin/forum/claims and PATCH /api/v1/forum/claims/:id.
  • Approving or rejecting a reputation subject claim under GET /api/v1/reputation/subject-claims/pending and PATCH /api/v1/reputation/subject-claims/:id (OpenAgentsWeb.ReputationController). The decision binds an attestation subject to an account under EXIT-001; it never issues, revokes, or alters an attestation, and REPUTATION-001's verification does not read it.
  • Suspending and reinstating an agent under /api/operator/agents/:handle.
  • Creating, authorizing, recording against, and deleting artifact listings under /api/operator/artifact-listings.
  • Creating, cancelling, resuming, and replaying continual-learning jobs under /api/operator/continual-learning/jobs.
  • Retracting a memory record or an engram from /memory (OpenAgentsWeb.MemoryLive, which rechecks the operator on the event, not only on mount). The write appends: it goes through OpenAgents.ProfileMemory.forget_active/2, which supersedes rather than deletes, so a retraction is another entry in the audit trail the same surface renders and never a row that quietly stops existing.
  • Admitting or rejecting a candidate system memory, refuting a challenge against one, and correcting an admitted one, through OpenAgents.Memories.Admissions (MEMORY-011). Only a steward admits and only a steward refutes, and the steward set is this allowlist, bootstrapped to the owner's account. Challenging is the one path here that needs no authority: any account may record that an admitted claim is wrong, which is the whole reason the record exists. The write appends: every record is inserted and never updated, and a correction supersedes rather than edits, so a reversal is a second record beside the first rather than a verdict that quietly changed. The authority buys no read — the operator sees no memory of another account through this module.
  • Promoting an admitted system memory to a knowledge-base stance, through OpenAgents.Memories.Promotions (MEMORY-012). Only a steward promotes, because a promotion records the outcome of a review rather than asserts one, and the steward set is this same allowlist. The write appends: the promotion is a superseding tombstone on the claim's slug, so the claim, its evidence, and its admission record all stay readable underneath it. The authority buys no read here either — the target is named by id and refused as absent when it is not a live system row.
  • Recording Gym runs and trials under POST /api/v1/gym/runs, the lifecycle routes POST /api/v1/gym/runs/start, POST /api/v1/gym/runs/:id/trials, and PATCH /api/v1/gym/runs/:id (OpenAgentsWeb.GymRunController, which rechecks the operator on every request over the bearer scope), and reading the scoreboard from /gym and a run's page from /gym/runs/:id (OpenAgentsWeb.GymLive and OpenAgentsWeb.GymRunLive, recheck on mount and on every event). A run is a benchmark record — recipe digest, task, model, lane, reward, duration — never account data. The one cross-record link a trial may carry, a thread_id, is verified at ingest: OpenAgents.Gym.record_trial/3 admits a thread only when OpenAgents.Threads.get_for_user/2 resolves it for the bearer's account, and an unknown thread and an unowned one refuse identically, so the Gym cannot be used to confirm that a foreign thread id exists. Since issue #242 that link is also read back: the run page streams a linked trial's thread transcript to the operator through OpenAgents.Gym.fetch_trial_thread/1, which resolves only through a stored, ingest-verified trial linkage — so this surface does read another account's data, exactly the transcripts a bearer deliberately linked to a benchmark trial, and nothing else (THREAD-001 names the same reader from the thread side).

Reading a private forum board and raising a repository's transparency tier to glass are operator reads that widen with the same allowlist (OpenAgents.Forum, OpenAgents.Transparency). Raising an unlinked work record to glass widens with it (OpenAgents.Transparency.WorkDisclosure): an attempt an agent requested carries no consent-bearing link and so has no owning account to raise, and an operator is the only reader that reaches its work job's report. Neither read touches the never list, which withholds the prompt, the authority snapshot, and the credential fields from an operator too.

None of these touches an account row, a conversation, a message, or a ban. That bound is what remains of the original read-only claim, and it is the part that is true.

The enumeration is executable. OpenAgentsWeb.RouteAuthority classifies every router entry, and the proof compares the operator-class routes against a declared table; separately it compares every module that consults OpenAgents.Accounts.admin?/1, read from each module's compiled import table, against a second declared table. A new operator route or a new operator gate anywhere in lib/ fails the proof until this contract is amended to name it.

Evidence: OpenAgents.Accounts.admin?/1, OpenAgents.Admin, OpenAgents.Admin.Call, OpenAgentsWeb.AdminLive, OpenAgentsWeb.AdminRecordingController, OpenAgentsWeb.RouteAuthority, OpenAgents.AdminTest, OpenAgentsWeb.AdminLiveTest, test/openagents_web/controllers/admin_recording_controller_test.exs, and test/openagents_web/operator_surface_test.exs.

DATA-004 — The authenticated user can export and delete OpenAgents product data

Status: Current

The server resolves export and deletion only from the active local user in the encrypted session and verifies that user owns the internal storage root. Export provides canonical messages, profile memory, voice summaries, tool-step evidence (raw arguments plus digests), and the account chat backend's own runs and event stream, with explicit bounds. That export is scoped to one conversation; what an account authors outside it leaves through GET /data/export/account under EXIT-001. Exact confirmation deletes the visitor root only while text and voice are inactive; database cascades remove conversation, memory, receipt, module, collective, and voice records — call audio included, through voice_recordings' cascade to the session. The export names each call's recording — status, container, size, duration claim, digest, and that it is encrypted at rest — without embedding the audio, because a JSON export is the wrong container for Opus and base64 in a text field would be worse. No product route returns stored audio to an account; what an account gets is disclosed and exported as metadata, and deletion removes it. The one route that returns the audio itself is the operator route ADMIN-001 enumerates, and the voice disclosure states that an operator can read it. Detailed terminal voice operations purge automatically after 90 days while the minimal provenance stub follows canonical voice messages until complete deletion. Disposable semantic rows cascade with their messages, and the same transaction removes content-free semantic invalidation receipts only after their authoritative conversation no longer exists. Governed preference observations, snapshots, effects, and receipts likewise cascade only through deletion of their owning visitor root. Private experience scopes, records, evidence, patterns, and frozen banks also cascade through that root; standalone experience deletion receipts are removed in the same transaction only after the owner no longer exists. Derived graph manifests and artifacts cascade with the owner; standalone graph memberships, outbox events, cascade plans, and operation receipts are likewise removed in that transaction only after deletion of the visitor root.

Cloud memories (MEMORY-010) are the one product-data family the visitor cascade cannot reach, because they key on the retained account row rather than on the visitor root. Deletion removes them by user_id explicitly, in the same transaction, so an account that asked for everything to be removed does not keep what it asked to have remembered.

The minimal local account record (GitHub numeric ID, current login/avatar, access status, authentication timestamps, and currently the encrypted GitHub token ciphertext) is retained so deletion cannot erase a ban or bypass authorization. The token is never exported. Gate 6 must add and prove explicit disconnect/revocation and token-removal behavior; product-data deletion must not be described as removing the token until that implementation lands. A later account-erasure contract must separately define moderation retention and re-enrollment behavior.

Evidence: OpenAgents.DataRights, OpenAgentsWeb.DataController, OpenAgents.Voice.Retention, database foreign keys and purge trigger, OpenAgentsWeb.DataControllerTest, and OpenAgents.Voice.ReleaseOperationsTest.

UI-001 — Authentication gates the one-conversation interface

Status: Current

The public default route is an authentication boundary and cannot invoke OpenAgents. It exposes one GitHub login action and only bounded authentication error copy. The protected /chat route exposes transcript, contextual turn state, bounded history, composer, and only the minimal account control required to show the authenticated GitHub avatar/login and submit logout. User transcript rows use that same validated GitHub avatar projection. The browser image policy allows only same-origin/data images and the exact GitHub avatar origin. The interface contains no conversation list and no workspace/settings chrome. The sidebar (2026-08-17, an owner-directed reversal of this invariant's earlier blanket "no sidebar" clause) navigates only OpenAgents's own surfaces — memory, leaderboard, the operator panel, export — and may never list or switch conversations.

/leaderboard is the one additional route reachable without a session. It is permitted only because it makes the same guarantee the public root makes: it is read only, cannot mount or invoke OpenAgents, holds no conversation, exposes no composer or action, and creates no identity state. It is a published projection governed by LEADERBOARD-001, not a second product interface, and it does not introduce navigation chrome into the conversation.

/admin is an operator tool rather than a product surface. It is reachable only by the allowlisted operator under ADMIN-001, cannot mount or invoke OpenAgents, and holds no conversation. The panel at /admin itself only reads and pages; the writes the wider operator surface holds are enumerated in ADMIN-001 and reach no account, conversation, message, or ban. It adds nothing to the conversation interface: no link, no affordance, and no chrome, for operators and non-operators alike. Being an operator tool is not license for product chrome — the anti-references in docs/architecture.md still describe what the product does not become.

The boundary clause is enumerated rather than sampled. Per-surface LiveView tests are what let the operator half of this contract stay green while it was wrong, and they would do the same for a new product route added outside the :authenticated pipeline. OpenAgentsWeb.AuthenticatedRouteGateTest dispatches every route OpenAgentsWeb.RouteAuthority classifies :authenticated_browser without a session and requires each to refuse, so a route that serves an anonymous visitor fails before anyone writes a test for it. OpenAgentsWeb.OperatorSurfaceTest enumerates the operator half.

Evidence: OpenAgentsWeb.HomeControllerTest, the OpenAgentsWeb.ChatLiveTest surface test, OpenAgentsWeb.LeaderboardLiveTest, OpenAgentsWeb.AdminLiveTest, OpenAgentsWeb.AuthenticatedRouteGateTest, OpenAgentsWeb.OperatorSurfaceTest, OpenAgentsWeb.Router browser policy, docs/architecture.md, and docs/component-library.md.

UI-002 — Tool activity is a bounded projection of PostgreSQL truth

Status: Current

The interface renders tool activity only from the durable, already-scrubbed step row: stable step ID, sequence, public capability label, status, the step's durable raw_arguments, the bounded durable result/error, executor id and disclosure, and lifecycle timestamps. Every argument-, result-, or error-derived string is byte-capped in OpenAgentsWeb.ToolActivity before it reaches a template — collapsed titles to one bounded line, expanded payloads to a hard cap. Provider identifiers (call/item/response IDs) and private recall content never enter socket assigns or HTML. The executor disclosure remains available verbatim for every terminal step — in the row's expansion; availability, not collapsed-row placement, is the contract. PubSub carries an invalidation signal and LiveView rereads PostgreSQL. Reload reconstructs activity, and terminal turn state clears the active band while restoring the composer and cancellation state.

(Amended 2026-08-17, issue #79: previously this invariant kept raw arguments and results out of the browser entirely. The owner directed that the durable, scrubbed step values render — bounded — so titles can say what actually ran.)

(Amended 2026-08-18, issue #85: one bounded scrubbed ephemeral live projection may also render — the streamed computer-delegation chunk stream, re-broadcast by OpenAgents.ComputerActivity on the owner conversation's PubSub topic (computer_live:<conversation id>) while OpenAgents.Computer collects it. The chunk text is the controller's already secret-scrubbed output, re-bounded server-side before broadcast: per event, cumulative (the same 65,536-byte ceiling the collection enforces), and in event count, with an explicit truncation marker once capped. PubSub stays projection, never authority: nothing about the stream is persisted, reload degrades to status-only, and the durable step outcome remains the record. Computer tokens, argv, env, prompts, and paths never enter a live event, and the topic is owner-scoped by construction, so only the owner's conversation ever receives it.)

(Narrowed 2026-08-23, issue #173: "never enter socket assigns or HTML" named a population nothing closes — the assigns a LiveView sets are not declared anywhere the way a struct is. The provider-identifier clause is therefore stated over what those assigns are built from, which is enumerable. Every map projection of a durable tool-step row anywhere in lib/ is read from source and must select no column of OpenAgents.Conversations.ToolStep or OpenAgents.Voice.ToolStep whose name begins with provider_, with the forbidden set read from the schemas so a new provider column joins it automatically. Seven such projections exist: the four the interface renders, recall's two tool-step documents, and the voice response-contract check. Each publishes an exact key set, so a key added to any of them fails until this contract names it. The recall-content half of the sentence stays a claim about the durable already-scrubbed row rather than an enumeration.)

Evidence: OpenAgents.Conversations.list_tool_step_activity/1, OpenAgents.Voice.list_tool_step_activity/1, OpenAgentsWeb.ToolActivity, OpenAgents.ComputerActivity, OpenAgentsWeb.ChatLive, tool activity tests in OpenAgentsWeb.ChatLiveTest, OpenAgentsWeb.ToolActivityTest, OpenAgents.ComputerActivityTest (whose exact live-event key sets pin the ephemeral projection), OpenAgentsWeb.ChatWorkSurfacesAbsentTest (which pins that /chat renders none of it), and the projection enumeration in OpenAgentsWeb.ToolActivityProjectionTest.

UI-003 — Product surfaces render only through the sanctioned component library

Status: Current

OpenAgents's interface is built from OpenAgentsWeb.UI components over Basecoat primitives vendored at a pinned tag and styled by the OpenAgents pack. Every web import exposes that one module; product surfaces compose its primitives with surface-specific layout classes from the sanctioned style pack. No component accepts provider identifiers or private recall content as an attribute; tool activity reaches event_header only as the bounded projection UI-002 sanctions, so UI-002 cannot be violated through a primitive. Basecoat's JavaScript is never loaded and the account menu uses the native popover API, so the identity control works without custom client-side script. Where Basecoat has no equivalent, the primitive wraps the browser's own control rather than reimplementing it: audio_player/1 is a native <audio controls> in an OpenAgents-styled box, keyboard operable and announced by the user agent, and it requires an accessible name because a page of recordings is otherwise a page of identically announced players. The shared corner radius, the self-hosted Geist faces, exactly two owned themes, and the reserved semantic color meanings hold across every component. The system preference selects light or dark through prefers-color-scheme; it does not introduce a third palette. Depth is limited to the sanctioned lift, halo, and state-ring tokens. Adopting an additional Basecoat component requires a docs/component-library.md change and an explicit per-component import. The application exposes one system, light, and dark preference control. A synchronous, content-free bootstrap applies the stored choice before first paint and synchronizes changes across tabs. A unique response-scoped CSP nonce admits only that bootstrap; script-src does not allow arbitrary inline code. Apps SDK UI glyphs are preferred; the pinned Heroicons fallback has an explicit inventory and no current product call sites.

Evidence: assets/vendor/basecoat/README.md, assets/css/openagents.css, priv/static/fonts, OpenAgentsWeb.UI, OpenAgentsWeb.ComponentCatalog, OpenAgentsWeb.UITest, OpenAgentsWeb.UIGalleryLiveTest, test/openagents_web/component_catalog_test.exs, test/openagents_web/icon_affordances_test.exs, test/openagents_web/live/components_live_test.exs, test/openagents_web/home_controller_test.exs, and assets/test/css_contract_test.mjs.

LEADERBOARD-001 — The public board publishes one bounded projection

Status: Current

IDENTITY-002 confines every other server path to the active user's own data. The leaderboard is the single deliberate exception, and it is an exception to publication rather than merely to cross-account reads: unauthenticated visitors and crawlers can read it.

What may be published is exactly the fields of OpenAgents.Leaderboard.Entry: a rank, the GitHub login, the GitHub name, the validated GitHub avatar URL, and one non-negative integer token total. Nothing else crosses the account boundary — no conversation, turn, receipt, or voice-session identifier, no model identifier, no message, transcript, memory, or recall content, no activity timestamp, no typed/spoken split, and no priced cost. The struct is the contract: a field added there is published to the internet.

Totals are derived only from the three planes that already hold a merged total: turn_receipts.usage, voice_sessions.usage, and — for thread-fenced grants only — inference_grants.usage, joined to the account through the thread's owner visitor. Provider steps, voice response receipts, tool-step invocation counts, off-path shadow-program runs, and conversation-fenced grants (whose chat-lane spend the turn-receipt plane already claims) are excluded, so no account is credited twice and none is credited for work it did not drive. Accounts appear only while active, not withheld by users.public_leaderboard_opted_out, and above zero tokens; banned accounts and legacy browser-only visitors never appear. Deleting product data under DATA-004 removes an account from the board by cascade, without a separate erasure path.

PostgreSQL stays authoritative. The board is computed once per interval by a single process and pushed to local subscribers, because a public surface has unbounded anonymous viewers and a per-socket reread would turn one busy voice call into a database amplifier. A lost cache costs a recompute, never data.

Evidence: OpenAgents.Leaderboard, OpenAgents.Leaderboard.Entry, OpenAgents.Leaderboard.Server, test/openagents/leaderboard_test.exs, OpenAgents.LeaderboardTest, and OpenAgentsWeb.LeaderboardLiveTest.

OBSERVABILITY-001 — Telemetry is bounded, content-free, and never authoritative

Status: Current

Immutable domain receipts remain the authority; operational telemetry is only a lossy health projection, and versioned evaluation reports remain separate release evidence. OpenAgents telemetry accepts only finite plane/status/surface vocabularies, bounded public identifiers, counts, and durations. Message, transcript, prompt, instruction, memory, argument, result, payload, person, and secret values are refused and never become labels, even as hashes.

Release read-back recomputes aggregate plane states and zero-tolerance leakage, consent, provenance, executor-disclosure, and attribution-reconciliation checks from authoritative PostgreSQL records without selecting private content. A metric cannot prove an answer or target-system effect. Nonzero zero-tolerance checks block release; stuck work is an explicit warning requiring review.

Evidence: OpenAgents.Observability, OpenAgents.Observability.Readback, OpenAgents.Observability.ReleaseGate, OpenAgentsWeb.Telemetry, test/openagents/observability_test.exs, and OpenAgents.ObservabilityTest.

RELEASE-001 — Schema precedes traffic

Status: Current

The production image runs all pending Ecto migrations before starting the HTTP server. Health is successful only when PostgreSQL answers.

Evidence: Docker CMD, OpenAgents.Release, the /status route, and HealthControllerTest.

RELEASE-002 — Secrets remain runtime-only

Status: Current

Session, database, provider, and GitHub OAuth credentials enter through ignored local runtime configuration or Secret Manager and are absent from source, the Docker build context, and image build arguments. Staging mounts only staging GitHub secret names through a dedicated runtime identity; production values and its prepared identity are distinct and remain unmounted until production cutover. Missing or environment-mismatched GitHub configuration fails startup without printing any credential value. The GitHub token-encryption key is runtime-only because the application retains delegated tokens as encrypted server-side ciphertext. The Cloud Logging default sink excludes only OpenAgents OAuth callback request entries so the platform cannot persist authorization-code or state query values; application and audit logging remain enabled.

Evidence: OpenAgents.GitHubOAuth.RuntimeConfig, OpenAgents.GitHubOAuth.RuntimeConfigTest, config/runtime.exs, .gitignore, .dockerignore, the openagents-oauth-callback-requests logging exclusion, and ops/ci/release-smoke.sh.

VAULT-001 — Each vault seals under its own key, so rotating one never unreads another

Status: Current

The application holds four hand-rolled encryption vaults — OpenAgents.Accounts.TokenVault for GitHub access tokens, OpenAgents.Machines.TokenVault for computer tokens awaiting pairing claim, OpenAgents.Voice.RecordingVault for call audio, and OpenAgents.ContentVault for private text nobody searches — and each seals under its own configured key. Rotating one vault's key never makes another vault's records unreadable or unverifiable, because no vault reads another vault's key to seal. A vault whose own key is absent fails with a typed configuration error at its boundary rather than silently borrowing key material that rotates on someone else's schedule; that silent borrow is exactly what #192 found, where the pairing vault read the GitHub vault's active key and the documented GitHub rotation would have unread every outstanding pairing.

Decrypt-side compatibility is narrower than sealing and stays explicit: the pairing vault opens with its dedicated key first and falls back to the GitHub keyring — active key plus :github_token_decryption_keys — because that is the only key material its historical records were sealed under, including every record sealed while config/runtime.exs bridged an unset MACHINE_TOKEN_ENCRYPTION_KEY to the GitHub key. That bridge is no longer the production configuration: the dedicated secret is provisioned, and the bridge is now only the fallback for a node that comes up without it. It cannot stay load-bearing unnoticed, because RuntimeConfig.validate/1 refuses a staging or production boot in which any two vault keys are equal — presence alone never distinguished a provisioned key from a bridged one, which is how the bridge outlived by a week the single deploy it was written for (#253). The bridge survives in config/runtime.exs so configuration still assembles; what it can no longer do is serve. The fallback never rewraps: the only reader nulls token_ciphertext in the same transaction as a successful open (IDENTITY-011), so no record survives a read, and every fallback-sealed record is claimed or expired within one ten-minute pairing lifetime.

Rotation posture per vault, as rehearsal 4 of docs/forge-exit-rehearsals.md requires: the GitHub vault rotates losslessly through its keyed envelope and keyring rewrap; the pairing vault's own rotation loses at most one ten-minute window of unclaimed pairings, which retry; the recording vault has no keyring, so rotating its key strands prior recordings — a bounded, recorded loss, not a silent one. The content vault has no keyring either, so rotating CONTENT_ENCRYPTION_KEY strands every column it seals, together. That is the price of one vault over several columns rather than one per column, and it is paid deliberately: the separation that carries weight is credential from content, and splitting the content columns from each other would buy a finer blast radius at the cost of one production secret per column while every one of them stays readable to the same operator through the same application.

The content vault is required rather than optional. RuntimeConfig.validate/1 refuses a staging or production boot without its key, because the alternative is a node that accepts a voice transcript, a compaction summary, a preference observation, or a project note and then cannot seal it. There is deliberately no bridge to another vault's key of the kind config/runtime.exs still offers the pairing vault: that bridge is what #192 found and #253 repeated, and a vault added after both should not reintroduce it.

Amended 2026-08-25 (issue #193). OpenAgents.Forge.AtRest.sealed_columns/0 names the column each vault seals, and test/openagents/forge/at_rest_test.exs reads each column back with raw SQL after a real write, so "sealed" is checked against PostgreSQL rather than against the vault's own unit tests.

Amended 2026-08-25 (issue #193, second pass). The fourth vault is OpenAgents.ContentVault, and it seals content rather than credentials: voice_transcript_items.content, voice_sessions.compaction_summary, preference_observations.summary, and project_notes.body. Its seals carry the column and the row identity as additional authenticated data, so ciphertext lifted from one row or one column does not open as another's sentence. The plaintext columns those replaced survive one more release, empty, because dropping a live column mid-roll breaks the nodes still writing into it; the contract migration removes them the way machine_pairings.user_id was removed a release after its last reader.

Amended 2026-08-25 (issue #253). "Its own key" is now checked rather than described. RuntimeConfig.validate/1 compares every configured vault key and refuses a staging or production boot on any duplicate, naming the borrowing setting and the setting it borrowed from. Distinctness is the predicate rather than "did the boot bridge fire", so it also refuses a dedicated secret provisioned by hand with another vault's value. Development and test are exempt: both already configure distinct keys, and a shared key there strands nothing a person would retry.

Evidence: OpenAgents.Machines.TokenVault, OpenAgents.Accounts.TokenVault, OpenAgents.Voice.RecordingVault, OpenAgents.ContentVault, OpenAgents.Forge.AtRest, OpenAgents.RuntimeConfig.validate/1, config/runtime.exs, test/openagents/machines/token_vault_test.exs, test/openagents/accounts/token_vault_test.exs, test/openagents/forge/at_rest_test.exs, test/openagents/forge/key_rotation_test.exs, and test/openagents/runtime_config_test.exs.

RELEASE-003 — Every published hostname can establish LiveView

Status: Current

Production accepts the primary PHX_HOST plus explicitly configured HTTPS aliases for Phoenix origin checks. Invalid, insecure, or path-bearing origins fail startup rather than silently weakening socket validation.

Evidence: OpenAgents.RuntimeConfig, config/runtime.exs, and test/openagents/runtime_config_test.exs.

RELEASE-004 — CI runs on owned infrastructure only, and gates every release

Status: Current

The target release gate permits no hosted CI: no GitHub Actions workflows, no GitHub-hosted or third-party runners, and no repository automation, secrets, or scheduling handed to external CI compute. All checks run on owned machines. The full local matrix binds unit, browser, distributed cluster, direct transaction, release, relup, version-chain, interrupted-install, rolling replacement, and repository-contract evidence to the exact candidate SHA. Relup and rolling coordinators refuse a stale or absent receipt before they change a node. .githooks/pre-push invokes the same gate. A bounded, logged emergency override exists only for operator-directed recovery.

Staging evidence remains a separate later gate. A local receipt does not claim that the candidate passed staging or authorize a production release.

The absence clause is checked by reading the repository rather than by running the gate. ops/ci/gate.sh and OpenAgents.Forge.GateReceiptTest establish that the owned gate runs and binds its receipt to a candidate SHA; neither would notice a .github/workflows/ci.yml appearing beside them. OpenAgents.HostedCIAbsenceTest reads the paths every hosted provider configures itself from and fails when one exists.

Evidence: ops/ci/gate.sh, .githooks/pre-push, OpenAgents.Forge.GateReceipt, OpenAgents.Forge.GateReceiptTest, ops/relup-proof/run.sh, ops/relup-proof/version-chain.sh, ops/relup-proof/kill-during-install.sh, and OpenAgents.HostedCIAbsenceTest.

RELEASE-005 — Every code change has a fail-closed deployment class

Status: Current

Direct BEAM candidates use an exact-fleet prepare, canary, apply, verify, commit, and rollback transaction. An application transition between any two concrete X.Y.Z versions uses a two-way relup, versioned process state, node-by-node health checks, and reverse installation. Every structural or unclassified candidate uses digest-addressed rolling replacement with readiness drain, remaining-capacity and quorum checks, exact rejoin verification, and last-known-good image recovery. A failed relup or replacement aborts before another node changes.

A packaged relup describes the two revisions it was built from. The appup is derived from both builds' compiled modules, so the relup carries an instruction for every module whose code differs, and packaging refuses when the generated relup omits one. A node therefore cannot install part of a revision while reporting itself converged. A version that a node already unpacked from different artifact bytes is refused rather than reused.

The reverse path restores the from release rather than a fixed one. Each direction's target state schema travels in the appup, so a downgrade migrates process state to the schema the from release compiled — including a pair whose schemas match, which keeps its schema instead of being forced back to schema 1. Reverse verification checks release status, node readiness, and that exact schema before it restores permanence, and a downgrade that is given no target schema refuses instead of guessing.

All deployment workers remain disabled until isolated staging proves their complete provider and topology. Current means that the local mechanism and its refusal and recovery paths exist; it does not authorize staging or production. OpenAgents.Forge.RelupDeployment.run/2 has no production caller, and the release gate runs neither ops/forge/package-relup.sh nor ops/relup-proof/install-proof.sh.

Evidence: OpenAgents.Forge.Deployment, OpenAgents.Forge.RelupDeployment, OpenAgents.Forge.RelupNode, OpenAgents.ReleaseState, OpenAgents.Release.Appup, OpenAgents.Forge.RollingReplacement, test/openagents/forge/relup_deployment_test.exs, test/openagents/forge/relup_node_test.exs, test/openagents/release/appup_test.exs, test/openagents/cluster/code_change_test.exs, test/openagents/forge/rolling_replacement_test.exs, and docs/operations/release-deployment-fallbacks.md.

RELEASE-006 — A rolling replacement admits only authority-bound image identities

Status: Current

Before the first node is replaced, the rolling coordinator publishes the authorized rolling identity — the exact source SHA, the exact image digest, the previous pair, and the exact expected node set — onto the newest needs_rolling_replace Forge target. That published record is the only thing that widens what a booting node may run.

While a roll is active a node may serve only when its booted image is exactly the live target's image identity or exactly the published rolling identity. A node matching neither is admitted by nothing durable: it stays out of readiness and out of the load balancer unless it can converge on the live target's artifact. Matching the SHA alone is not enough, and neither is matching a digest that no target authorized.

Publishing the same identity again resumes an interrupted roll and preserves every recorded observation. A different identity is accepted only while no node has yet been observed under the published one, so an in-flight roll can never be redirected under running nodes. As each node rejoins, its exact observed SHA and image digest is recorded against that authority; a node the coordinator rolled back records the previous identity instead.

Settlement to live is bound to the same authority. It requires the published record, a result carrying the authorized SHA, image digest, previous pair, and exact expected node set, and an exact-identity observation from every expected node. A roll that ended with any node on another identity refuses with rolling_nodes_not_converged and leaves the target needs_rolling_replace, which is recoverable by rerunning the roll and auditable from the target row. Settlement clears no evidence: the authority and its observations remain on the settled row.

No part of this path is an operator flag change or a manual restart of OpenAgents.Forge.BootConverge. A node that boots into the authorized image enters readiness on its first convergence attempt, and the worker's own periodic attempt follows the target to live after settlement.

Evidence: OpenAgents.Forge.RollingReplacement, OpenAgents.Forge.Targets, OpenAgents.Forge.BootConverge, test/openagents/forge/rolling_boot_convergence_test.exs, test/openagents/forge/rolling_replacement_test.exs, test/openagents/forge/target_lifecycle_test.exs, test/openagents/forge/boot_converge_test.exs, and docs/operations/production-deploy-runbook.md.

RELEASE-007 — The release image keys toolchain layers on pinned inputs only

Status: Current

A container layer's cache key includes every ARG and ENV declared above it in the same stage, whether or not the instruction reads them. A value that moves with the source — the candidate SHA, its commit timestamp, the release version — therefore rebuilds everything below it. Declaring OPENAGENTS_BUILD_REVISION at the top of a stage reinstalls the operating system, Node.js, Codex, and OpenCode for every candidate, so two adjacent revisions share nothing.

Every per-candidate value is declared as late as the build allows. In the builder stage the Debian snapshot, the pinned Node.js toolchain, Hex, rebar3, mix deps.get, mix deps.compile, the npm install, and the Tailwind and esbuild install sit above OPENAGENTS_BUILD_REVISION and SOURCE_DATE_EPOCH, which enter immediately above the first application source layer. OPENAGENTS_RELEASE_VSN enters immediately above the COPY VERSION that already keys those layers. In the runtime stage the Debian snapshot, the pinned Geist faces, the pinned Codex package, the pinned OpenCode binary, and the generated locale sit above SOURCE_DATE_EPOCH. Those layers key on their own checksum-pinned inputs, so two adjacent source revisions reuse all of them.

Lateness never costs identity. OpenAgents.BuildInfo reads OPENAGENTS_BUILD_REVISION at compile time, so the revision is still declared before mix compile and the packaged release still carries the exact candidate SHA. SOURCE_DATE_EPOCH still reaches the runtime image as an ENV. Both publishing paths refuse an image whose embedded revision or whose org.opencontainers.image.revision label is not that exact SHA, and the release gate runs the ordering proof in its contracts stage.

Evidence: OpenAgents.BuildInfo, ops/deploy/build-image.sh, ops/staging/publish-candidate.sh, ops/ci/contracts.sh, and test/openagents/release/image_layer_cache_test.exs.

RELEASE-008 — A relup refuses a topology OTP cannot inspect, before it installs

Status: Current

:release_handler.install_release/1 first builds the set of processes it will suspend, code-change, and resume. For every running application it asks :supervisor.get_callback_module/1 for the application's top supervisor, and that function reads the process state as a supervisor record. An application whose top process is an Elixir DynamicSupervisor, a Horde supervisor, or a bare GenServer returned from Application.start/2 raises badrecord there. release_handler reports cannot find top supervisor and drops that supervisor from the set it upgrades, so the application's own top process is never suspended and never code-changed while the release installs around it. :release_handler.check_install_release/1 never performs this walk, so it cannot refuse the transition.

OpenAgents.Forge.RelupTopology performs the same walk first. OpenAgents.Forge.RelupNode.check_topology/2 is the coordinator's first fleet step, ahead of staging, unpacking, and check_install, so a refusal transfers no artifact and reaches no point of no return. The node keeps its previous permanent release, and because the current release never changed the coordinator attempts no reverse installation. The refusal is bounded and explicit: it names each incompatible application and its registered supervisor, and the deployment receipt records that exact reason against the exact candidate identity, for example check_topology:incompatible_topology:libring:HashRing.Supervisor.

A refusal is a classification, not a fault to route around. It means OTP release handling cannot express this fleet's topology, so the candidate belongs on the digest-addressed rolling replacement lane, which does not depend on release handling and remains eligible for the same bytes. The release gate proves the refusal in its relup_topology stage against the running libring application, whose HashRing.App.start/2 returns a DynamicSupervisor registered as HashRing.Supervisor. Widening the preflight to admit such an application is not a fix; starting an OTP supervisor as that application's top process is.

Evidence: OpenAgents.Forge.RelupTopology, OpenAgents.Forge.RelupNode, OpenAgents.Forge.RelupDeployment, test/openagents/forge/relup_topology_test.exs, test/openagents/forge/relup_deployment_test.exs, ops/ci/gate.sh, and docs/operations/release-deployment-fallbacks.md.

RELEASE-009 — A candidate's lane is chosen from the fleet's topology verdict

Status: Current

A deployment lane is a decision taken in front, not the residue of a failure. Before any node is touched, OpenAgents.Forge.DeploymentLane.classify/2 folds three inputs into one lane: the build manifest's structural classification, the hot-load allowlist, and the fleet's own relup topology verdict read from every member by fleet_topology/1. The chosen lane, the reasons that chose it, and the verdict it was chosen against travel together, and the rolling target carries all three.

The verdict is a property of the running fleet, not of the candidate bytes, so it is a runtime read rather than a gate artifact. The release gate runs on a builder that is not the fleet, and a fleet node can restart into a different application set between the gate and the deployment, so the only reading true at the moment of choosing is the one taken then. The gate keeps proving the RELEASE-008 refusal in its relup_topology stage.

The read fails closed in every direction. A member that cannot be reached, that raises, or that answers with anything but a report counts as unreadable; an empty member list is unread rather than unanimous; and an absent verdict is treated as unsupported. Only a fleet where every member answered and no member named an application OTP release handling cannot inspect supports relup.

A fleet that cannot support relup therefore never enters the relup lane. The candidate is classified onto digest-addressed rolling replacement with the verdict recorded as its reason — topology_incompatible:libring:HashRing.Supervisor for the concrete case RELEASE-008 describes — instead of entering the lane and refusing on its first preinstall step. That refusal is unchanged and remains the backstop: this invariant decides, and RELEASE-008 still guards.

The relup lane also requires its caller to admit it, which OpenAgents.Forge.HotLoader does not. RELEASE-005 keeps the relup workers disabled until isolated staging proves their provider and topology, and OpenAgents.Forge.RelupDeployment.run/2 still has no production caller, so the coordinator sees only the direct and rolling lanes and a relup-shaped candidate records relup_lane_unadmitted. Neither condition is an operator flag: the verdict comes from the fleet and the admission is a named argument.

The verdict is content-free. It carries a count of fleet members and a count of unreadable ones, never their names, alongside the bounded application-to-supervisor entries OpenAgents.Forge.RelupTopology already produces.

Evidence: OpenAgents.Forge.DeploymentLane, OpenAgents.Forge.HotLoader, OpenAgents.Forge.RelupTopology, test/openagents/forge/deployment_lane_test.exs, test/openagents/forge/hot_loader_test.exs, and docs/operations/production-deploy-runbook.md.

STATUS-001 — The status page publishes one bounded, content-free projection

Status: Current

The public /status page and /api/status publish exactly one projection (OpenAgents.NetworkStatus, schema-versioned): cluster membership and quorum, Raft membership, per-node release/hot-load versions, uptimes, and counts. Counts only, never content — no computer names, job goals or ids, conversation data, provider identifiers, or internal node names/addresses (nodes render as stable positional labels). It shares the leaderboard's UI-001 posture (read-only, cannot mount or invoke OpenAgents) and renders through the sanctioned component library (UI-003).

The page must render DURING incidents: nothing in the projection may require quorum, the database, or a full fleet — every gathered field degrades independently (an unreachable node reports as unreachable; a failed count is absent), and the per-node fan-out is time-bounded and briefly cached so page traffic cannot become an rpc storm. Legacy JSON pollers of /status keep the old health payload via content negotiation until they migrate to /health or /api/status.

The published key set is exact, not a shape a test pattern-matches. Every key path OpenAgents.NetworkStatus publishes, nested sections and list elements included, is enumerated against a set this contract declares, so a key added anywhere in the projection fails until someone decides it may go to an anonymous caller — the posture LEADERBOARD-001 gets from OpenAgents.Leaderboard.Entry's field set. Degrading may not introduce a key either: a failed read is absent, never an error string.

A list-element key path is published only while that list has an element, so the enumeration follows the data in both directions. independence.export.gaps has one: EXIT-001 records the trace family blocked, because POST /api/v1/traces accepts an ATIF upload and no route reads one back. Its family, status, and issue paths are therefore declared, and an anonymous caller reads that this deployment holds a kind of record it cannot yet hand back, and which issue tracks that. Publishing the gap is the point of the disclosure: a gap nobody outside can see is one nobody outside can hold us to. Should the list empty again, the three paths come back out — the enumeration follows the data, and it fails in both directions until someone decides.

The independence disclosure also carries the anchor's own address (/.well-known/openagents-forge-anchor.json), which is a fixed path and not instance data: it is where a reader goes to hold the forge to EXIT-005, and a disclosure that named a gap without naming the surface that closes it would be asking the reader to go looking.

It carries one more decision (issue #246). independence.deployment publishes how far the code assembling this projection is from the head of the ref its proofs ran against: proven_ref (refs/heads/main, a fixed ref name and not instance data), known, and behind. A count of commits is a count, so the content rule is unchanged and the shas that count lies between stay off this page — EXIT-006's proof turns red for one, and this decision does not relax that. It belongs in the same bounded projection for the reason the disclosure does: a status page assembled by code 57 commits behind the ledger reports health for surfaces the reader cannot reach (#187), and nothing else on this page can tell them so.

What the projection carries beside counts is the bounded public SCV activity band (scvs: a derived public id, a label, a status, a weight, and one bounded activity line), the forge deploy lane (short shas, statuses, timings, module counts, and the promoter's role), and the independence disclosure (OpenAgents.Forge.Independence, EXIT-006). No computer names, job goals or ids, conversation data, provider identifiers, commit messages, module names, operator identities, or internal node names or addresses.

Evidence: OpenAgents.NetworkStatus, OpenAgentsWeb.NetworkStatusLive, OpenAgentsWeb.Plugs.StatusProbeCompat, OpenAgents.NetworkStatusTest (the published key set included), and OpenAgentsWeb.NetworkStatusLiveTest.

TRANSPARENCY-001 — Public transparency surfaces publish per-repo leveled projections

Status: Current

The public transparency surfaces — /api/changelog and the forge web UI (/<owner>/<repo>, /<owner>/<repo>/commit/:sha, /<owner>/<repo>/tree/:ref/*path, /<owner>/<repo>/blob/:ref/*path — addressed exactly like the GitHub URLs they replace, with the owning account as a literal route scope rather than a wildcard first segment, so no other path on the domain is shadowed) — publish bounded projections of the forge receipt chain and repository content at an explicit per-repo disclosure level (OpenAgents.Forge.Visibility: :l0 dark → :l1 pulse → :l2 ledger → :l3 glass). The level map is operator-owned configuration (:forge_public_visibility), never derived from request data; a repo without a configured level is :l0 and its surfaces 404, indistinguishable from a repo that does not exist. Ledger surfaces (:l2) may publish shas, summaries, changed-file paths, module and node counts, timings, deploy results, WAL sequence numbers, and principal roles; browsable source and diff bodies require :l3.

A private repository publishes documents, not history. openagents is private and runs at :l2. Below :l3, the blob view serves only paths on the operator-owned published allowlist (:forge_public_paths), and only at the current default-branch head: an allowlisted path at an arbitrary ref is a 404, because publishing one document must never become a window into every past revision of that file, or into the repository's history. Adding a path to that allowlist is a deliberate publication decision, and operator documentation (runtime configuration, deployment mechanics, operator identifiers) stays off it — docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md is never published.

Bounds that hold at every level: no secrets or credentials beyond what the repository content itself carries (RELEASE-002 keeps secrets out of the repo), no cross-user conversation or memory content, no operator identity (role prefixes only — OpenAgents.Changelog and the commit view publish the principal's kind, never its id), no internal node names, and every git read and receipt scan is size- and count-bounded with honest truncation markers (OpenAgents.Forge.Browse caps blobs, diffs, messages, and listings). An entry whose visibility is l1 renders without its sha or links until its disclosure_after passes — the security-embargo lane — and is shown, never silently omitted.

Projections are derived, append-only, and never authority: changelog_entries rows join to receipts but the pushed commit, the WAL, and the receipt rows remain the only truth about what shipped (A7); deleting or down-leveling a public entry never alters them. The pages share the leaderboard's UI-001 posture (read-only, mount without a session, cannot mount or invoke OpenAgents), render only through the sanctioned component library (UI-003), and the timeline is briefly cached so anonymous traffic can never become a query storm (LEADERBOARD-001's amplifier rule). STATUS-001 is unchanged: /status stays content-free; content publication happens only on these surfaces and only per the repo's configured level.

Which surfaces those are is read from the router, not from this list. Every route OpenAgentsWeb.RouteAuthority classifies :public_read under the repository scope is behind one of two gates, and both memberships are read from compiled import tables:

  • Disclosure level. Repository content — the repo home, a commit, a tree, a blob, and a pull request's diffs — reaches OpenAgents.Forge.Visibility through OpenAgentsWeb.RepositoryAccess, which is the one composer of the dial for the web surfaces. OpenAgents.Changelog, OpenAgents.Reputation, and OpenAgents.Settlement read the dial for their own published projections; those five modules and OpenAgentsWeb.ChangelogLive are its only readers.
  • Repository readability. The issue tracker — issues, labels, milestones, projects, and the pull-request index — resolves through OpenAgents.Repositories.get_visible_by_path!/3 (REPOSITORY-001). Those surfaces are readable exactly when the repository is; the disclosure dial governs source and history, not them.

An anonymous route added under the repository scope fails until this contract says which gate it is behind, and a handler that loses its gate fails with it.

Work in progress is disclosed field by field, not record by record. The tracker surfaces above are readable exactly when the repository is, which settles who may open an issue and settles nothing about how much of an attempt, a work job, or a deployment receipt that issue then shows. An attempt carries a branch and a revision; a work job carries a prompt, a budget, and a report; an evidence edge names an environment. Some of that is what public transparency is for and some of it restates a private repository in a place the repository's own gate does not cover, so the unit of decision is the field.

OpenAgents.Transparency.WorkDisclosure is that decision, written down once: per family, the tier that first exposes each field, and the columns no tier exposes in any form. Three rules hold over it.

  • Every column is classified. Each column of forge_assignments, work_jobs, and issue_evidence is either the source of exactly one scheduled field or a member of that family's never list, never both and never neither. A new column fails the enumeration until somebody decides, which is what keeps the schedule from falling behind the schema.
  • The rungs mean the same thing everywhere. pulse says work of a named shape ran and how it came out, and names no ref, revision, receipt, or place; ledger adds those, which is exactly where this contract already puts shas, paths, counts, and timings; glass adds only the work job's own output — the report, the usage, the model — and reaches only the account the work belongs to and an operator; dark removes the record from the projection rather than leaving an empty shell that still says it existed.
  • The never list is not a fourth rung. The prompt, the goal, the delegation, the authority snapshot, the owner node, the credential delivery fields, the conversation, and an evidence edge's actor string are withheld from every viewer including an operator, because publishing them would restate a private repository, an internal node name, or a principal's id — each of which the bounds above already refuse.

A reader's rung is their relationship to the repository, not to the record: an operator is glass, a member is ledger, and every other reader who got this far is pulse. artifact_links carries the ceiling and the consent, so Transparency.effective_tier/2 raises the account that requested the attempt to glass and resolves a revoked link to dark; an attempt an agent requested names no account and so has no owner to raise. Evidence edges inherit their attempt's link, so revoking an attempt takes its receipts with it.

Repository authority stays stronger than every rung. Repositories.readable_by/2 runs first and raises, so a tier can only narrow what a reader who already passed it sees. A record whose link and column both say glass, in a repository that went private, is invisible to a non-member — the one case no rung of this ladder can reach, and the case the proof exercises. Every rung assertion runs on a public repository, where readable_by/2 admits everybody and the tier is the only gate; a private repository would have proved the tier worked when the repository gate was doing the work.

ArtifactLink.artifact_types/0 gained attempt, work_job, deployment, and trace, and PostgreSQL now holds that vocabulary and the ref-kind vocabulary as check constraints rather than leaving both to a changeset a direct writer can skip. trace has no producer: the vocabulary admits one, nothing writes one, and the enumeration asserts exactly that.

(Amended 2026-08-23, issue #173: /<owner>/<repo>/tree/:ref/*path was already served and gated when this contract listed three forge paths, and the tracker surfaces were public without the contract saying what governed them. Both are now stated.)

(Amended 2026-08-24, issue #149: the tracker rule said which readers reach an issue and nothing about how much of the work on it they see. The field-by-field schedule above is that missing half.)

Evidence: OpenAgents.Forge.Visibility, OpenAgents.Forge.Browse, OpenAgents.Changelog, OpenAgents.Changelog.Entry, OpenAgentsWeb.ChangelogLive, OpenAgentsWeb.CodeRepoLive, OpenAgentsWeb.CodeCommitLive, OpenAgentsWeb.CodeTreeLive, OpenAgentsWeb.CodeBlobLive, OpenAgentsWeb.RepositoryAccess, OpenAgentsWeb.ChangelogController, their tests, the surface enumeration in OpenAgentsWeb.TransparencySurfaceTest, OpenAgents.Transparency.WorkDisclosure, OpenAgents.Transparency.ArtifactLink, the artifact_links type and ref check constraints, the transparency_tier check constraints on forge_assignments and issue_evidence, and the field-by-field enumeration in OpenAgents.Transparency.WorkDisclosureTest.

REPOSITORY-001 — GitHub identity names repositories; OpenAgents owns stored snapshots

Status: Current

Every hosted namespace retains one immutable GitHub user or organization ID. GitHub slugs are projections and old slugs remain aliases after a rename. Reserved product-route segments cannot become namespaces. A database repository row resolves every browser, API, and Git request to one opaque storage key; the initial OpenAgentsInc/openagents.com repository keeps the historical openagents.com key so existing WAL and object storage remain authoritative.

Repository creation atomically records the repository, owner membership, idempotency receipt, and durable provisioning work. A GitHub import freezes an authorized branch-and-tag ref map, stores no GitHub credential, persists the accepted objects through the forge WAL, and schedules no later synchronization. OpenAgents becomes the source of truth for the imported snapshot. Public ready repositories permit anonymous Git reads. Private reads and every write require an admitted repository principal, and read-only members cannot push.

Amended 2026-08-21 (workspace-wide issue and project lists): who may read a repository is one composable predicate, OpenAgents.Repositories.readable_by/2 — public and ready, or a membership in a reading role — and the surfaces that answer "which repositories may this reader see" compose it rather than restating the join. That is OpenAgents.Repositories, OpenAgents.Issues, OpenAgents.Projects, and OpenAgents.Notifications.

Amended 2026-08-23 (issue #166): the sentence above used to say every surface that lists or resolves a repository, which is more than its proof covers and more than is true. Roughly thirty modules join the repositories table, and most reach a row by an identifier a caller already passed authorization for — a milestone's repository, a stack entry's repository, a pull request's repository. Those are not visibility decisions and do not compose the predicate. Nothing enumerates which joins are which, so a module that does make a visibility decision with its own restated join would not fail any proof here.

Amended 2026-08-23 (issue #175): the two kinds are now separated by the code rather than by a reader, and the separation is enumerated by OpenAgents.Repositories.VisibilityJoinTest.

A visibility decision starts from something the caller supplied — an owner and a name, or a listing with no prior authorization — and ends with a row. An ownership reach starts from a row the caller was already authorized for and follows repository_id; it decides nothing and owes nothing. Every visibility decision either composes readable_by/2 or is one of five sites that states the rule itself for a principal the predicate does not model, each named below.

  • Path resolution is five exports and no more. OpenAgents.Repositories.get_visible_by_path!/3 and visible_by_path/3 apply the caller's own predicate; get_public_by_path!/2 applies it with an anonymous reader; get_writable_by_path!/3 applies the write predicate; and get_by_path!/2 applies none. The exports matching *_by_path* are an exact set, so a sixth way to turn a caller-supplied path into a row is classified before anything can call it. The callers of the two that do not apply the caller's predicate are exact sets too: OpenAgentsWeb.DeploymentController for the unfiltered resolver, whose :workflow, :operator, and :system principals are not users and are gated by OpenAgents.Deployments.Authority; and OpenAgents.Issues, OpenAgents.Projects, OpenAgentsWeb.CommentController, OpenAgentsWeb.IssueController, and OpenAgentsWeb.OgImageController for the anonymous one.
  • Listing composes the predicate. The modules that compose readable_by/2 are OpenAgents.Repositories, OpenAgents.Issues, OpenAgents.Projects, OpenAgents.Notifications, OpenAgents.DataRights.AccountExport, OpenAgents.Forge.Anchor, and OpenAgents.Plugins.ForgeSource — seven, not the four the amendment above named. The last two are anonymous surfaces and must name exactly the repositories an anonymous reader already reaches: the published WAL anchor (EXIT-005), and the plugin registry index, where a private repository's manifest would disclose the repository the same way a published anchor would.
  • The predicate's terms live in one file, plus four stated exceptions. Every site in lib/ naming a repository's visibility or lifecycle_state against "public" or "ready" is classified, and the four that decide reach outside OpenAgents.Repositories are OpenAgents.Forge.GitHTTP (Git transport admits :operator, :machine, and :assignment principals the predicate does not model), OpenAgents.Deployments.Authority (the deployment plane's non-user principals), OpenAgents.Reputation (the attestation transparency tier, which is disclosure rather than row reach), and OpenAgentsWeb.RepositoryAccess (a narrower file-level allowlist layered above row admission it takes from get_visible_by_path!/3).

Amended 2026-08-24 (issue #182): the :machine principal the Git transport admits is now reachable.

repository_machine_grants was a complete, enforced authorization input with no writer. OpenAgentsWeb.Plugs.ForgeGitAuth accepted a paired computer's smct_ token and assigned %{kind: :machine, id: machine.id}, OpenAgents.Forge.GitHTTP routed that principal to OpenAgents.Repositories.machine_access?/3 for both read and write, and that predicate required a repository_machine_grants row that no controller, LiveView, route, or API could create. So every Git request a paired computer made answered 404 unknown repository, indistinguishable from an unauthorized one, and the tests passed because they inserted the row themselves.

The mechanism is kept and given its surface rather than removed. IDENTITY-010's assignment credential is repository-and-branch scoped and lives as long as one assignment; a computer that clones or fetches outside an assignment has no other path, and narrowing the principal set would have taken that away.

OpenAgents.Repositories.grant_machine_access/4 and revoke_machine_access/3 are the write and the withdrawal, and OpenAgentsWeb.ComputersLive is the surface, on the /computers page the owner already uses to approve and revoke a computer. No route is added: the authority is in the handler, which is where a route table cannot see it, so neither identifier the event carries selects anything on its own. The computer resolves through OpenAgents.Machines.get_machine/2 against the acting account, and the repository through that account's own owner or maintainer membership, so a foreign computer, a repository this account does not administer, and an identifier that names nothing are one refusal (IDENTITY-002). grant_machine/4 is private behind that entry point, so there is no way to reach the write without passing an acting account for both halves. The LiveView reaches no OpenAgents.Repo.

The grant is standing authority, so it owes a withdrawal, and there was none: revoke_machine_access/3 deletes the row and audits it as repository.machine_grant.revoked beside the existing repository.machine_grant.updated. The audit carries the account that created the grant, which is otherwise lost with the row. Revoking the computer itself still ends every grant's effect without deleting any — machine_access?/3 joins the computer's status and token expiry — and that remains the all-or- nothing path.

Evidence for this clause: OpenAgents.Repositories.grant_machine_access/4, OpenAgents.Repositories.revoke_machine_access/3, OpenAgentsWeb.ComputersLive, test/openagents_web/live/computers_repository_access_test.exs, and OpenAgents.Forge.GitHTTPTest, which obtains its grant through that entry point and then clones and pushes with the computer's own credential over real HTTP.

Three restatements were removed rather than declared, and two were wrong. OpenAgents.Issues.get_issue_by_path!/3 and OpenAgents.Projects.get_project_by_path!/3 each carried a copy of the public half that omitted lifecycle_state, so an issue or project in a repository that had not finished provisioning resolved there and nowhere else. OpenAgents.SCV.Deployments carried a structural clone that admitted any membership row rather than one in a reading role, and resolved the path without the namespace-alias join a rename leaves behind. All three compose the predicate now.

The role filter inside readable_by/2 is a guard for a role that does not exist yet: every role repository_memberships_role_check admits is a reading role, so removing the filter reddens nothing. The vocabulary is pinned against that constraint instead, and a fifth role fails until someone says whether it reads.

Amended 2026-08-23 (issue #185): the one instance of that residue is closed. OpenAgents.DataRights.AccountExport's push-receipt and deployment joins used to select a repository's owner and name with no predicate at all, so an export named the current owner and name of a private repository the account had been removed from, or of one renamed after they left. All three now join the same readable_by/2 subquery the module's repository_work_export/1 composes, and the module states one rule for all four collections that render a path: a repository's current owner and name travel only where the predicate admits the repository to that account. What the rule does to the record differs by family and is stated rather than left to each query — an account-keyed record survives with a null repository, because a push receipt is the account's own evidence under EXIT-005, and a repository-keyed record under repository_work is withheld entirely, because it has no meaning apart from its repository. OpenAgents.DataRights.AccountExportTest proves both halves for each collection.

What is still not enumerated: a listing that applies no predicate at all names no term and calls no resolver, so it passes every test above. Closing the one instance does not close the class — a predicate-free listing added tomorrow would still pass every test here, and only a reader would catch it. docs/2026-08-23-invariant-proof-audit.md records the residue.

Reading across repositories obeys the same rule as reading one: the workspace-wide lists at /issues and /projects join their tables to that predicate, so no filter, search term, or page number they accept can surface a row from a repository the reader could not open directly. The duplicate that list_visible_repositories/1 held had already lost the ready half and disagreed with the paged read about the same repository; there is one copy now.

Amended 2026-08-22 (explicit issue and project repository scope): production contexts accept a repository or a resource that already carries its repository identity. No context function selects a default repository or grants membership as a side effect. The Projects V2 API uses /api/v1/repos/:owner/:repo/projectsV2; every project, item, and field query includes that repository. Optional bearer authentication lets a member read a private repository's issues and projects, while anonymous readers and nonmembers receive 404 Not Found. Every write still requires forge:write and a writable membership in the same repository.

Evidence: OpenAgents.Repositories, OpenAgents.Repositories.Provisioner, OpenAgents.Repositories.Importer, OpenAgents.Forge.GitHTTP, test/openagents/repository_lifecycle_test.exs, test/openagents/repositories/provisioner_test.exs, test/openagents_web/controllers/repository_controller_test.exs, test/openagents_web/controllers/issue_controller_test.exs, test/openagents_web/controllers/project_controller_test.exs, test/openagents/issues_workspace_test.exs, test/openagents_web/live/issue_workspace_live_test.exs, test/openagents_web/live/project_workspace_live_test.exs, test/openagents/repositories/visibility_join_test.exs, and test/openagents/forge/git_http_test.exs.

API-001 — Every OpenAgents extension field is published before it is served

Status: Current

The GitHub-shaped API under /api/v1 grows OpenAgents-specific fields in one namespaced object per resource. GitHub-shaped keys keep their exact shape, so a GitHub client sees an additional openagents object and nothing else, and every OpenAgents field lives inside it.

Discovery is mechanical, not tribal. GET /api/v1 enumerates each extension field with its type, its enum values, its owning version, and the endpoints it belongs to. A response carrying an extension names the namespace in the x-openagents-extensions header. A filter the root document lists is refused by the endpoint that names it when the value falls outside the published enum, with a stable field-level 422.

These are enforced, not merely followed. The governance test reads the root document and the live responses and fails on any disagreement: a field served inside openagents that the root document does not enumerate, a documented field no response carries, a documented filter an endpoint accepts any value for, or a published enum that has drifted from the value the context derives. A governance rule nothing enforces would be a contract with no proof.

Derived fields state their sources, including whose visibility. issue.openagents.progress is derived from three records the reader may already read, each through the authority that record answers to: an attempt holding the issue in forge_assignments, admitted at pulse by OpenAgents.Transparency.WorkDisclosure and withheld when its own tier is dark or its artifact link is revoked; a thread bound to the issue, narrowed by OpenAgents.Threads.readable_by/2 (THREAD-002); and a board column, narrowed by OpenAgents.Repositories.readable_by/2, the one predicate every repository surface composes. So neither a column on a board in a private repository, nor an owner-only session, nor a withheld attempt ever becomes a fact about a public issue. Each input also says when it stops counting — a passed deadline, a quiet session, a column somebody moves — so a claim cannot read as work forever. The filter and the field compose the same expression, so a listed issue always reports the value it was listed under.

Evidence: OpenAgentsWeb.ApiExtensionController, OpenAgentsWeb.IssueJSON, OpenAgents.Issues, test/openagents_web/controllers/api_extension_governance_test.exs, test/openagents_web/controllers/api_extension_controller_test.exs, test/openagents_web/controllers/issue_controller_test.exs, and test/openagents/issue_progress_test.exs.

CONTRIBUTION-001 — The agent front door is derived from the application

Status: Current

Every deployment publishes one participation contract in two representations: /agents.md for a reader and /agents.json for a client. Both are rendered from OpenAgentsWeb.ContributionContract, so they carry the same contract identifier, version, revision, and digest and cannot drift apart by editing one of them. GET /api/v1 points at both and republishes that digest, so an agent that starts at the API description finds the contract without guessing a path, and an agent receipt can record which instructions it followed.

A consumer detects a breaking change from the identifier. The major version is part of contract, matching the other published contracts here: while it reads openagents.contribution.v1 every difference is additive, and a breaking change publishes a new identifier. digest is the SHA-256 of the document with its own digest removed and object keys sorted, so a changed digest under an unchanged identifier means the wording or a derived value moved.

The document is derived rather than written beside the application. Each published request carries the classification of the authority that owns its surface, and only that one: /api/v1 requests carry the principal, family, and error contract from OpenAgentsWeb.ApiRouteAuthority, which is proven against what the enforcing pipeline does to an anonymous request, and every other request carries the class, principal, and scope from OpenAgentsWeb.RouteAuthority. Publishing both for one route would let the document contradict itself. Credential scopes come from OpenAgents.ApiTokens.allowed_scopes/0, and the base URL is the origin the request arrived on, so a staging deployment describes staging.

The contract advertises nothing that does not exist. It names the capabilities an agent would reasonably try and that no route serves, and the proof fails the moment one of those routes starts resolving, so implementing a listed absence forces the list to be corrected. It never directs a push to GitHub: the remote it publishes is admitted by ops/ci/push-remote-check.sh and every target it names as refused is refused by that same guard.

It carries no instance data. The bytes are identical for an anonymous and an authenticated reader, and private repositories and issues appear in neither representation; private data is reached only by an authenticated call to a route the contract names.

Evidence: lib/openagents_web/contribution_contract.ex, lib/openagents_web/controllers/agent_front_door_controller.ex, lib/openagents_web/controllers/api_extension_controller.ex, and test/openagents_web/contribution_contract_test.exs.

REPOSITORY-002 — Development pushes go to the forge, never to the mirror

Status: Current

This repository's own commits reach the forge first. The forge records each push in the durable WAL and serves what the WAL holds, so GitHub is a projection of the forge in the same sense that a slug is a projection of a GitHub ID. A push sent straight to GitHub inverts that: the WAL never sees the objects, and nothing reports the divergence until a clone disagrees with the site.

A mirror is running. mirror_url/1 reads :forge_mirror_urls, which is empty in config/config.exs but is set from OPENAGENTS_FORGE_MIRROR_URLS_JSON in config/runtime.exs, and production sets it for openagents.com, so MirrorWatch publishes a per-repo state rather than off. mirror_now/1 is a git push --mirror, a force push of every ref, so the mirror overwrites whatever a direct push left on GitHub rather than merging with it. That is the reason this contract admits no direct GitHub push: a push that lands there is not merged later, it is erased.

This paragraph read the other way — that no mirror was configured — until 2026-08-25, while production had one. A contract that says the destructive thing is switched off, while it is switched on, is worse than one that says nothing, so what is configured is checked here against the running node rather than against config/config.exs alone.

ops/ci/push-remote-check.sh admits only forge hosts and refuses every other remote, whatever URL form it takes. .githooks/pre-push runs it before the release gate, since where a push is going costs nothing to check and the gate costs minutes. A bounded, logged override exists for operator-directed recovery, such as mirroring by hand while the forge is unreachable.

The check is a guard, not a deployment: it refuses a wrong destination and makes no claim about the candidate. ops/dev/install-push-guard.sh installs it at Git's default hook path, so a clone refuses the wrong destination without also owing a release-gate receipt for every push; a machine that sets core.hooksPath runs the guard and the gate together instead. mix precommit runs the installer in --ensure mode, so a clone becomes guarded on the way to its first push without anyone having read this entry; --ensure never fails the build, because a machine that has chosen core.hooksPath or that keeps its own pre-push hook has made a decision the installer will not overrule. A clone that never runs precommit is still unguarded, which is why AGENTS.md states the rule as well.

Evidence: ops/ci/push-remote-check.sh, ops/dev/install-push-guard.sh, .githooks/pre-push, OpenAgents.Forge.Pushes, OpenAgents.Forge.MirrorWatch, and test/openagents/push_remote_contract_test.exs.

REPOSITORY-003 — Every accepted push replays onto an empty cache

Status: Current

The WAL is the durable push authority and each node's bare repository is a disposable projection of it, so an entry that cannot re-materialize is lost data, not a slow start. A node that loses its cache must be able to rebuild the repository from seq 0 alone.

Replay applies one entry at a time and moves the refs to the post-state that entry recorded before the next entry runs. It has to. git bundle unbundle writes objects and no refs, a ref_update entry carries no payload at all, and git receive-pack re-runs push admission policy — expected-old-OID locks and shallow-boundary checks — that was already decided when the push was accepted. Replayed against the wrong ref state git refuses the request, a refusal discards the entry's whole object quarantine, and receive-pack exits 0 while doing it. Converging after every entry replays each request against exactly the ref state its client saw, which is the state the WAL recorded.

An entry's exit status is therefore not evidence of anything. Each entry proves its outcome instead: every object ID it introduces must exist before its refs move. An entry that cannot prove it does not advance the applied sequence. The node then rebuilds from seq 0, and a rebuild that also cannot prove it fails closed with a 503 rather than serving a repository missing commits.

A bundle entry states the shallow graft only when it records a shallow key. An import records one, including an explicit empty list for a complete clone; a GitPlane.batch_update_refs/3 batch records none, and that silence leaves the graft alone. Reading silence as "no boundaries" ungrafts a shallow repository mid-replay, after which git walks past the boundary into parent commits the WAL never held and every later entry fails on them.

No entry format changed to make this hold, so entries written before it replay under it unchanged: the contract is about how replay reads the log, not about what pushes write. receive.shallowUpdate stays off, so an accepted push cannot move the graft either, and the boundary an import records remains the only one.

Amended 2026-08-23 (issue #179): a recorded boundary is not the only boundary there is. Entries written before the shallow key existed record nothing, so a log seeded from a shallow fetch states no graft at all and replay had none to write — which left the projection holding a commit whose parent it did not have, unwalkable and therefore unservable, while every ref tip resolved. The graft is now reconciled against the objects rather than read only from the log: after replay, a commit whose parent is absent is treated as a boundary and added to whatever the log recorded. It runs only when the projection cannot be walked, and is gated on the applied sequence, so a current cache pays for one traversal and a cache damaged before this existed repairs itself on the next read rather than on the next deploy.

Evidence: OpenAgents.Forge.Sync, OpenAgents.Forge.Repos, OpenAgents.Forge.CacheReadiness, test/openagents/forge/wal_replay_test.exs, test/openagents/forge/sync_test.exs, and test/openagents/forge/independence_test.exs.

EXIT-001 — The export ledger matches the surface in both directions

Status: Current

One operator runs this forge, so "you can leave with your work" is a claim a user cannot check by inspection. OpenAgents.DataRights.ExportInventory turns it into a ledger with four statuses — portable, partial, blocked, and not_user_data — and the ledger is enforced against the surface rather than maintained beside it.

Coverage is derived, not curated. Every resource family OpenAgentsWeb.ApiRouteAuthority.families/0 publishes must appear, in both directions, so a family reaching /api/v1 without someone deciding whether a user can export it fails the build, and a family the API drops leaves no stale claim behind. Families that leave through routes outside /api/v1 — Git transport for repository content, the DATA-004 exports for conversations and memory, and GET /data/export/account for the forge-owned and forum-owned records an account authors — are listed alongside them.

The ledger is deliberately pessimistic, because an unproven portability claim is the kind of claim this repository does not make. portable requires a named mechanism and a named proof, and owes no issue. partial means the records are reachable and nothing here shows an account getting its own records back; it owes an open issue. blocked means the account cannot read its own records at all, and it is probed rather than asserted, so a fix that lands without updating the ledger turns the proof red. not_user_data claims nothing and states why.

The probes run against a private repository the account owns, and they check both directions: a read that stopped returning the owner's records fails, and a read that started working fails until the ledger says so. The comment, label, milestone, assignee, issue_label, and issue_assignee families were blocked until they resolved the repository through the same visibility predicate every other repository surface composes; they are portable now, and this ledger records the change rather than trailing it.

GET /data/export/account is the account-scoped export, and the probes round trip it rather than reading its source: one forum post and topic, one thread with a transcript entry, one push_receipt, one box lease and run, one computer, one agent link, one deployment request, one pull_request, one stack with its entry, one issue_dependency, and one reputation attestation whose subject the account established are seeded and read back through the route in an authenticated session. A family whose record stops coming back turns this red, and so does a receipt returned under a principal that is not the requesting account. push_receipt is probed there rather than against the route inventory: what the account gets back is its own forge_pushes rows, matched exactly on the user:<account-id> principal a person's push records. A repository-scoped read is published beside it — GET /api/v1/repos/{owner}/{repo}/pushes (#167), which serves the WAL's own entries and the EXIT-005 chain link git push printed to the pusher — and it is proven by its own test rather than by this probe, because the question here is what an account gets back.

pull_request, stack, and issue_dependency key on a repository rather than on an account, so the export's repository_work section is the one read on this surface that crosses repositories, and authorization rather than enumeration is what it has to get right. Each query filters on the column naming the authoring account and joins OpenAgents.Repositories.readable_by/2, the predicate every per-repository read composes, rather than restating a second rule. Both halves are proven separately in test/openagents/data_rights/account_export_test.exs: a record whose authoring column names the account, in a private repository the account is not a member of, is withheld, and another account's records in a repository this account can read never appear. Dropping the readable_by join turns the first red while every other assertion still passes.

Amended 2026-08-23 (issue #185): a repository's current owner and name are the repository's rather than the account's, and the export applies one rule to all four collections that render a path. push_receipt, deployment request, and deployment approval are keyed on the account, so the record comes back whatever the repository says now — with "repository": null where readable_by/2 no longer admits it, keeping the storage_key, wal_seq, and refs an EXIT-005 chain link needs. repository_work withholds the record instead, because a pull request separated from its repository is not a record an account can use. Nothing moves off portable: an account still gets every record it authored, and what it stops getting is a name that was never its own. The document says so in its own not_included section under the family repository_identity, so a recipient reading the file offline is not left to infer why a receipt names no repository.

Ownership of a migrated forum post is decided, not guessed. Two identities resolve to an account and no third: user:<account-id>, which every topic and post written on this surface carries, and any actor_ref the account holds a linked claim on in forum_actor_links. A post under an unclaimed, pending, or rejected legacy identity is not exported, because nothing has established it is that account's writing, and forum_actor_links' unique index on actor_ref means two accounts cannot both resolve one legacy identity. The claims themselves travel in the document at every status, so an account can see what it asked for and what the operator decided.

No family is blocked or partial today, which is a result rather than a default: #142 opened the private-repository metadata reads, #143 exported the forge-owned and forum-owned families, #165 added the cross-repository read, and #171 closed the last gap. reputation was that gap, and it was never an enumeration problem. An attestation names its subject with a bare string the issuer supplies, so what was missing was a binding rather than a query. The binding is reputation_subject_claims, and it resolves a subject the way forum_actor_links resolves a legacy forum identity: a claim an account makes, an operator decides, and only a linked claim resolves. Its unique index is on subject_id alone rather than on the kind and the string together, because an attestation names its subject with the bare string — so one string resolves to at most one account, and two accounts cannot both answer to it. The attestations travel under repository_work behind the same readable_by/2 join the other three repository-keyed families use, and the repository and private transparency tiers stay behind the membership test OpenAgentsWeb.ReputationController applies, so widening the export never widens disclosure. What the export still omits it names in its own not_included section rather than leaving a recipient to infer it.

The subject kind lives on the claim rather than on the attestation, and that follows from REPUTATION-001 rather than from convenience. An attestation's subject_id sits inside the Ed25519-signed claim, so a subject_kind column on reputation_attestations would either stay outside the signature — a field a verifier must not trust — or change the claim shape and invalidate every signature already published. Three kinds stay distinct, because collapsing them is what produced a bare string: account, whose subject is this forge's own user:<account-id> reference and which the table checks without an operator; forum_actor, which carries the forum_actor_links row that established a legacy identity; and agent, which carries the agents row. One CHECK constraint states all three rules in SQL, so the shape is the database's decision rather than a changeset's, and a row inserted past every changeset is still refused.

Evidence: OpenAgents.DataRights.ExportInventory, OpenAgents.DataRights.AccountExport, OpenAgentsWeb.ApiRouteAuthority, test/openagents/data_rights/export_inventory_test.exs, test/openagents/data_rights/account_export_test.exs, test/openagents/reputation/subject_claim_test.exs, and docs/forge-operator-independence.md.

EXIT-002 — Served state is checkable against the WAL with no database

Status: Current

OpenAgents.Forge.Pushes acknowledges a push only after the WAL accepts it, so the WAL is the record of what was pushed and each node's bare repository is a projection of that record. Whether the projection still matches is therefore a question with an answer, and OpenAgents.Forge.Verification computes it from the WAL and the repository alone.

The verifier accepts a repository nameopenagents.com, or the owner/name path a person clones — or a Repository.storage_key. OpenAgents.Forge.RepoRef is the one place the two are told apart, and it answers in a fixed order: a string under which the WAL holds a log is a storage key and resolves to itself, reaching no database; anything else is a name and is resolved through the same repository mapping the serving path uses, namespace aliases included. Only the resolved key is checked against the WAL, and the report names both the requested reference and the resolved key so an operator sees the mapping.

A reference that settles on no single repository stops there, and stops with a finding that says so: repository_not_found, repository_name_ambiguous for a name two repositories answer to, or repository_lookup_unavailable when the lookup a name needs could not be made. None of them turns the string into a path. That is the difference this rule now carries: before it, a name went straight into a path segment, and a name whose repository was stored under a different key reported wal_unreadable — the finding that means the log is gone — about a log that was intact. Repos.allowed_repos/0 returns names, so the list an operator reads before verifying is a list of references the verifier accepts.

(Amended 2026-08-25, issue #190: the live forge's first repository is stored under an opaque key while its configured name is openagents.com, so the documented first step of rehearsal 2 verified nothing and said the log was missing. REPOSITORY-001's sentence about that repository keeping the historical openagents.com key describes the migration's seed and no longer describes the live row.)

Independence here is structural, not a promise: the WAL-and-repository check itself reaches no database, because a verifier that queried PostgreSQL for the log or the bare repository would be asking the operator to confirm the operator. The owner/name resolution does query the repository table, but it is a separate, exercised step. The proof reads the module's compiled import table and fails on a call into OpenAgents.Repo, Ecto, or Postgrex, so the property cannot decay through an added convenience.

Five disagreements are distinct findings, and each is exercised by breaking it. An entry the store cannot produce is entry_object_missing. An entry whose bytes no longer hash to the key the index recorded is entry_digest_mismatch, because WAL entry keys are content-addressed. Entries that are not the contiguous run from zero are entry_sequence_broken, which is how a removed or renumbered entry surfaces. A ref the repository serves that the WAL never recorded, or records differently, is served_refs_diverged — checked in both directions, so a smuggled ref is caught as well as a moved one. An object the WAL says a push introduced that the repository cannot produce is object_missing.

What this does not do is stated as plainly as what it does. Entries are not signed and no commitment to the log is published outside the operator's own storage, so an operator who rewrites an entry, its key, the index, and every chain link after it produces a self-consistent log and this reports it clean. Content addressing and EXIT-005's chain make tampering evident, not impossible. What the chain adds is that a rewrite can no longer be local, which is what makes one externally held link enough to check a whole prefix — verify/2's :anchor option is that check. EXIT-005 returns that link to the pusher at acknowledgment, so the party who pushed can hold one; nothing is published to a stranger yet. docs/2026-08-23-forge-wal-anchoring.md stages the publication, and #151 carries it.

REPOSITORY-003 proves that an accepted entry re-materializes onto an empty cache. This proves that divergence between the WAL and what is served is detectable. The first is about replay; the second is about detection, and neither substitutes for the other.

Amended 2026-08-25 (issue #251). The WAL is shared and the projections are not, so a node is routinely a few entries behind a push another node accepted seconds ago. Reporting that as served_refs_diverged and object_missing, which is what this did, made the two findings that mean "the served state contradicts the record" fire on a healthy forge under ordinary push traffic — so the check that would catch a real one is the check an operator learns to skip. Lag and divergence are now separated, and the separation is a property of the log rather than a tolerance.

A projection is located on the log: the greatest sequence whose recorded post-state refs it serves exactly is its position, and head_seq - position is how far behind it is. A projection at some sequence carries no finding and reports status: :behind; a projection at no sequence reports served_refs_diverged against the state it claims, and status: :diverged. Lag runs one way only, which is what keeps the boundary tight: a node that has not replayed an entry is missing what that entry introduced and can never serve a ref the log has no record of, a ref at a value the log never recorded, or a value recorded before the state it is serving. object_missing is bounded by the position rather than waived: every object an entry at or below it introduced must still be present.

The applied-sequence marker OpenAgents.Forge.Sync writes is the projection's own claim, so it bounds the search rather than answering it. Only sequences at or above it are candidates, which means rolling the marker forward makes the check stricter rather than quieter, and rolling it back to -1 admits only older states the log itself records. A marker naming a sequence the log does not have is applied_seq_beyond_log, which is how a WAL truncated at the tail — contiguous 0..n-1 afterward, and so invisible to every other check here — surfaces on a node that had already applied past it.

Two cases are named rather than hidden. A projection rolled back to a state the log passed through, with its marker rolled back to match, is reported as behind: from the WAL and the repository alone, that is the same observation as a node mid-replay. A deleted cache is an empty projection at sequence -1, which is what a node that has never replayed looks like; the report still says how far from the log it is, and REPOSITORY-003 is what brings it back.

Because a node answers only for its own projection, a report names the node, its applied sequence, its position, and its distance from the head. verify_cluster/2 combines the members' answers into a verdict lag cannot move: :converging while nothing contradicts the log and some member is behind or silent, and :diverged only when a member contradicts the log, when a member cannot see a repository the others verified, or when two members report different chain links at the same sequence. A scheduled pass (#179) can therefore publish findings without publishing the fleet's replay window.

Evidence: OpenAgents.Forge.Verification, OpenAgents.Forge.RepoRef, OpenAgents.Forge.WAL, OpenAgents.Forge.Repos, test/openagents/forge/independence_test.exs, test/openagents/forge/verification_test.exs, and test/openagents/forge/repo_ref_test.exs.

EXIT-003 — Recovery comes from the WAL, and the mirror is strictly lossy

Status: Current

GitHub is a mirror and never authority. That direction is load-bearing, and it holds only while nothing on the recovery path can consult the mirror. The proof reads the compiled import tables of OpenAgents.Forge.Sync and OpenAgents.Forge.Repos and fails on a call into OpenAgents.Forge.MirrorWatch or into the mirror functions of OpenAgents.Forge.Pushes, so a lost forge cannot quietly promote GitHub to source of truth through a fallback someone added in an incident.

What survives a lost forge splits cleanly. From the WAL: every ref, every object, and the push record — OpenAgents.Forge.Pushes.reconcile_receipts/1 re-derives every forge_pushes row after the table is emptied, at the WAL's own sequence numbers and principals, because receipts are derived from the WAL and never a second authority. From the mirror: every commit, tree, blob, tag, and advertised ref, and nothing else. mirror_now/1 is a git push --mirror, which carries a ref map and a pack. No sequence, no principal, and no push time travels with it, so a forge restored from its mirror serves the same source with no evidence of who produced it. The proof asserts both halves by losing the WAL and the receipts and then checking what the mirror can and cannot give back.

A receipt also carries the EXIT-005 chain link of the entry it derives from, and the direction holds there too: the link is copied from the entry, and reconcile_receipts/1 re-derives it from the entries after the table is emptied, so PostgreSQL never becomes a second opinion about the chain. The proof rewrites every stored link to a value the log never produced and asserts that verification is unmoved, because OpenAgents.Forge.Verification recomputes the chain from the WAL and reaches no database at all. What the column buys is small and stated as such: a consistent rewrite of an accepted push now has to edit object storage and PostgreSQL together rather than object storage alone. A row written before the column existed carries no link and is not repaired in place, because a link the operator writes over their own store is not evidence.

The direction is also what keeps a column out of this table. ISSUE-003 gave forge_builds and forge_deploys a repository_id because their repo string is a repository name two repositories can share. forge_pushes.repo is a storage key, which is unique, so it already names exactly one repository — and a key only PostgreSQL could produce would not survive reconcile_receipts/1, which rebuilds this table from the WAL alone. The absence is the invariant holding, not an omission.

Two operational facts bound the claim. :forge_mirror_urls is empty in config/config.exs but set from the environment in config/runtime.exs, and production sets it for openagents.com, so a mirror does run and GitHub holds what the forge last pushed there, which is the trade REPOSITORY-002 records. And mirror_now/1 is a force push of every ref, so the mirror overwrites what a direct push left there rather than merging with it.

Amended 2026-08-25 (issue #188). "Strictly lossy" is a claim about evidence, and it was being read as a claim about objects. Where a repository's log was seeded from a shallow fetch, the mirror holds ancestors the WAL never held and no rebuild can produce — 307 commits for this repository, measured on both sides — so before the seed the mirror is strictly richer and is the only copy. The invariant is unchanged and the direction still holds: the recovery path consults no mirror, and a receipt still derives from the WAL alone. OpenAgents.Forge.Backfill.import_history/3 closes such a gap the only way an append-only log can, by appending the missing objects as a git_bundle entry that leaves the ref map unchanged and asserts no push. It takes a file an operator supplies rather than reaching for a mirror, so it adds no call this invariant's proof would fail on, and it writes no principal, sequence, or time that would turn borrowed bytes into evidence of a push. docs/forge-operator-independence.md records which half of the relation applies to what.

Evidence: OpenAgents.Forge.Sync, OpenAgents.Forge.Pushes, OpenAgents.Forge.PushReceipt, OpenAgents.Forge.Verification, OpenAgents.Forge.Backfill, test/openagents/forge/backfill_test.exs, and test/openagents/forge/independence_test.exs.

EXIT-004 — A clone is complete and self-hosting

Status: Current

Exit for source is a property, not a policy. A clone taken through the published Git transport with an oa_pat_ token carries every advertised ref the WAL records, every object those refs name, and passes git fsck. Cloned from that copy with the forge's cache and WAL deleted, the history re-serves from somewhere else with no forge dependency and no forge-specific ref namespace required.

One namespace is withheld and it is named rather than implied. refs/internal/ retains stack boundary commits without advertising them, through the transfer.hideRefs setting OpenAgents.Forge.Repos applies to every bare repository, so a clone is complete with respect to the advertised set and not with respect to the raw WAL ref map. OpenAgents.Forge.Verification.exportable_refs/1 is that set, and the proof asserts the withheld namespace is the only omission: hiding a branch, or widening the exported set to include internal bookkeeping, turns it red.

This is exit for the Git plane. It is not exit for the metadata plane. Forum posts, threads, push receipts, deployment requests, Box work, computers, agent links, pull requests, stacks, and issue dependencies leave through GET /data/export/account under EXIT-001, and the private-repository metadata reads answer their own members now. A stack's boundary object ids travel in that document precisely because a clone cannot fetch the refs holding them, so the withheld namespace costs the account the refs and not the shape of its own work. Reputation attestations leave through that document too, now that #171 bound an attestation subject to an account, and they carry their signed claim verbatim rather than depending on this invariant to be checkable. One complete invariant about the Git plane is worth more than four that assert less than they appear to.

Amended 2026-08-23 (issue #179): the advertised ref set was the whole population, and it is not what a clone walks. A clone follows every advertised ref into its ancestors, and git upload-pack aborts the entire transfer on the first object it cannot read, so a repository holding every tip the WAL recorded can still be impossible to clone. This forge's own repository was in exactly that state. Its log was seeded from a --depth=1 fetch before WAL entries carried a shallow key, so it recorded no boundary, the projection reached disk ungrafted, and every full clone aborted on the parent of the seed commit while every tip resolved and every check here stayed green.

The population is closed by the walk rather than by a list. OpenAgents.Forge.Verification runs git rev-list --objects over the exportable ref set and reports object_unreachable for anything the walk cannot read, so an object nobody thought to name is covered by the same traversal that would fail a clone. OpenAgents.Forge.Sync reconciles the shallow graft against the objects the projection actually holds — a commit whose parent is absent is a boundary, whatever the log recorded — so a projection that cannot be walked repairs itself from the WAL rather than waiting to be found by someone cloning. A grafted repository is clean here, because it is servable: history that says where it stops is a different thing from history that dangles.

The proof builds the failing shape rather than describing it — a seq 0 bundle from a --depth=1 fetch recorded with no shallow key, a real push on top, and a real clone over the transport. Removing the reconciliation reproduces #179's abort in the test suite, which is what "against a real history" has to mean for an invariant that was green while the live forge could not be cloned.

Evidence: OpenAgents.Forge.Verification, OpenAgents.Forge.Repos, OpenAgents.Forge.Sync, OpenAgents.Forge.GitHTTP, and test/openagents/forge/independence_test.exs.

EXIT-005 — Every WAL entry commits to the entry before it

Status: Current

EXIT-002 compares two things the operator holds, so an operator who edits both consistently leaves nothing to disagree with. Closing that needs a commitment held somewhere the operator does not solely control, and a chain that binds each entry to its predecessor so a rewrite cannot be confined to one entry. This is the second half. The first half is not built, and this invariant claims only what the second half proves.

Every entry appended to a WAL index carries a link: sha256 over a domain tag, the previous entry's link, and a canonical encoding of the entry's own fields. The chain is computed in OpenAgents.Forge.WAL.append_entry/2, which is the one function every writer reaches the log through — pushes, stack ref batches, and GitHub imports alike — so the chain has no holes for a writer that took a different route. A push that retries after a CAS conflict rebuilds its entry against the index it actually lands behind, so the link names its real predecessor. The encoding is not JSON: BEAM map order is not part of any contract and encoders disagree about it, so keys are sorted and every value carries its own length or terminator.

What the chain proves is that a rewrite is total. Changing any accepted entry changes the link of every entry after it, so one remembered link checks the entire prefix before it. OpenAgents.Forge.Verification.verify/2 recomputes the chain and reports chain_link_mismatch for an entry whose recorded link is not the one its contents produce, and chain_link_missing for an entry that carries no link although an earlier one does. It reports the log's head and chained_from, and it accepts an :anchor — a %{seq:, link:} commitment obtained anywhere else — against which it reports anchor_mismatch and anchor_unreachable. The proof performs the rewrite that defeats EXIT-002, recomputing the entry, its content-addressed key, the index, and every link, and asserts both halves: clean with no anchor, reported with one.

The link leaves the forge at acknowledgment. OpenAgents.Forge.GitHTTP appends one side-band band-2 message to the receive-pack response, so an ordinary git push prints remote: openagents wal-receipt seq=<n> link=<sha256> and the pusher can keep it; GET /api/v1/repos/{owner}/{repo}/pushes serves the same values from the WAL afterwards. A pusher who keeps that line holds a value the operator cannot retroactively change, and verify/2 with it as :anchor reports anchor_mismatch against a log rewritten at or before that sequence.

That covers one repository's log, up to one sequence, for the one party who wrote it down. The link also leaves the forge on a schedule, for everyone else. OpenAgents.Forge.AnchorPublisher writes one document per interval, served verbatim at /.well-known/openagents-forge-anchor.json with no credential: per repository an anonymous reader can already see, the entry count, the head sequence, the head chain link, the ref-map digest, and the sequence the chain starts at. The proof takes the head out of the served bytes, performs the rewrite that defeats EXIT-002, and asserts both halves — clean with no anchor, anchor_mismatch with the one a stranger kept.

The publication is chained the way the entries are: each anchor names the sha256 of the anchor document before it, so one archived anchor pins every anchor before it, and the served bytes are stored and returned verbatim because a reader hashes what they fetched. published_at advances every interval whether or not the log moved, which is what lets a reader see that publication has stopped.

Publishing an anchor is not having one witnessed, and the two are published as two facts. The operator serves this document and could serve any document, so it proves nothing on its own; its value is that keeping a copy is cheap and a copy is what contradicts a later rewrite. OpenAgents.Forge.Independence reports anchor_published from the anchors that exist and anchor_witnessed as false, and EXIT-006 stays degraded on the second axis. The head is not signed: a signature made with a key the operator holds, over a document the operator serves, adds nothing against the operator. ADR 0008 records the surfaces rejected — a mirror commit, a public transparency log, a Nostr relay set, a Bitcoin-anchored commitment — with what each would require, and each of them would publish this document's digest rather than replace it.

The bounds stay the point. A reader who keeps nothing holds nothing. Everything pushed after the last anchor is unanchored, so the exposure window is the interval. A split view is narrowed and not closed. A private repository is anchored for nobody, because naming it would publish its existence on an anonymous surface TRANSPARENCY-001 keeps a dark repository off entirely, and its pusher's own receipt stays the only commitment to it. Re-fetching either the anchor or the receipt route returns the forge's current answer rather than independent evidence.

No push may fail on this. Publication is a scheduled job beside OpenAgents.Forge.MirrorWatch and is not on the push path at all — not even on the derived side of the acknowledgment barrier — because a slow or failing anchor must never be able to refuse a push. The link is derived from data already in hand with no I/O, by an encoder that is total by construction, and the derivation is wrapped so a link that cannot be produced is omitted rather than raised. The entry then enters the log unchained and the verifier reports chain_link_missing, which is something to find out about rather than a reason to refuse a push the forge can accept. The side-band line inherits the same discipline: it is formatted after the acknowledgment barrier, inside rescue and catch, it performs no I/O, and it is appended only to a response that already parses as a side-band-framed pkt-line stream ending in a flush — git treats an unparseable report-status as a failed push, so a response it cannot safely annotate is returned exactly as git produced it.

Entries written before this contract carry no link and no backfill is possible, which is the correct outcome: a link the operator computes over entries the operator holds proves nothing. The chain therefore covers a suffix of each log, chained_from names where it starts, and a missing link is a finding only when an earlier entry has one — a chain that stops in the middle is tampering, a chain that starts in the middle is history. An operator who strips every link and calls the whole log historical is not refuted by anything inside their own storage, which is exactly why publication is the missing half.

This detects rewriting, never withholding. An operator who serves nothing, serves stale state, or refuses a clone holds every one of those powers still.

Evidence: OpenAgents.Forge.WAL, OpenAgents.Forge.Verification, OpenAgents.Forge.GitHTTP, OpenAgentsWeb.PushReceiptController, OpenAgents.Forge.Anchor, OpenAgents.Forge.AnchorPublisher, OpenAgentsWeb.ForgeAnchorController, test/openagents/forge/wal_test.exs, test/openagents/forge/git_http_test.exs, test/openagents/forge/independence_test.exs, test/openagents_web/controllers/push_receipt_controller_test.exs, test/openagents_web/controllers/forge_anchor_controller_test.exs, docs/2026-08-23-forge-wal-anchoring.md, and docs/decisions/0008-publish-the-forge-wal-anchor-at-a-well-known-path.md.

EXIT-006 — The status surface discloses every gap the ledger records

Status: Current

A forge that records its own limits in a document and reports itself healthy on its status page has hidden them. docs/forge-operator-independence.md states the trust boundary plainly, and EXIT-001 through EXIT-005 bind the parts that are checkable, but both are read by someone who already went looking. OpenAgents.Forge.Independence publishes the same statement where a person checking whether the service is working will see it, in OpenAgents.NetworkStatus and therefore on /status and GET /api/status.

Every claim is derived rather than restated, because a disclosure maintained by hand drifts away from the thing it describes, and the drift always runs in the flattering direction. The export section is counted from OpenAgents.DataRights.ExportInventory: a family that regresses to partial or blocked appears on the status page without anyone editing the disclosure, and a gap closed elsewhere disappears from it in the same commit. The verification section counts the anchors OpenAgents.Forge.Anchor has actually published rather than reading a config flag, so a publisher that has stopped reports false without anyone editing the disclosure. It publishes anchor_published and anchor_witnessed as two facts, because they are two facts: the operator serves the anchor document, so publishing one leaves a consistent rewrite undetectable to anyone who kept no copy of it, and anchor_witnessed is false while no party outside the operator attests to it (ADR 0008; issue #151 carries the witness). The difference between EXIT-005's tamper-evident chain and a tamper-proof log is therefore published rather than blurred. degraded is the disjunction of the axes, so nothing waits on a person deciding when to say so, and it is expected to be true today. Two of the axes are constants, so degraded?/0 would stay true even if one were dropped from the disjunction; degraded?/3 is public for exactly that reason and the proof varies one axis at a time through it.

The private-data section is two facts that only mean something together, and one of them is now derived. export_recipient_encryption reads whether OpenAgentsWeb.DataController was compiled against OpenAgents.DataRights.Age — the same compiled-import-table read EXIT-002 and EXIT-003 use — so an export route that stopped encrypting stops being claimed to, in the same commit. access_controlled is derived from AccountExport.build/1 existing at arity one and no other, which is the whole access-control claim for this document: no parameter can widen it. operator_reads_source is derived from encrypted_at_rest, because it is the same fact stated from the reader's side, and publishing the encryption without it would let a reader conclude the operator cannot read an export.

encrypted_at_rest is derived too, from OpenAgents.Forge.AtRest: the private store is encrypted at rest exactly when no private column rests as plaintext. The value has not moved and is not expected to — it is false, and #193 stays open — but it can now fail, which as a literal it could not.

The earlier wording of this entry said no Ecto column in this repository is encrypted at rest. Three are: users.github_token_ciphertext, machine_pairings.token_ciphertext, and voice_recording_chunks.data, each under the separate key VAULT-001 binds. The sentence understated the vaults and, worse, was a quantified claim about columns with no population behind it, which is the failure this ledger's own preamble names.

The registry that entry declined to invent is still declined. What replaced it is the smaller thing the preamble asks for: a population information_schema supplies. Every column carrying secret-shaped vocabulary is classified, no column may be classified :plaintext_secret, and the proof reads the catalog rather than a list, so a migration that adds a plaintext token column fails the day it lands rather than the day someone looks. The counts stay off the status page; only the boolean is published, and it is published because it can be wrong.

The derivation fails downward. plaintext_private_columns/0 is a floor, so a private column missing from it leaves the boolean at false — where it already is — and an incomplete list understates the store instead of flattering it. That is the direction every other gather in this projection fails in.

Amended 2026-08-25 (issue #193). The decision about which columns stop being server-readable is recorded in docs/2026-08-25-encryption-at-rest.md with its threat model, the cost to an account under an account-held key, and the options rejected. The published key set did not change, so STATUS-001 has nothing to move.

Amended 2026-08-25 (issue #193, second pass). Four content columns are now sealed under OpenAgents.ContentVault — the voice transcript, the in-call compaction summary, the preference observation, and the project note — and the reason is narrower than "encryption at rest": each is read whole and searched by nothing, so a seal costs no feature. That is the whole test applied. The columns left in plaintext are left because a query reads them in a way a seal would end: messages.content carries a search_vector PostgreSQL generates from it, issues.body, comments.body, and forum_posts.body_text are matched with ILIKE, and the two account_chat_runs columns hold the same words that rest verbatim in account_chat_events.payload beside them, where the replay path reads them structurally. plaintext_private_columns/0 now carries that reason per column, so the ledger records why a gap is open rather than only that it is.

encrypted_at_rest is still false, and sealing four columns did not move it, which is the point. The boolean is true only when no private column rests as plaintext, and operator_reads_source is its negation, so this change cannot be read as protection from the operator: these seals are under a key the operator holds and defend against a stolen dump. EXIT-006 exists to keep the larger claim off the page, and it still does.

Amended 2026-08-24 (issue #178). The decision that a private export can be encrypted to a key the operator does not hold is recorded in docs/2026-08-24-private-export-encryption.md with its threat model and four rejected options. degraded? gained the at-rest axis in the same change, because closing the export half without it would have let a forge with plaintext columns report itself independent the moment an anchor appeared. The published key set moved with the claim, as STATUS-001 requires: independence.private_data.exports_encrypted was replaced by export_recipient_encryption, and operator_reads_source was added. That is the decision being asked for, not an accident.

Amended 2026-08-25 (issue #246). The disclosure publishes its own distance from the revision its proofs ran against. Every claim above is derived from the running node, and the running node's code can be older than the ledger those claims answer to: #187 found the forge serving a revision 57 commits behind main, where the export route, the EXIT-005 chain, and this module itself did not exist, while every proof of them stayed green. An invariant that is proven and not deployed reports nothing, which is the outcome this contract exists to prevent, reached by a second route. So independence.deployment publishes the number of commits on the head this node serves that the running revision does not carry, and /status renders it in words.

It is a distance and nothing else. Both revisions it lies between are commit shas, and adding one to this projection was among the mutations below; the proof stays red for them, and this amendment does not touch that rule. The distance is derived the way everything else here is: the running revision is the one /api/status already publishes for this node, the proven revision is the head of refs/heads/main in the bare projection this node serves, and the count is git rev-list over objects already on disk. RELEASE-004 binds the proof matrix to the exact candidate sha and .githooks/pre-push refuses a push without it, which is what makes that head the newest proven revision.

The claim is narrow on purpose. It is not "how far behind main this forge is" — nothing on this node can see a main it declines to serve. A forge that will not serve its own repository, a node whose bare projection is empty, and a release built from a revision this forge never accepted all report known: false and no distance, which is the same withholding EXIT-005 and EXIT-006 already decline to detect. The distance is also not an axis of degraded?: a node one commit behind is not less independent, and folding ordinary deploy lag into the independence verdict would make that verdict mean nothing on the day it mattered. It is published beside the verdict, as the verdict's margin of error. The published key set moved with the claim, as STATUS-001 requires: independence.deployment.proven_ref, .known, and .behind were added, and deployment/1 is public with the revision as a parameter for the same reason degraded?/3 is — the node running the proof reports a build revision that is not a commit at all, so the projection alone could only ever exercise the branch that withholds.

STATUS-001's rule holds here: the section carries counts, booleans, family names, issue numbers, one ref name, and one document path, and the proof asserts that every string it publishes is a ledger family name or fixed vocabulary — a repository path, an account id, a node name, or a commit sha reaching it turns the proof red. The whole section degrades to nil like every other gather, so a node that cannot assemble it renders the page without it rather than failing.

Six mutations were confirmed to fail the proof and reverted: publishing an empty gap list while the ledger records gaps; making degraded constant; adding the forge's repository name to the projection; claiming exports are encrypted; dropping the witness axis from the disjunction, which degraded?/0 cannot detect and degraded?/3 does; dropping the at-rest axis, which the plaintext-store assertion catches once an anchor is configured; and hardcoding export_recipient_encryption to true while removing the encryption from the route, which the derivation assertion catches even though the projection does not.

Four more were confirmed and reverted for the at-rest derivation: a removed classification, standing in for a migration that adds an unclassified column; api_tokens.token_digest reclassified as a plaintext secret; a vault made to return its plaintext, which the raw-column read catches; and this module reverted to a literal false, which comparing the two values cannot catch — false is the correct answer today — so the proof reads the compiled import table the way export_recipient_encryption already does.

Three more were confirmed for the deployment section and reverted: publishing the running revision beside its distance, which the vocabulary assertion and both STATUS-001 key-set assertions catch; hardcoding the distance to zero, which the seeded-repository counts catch and the compiled-import-table read catches independently of them; and dropping the section from the projection, which STATUS-001's stale-key assertion catches.

Evidence: OpenAgents.Forge.Independence, OpenAgents.Forge.AtRest, OpenAgents.NetworkStatus, OpenAgentsWeb.NetworkStatusLive, OpenAgents.DataRights.Age, test/openagents/forge/independence_disclosure_test.exs, test/openagents/forge/at_rest_test.exs, test/openagents/data_rights/age_test.exs, docs/forge-operator-independence.md, docs/2026-08-24-private-export-encryption.md, and docs/2026-08-25-encryption-at-rest.md.

STACK-001 — A pull request stack is a durable object, not inferred topology

Status: Current

A stack of pull requests exists as a row, not as a reading of branch bases. Branch topology alone is ambiguous: a branch can be based on another without intending a stack, a retarget can be accidental, and closed pull requests blur any inferred chain. pull_request_stacks carries the identity — a repository-local number, the trunk ref, an open/completed/dissolved state, and an optimistic version — and pull_request_stack_entries carries the order: contiguous positions from 1, the boundary object ID that marks where each layer's unique commits begin, and the observed head. Object IDs store as raw bytes so SHA-1 and SHA-256 repositories both fit; nothing assumes a 40-character column.

Structure is validated, not trusted. Creation requires same-repository membership, same-repository heads, open pull requests, unique entries, unique branches, and an unbroken direct-base chain from the trunk upward. A partial unique index keeps a pull request in at most one active stack, and a second partial index keeps active positions unique per stack. While a pull request is stacked, a generic base edit fails with stack_managed_base; the base belongs to the stack service.

Health is an observation and state is a lifecycle, and the two never merge. A stack whose graph has gone stale reads needs_rebase, conflicted, missing_ref, head_changed, policy_blocked, or operation_in_progress while its state stays open. A stale graph never dissolves a stack; only an explicit transition does, and that transition bumps the version so concurrent operations see the change.

Evidence: OpenAgents.Stacks, OpenAgents.Stacks.Stack, OpenAgents.Stacks.StackEntry, OpenAgents.Stacks.OID, and test/openagents/stacks_test.exs.

ISSUE-001 — A commit closes an issue only from the default branch

Status: Current

A commit whose message says Closes #N, Fixes #N, or Resolves #N closes issue N when that commit becomes reachable from the repository's default branch, and at no other moment. A push to a topic branch records nothing; the same commit closes the issue when it arrives on the default branch. That is what makes the mechanism safe: an unmerged branch can never close work.

Four boundaries hold around it.

A malformed reference cannot fail a push. OpenAgents.Forge.CommitReferences is pure and total — it reads attacker-controlled commit text, returns a list for every input including a non-binary one, and never queries or decides. The acting side runs after the WAL acknowledgment barrier and after the receipt insert, and catches everything, because refusing at that point would ask a client to retry a push the forge has already accepted.

Same repository, and only with authority. #N resolves against the repository that received the push; a owner/repo#N reference is read, reported on the commit page, and not acted on — the same boundary the prerequisite edges draw. The push principal must be a user who can write that repository, so an operator token, a machine, and an assignment credential record nothing. The close is attributed to that user, not to a system actor.

Once. The {issue_id, commit_sha} unique index on issue_closing_references is the gate. WAL replay, receipt reconciliation, and a force push that re-presents the same commits all find the row already there and stop, so no second close and no duplicate timeline entry follows. An issue that is already closed records the reference and keeps its state.

No reopening on revert. A revert is a new commit. Reopening on one is a separate policy decision with its own failure modes and is not admitted here.

Closing through this path is an ordinary close, so the derived blocked value a dependent reads clears exactly as it does for a manual close; nothing stores that flag.

Evidence: OpenAgents.Forge.CommitReferences, OpenAgents.Issues.ClosingReferences, OpenAgents.Issues.ClosingReference, OpenAgents.Forge.Pushes, test/openagents/forge/commit_references_test.exs, test/openagents/issues/closing_references_test.exs, and test/openagents/forge/push_closes_issues_test.exs.

FORUM-001 — The forum serves from Postgres, with no legacy mirror to read

Status: Current

openagents.com/forum is served by OpenAgents.Forum out of this application's own database. The legacy Effect forum is retired, and no request this application answers reads the khala_sync_prod mirror.

Nothing running can name the mirror. The one-time import task is the only code that names it. It is a Mix.Task: it takes its connection from FORUM_IMPORT_* in the environment, nothing in the application calls it, and Mix is not loaded in a release, so it cannot run on a served node. The application starts exactly one Ecto repository, OpenAgents.Repo. Retiring the mirror instance and archiving its credentials are operations tasks outside this repository, and neither is a precondition for this contract.

Legacy links go through an explicit redirect map. The canonical paths are /forum and /forum/t/:topicId. A legacy post permalink at /forum/post/:postId and a legacy /forum/topic/:topicId alias resolve through OpenAgentsWeb.LegacyForumController: the controller reads OpenAgents.Forum and redirects to the right topic. Unknown legacy ids answer 404 and never read the mirror.

The reads are public, and the classifier says so. /forum, /forum/f/:slug, and /forum/t/:id sit in the public live session and are classified :public_read by OpenAgentsWeb.RouteAuthority, which is what stops the router and the authority inventory from disagreeing about a surface anyone can reach. Posting, /forum/claim, and /forum/tips stay authenticated, and /admin/forum/claims stays operator-only. What an anonymous reader sees is still decided by the context's readability predicates: a private board answers to operators only, and an unlisted board stays out of every listing while answering to its slug.

The page a reader is given says so too. priv/docs/forum.md, served at /docs/forum, is where someone learns how to use the forum, and it went on telling them to sign in before reading. A test reads that page beside the classifier: while GET /forum is :public_read, the page's reading section may not ask for a session, and its posting section must still say an account is what writing needs.

Evidence: OpenAgents.Forum, OpenAgentsWeb.RouteAuthority, OpenAgentsWeb.LegacyForumController, test/openagents_web/controllers/legacy_forum_controller_test.exs, OpenAgentsWeb.LegacyForumControllerTest, test/openagents/forum/legacy_surface_test.exs, test/openagents_web/live/forum_live_test.exs, test/openagents_web/route_authority_test.exs, and test/openagents_web/sidebar_state_test.exs.

ISSUE-002 — A task-list checkbox is a projection of issue state

Status: Current

A Markdown task-list item that names an issue in the same repository is not an independent assertion anyone has to maintain. - [ ] #6 reads checked exactly when issue 6 is closed, and the forge keeps it that way without a human edit.

Two triggers hold it, and it needs both. OpenAgents.Issues.TaskReferences.synchronize/2 runs after an issue opens or closes and rewrites the bodies in the same repository whose task-list items point at it. OpenAgents.Issues.TaskReferences.render/2 runs on every issue and comment write, so a body arrives already agreeing with what it names. Fan-out alone loses to a person who saves a body they loaded before the checkbox moved; rendering on write alone only fixes a body somebody happens to touch.

Together they make the rendered body a fixed point. OpenAgents.Issues.TaskList.render/2 is a pure function of the body text and the current states, so two writers that cross still converge: whichever lands second recomputes from what is then true. A person can no longer hold a checkbox open against a closed issue. Reopen the issue if the work is not done.

No second extractor. OpenAgents.Forge.CommitReferences.all/1 reads every #N in a stretch of text, closing or not, with the cross-repository form separated out. This mechanism calls it once per task-list item and owns only the checkbox, so one regex decides what a reference is on both the push path and the issue path.

Same repository, so nothing leaks. #N resolves against the repository holding the body, and an item naming owner/repo#N is read and left alone — the same boundary the prerequisite edges and the closing references draw. No body can therefore learn anything about a repository its reader cannot already read, so a private issue's state cannot reach a public one.

Idempotent, and bounded. A body is written only when rendering returns something other than what is stored, which is the whole idempotency gate: a repeat writes nothing and records nothing, and a checkbox already in the right state produces no history entry. There is deliberately no unique index — closing, reopening, and closing an issue again are three real edits the history should show three times. The candidate query filters on the literal #N before any body is read, and one state change rewrites at most 200 bodies.

No concurrent edit is clobbered. Each row is re-read under FOR UPDATE and the new body is computed from what the lock returns, never from the row the candidate query saw, so the rewrite cannot revert prose a person saved in between.

The edit is the forge's, not a person's. Every rewrite records an OpenAgents.Issues.TaskSync row whose principal is system, and the issue history renders it beside comments and closes. The person who closed the referenced issue did not edit anyone's tracking issue, and the record does not say they did.

A failure anywhere here is caught and logged rather than propagated. A tracking issue that stayed stale is a smaller failure than a close that did not happen.

Evidence: OpenAgents.Issues.TaskList, OpenAgents.Issues.TaskReferences, OpenAgents.Issues.TaskSync, OpenAgents.Issues, test/openagents/issues/task_list_test.exs, test/openagents/issues/task_references_test.exs, and test/openagents_web/live/issue_show_live_test.exs.

ISSUE-003 — An issue's evidence is an edge to an exact commit and environment

Status: Current

An issue is the requested outcome. The receipts that evaluated the work are immutable rows in the tables their families own. issue_evidence joins the two and stores nothing else: it has no steps, no report, no budget, no prompt, and no output, so it is an edge rather than a second work record.

Every row binds one receipt to one exact commit. OpenAgents.Issues.Evidence re-reads the receipt before writing, so a caller that names a different commit is refused with :evidence_commit_mismatch and one that names a different environment with :evidence_environment_mismatch. A receipt id from one family looked up in another family's table is not found, which is why a push receipt can never be recorded as a deployment receipt.

Two sources resolve a commit to an issue, and there is no third. issue_closing_references carries the authoritative half — the trailer #130 extracted, verified against the pusher's write authority and against reachability from the default branch. forge_assignments.terminal_commit carries the weaker half, an attempt's own report of the revision it produced. A commit claimed by both records once, attributed to the trailer, because a merge is a stronger fact than an executor's self-report. No second reader of commit prose exists: OpenAgents.Forge.CommitReferences is still the only one.

Written when the receipt is written, in either order. Forge.receipts_for/2 and the changelog's receipt index scan bounded windows and honestly return empty for an older commit, so scanning later would silently lose evidence. The edge is appended where the receipt is created, and an attempt that finishes after its receipts sweeps {repo, sha} through an index for what already exists. The two directions meet on the same row.

Once. The {issue_id, commit_sha, family, receipt_id} unique index is the gate, and a read precedes every insert so a duplicate is skipped rather than raised inside the transaction that records a close. WAL replay, OpenAgents.Forge.Pushes.reconcile_receipts/1, and a force push that re-presents the same commits all produce no second edge and no second timeline entry.

Nothing is deleted to tidy a timeline. A failed build, a reverted deployment, a cancelled attempt, and a superseded run each keep their edge with the receipt's own terminal word in result. An issue's history is what happened, not what worked.

A receipt names its repository, and a name it cannot settle stays unsettled. forge_builds.repo and forge_deploys.repo hold Target.repo, a repository name or an owner/name path, and repositories is unique on {namespace_id, name_key} rather than on name — so one string can answer for two repositories, and #148 recorded no evidence at all when it did. Both tables carry repository_id now (#181): every receipt written since names its repository, and the evidence chain reads that key rather than a name. The string survives as the fallback for the rows the backfill could not settle, and it is read only for those rows, so a shared name can no longer pull one repository's receipts into another's answer. The refusal survives where it still applies: an unsettled name resolves to nothing, and nothing resolves to no evidence rather than to a guess. A null key means "not settled", never "no repository".

forge_pushes deliberately has no such key. Its repo is Repository.storage_key, which carries a unique index, so a push receipt already names exactly one repository — and EXIT-003 requires every column there to be re-derivable from the WAL by OpenAgents.Forge.Pushes.reconcile_receipts/1. A key only PostgreSQL can produce would make the database a second opinion about a record the WAL alone decides, which is the thing EXIT-002 and EXIT-005 exist to prevent.

The two deployment planes stay distinct. An issue in this repository is evidenced by forge_deploys on the forge plane, whose one environment is the fleet. An issue in a tenant repository is evidenced by deployment_runs on the tenant plane, whose environment is the one the run named. plane is on the row, so no reader has to infer which store a receipt came from.

Qualification has one authority here. deployment_check_results is the qualification receipt an issue's evidence chain binds, because it is repository-scoped and pins both the commit and the artifact digest, so it resolves to an issue without a priced claim standing behind it. settlement_verifications stays authoritative for a settled claim's payout: it is keyed on a claim and carries no repository, which is a different question with a different authority.

Failing to write an edge never fails the receipt, the close, or the attempt that produced it. Missing evidence is a smaller failure than a lost receipt.

Evidence: OpenAgents.Issues.Evidence, OpenAgents.Issues.EvidenceEntry, OpenAgents.Issues.ClosingReferences, OpenAgents.Forge.Assignments, OpenAgents.Forge.ReceiptRepository, test/openagents/issues/evidence_test.exs, test/openagents/forge/receipt_repository_test.exs, and test/openagents_web/controllers/issue_controller_test.exs.

ISSUE-004 — Agent work on an issue starts through one admission

Status: Current

An issue page can start the work it describes, and it does so by reaching OpenAgents.Forge.Assignments.create/1 — the same admission the API route uses. There is no second executor, no queue, and no second work record: the button produces one forge_assignments row, which is the attempt, and one work_jobs row, which is the execution.

Authority is re-checked, not only hidden. The control renders for a viewer with write authority on the repository. Hiding it is a courtesy; the refusal is the contract. IssueShowLive re-reads the repository and the membership on every write, and Assignments.create/1 refuses again with :repository_not_writable, so a crafted event from a reader starts nothing.

The objective is read from the issue. The prompt is built from the issue's number, title, and body rather than typed beside it, so what the agent was asked to do and what the issue asked for cannot drift. The body is clamped well inside the 8,000-byte prompt bound rather than refused for being long.

The scope and the limits are the issue's, at the admission rather than at a surface. The objective moved out of IssueShowLive into OpenAgents.Issues.WorkScope, which Assignments.create/1 applies, so the API route gets what the page always had: a caller that supplies no prompt gets the issue's, and the prompt names the branch the attempt was actually admitted on rather than the one that was suggested.

The wall clock is read from the issue too. OUTCOME-001 says a claim against an issue that does not state its problem, scope, acceptance criteria, and success metrics is incomplete and can never be accepted — so an unscoped issue cannot buy delivery, because no amount of agent time on it can produce a graded outcome. It buys a short exploratory window instead; an issue that states all four buys the full hour. The deadline is the earliest of that window, the deployment's TTL, and whatever the caller asked for: narrowing is admitted, widening is not, because a bound the requester can raise is a suggestion.

The bound outlives the request. It becomes timeout_ms on the delegation, then wall_clock_ms in work_jobs.budget_snapshot, which is the budget field OUTCOME-001 grades the attempt's binding against — so the issue's own scope is what the attempt is later held to, through records rather than through a parameter that was true once. The four sections are read by OpenAgents.Issues.CompletionClaims.sections/1, the grader's own parser: a second one could disagree about whether an issue is scoped, and then an attempt could buy a budget for work its own grader would refuse.

The target's own declarations decide, never the request. The working directory is chosen from the computer's roots and the agent from its probed acp_agents; a value outside either is replaced by one the computer declared rather than forwarded. A crafted event therefore cannot widen the scope the computer published, and ComputerAgentJobs' :cwd_not_allowed and :agent_not_available remain as the second refusal for a computer whose declarations changed between the render and the admission.

The branch is never the default or a protected one. Assignments already refused both; the suggestion is agent/issue-{number}, and the credential the attempt mints can write that branch and nothing else (IDENTITY-006).

One attempt may be live per issue. forge_assignments_one_active_issue_index is a partial unique index over admitted and running, so terminal attempts all stay and one is live at a time. That refusal is now typed: persist_assignment/7 returns :assignment_issue_claimed rather than raising on the constraint, which is what lets the page name the branch the live attempt is running on instead of failing opaquely. Every other refusal the admission returns — an offline or revoked computer, a busy target, a protected branch, a disabled controller — is shown as itself.

A live attempt is shown, not narrated. Assignments.report_claim/1, report/1, and report_release/1 wrote the claim, the result, and the release as Markdown comments on the issue. Every fact in them is a column of forge_assignments, so the timeline derives them and the write path writes nothing. Comments already written stay and still render; only new ones stop.

That prose was a surface asserting things nothing could contradict. "Assignment claim released" described the credential revocation, which lives in forge_assignment_credentials.revoked_at and which finish/1 sets in the same transaction that makes an attempt terminal — for every terminal state, while the comment fired for failed and cancelled only. Nothing compared the sentence to the row. report_claim/1 was also load-bearing: create/1 failed the whole assignment as claim_event_failed when the comment could not be written, so a failure of the comment table could fail agent work. Both are gone.

One thing the prose carried that no record holds is now published as a kind rather than an id: the comments were authored by the requesting agent, so an agent-requested attempt named the agent and a person's own attempt named nobody at all. The attempt's requester_kinduser or agent, at pulse — replaces it under TRANSPARENCY-001's rule that a principal's kind is published and its id is not. The specific agent's identity is deliberately not restored.

The page moves when the attempt moves, and re-reads to find out how. IssueShowLive subscribes to issue_attempts:{issue_id} at mount. Assignments.announce/1 broadcasts {:attempts_changed, issue_id} and nothing else — not the state, not the branch, not the row, because each of those is disclosed at a rung and a message carrying one would carry it past the gate that decides the rung. The subscriber marks the panel stale and re-reads through refresh_panel/2, which re-resolves the repository with Repositories.get_visible_repository/2 and rebuilds the attempts at this viewer's own WorkDisclosure.viewer/2 rung, so a viewer whose membership was removed while the socket was open loses the branch on the next announcement. Elapsed time is a clock and not a poll: a one-second timer, armed only while an attempt is live, that re-reads nothing and recomputes from the attempt's own start.

Cancelling reaches the same terminal path. A viewer with write authority cancels from the issue; Assignments.cancel/2 reads that authority from the attempt's own repository rather than from the socket, and reaches finish/1, so a cancelled attempt revokes its credential, releases its issue claim, and binds its evidence exactly as a failure does. A reader is offered no control and a crafted event cancels nothing. An attempt that started and never finished renders as started and nothing more: the timeline invents no terminal event.

Evidence: OpenAgentsWeb.IssueShowLive, OpenAgents.Forge.Assignments, OpenAgents.Issues.WorkScope, OpenAgents.ComputerAgentJobs, test/openagents_web/live/issue_start_work_live_test.exs, test/openagents_web/live/issue_live_work_test.exs, test/openagents/issues/work_scope_test.exs, and test/openagents/forge/assignment_test.exs.

(Amended 2026-08-24, issue #147: an attempt was narrated in prose and shown without moving. It is now shown, live, and narrated nowhere.)

(Amended 2026-08-25, issue #10: the objective was read from the issue on the page and from the caller in the API, and the wall clock came from the caller on both. Both now come from the issue at the admission, and a caller may narrow the bound but not widen it.)

ISSUE-005 — An issue says an agent trajectory exists and never publishes one

Status: Current

An ATIF trace is the whole run: every prompt, every assistant message, every tool call's raw arguments, every tool result. OpenAgents.Transparency.WorkDisclosure already refuses work_jobs.goal, context_hint, and delegation because publishing them would restate the contents of a repository in a place the repository's own gate does not cover, and a trajectory restates more of it than all three. So the trace family's schedule stops at the digest.

No rung returns the document. pulse publishes that a trajectory exists, its schema version, its step count, and when it was recorded. ledger adds the digest — by which a holder of the bytes can prove they hold these bytes — and the size. glass adds nothing. Not for an operator, and not for the account that uploaded it: an owner reads their own document through their own account, and this schedule governs what an issue discloses. document is the source column of two counted facts and of no third, which OpenAgents.Transparency.WorkDisclosureTest enforces against the live schema in both directions.

This is therefore not a readback, and must not be mistaken for one. EXIT-001 publishes to anonymous callers that POST /api/v1/traces accepts an upload and no route reads one back. That gap stays open and stays disclosed.

Two gates, and both must pass. Consent is traces.visibility, which the uploader sets and which defaults to dark; a dark trace is invisible on an issue to everyone, an operator included, and no viewer's own rung raises it. Repository access is applied first by OpenAgents.Issues.Activity, exactly as it is to receipts: a reader who cannot read the repository sees no traces however widely the uploader consented, because consenting to publish your own trajectory is not consenting to publish which attempts ran in somebody else's private repository. The effective tier is the lower of the two and is then clamped again by the viewer, so adding a gate can only remove fields.

The binding is checked, not believed. A trace names the attempt it is a trajectory of, never the issue: the attempt already records which issue and which repository it was admitted against, so the issue gains its traces and the repository gate gains something to act on without the issue holding a second work record. Only the account named as the attempt's requesting principal may bind, and any other is refused with trace_assignment_forbidden rather than having the field dropped — a caller that believed it was filing evidence should not be told it succeeded.

Evidence: OpenAgents.Issues.TraceDisclosure, OpenAgents.Traces, OpenAgents.Transparency.WorkDisclosure, test/openagents/issues/trace_disclosure_test.exs, test/openagents/transparency/work_disclosure_test.exs, and test/openagents_web/live/issue_show_live_test.exs.

CAPACITY-001 — Capacity is a bounded, owner-safe quantity projection

Status: Current

OpenAgents.Capacity publishes logical inventory, active reservations, free capacity, queue pressure, and evidence freshness as separate quantities. It does not turn missing or stale evidence into a reported zero, and it refuses when evidence cannot support a safe decision. A connected customer computer is available only as an explicit target. Provider topology, credentials, and workspace content never leave the projection.

The capacity context reads managed evidence through the configured broker source and reads connected evidence through owner-scoped machines and work_jobs queries. OpenAgents.Capacity.Math applies the configured ceiling, reserved headroom, and broker-reported free capacity without subtracting active reservations twice. The executable proof exercises fresh, stale, private, redacted, and quantity-based projections.

Evidence: OpenAgents.Capacity, OpenAgents.Capacity.Math, OpenAgents.Capacity.Broker, OpenAgents.Capacity.Connected, and test/openagents/capacity_test.exs.

PROMISE-001 — LIVE promises require accepted-outcome evidence

Status: Current

A promise can enter LIVE only when at least one readable accepted_outcome evidence entry names an OpenAgents.Compensation.OutcomeDecision whose decision is accepted. Issue, changelog, and forge receipt entries remain supporting evidence, but they cannot certify LIVE; links and issue comments do not satisfy this gate. Evidence is redacted separately from certification when its referenced repository or issue is outside the reader's visibility boundary.

PROMISE-002 — Project item history is append-only

Status: Proposed

Every promise item create, update, and state change records an actor-attributed event. PostgreSQL rejects updates and deletes of project item events, and the API exposes only paginated reads.

NOTIFY-001 — A notification is a durable, idempotent pointer that reveals nothing

Status: Current

Issue delivery records are written inside the transaction that writes the comment or the issue they announce, so a delivery exists exactly when its event does and there is no queue whose loss silently drops it. Each event derives one dedupe_key per recipient and a unique index over (user_id, dedupe_key) makes a replayed fan-out a no-op, so a retried request never notifies twice and never returns a read record to unread.

A record stores identifiers, a kind, and the actor's login — never a title, a body, a label name, a state, or any other repository content. Fan-out refuses a recipient who cannot read the repository, and the two reads that return records — the inbox page and the unread count — are both built from one private visible_query/2 that joins through OpenAgents.Repositories.readable_by/2 against the reader's current membership, so a record that outlives the recipient's access stops rendering rather than disclosing a private issue. Marking read is scoped to the addressed account.

Naming the two reads is narrower than the earlier "every read", which nothing proved: test/openagents/notifications_test.exs exercises these two and would not fail for a third added beside them. One private query is what keeps the count and the page agreeing, so a read that does not use it is the thing to notice in review.

Assignment, label and state notifications are derived from the difference between the issue before and after an update, inside Issues.update_issue/3 — the one path every such change takes. There is no issue_events table, so the difference is the event; announcing one anywhere else would be a second write path that could disagree with this one. A derived event has no row of its own to key on, so its dedupe_key names the issue, the second its update landed on, and the field with its new value. That keeps replay a no-op from both sides: a retried request derives nothing, because the second attempt sees the change already applied, and two writers racing to the same transition in the same second collide on the key instead of notifying twice.

Every delivery category names what it delivers, so switching one off has an effect you can predict from its name and no category silently widens to carry a kind it is not named for. Four default on, because none of them can reach an account that has not already taken part in the issue, been named in it, or been assigned it. Label changes default off: a label moves for a query rather than for a reader, and it addresses nobody.

The unread count shown outside the inbox is the same authorized read, expressed as an aggregate. It counts rows joined through readable_by/2 against the reader's membership on this request, never the length of a loaded page, so it stays correct past the page the inbox renders and drops repositories the reader can no longer read. It is keyed by the session's own account and refreshed only over that account's own topic.

Delivery is in-product first and by email second, and the second channel is the only one that leaves the application. Four things gate it, and all four are decided twice — once when the delivery is queued and again when it is sent — because a sent message is the one notification no later authorization check can withdraw.

The address is the first gate. An account has no address until somebody types one here; GitHub OAuth is not asked for user:email, so no address arrives that its owner did not choose to give this deployment. A typed address is inert: OpenAgents.Notifications.EmailChannel.verified_address/1 is the only read any send resolves a recipient through, and it returns nil until a code mailed to that address comes back. The code is held as a SHA-256 digest, compared in constant time, expires in thirty minutes, and is retired after five wrong guesses. The check constraint users_notification_email_state_check refuses a verified timestamp on a row that names no address, so the gate holds independently of this application's code. The verification message is the one thing sent to an unconfirmed address, and it carries the code and nothing about the account.

The channel is the second. email_enabled defaults off and is backfilled false for every account that already exists, because turning on a channel that leaves the application for people who chose the inbox is not a default anybody picked. The category is the third: only mention is carried, so no other category becomes an email by widening. Repository access is the fourth, re-read through Repositories.readable_by/2 at send time.

The queued half is durable rather than immediate, because a send happens after the transaction commits and can fail on somebody else's server. Delivery.enqueue/1 writes one email.delivery effect inside the transaction that writes the record, keyed to the same (user_id, dedupe_key) pair the unique index uses, so a replayed fan-out is one record and one message. OpenAgents.Effects owns attempts, backoff, and the terminal failed. A refusal at send time is recorded as a completion with its reason rather than retried, because none of these four answers improves by being asked again. The payload names an account and an event and never an address, so no caller can put a recipient into the queue.

A message carries what a record carries — the kind, the actor's login, the repository path, and the issue number — plus a link. Not the issue's title, and not a body.

Evidence: OpenAgents.Notifications, OpenAgents.Notifications.Mentions, OpenAgents.Notifications.EmailChannel, OpenAgents.Notifications.Delivery, OpenAgents.Notifications.Email, OpenAgents.Effects.Handlers.EmailDelivery, OpenAgents.Issues.update_issue/3, OpenAgentsWeb.NotificationsLive, OpenAgentsWeb.UserAuth.on_mount/4, test/openagents/notifications_test.exs, test/openagents/notifications/email_channel_test.exs, test/openagents/notifications/email_delivery_test.exs, test/openagents/notifications/delivery_test.exs, and test/openagents_web/live/notifications_live_test.exs.

FORGEAPI-001 — One error envelope, and a route inventory derived from the router

Status: Current

Every refusal from an issue-family /api/v1 route carries one envelope: message, code, status, documentation_url, request_id, and errors. OpenAgentsWeb.ApiError owns it, and a code determines its status there, so no controller chooses a status for a failure the API has already named. Two refusals additionally carry the legacy error key that published clients already read; no key a client read has been renamed or removed.

Non-disclosure is preserved by construction rather than by care: a private resource and an absent one both refuse with not_found, and no code in the table distinguishes them.

Amended 2026-08-23 (issue #186): that ambiguity is load-bearing, so nothing else may hide behind it. A rescue Ecto.NoResultsError wrapped around a whole controller action caught every bang lookup the action reached, not only the repository one it was written for, so POST /api/v1/repos/{owner}/{repo}/issues answered 404 for a label the request body named that the repository does not have — the same 404 a repository the caller cannot see answers with, which made the honest error unreachable and sent one reporter to the router and the token before the label. A lookup that may not resolve is now wrapped one at a time by OpenAgentsWeb.ControllerHelpers.lookup/1; neither OpenAgentsWeb.IssueController nor OpenAgentsWeb.CommentController names Ecto.NoResultsError at all, and a proof reads both files for it, so a lookup added later cannot rejoin the repository's 404 without someone deciding to. A label, assignee, or milestone the repository does not have raises OpenAgents.Issues.UnknownReference and answers 422 with validation_failed and the request-body field naming the value that did not resolve, the way GitHub answers. The privacy 404 is unchanged and proven beside it: a POST to a private repository the caller is not a member of still answers not_found, with errors empty and a body that names neither the repository nor the label.

The published route inventory at GET /api/v1 is derived from OpenAgentsWeb.Router.__routes__/0 through OpenAgentsWeb.ApiRouteAuthority, never maintained beside it. Each route carries three mandatory classifications — principal, resource family, and error contract — so a route added to the router without choosing all three fails the build, and a route classified as answering with the envelope that answers with something else fails the build.

Evidence: lib/openagents_web/api_error.ex, lib/openagents_web/api_route_authority.ex, lib/openagents_web/controllers/api_extension_controller.ex, lib/openagents_web/controllers/controller_helpers.ex, lib/openagents/issues/unknown_reference.ex, test/openagents_web/controllers/issue_controller_test.exs, test/openagents_web/api_error_test.exs, test/openagents_web/api_route_authority_test.exs, test/openagents_web/controllers/api_error_contract_test.exs, and test/openagents_web/controllers/api_extension_controller_test.exs.

FORGEAPI-002 — The path names this API's version, and /api/v3 is gone

Status: Current

The version segment in /api/v1 names this API's own version. It does not name GitHub's. The API is GitHub-shaped — familiar paths and response shapes, so a client you already know is cheap to adapt — and it is not GitHub, so a version GitHub chose is the wrong number to serve under.

That distinction is load-bearing because one tool disagrees with it. GitHub's gh reaches any non-github.com host at a hardcoded /api/v3 prefix, which reads as a reason to serve that prefix forever. It is not one. Measured against gh 2.89.0, the ported commands never reach REST: gh issue list sends GraphQL to /api/graphql and gh issue view probes GET /api/v3/meta, and this application serves neither, so the prefix buys no working command. The one surface it does serve, the gh api passthrough, already reaches /api/v1 when it is given a full URL. gh is therefore not a supported client, and /api/v3 was a migration alias rather than a compatibility promise. The reasoning and the measurements are docs/decisions/0009-serve-a-github-shaped-api-not-a-gh-compatible-one.md.

The alias was deleted on 2026-08-25 (#216), the day @openagentsinc/cli@0.4.0 was published and the one client still on the old prefix was upgraded to it. Nothing under lib/ names the old prefix now, and a proof reads the tree for that rather than trusting care: the trace ingest route landed mid-rename and returned a url field naming /api/v3/traces/{id}, a link that went dead the day the alias did. Every versioned route is declared at /api/v1 and every URL a response emits names /api/v1.

Evidence: lib/openagents_web/router.ex, lib/openagents_web/controllers/trace_controller.ex, and test/openagents_web/api_version_posture_test.exs.

Executable proof index

This index is part of the ledger. Every Current invariant has at least one repository-owned executable proof. The documentation check requires the ID set and every file path below to remain valid. A shared test can prove more than one contract; the invariant prose above defines the assertion, not the filename.

Invariant Executable proof
CANON-001 test/openagents/persona/source_manifest_test.exs
CANON-002 test/openagents/vocabulary_test.exs, test/openagents/audit_test.exs, test/openagents/machines/token_vault_test.exs
PERSONA-001 test/openagents/persona_test.exs, test/openagents/providers/persona_boundary_test.exs
PERSONA-002 test/openagents/context/composer_test.exs, test/openagents/roles_test.exs
PERSONA-003 test/openagents/persona/evaluation_test.exs
BLUEPRINT-001 test/openagents/blueprint_test.exs
PROGRAM-001 test/openagents/program_artifacts_test.exs
DEGRADE-001 test/openagents/program_artifacts_test.exs, test/openagents/turn_provenance_test.exs
PROGRAM-002 test/openagents/shadow_programs_test.exs
PROGRAM-003 test/openagents/program_lifecycle_test.exs
IDENTITY-001 test/openagents/github_oauth_test.exs, test/openagents_web/auth_controller_test.exs, test/openagents_web/device_sign_in_return_test.exs, test/openagents_web/authenticated_route_gate_test.exs
IDENTITY-002 test/openagents_web/auth_gate_test.exs, test/openagents_web/authenticated_route_gate_test.exs, test/openagents_web/live_view_scope_test.exs
IDENTITY-003 test/openagents/memory_portability_test.exs
IDENTITY-004 test/openagents/agents_test.exs, test/openagents_web/controllers/agent_controller_test.exs
IDENTITY-005 test/openagents_web/controllers/box_controller_test.exs
IDENTITY-006 test/openagents/forge/assignment_test.exs, test/openagents/forge/assignment_start_race_test.exs
IDENTITY-007 test/openagents/agents_test.exs
IDENTITY-008 test/openagents_web/controllers/computer_control_api_test.exs, test/openagents/inference/computer_revocation_test.exs, test/openagents/computer_projection_test.exs, test/openagents/machines/index_reach_test.exs
IDENTITY-009 test/openagents_web/controllers/delegations_controller_test.exs
IDENTITY-010 test/openagents/forge/assignment_test.exs, test/openagents/forge/assignment_credential_reach_test.exs
IDENTITY-011 test/openagents/machines/pairing_expiry_test.exs, test/openagents/machines/index_reach_test.exs
IDENTITY-012 test/openagents/machines/constraint_reach_test.exs
CAPACITY-002 test/openagents/box_fanout_test.exs
CAPACITY-003 test/openagents/box_reconciler_test.exs
WORK-002 test/openagents/box_runs_test.exs
PROMISE-001 test/openagents/promise_registry_test.exs, test/openagents_web/controllers/project_controller_test.exs
PROMISE-002 test/openagents/promise_registry_test.exs
NOTIFY-001 test/openagents/notifications_test.exs, test/openagents/notifications/email_channel_test.exs, test/openagents/notifications/email_delivery_test.exs, test/openagents/notifications/delivery_test.exs, test/openagents_web/live/notifications_live_test.exs

| FORGEAPI-001 | test/openagents_web/controllers/api_error_contract_test.exs, test/openagents_web/controllers/api_extension_controller_test.exs, test/openagents_web/api_error_test.exs, test/openagents_web/controllers/issue_controller_test.exs | | FORGEAPI-002 | test/openagents_web/api_version_posture_test.exs | | DATA-001 | test/openagents/conversations_test.exs | | DATA-002 | test/openagents/accounts_test.exs, test/openagents/conversations_test.exs | | DATA-003 | test/openagents/conversations_test.exs | | MEMORY-001 | test/openagents/memory/lexical_recall_test.exs, test/openagents/memory/scope_boundary_test.exs, test/openagents/memories/system_recall_test.exs | | MEMORY-002 | test/openagents/memory/evidence_test.exs, test/openagents/turn_memory_evidence_journeys_test.exs | | MEMORY-003 | test/openagents/profile_memory_test.exs | | MEMORY-004 | test/openagents/memory/lexical_recall_test.exs, test/openagents/tools/conversation_recall_tools_test.exs, test/openagents/memory/scope_boundary_test.exs | | MEMORY-005 | test/openagents/tools/profile_memory_tools_test.exs | | MEMORY-006 | test/openagents/semantic_recall_test.exs | | MEMORY-007 | test/openagents/preferences_test.exs | | MEMORY-008 | test/openagents/experience_memory_test.exs | | MEMORY-009 | test/openagents/graph_memory_test.exs | | MEMORY-010 | test/openagents/memories_test.exs, test/openagents_web/controllers/memory_controller_test.exs, test/openagents_web/controllers/responses_controller_test.exs | | MEMORY-011 | test/openagents/memories/system_memory_test.exs, test/openagents/memories/challenge_test.exs, test/openagents/memories/system_recall_test.exs | | MEMORY-012 | test/openagents/memories/promotion_test.exs | | PRIVACY-001 | test/openagents/memory/policy_and_redaction_test.exs, test/openagents/memory/scope_boundary_test.exs | | TURN-001 | test/openagents/conversations_test.exs | | TURN-002 | test/openagents/conversations_test.exs | | TURN-003 | test/openagents_web/live/chat_live_test.exs | | TURN-004 | test/openagents/conversations_test.exs, test/openagents/tool_step_persistence_test.exs | | TURN-005 | test/openagents/turn_tool_loop_test.exs | | PROVENANCE-001 | test/openagents/turn_provenance_test.exs | | PROVIDER-001 | test/openagents/providers/provider_contract_test.exs, test/openagents/turn_provider_events_test.exs, test/openagents/dependency_boundary_test.exs | | PROVIDER-002 | test/openagents/inference/models_test.exs, test/openagents_web/controllers/model_catalog_controller_test.exs, test/openagents_web/controllers/inference_proxy_controller_test.exs, test/openagents_web/controllers/inference_proxy_fallback_test.exs, test/openagents_web/controllers/thread_controller_test.exs | | METER-001 | test/openagents/inference/pricing_test.exs, test/openagents/inference/credit_test.exs, test/openagents/threads_test.exs, test/openagents_web/controllers/model_catalog_controller_test.exs, test/openagents_web/controllers/thread_controller_test.exs, test/openagents_web/controllers/inference_proxy_fallback_test.exs, test/openagents/providers/open_router/stream_decoder_test.exs, test/openagents_web/live/thread_show_live_test.exs, test/openagents_web/live/model_catalog_live_test.exs | | EFFECT-001 | test/openagents/effects_test.exs, test/openagents/effects/work_launch_test.exs | | EFFECT-002 | test/openagents/effects_test.exs, test/openagents/effects/work_launch_test.exs | | TOOL-001 | test/openagents/tools/registry_and_runner_test.exs | | COLLECTIVE-001 | test/openagents/collective_test.exs | | COLLECTIVE-002 | test/openagents/collective_generalizer_test.exs | | COLLECTIVE-003 | test/openagents/collective_publication_test.exs | | COMPENSATION-001 | test/openagents/compensation_test.exs | | REPUTATION-001 | test/openagents/reputation_test.exs, test/openagents/forge/key_rotation_test.exs, test/openagents_web/controllers/reputation_controller_test.exs | | SETTLEMENT-001 | test/openagents/settlement_test.exs, test/openagents/settlement/constraints_test.exs, test/openagents/settlement/simulated_bounty_proof_test.exs | | MODULE-001 | test/openagents/modules/registry_test.exs, test/openagents/tool_step_persistence_test.exs | | MODULE-002 | test/openagents/modules/discovery_test.exs, test/openagents/modules/lifecycle_test.exs | | MODULE-003 | test/openagents/modules/router_test.exs, test/openagents/turn_tool_loop_test.exs | | MODULE-004 | test/openagents/surface_eval_test.exs | | TOOL-002 | test/openagents/tools/registry_and_runner_test.exs | | TOOL-003 | test/openagents/tool_step_persistence_test.exs | | TOOL-004 | test/openagents/tools/registry_and_runner_test.exs, test/openagents_web/tool_activity_test.exs | | DEGRADE-002 | test/openagents/tools/registry_and_runner_test.exs, test/openagents/tools/conversation_recall_tools_test.exs | | TOOL-005 | test/openagents/tools/reach_test.exs, test/openagents/chat/open_router/tool_runtime_test.exs, test/openagents/chat/account_turns_test.exs, test/openagents/dependency_boundary_test.exs | | TOOL-006 | test/openagents/tools/shipped_catalog_test.exs | | WORK-001 | test/openagents/work_job_test.exs, test/openagents/deep_work_tool_loop_test.exs | | SELF-EDIT-001 | test/openagents/tools/repository_mutation_tools_test.exs, test/openagents/coding_job_test.exs, test/openagents/dependency_boundary_test.exs | | SCV-001 | test/openagents/scv/deployments_test.exs, test/openagents/dependency_boundary_test.exs | | THREAD-001 | test/openagents/threads/grant_fence_test.exs, test/openagents/threads/grant_token_reach_test.exs, test/openagents/threads_test.exs, test/openagents/threads/credit_race_test.exs, test/openagents/threads/local_lane_test.exs, test/openagents_web/controllers/thread_controller_test.exs | | THREAD-002 | test/openagents/threads/visibility_test.exs, test/openagents_web/thread_visibility_test.exs, test/openagents/threads/grant_token_reach_test.exs | | THREAD-003 | test/openagents/threads_test.exs, test/openagents/threads/visibility_test.exs | | OUTCOME-001 | test/openagents/accepted_outcome_test.exs, test/openagents/issues/completion_claims_test.exs, test/openagents_web/controllers/issue_completion_claim_controller_test.exs | | DEPLOYPLANE-001 | test/openagents/deployments_test.exs, test/openagents_web/controllers/deployment_controller_test.exs, test/openagents_web/api_route_authority_test.exs | | DEPLOYPLANE-002 | test/openagents/deployments_test.exs | | DEPLOYPLANE-003 | test/openagents/deployments/policy_test.exs, test/openagents/deployments_test.exs | | DEPLOYPLANE-004 | test/openagents/deployments/lifecycle_test.exs, test/openagents/deployments_test.exs | | DEPLOYPLANE-005 | test/openagents/deployments_test.exs, test/openagents/runtime_config_test.exs | | FLEETPROMOTE-001 | test/openagents/forge/promotion_test.exs, test/openagents_web/controllers/fleet_target_controller_test.exs, test/openagents_web/route_authority_test.exs, test/openagents/dependency_boundary_test.exs | | VOICE-001 | test/openagents/voice/config_test.exs | | VOICE-002 | test/openagents_web/controllers/voice_call_controller_test.exs | | VOICE-003 | test/openagents/voice_test.exs, test/openagents/voice_sessions_test.exs | | VOICE-004 | test/openagents/voice/open_ai/event_decoder_test.exs, test/openagents/voice_test.exs | | VOICE-005 | assets/test/voice_state_test.mjs, assets/test/voice_recording_test.mjs | | VOICE-006 | test/openagents/voice_sessions_test.exs, test/openagents_web/live/chat_live_test.exs | | VOICE-007 | test/openagents/voice_sessions_test.exs | | VOICE-008 | test/openagents/voice_sessions_test.exs, test/openagents/voice_test.exs | | VOICE-009 | test/openagents/voice_test.exs | | VOICE-010 | test/openagents/voice/release_operations_test.exs, test/openagents/voice/usage_test.exs | | VOICE-011 | test/openagents/voice/release_operations_test.exs, test/openagents_web/controllers/voice_telemetry_controller_test.exs | | VOICE-012 | test/openagents/voice/recordings_test.exs, test/openagents_web/controllers/voice_recording_controller_test.exs, test/openagents_web/operator_surface_test.exs | | ADMIN-001 | test/openagents_web/operator_surface_test.exs, test/openagents_web/controllers/admin_recording_controller_test.exs, test/openagents/admin_test.exs, test/openagents_web/live/admin_live_test.exs, test/openagents_web/live/admin_forge_live_test.exs | | DATA-004 | test/openagents_web/controllers/data_controller_test.exs, test/openagents/data_rights/atif_export_test.exs, test/openagents_web/operator_surface_test.exs | | UI-001 | test/openagents_web/auth_gate_test.exs, test/openagents_web/live/chat_live_test.exs, test/openagents_web/authenticated_route_gate_test.exs, test/openagents_web/operator_surface_test.exs | | UI-002 | test/openagents_web/tool_activity_test.exs, test/openagents_web/live/chat_live_test.exs, test/openagents_web/tool_activity_projection_test.exs | | UI-003 | test/openagents_web/ui_test.exs, test/openagents_web/component_catalog_test.exs | | LEADERBOARD-001 | test/openagents/leaderboard_test.exs, test/openagents_web/live/leaderboard_live_test.exs | | OBSERVABILITY-001 | test/openagents/observability_test.exs | | RELEASE-001 | ops/ci/release-smoke.sh, test/openagents_web/controllers/health_controller_test.exs | | RELEASE-002 | test/openagents/github_oauth/runtime_config_test.exs, ops/ci/reference-check.sh | | VAULT-001 | test/openagents/machines/token_vault_test.exs, test/openagents/forge/at_rest_test.exs, test/openagents/runtime_config_test.exs | | RELEASE-003 | lib/openagents/runtime_config.ex, config/runtime.exs, test/openagents/runtime_config_test.exs | | RELEASE-004 | ops/ci/gate.sh, test/openagents/forge/gate_receipt_test.exs, test/openagents/hosted_ci_absence_test.exs | | RELEASE-005 | test/openagents/forge/relup_deployment_test.exs, test/openagents/forge/relup_node_test.exs, test/openagents/release/appup_test.exs, test/openagents/cluster/code_change_test.exs, test/openagents/forge/rolling_replacement_test.exs | | RELEASE-006 | test/openagents/forge/rolling_boot_convergence_test.exs, test/openagents/forge/rolling_replacement_test.exs, test/openagents/forge/target_lifecycle_test.exs, test/openagents/forge/boot_converge_test.exs | | RELEASE-007 | test/openagents/release/image_layer_cache_test.exs, ops/ci/contracts.sh | | RELEASE-008 | test/openagents/forge/relup_topology_test.exs, test/openagents/forge/relup_deployment_test.exs, ops/ci/gate.sh | | RELEASE-009 | test/openagents/forge/deployment_lane_test.exs, test/openagents/forge/hot_loader_test.exs | | STATUS-001 | test/openagents/network_status_test.exs, test/openagents_web/live/network_status_live_test.exs | | CAPACITY-001 | test/openagents/capacity_test.exs | | TRANSPARENCY-001 | test/openagents/forge/visibility_test.exs, test/openagents/forge/browse_test.exs, test/openagents_web/live/code_live_test.exs, test/openagents_web/transparency_surface_test.exs, test/openagents/transparency/work_disclosure_test.exs | | REPOSITORY-001 | test/openagents/repositories/visibility_join_test.exs, test/openagents/data_rights/account_export_test.exs, test/openagents_web/live/computers_repository_access_test.exs, test/openagents/repository_lifecycle_test.exs, test/openagents/repositories/provisioner_test.exs, test/openagents_web/controllers/repository_controller_test.exs, test/openagents/issues_workspace_test.exs, test/openagents_web/live/issue_workspace_live_test.exs, test/openagents_web/live/project_workspace_live_test.exs, test/openagents/forge/git_http_test.exs | | API-001 | test/openagents_web/controllers/api_extension_governance_test.exs, test/openagents/issue_progress_test.exs | | CONTRIBUTION-001 | test/openagents_web/contribution_contract_test.exs | | REPOSITORY-002 | ops/ci/push-remote-check.sh, ops/dev/install-push-guard.sh, test/openagents/push_remote_contract_test.exs | | REPOSITORY-003 | test/openagents/forge/wal_replay_test.exs, test/openagents/forge/sync_test.exs, test/openagents/forge/independence_test.exs | | EXIT-001 | test/openagents/data_rights/export_inventory_test.exs, test/openagents/data_rights/account_export_test.exs | | EXIT-002 | test/openagents/forge/independence_test.exs, test/openagents/forge/verification_test.exs | | EXIT-003 | test/openagents/forge/independence_test.exs | | EXIT-004 | test/openagents/forge/independence_test.exs | | EXIT-005 | test/openagents/forge/independence_test.exs, test/openagents/forge/wal_test.exs, test/openagents/forge/git_http_test.exs, test/openagents_web/controllers/push_receipt_controller_test.exs, test/openagents_web/controllers/forge_anchor_controller_test.exs | | EXIT-006 | test/openagents/forge/independence_disclosure_test.exs, test/openagents/forge/at_rest_test.exs, test/openagents/data_rights/age_test.exs | | STACK-001 | test/openagents/stacks_test.exs | | ISSUE-001 | test/openagents/forge/commit_references_test.exs, test/openagents/issues/closing_references_test.exs, test/openagents/forge/push_closes_issues_test.exs | | FORUM-001 | test/openagents/forum/legacy_surface_test.exs, test/openagents_web/live/forum_live_test.exs, test/openagents_web/route_authority_test.exs, test/openagents_web/sidebar_state_test.exs | | ISSUE-002 | test/openagents/issues/task_list_test.exs, test/openagents/issues/task_references_test.exs, test/openagents_web/live/issue_show_live_test.exs | | ISSUE-003 | test/openagents/issues/evidence_test.exs, test/openagents_web/controllers/issue_controller_test.exs | | ISSUE-004 | test/openagents_web/live/issue_live_work_test.exs, test/openagents_web/live/issue_start_work_live_test.exs, test/openagents/issues/work_scope_test.exs | | ISSUE-005 | test/openagents/issues/trace_disclosure_test.exs, test/openagents/transparency/work_disclosure_test.exs, test/openagents_web/live/issue_show_live_test.exs |