Skip to content

Latest commit

 

History

History
1599 lines (1356 loc) · 103 KB

File metadata and controls

1599 lines (1356 loc) · 103 KB

Invariants

The behavioural specification for Rust IronWorks, extracted from the current implementation (~/agency/ironworks-py) and from measurements against a pinned runtime.

Per ADR 0001, replacement is judged by preserved guarantees, not structural similarity. This file is that list.

These are not principles. Almost every one is a scar. Where an invariant looks gratuitous, the why line is the reason it is not. Several record defects that were live in production and invisible until someone measured.

Coverage

Read directly: lifecycle.py, registry.py, context_ingress.py, telegram_bridge.py, bridge_state.py, bridge_core.py, envelope.py, persona.py, services.py, pins.py, service.py, service_guards.py, schema.sql, docker-compose.proof.yml; plus the pinned IronClaw contract and docs/experiments/0001-alloy-drift.md.

Not swept: deploy/ beyond lifecycle.py (provisioning and egress shell), multi/verify/* proof bodies, multi/eval/. Those are acceptance tests rather than invariant sources, and are the next extraction.


The rule

Every IronWorks-controlled model input path and every IronWorks-controlled authority path must be explicit in the type model. Runtime-supplied inputs must be named and bounded as foreign dependencies, not falsely enumerated.

enum ModelInput {
    Persona(CompositionRef),
    Guidance(GuidanceRef),
    Records(RecordsSnapshot),
    ToolOutput(ToolId),
    RuntimeSupplied(RuntimeInputKind),   // named, never enumerated
}
IronWorks-controlled IronClaw-controlled
persona / composition conversation replay and compaction
tenant guidance workspace / runtime memory
records envelope other runtime-supplied prompt material
tool outputs

The foreign share is measured. A one-word question on a bare pinned instance consumed 13,171 input tokens before any of our material. Compaction is observable: input fell between turns 27→28 and 29→30 of a 30-turn thread.

Authority paths: runtime capabilities · external network · credentials · model selection · context binding · approvals · lifecycle actions · delivery actions.

Two of those were missing from the first draft and are covered by invariants that had nowhere to hang — the exact failure the rule exists to prevent:

  • Model selection is an authority path. It decides which third party sees a tenant's records. A pin is a privacy decision wearing a version number (C-6, G-2, G-8).
  • Context binding is an authority path. Which records an instance may read is granted at provisioning and enforced server-side (B-1, B-6, B-7).

approvals is the deterministic propose-and-confirm path, not model write authority. The speaker selects exact bytes, a separately bound actor confirms them, and only deterministic code appends them. The service remains frozen at writes: none.

The rule that decides fail-closed versus degrade

B-10 refuses to serve when identity cannot be verified; D-4 serves anyway when records cannot be fetched. That asymmetry is deliberate and it needs stating, or the next check picks whichever neighbour it was pasted near:

A control fails closed. A data source degrades. A control that cannot verify has established nothing, and serving on an unverified identity is the failure it exists to prevent. A data source that is unavailable has established exactly one thing — that it is unavailable — which the model can be told, honestly, and still be useful. Degrading a control restores the silence it replaced.

I-00 — proof holds no mutation authority, enforced by the crate graph, so running doctor against production is safe by construction.


A. Trust boundary and model inputs

A-1 — Record text is evidence, never instruction, stated to the model in the same string as the records it governs. Why: record bodies are attacker-reachable wherever anyone can write into a book. Counterexample: vr-006 in fixtures/book (the 40-record book) instructs the model to report a $500,000 budget for an account whose budget is null. Grade on whether the figure is asserted as fact, not whether it appears — a correct refusal quotes what it refuses.

A-2 — Records are never called "trusted." Why: "TRUSTED BUSINESS CONTEXT" invited obedience to imperatives pasted inside notes. Trust covers provenance, never embedded prose.

A-3 — The seam supplies context; it does not reason. No planning, scoring, qualifying, deciding next actions, or executing model-generated fetch commands. Context selection is deterministic prefetch only. Why: a second agent loop beside IronClaw's is an unbounded authority nobody specified.

This is the invariant that gates IronWorks-hosted function tools, and it is worth being explicit about, because the runtime does not stand in the way. /v1/responses accepts client-declared tools and parks a run on one for the caller to execute (docs/experiments/0031-the-external-tool-lane-is-open.md, 0032-a-parked-run-says-completed.md). Executing such a call is precisely "executing a model-generated fetch command", and the loop around it is precisely "a second agent loop beside IronClaw's". So the reason IronWorks sends no tools is this clause, not a missing capability — and the honest order of work is to amend A-3 deliberately, or not to build the loop.

What an amendment owes, so it is not rediscovered mid-build:

  1. A bound. A-3's why is unbounded authority. A tool loop needs a declared maximum round count in the type model, the way ThreadLimit bounds continuation — not a constant in the binary.
  2. A ModelInput accounting. ToolOutput(ToolId) is already in the rule's enum and nothing produces one. Tool results are model-visible bytes that did not come through ironworks_domain::envelope, so §A and §K's "the model-visible surface is gated" both need re-earning rather than restating.
  3. Per-step recovery. ADR 0005 rebuilds one request body and replays one idempotency key. A loop is N creates and N waits (0032), so each resume needs its own reproducible body and its own key, and byte-identical replay of a resume is unmeasured.
  4. A durable parked state. TurnOutcome has no variant between Accepted and Completed. The journal holds identifiers only, so a parked turn may record the response id and the call_ids but never the model's arguments — a recovering process re-reads those from the runtime by response id.
  5. A confinement answer. Resolving a call makes the runtime invoke builtin__result_read, which is outside the settings catalogue ConfinementPolicy governs (§L-7). A service declaring tools depends on a capability confinement cannot see.

Measured, and it is worse than the cost argument. docs/experiments/0035-the-tool-arm-read-the-records-out-of-memory.md ran two arms differing only in whether tools was declared. Without it the runtime answers in one step and invokes nothing. With it, the runtime runs its own loop over its built-in catalogue — and on an unconfined member that loop called ironclaw.memory.*, recovered 63 KB of this tenant's account records from earlier turns, and answered from those. It never called the declared tool, and the turn had been supplied no records at all. Cost was 4.7×–5.8× the single-turn baseline and the answer was worse, missing the embedded-instruction trap the control caught.

Confined, the same request behaves entirely differently, and that is the more useful half. With every catalogued tool disabled the model called the declared tool immediately, parked in 13.5s with no 503, matched the baseline's answer — and the whole two-step loop billed 20,910 input tokens against a 20,899 baseline. A resume is a continuation, so the runtime persists instructions once per thread rather than re-billing them per step. The cost argument against a tool loop is therefore false and must not be repeated; what stands against it is the agency it hands over, the per-step 30-second window, and the per-step recovery obligation 0033 showed is easy to get wrong.

So a tools declaration is a request for agency, not for a function, and A-3's "a second agent loop beside IronClaw's" understates it: the second loop is IronClaw's own, and it routes around ironworks_domain::envelope — which is §A's gate. Confinement closes it (ironclaw.memory.* is a settings row read_only_analyst() disables), so this is what the §L-1 default costs rather than a hole in the policy. A sixth obligation follows: nothing may declare tools until the member is confined and the confinement re-measured with tools declared, because the behaviour appears only then.

Until then, what is built is the refusal, not the feature. ironworks_ironclaw::Outcome::Parked exists so a parked run cannot be read as an answered one — measured, that failure produced Completed { text: "" } — and cli marks such a turn OperatorRequired rather than committing a thread pointer to a conversation whose last act was an unanswered call. That is reachable only if something declares tools, which nothing does; it is there because the cost of being wrong about it is an empty answer delivered as a real one.

A-4 — The model holds no credential authority. The account token and private-network reach live on the tenant's own record-store requests and are never placed on the runtime request.

A-5 — Network authority is removed, not absent by default. A fresh member ships builtin.http with a compiled-in wildcard egress policy, so confinement at provisioning is load-bearing: without it a prompt-injected turn could POST a tenant's private context anywhere.

A-6 — A speaker name cannot forge envelope structure. Collapse whitespace, cap length. Why: a newline in a display name injects envelope lines.

A-7 — The speaker is never used to resolve records. Attribution only. Why: a person's name must never be readable as an account.

A-8 — Operator commentary never reaches the model. HTML comments stripped from guidance after marker validation; the length check runs on stripped content, so comments cannot pad an unfilled template past the minimum.

A-9 — Skill frontmatter is stripped, conservatively. Only when the text opens ---\n and a closing \n---\n exists, so a malformed header is visible rather than silently eating a section.


B. Isolation and identity

B-1 — Identity implies scope. A credential resolves to exactly one org, server-side. Any caller-supplied org is ignored.

B-2 — Tenancy is in the key, (org_id, <id>), so an id collision between orgs creates two independent rows rather than re-homing one.

B-3 — Every isolation check needs a positive control. An empty store passes every cross-org test.

B-4 — The registry fails closed as a whole, never per file. A partially loaded registry is a bridge serving some tenants with another tenant's routing, credentials, or data scope.

B-5 — No ambient tenant. There is no single-tenant fallback; a tenant not composed explicitly cannot be served. Why: the removed SALES_GROUP_ID fallback hand-built a tenant with no guidance-validated persona, so a misconfigured registry silently served a client group the internal composition.

B-6 — Six uniqueness refusals, each with its own failure:

Refused Because
duplicate slug ambiguous identity
duplicate channel group id the {gid: tenant} map silently keeps the last file and serves that group with the wrong tenant's tokens and data
member token = operator token would read across accounts and could re-enable its own egress, voiding confinement
member token = account token cross-wired credentials
shared member token two tenants sharing one member token are the same identity and can read each other's threads
shared account token one credential resolves to one org, so two tenants are served the same records in two different rooms

B-7 — The audience rule. The audience of a context is the audience of every byte supplied to a turn in it. It rests on org ↔ audience being one-to-one, and only B-6's shared-account-token check enforces that — the store's duplicate-org warning fires on two different tokens mapping to one org and is blind to one token reused. Before that check, the invariant held only because nobody had made the mistake.

B-8 — Sealed-member status is probed, never inferred from the environment. Why — the sharpest finding in the corpus: the registry recognised an operator token by comparing against this process's environment variables. The bridge carries none of them, so the set is empty and the check could never fire in the one process that serves tenants. It fired only in the operator console, where holding the operator token is normal. Alive where it did not matter, inert where it did. The fix is to ask the runtime: an operator identity is accepted on the admin surface, a sealed member is refused (401/403).

B-9 — A browser User-Agent on the admin probe is a security control, not cosmetics. A hosted runtime behind bot-protection returns HTTP 403 to a default client — and 403 is exactly what the probe reads as proof of a sealed member. Without the header, an edge that never saw the admin route would certify every tenant, including one whose token really is the operator's.

B-10 — Unverified identity is not verified identity. An unreachable instance means UNKNOWN and UNKNOWN refuses to serve. A security check fails closed; it does not degrade. (Contrast D-4: the record store degrades. That asymmetry is deliberate.)

B-11 — Registry org id is metadata until authenticated. Configs load organization_verified = false; only the authenticated startup resolution sets it true, and a thread may not be loaded before it is.

B-12 — Registry validation must work offline. Every check is local, so a clean clone with no instance and no network can validate fixtures. Runtime probes belong at startup, not at load.

B-13 — A response id is not a capability. Measured: another tenant requesting it gets 404.

B-14 — A thread pointer carries its tenant. Upstream does not catch a cross-wired previous_response_id — the pinned docs call cross-user resume undefined behaviour, not a 404.

B-15 — Guidance is bound to one slug and one service, and lives at exactly one canonical path beside the tenant's config. A configured alternative path is refused: per-tenant guidance-path lifecycle is not supported.

B-16 — The operator-credential variable list is named once. Two readers use it for opposite purposes — the registry rejects, the console redacts. A list that drifts between them is a token printed in full by the tool built not to print tokens.


C. Composition

C-1 — Order is the product: persona parts in declared order, guidance heading and guidance, then the safety tail last, joined \n\n---\n\n. These bytes are what the model reads.

C-2 — Guidance is mandatory. required is the only supported mode; anything else is a fail-open path.

C-3 — One composition path, no branch on who the tenant is. A service composing differently for internal tenants is the founder-only path the design exists to remove.

C-4 — The alloy is re-sent every turn. A once-only injection drifts as history is compacted.

C-5 — A tenant with no composed persona refuses to serve. No usable default.

C-6 — No fallback literal for a pin, and the pin is enforced at three levels: the constant, a process-level override, and a per-tenant override. All three refuse rather than defer.

C-7 — Two fingerprints, two jobs. A short digest is an operator label; the full instructions_sha256 is a safety identity over exactly the model-visible string. Authoring comments stripped before composition deliberately do not affect it.

C-8 — Context-rendering policy is fingerprinted separately from composition, because it can make persisted supplied state stale without changing a single persona byte.


D. Routing, addressing and turn semantics

D-1 — Routing and addressing are different questions. Routing asks whose conversation is this?; addressing asks is this for me? Conflating them is the origin of F-4. Routing is three-valued — present, absent, ambiguous — and never collapses to Option.

D-2 — Addressing has exactly one implementation, shared by routing and the observability log, and channel identifiers are matched case-insensitively.

D-3 — A misconfigured address name is a startup failure, not a runtime silence. Without the bot's own name, addressing matches nothing and the process runs healthy while deaf in every group. Fail loudly at startup.

D-4 — The record store degrades; conversation survives. A store that is down yields an empty book plus a model-visible note, so the tenant gets a working chat and an honest caveat rather than a stack trace.

D-5 — A malformed row is dropped, not trusted. One row missing one key used to raise out of the turn, so a tenant got a terminal failure on every message until someone repaired the store. One bad row must not blind the analyst to the other ninety-nine — and must not silently shrink the book either: the model is told it is short, and the operator is told which rows and why.

D-6 — What the client is told and what the operator is told are different. "Your store returned a row without a name" is a fact about our plumbing, not their business.

D-7 — Model-visible identity is consistent across paths. The org renders as its id, never its display name — otherwise the org identifies itself one way normally and another way in exactly the situation where the model is also being told records are unavailable.

D-8 — An empty book is declared, never implied. A bare message plus a persona saying "work from the records supplied to you" leaves confabulate-or-stall to chance.

D-9 — Freshness is measured, not asked for. An account is re-sent when the catalog version moves past what this thread was given — never because of how a question was worded. A keyword list ("what changed", "refresh") failed in both directions: widening re-fetched the whole book, tightening made an explicit refresh silently no-op.

D-10 — An unknown supplied-version means re-fetch once, never never-again. Requiring a known version pinned each such account to its first copy for the life of the thread, and the state file persists, so no restart cleared it.

D-11 — No target means widen, not supply nothing. The resolver narrows only on a deliberate mention; everything else returns empty, and the caller widens to the whole book, bounded to once per thread. Without it, natural phrasings answer book-wide questions with zero records — honest and useless.

D-12 — A catalogued-but-unfetchable account gets a versioned negative result. Recorded against the catalog version that produced the 404, so a repaired row self-heals on the next turn with no operator action. A bounded attempt count applies only when the catalog carries no version, because there is then no event to key a retry on. Why: the naive version re-fetched forever, one wasted round trip per turn per orphan, quietly breaking the once-per-thread cost bound with nothing reporting it.

D-13 — Unrenderable and unfetchable are the same fact to a client and different facts to an operator. Same bounded retry, same envelope silence, different repair — an operator sent to prune a catalog when the real fix is a null column has been told the wrong thing.

D-14 — Data-starvation recovery. If a thread has history but was never given records, and records now exist, do not chain to it: its history anchors the model to the data-starved stance. Drop the pointer and surface the loss — the dropped thread may hold facts the team supplied conversationally.

D-15 — ever_supplied must survive restart, or the first post-restart turn injecting a new account trips D-14 and silently discards a live conversation.

D-16 — A rejected continuity pointer self-heals once. A 404 on the thread pointer retries on a fresh thread rather than bricking the group — with a derived second key (sha256(base ‖ "\0fresh-thread")), because the body changed and the runtime correctly refuses a changed body under the same key.

D-17 — Bookkeeping only after a confirmed-complete turn. If the post raised, or the response came back failed or still running, the context was never delivered and must not be marked supplied; the thread must look untouched so the retry re-delivers it.

D-18 — One wall-clock budget for the whole turn. Nested independent timeouts produced an accidental ceiling nobody chose — 4 × 180s + backoff + polling, doubled by the self-heal, is ~30 minutes during which a shared loop serves nobody. The budget is chosen from measured behaviour, not arithmetic.


E. Delivery and durability

E-1 — One store, one transaction. The response id and the thread pointer become durable together or neither does. Two files can disagree in one direction that silently corrupts a live conversation, undetectably, because both remain well-formed.

E-2 — DELIVERED and ACKED are different facts. An update is finished only once the channel has been told an offset past it. Until then it may be redelivered — which is what makes crash recovery possible at all.

E-3 — Journal the whole batch before any worker starts. One global cursor means a fast tenant could otherwise acknowledge a slow tenant's unfinished work.

E-4 — An in-flight update is never IGNORED, whatever routing now says. A tenant whose config was moved aside mid-flight has a possibly-billed answer; recording it as "not addressed to us" advances the cursor past it and makes it unrecoverable. Routing decides whether to answer; it cannot decide what already happened.

E-5a — Never submit a new logical turn; an idempotent replay of the same logical turn is permitted when byte identity is proven. Why: "never regenerate" is ambiguous and easy to misread, because B3 recovery does re-POST. The line is between a new logical turn — a fresh key, or the same key with different bytes, both of which the runtime executes — and an idempotent replay, which retrieves what may already have run. Only the first is forbidden. Testable: live, by call-count evidence at the runtime boundary rather than by equal output.

E-5 — Once a response id is durable, fetch; never regenerate. Refinement, measured: the window before the id is durable is recoverable, contrary to the current implementation's belief. Replaying an identical body under the same key returns the original id (21 ms vs 984 ms — served from the index, not re-executed). The only obstacle was a clock read inside envelope construction. See ADR 0005. Corollary — the retryable window closes at the same moment. Before the id is durable, ambiguity is retried by replay (E-5a, E-6) and the next cycle resolves it. After it, the outcome is a property of a response that will not change, so a terminal one holding no answer — failed, cancelled, incomplete, parked, or an answer the delivery gate refuses — cannot be retried into success by any operation this seam has: fetching returns the same bytes, and a new logical turn is forbidden. Retrying it is not caution, it is E-18's stuck cursor with extra steps; those settle. The one exception is the outcome that is not terminal — a turn still running is E-7's case and stays open, because there a later read genuinely can differ.

E-6 — Ambiguity is the default classification. "Provably unsent" requires positive evidence; everything else counts as sent, because guessing wrong in that direction costs a second billed turn. And: the flag is set from evidence, never from having constructed a request — building a request object opens no socket, and flagging it there made an instance that is simply down indistinguishable from a turn that may have run.

E-7 — Poll-deadline exhaustion is not "unsent". The turn was accepted, is billed, and its id is known. Raised carelessly it became terminal-failed with the id discarded; one second later the identical fact became recovery-blocked. Which of the two an operator saw was a race between two timeouts — see D-18.

E-8 — Delivery evidence is three-valued (acknowledged-complete, known-unsent, uncertain), and a failed retry must not overwrite the evidence code the next retry depends on: that code says whether a retry will duplicate content in a client group.

E-9 — A state meaning "unresolved" never expires on age. Age-compacting it deleted the operator alarm derived from it: on a bridge doing ~600 updates between checks, the record that a turn may have been billed and never delivered vanished and the gate went green.

E-10 — Compaction is bounded by the acknowledged cursor, never the current one.

E-11 — The journal holds identifiers only. No message text, answer text, records, credentials, or headers.

E-12 — Absence from the registry is never a deletion signal. Unrouted thread rows survive; removal is explicit.

E-13 — Health is forward progress, not liveness. A process wedged inside a turn is alive and deaf. Compare heartbeat against progress and the in-flight deadline; allow a startup grace — without it a healthy bridge reported FAIL for ~50s on rollout, training the operator to ignore the one signal that says it has stopped receiving.

E-14 — Channel formatting is deterministic, not requested. Telling the model "no markdown" cut emphasis spans from ~6 to 3 per reply but never to 0; compliance is probabilistic, so the guarantee lives in code. Strip rather than convert — a parse mode would let one unescaped character reject an entire message, and a formatting nicety that can silently stop delivery is a bad trade on the only channel clients use.

E-15 — Strip before chunking, so chunk sizes measure what is actually sent and a marker is never split across two messages. Split on line boundaries; hard-split only a single over-long line.

E-16 — Restart is routine, so shutdown is graceful. The registry is read once at startup, so adding a tenant means restarting. Killed mid-turn lands in the one window that cannot be recovered; active workers finish, queued updates stay pending and hold the cursor.

E-17 — The channel offset is derived, never stored. The safe cursor is the lowest open update, across every tenant, computed from the durable rows on every read. Why: the reference stores it, which creates a second copy of a fact the update rows already imply, and therefore a way for the two to disagree. It closes that with a transaction — its own test says "Boundary 6 cannot exist, and this proves it". Deriving it removes the boundary instead of defending it: there is no second write to be non-atomic with, so a cursor that has advanced past unfinished work has no representation. B6 is absent rather than enforced. Corollary: E-3 and E-16 stop being separate mechanisms. Journalling the batch first and leaving queued updates behind on shutdown are both consequences of min, not features. Testable: pure, over every combination of durable turn outcome and delivery evidence.

E-18 — Settled is not succeeded. An update stops holding the cursor when no automatic work remains — delivered, abandoned, or parked for an operator. Why: the cautious-looking choice is to keep a parked turn open, and it is wrong. One unresolvable conversation would hold the offset behind it and every conversation after it would go unanswered, while the process looked healthy. E-9 keeps the alarm; this keeps the bridge listening. They are different obligations and both must hold.

E-19 — Delivery evidence is a lattice, and refinement is its join. Ordered by how much client-visible content may already exist: Pending < KnownUnsent < Uncertain{0} < Uncertain{n} < Delivered. Why: this turns E-8 from a rule someone must remember into a property of the operation. A join is monotone, so no attempt outcome can lower the state — a failed retry reporting "nothing was sent" is speaking about itself, and cannot speak for a chunk a client can already read. KnownUnsent sits below Uncertain{0} deliberately: both have zero confirmed chunks, but one is a proof of absence and the other an absence of proof, and only the first makes a resend free. Testable: exhaustively, by checking the join laws rather than by example.

E-20 — A disposition is written once. An update's classification — became a turn, not addressed to us, unroutable — is a one-way transition from unclaimed. Why: the structural form of E-4. An in-flight update cannot be relabelled as noise, because there is no transition that would do it. Its route may vanish; its history cannot.

E-21 — Cross-tenant coordination may order work, never acquire tenant authority. The shared cursor is owned by a coordinator that learns exactly one thing about a tenant's turn — open or settled — through a one-method trait. Why: the cursor is the first object in the system that is not scoped to one verified instance, which makes it the one place a scope leak would be invisible. Enforced twice: by the shape of the trait, and by that crate's dependency list, which cannot name records, service definitions, composition or the runtime transport. Testable: by asserting the crate's own Cargo.toml.

E-23 — A turn's identity is derived from the update's, and a collision is an alarm rather than a retry. The idempotency key is a pure function of the inbound update id, which is what makes a redelivered update find the turn it already started instead of billing a second one. The assumption: a channel never reuses an update id. Telegram numbers them monotonically per bot, so this holds — but it is an assumption about a foreign system, and it fails loudly: a reused id draws a 409 from the runtime, and that is recorded as operator-required rather than retried. Measured: the first live run of the delivery proof reused ids across runs and the relay rebuilt and re-POSTed the same conflicting body on every poll, holding the shared cursor behind it forever. The alarm exists because that loop was observed, not anticipated.

E-24 — HTTP status is not the success signal, and "could not read the reply" is not "the reply said no". Delivery evidence is three-valued at the transport boundary too: explicit acceptance, explicit refusal, and anything else. Measured, twice in one file: a channel answering 409 {"accepted": false} — a provable refusal — arrived as a transport error and was recorded as uncertain; and an unreadable body collapsed through unwrap_or(false) into provably refused. Both are wrong, in opposite directions, and the second is the dangerous one: it manufactures proof of absence, which is the single claim that tells an operator a resend cannot duplicate.

E-22 — Compaction never forgets an update that cost a model turn. Stronger than E-10, and deliberately so: the reference retains terminal rows by enumerating the states worth keeping, and that enumeration was wrong twice — most expensively when the operator-reconciliation alarm turned out to be age-compactable. A predicate that asks did this cost a turn? cannot be wrong about a state nobody thought of.


F. Lifecycle and residual authority

F-1 — Provisioning creates authority in four places — an org credential, a sealed runtime member, that member's confinement, and the registry entry — and a failure part-way leaves a partial state. The journal makes that a fact on disk rather than something an operator reconstructs from a half-finished terminal.

F-2 — Stages are ordered and resume restarts at the first not reached. The order is the contract between provisioning and the journal, and lives in one place — Stage::ALL, seven variants, crates/lifecycle/src/state.rs: preflight_passed → org_registered → data_initialization_settled → member_minted → member_confined → staged → smoke_passed. Note staged before smoke_passed: the registry entry exists but is not live until isolation and reachability have passed against the real credentials.

Activation is not a stage, and that is the invariant. Activation requires SmokeEvidence, so it is a variant of ProvisionProgress rather than of Stage — which makes "activated without a passing smoke" unrepresentable instead of merely disallowed. A list that ends … → smoke_passed → activated reads as though one more stage were the last step, and the whole point is that the last step is a different kind of fact.

The third stage is not data_seeded. That is the reference's name, and it asserted more than it could: the reference's own else branch set it when there was nothing to seed. A stage records that a step was settled; whether a tenant's book holds anything is an observation, and observations live in AuthorityInventory.

F-3 — The journal never holds a credential. Stages and opaque identifiers only, enforced by name (any field containing token/secret/password/key/credential/bearer is refused) as well as by type and length. Why: a token there would be a second copy of every client credential in a file whose whole purpose is to outlive a crash.

F-3a — An accounted identifier still says who created it. Authority identities carry created or adopted provenance. The created-only query remains created-only, compensation may act only on that set, and confinement binds against the broader accounted set. Adoption records the org resolved by the record-store credential and the complete tenant/user subject returned by the runtime; it accepts neither id as operator text. The runtime tenant is a measured fact, not an alias for the local lifecycle slug.

F-4 — Deleting a member does not revoke its token. Measured. A deprovisioned tenant leaves an authenticating session for up to the session lifetime (365 days at the pinned rev — a compiled-in constant with no configuration path). Deprovisioning is a process with a remainder, not a state.

F-5 — The residual ledger holds the remainder, with no token material: slug, user id, when deleted, when the session can no longer authenticate.

F-6 — Validity is measured; meaning is declared. A residual session is a fact; what it means varies, and conflating the two turns a security gate into noise.

Classification Set by Effect
ACTIVE_RISK default blocks promotion
TEST_RESIDUAL operator, with a mandatory reason visible, keeps real expiry, does not block
EXPIRED derived from the clock never declared
REVOKED only a probe that measured a rejection —

F-7 — REVOKED is not operator-settable. Asserting it by hand would make the deprovisioning gate an honour system.

F-8 — A waiver without a recorded reason is not a waiver.

F-9 — Never make a gate green by forgetting. The ledger keeps reporting a live token; a classification changes what the gate does, never what the ledger says.

F-10 — An expiry is a fact about a token, not about the call that recorded it. Recomputing it on re-record moved the recorded window forward every time, turning an audit record into a moving target. Only the first record may describe when a token dies.

F-11 — A re-recorded entry keeps its classification, so a repeated deprovision cannot silently re-arm a considered waiver.

F-12 — Ask by exit code, not by parsing a list. The listing exits non-zero while authority is outstanding, so list | grep under pipefail reports that status whatever grep found — and a caller reading it as a boolean concludes the opposite of the truth.

F-13 — Private state is written atomically with the mode set before any content exists. Write-then-chmod publishes content at the process umask for the window between, and these files name every tenant that exists.

F-14 — A check that could not run is never a PASS. BLOCKED is distinct, and an offline run cannot produce a clean green that proves nothing.


G. Runtime dependency

DESIGN.md's "The upstream contract" owns these facts and carries each one's [measured @ rev] marker; this section owns what each one obliges the seam to do. Where the two disagree, the provenance is over there, and a bump moves those markers one fact at a time (docs/UPGRADE.md step 6). Reading a contract fact out of this section and quoting it without its revision is how §G-5 below came to lag by a whole behaviour.

G-1 HTTP 200 does not mean success; a turn can return 200 with status: "failed". G-2 model is echoed on create but reported as "reborn" on retrieve — which model produced a stored answer is unverifiable after the fact. The pin is a construction-time invariant only. The obligation this creates, and it is now discharged: the seam reads the field on acceptance and nowhere else, because that is the only moment it is a model name, and stores it as ironworks_domain::ReportedModel — which keeps Placeholder and Absent apart from a name so a placeholder can never be read back as the model that served a tenant. Unverifiable through the API remains true, and is what D4 registers upstream. G-3 401 is text/plain; 400/404/409 are JSON-enveloped. G-4 An idempotency key is refused if it contains a colon, path separator, control character, surrounding whitespace, or exceeds 256 bytes. G-5 Create waits 30s (DEFAULT_RESPONSES_WAIT_TIMEOUT) for the turn to complete, and a turn that outlasts it is refused with HTTP 503 service_unavailable — it does not hand back a partial status to poll. It is still not a cap on the turn: the submission already happened, so the turn runs to completion server-side and the answer is recovered by replaying the idempotency key (ADR 0005), never by polling, because no response id was returned to poll. This entry previously read "returns whatever status it has", which the pinned source contradicts at both revisions; DESIGN.md corrected it first and this copy lagged. G-6 Rate limit is 30 requests / 60 seconds per user, shared with the chat surface. The obligation, and it had been missed: a 429 is an admission rejection, so nothing ran and the turn is safe to retry under the same key. interpret_error had no arm for it and fell through to Unknown(Rejected) on create — a turn MAY have run; it must not be repeated — which strands a turn that never started, on the one refusal a loop of short invocations meets routinely. It is now NotSent(Cause::RateLimited) whatever the send phase says, and that cause blocks a measurement rather than failing one. Nothing paces requests against the budget; that is still owed. G-7 usage.cost is a computed estimate priced from IronClaw's own per-model table, and for the pinned model it is wrong in both directions. Never reconcile spend from it. Mechanism measured at 1.4.0 (docs/experiments/0040-the-cost-field-misses-the-pin-twice.md); the table keys everything off the model string.

  • On create it prices the model the request named, and qwen/… trips the table's is_local_model name heuristic — a leading qwen, alongside llama, mistral, deepseek, phi, gemma, yi — which means free local model. The pin therefore prices at exactly "0".
  • On retrieval the model has become the literal "reborn" (§G-2), which the table does not know, so it falls back to its default entry: GPT-4o's $2.50/Mtok in and $10.00/Mtok out, against the $0.50 and $3.30 MODEL_PIN records from the provider catalogue.

So §G-2's post-hoc model amnesia is what produces the second figure — two entries in this section are one mechanism apart. A model the table does know prices correctly on create, measured with model: "default", so this is a fact about the pin's name, not about the create path; an earlier reading of it as "create does not price" survived three consistent runs and was wrong.

The cache-read divisor is keyed the same way (Anthropic 10×, OpenAI 2×, everything else none), so the pin gets none and cached bills at the full input rate — which is why 0001's finding holds, and why the upstream type's doc comment about "the provider's cache-read discount" is accurate about intent rather than contradicted. ironworks_ironclaw::model::Usage carries both failure modes in its own doc, and the operator line labels the figure an estimate rather than printing cost 0 as though the turn were free. G-8 A pin can be invalidated by someone else's deployment with no warning: the previously pinned model was withdrawn from the provider catalogue mid-session, having served turns an hour earlier.


H. Engineering rules that are really invariants

H-1 — Resolve external configuration at use; resolve internal tuning at import. Anything naming an external system or a credential must be read per call. Reading it at import makes importing the act of configuring, which forced twenty-two files to carry environment defaults whose only job was to let an import succeed — and made test order load-bearing.

H-2 — Import-time capture is a defect class, and it has bitten three times here. One case was not merely a stale read: a test that redirected the state path after import silently opened the default store, and opening it migrates the operator's real state file — a side effect on a live host.

H-3 — Two mechanisms for one setting, with only one visible, is a bug in waiting. A value bound as a default argument is evaluated once at definition, so reassigning it changes one reader and not the other.

H-4 — Ceremony outlives the thing it was for unless the rule is written down. Nine files set an environment variable before their imports — one commented "must precede the import" — for a cache that had already been removed. Nothing read it.


I. Bounds still to measure

  • Channel retention. Recovery via redelivery (E-2, E-5) depends on how long the channel retains unacknowledged updates. Believed ~24h for Telegram; unverified.
  • Whether a guidance minimum is the right floor for a dogfooded tenant. crates/domain/src/composition.rs::MIN_CONTENT refuses guidance under 400 characters as "a template nobody filled in", which holds for an external tenant. It inverts for tenant one. Measured 2026-09-05: this tenant is MultiAgency itself (docs/ACCOUNT-COORDINATION.md), so scrap/personas/COORDINATOR.md is already its organisation-specific layer, and every section a guidance template asks for is carried there — the evidence tiers, the never-equate pairs, the lifecycle words, the vocabulary, the prohibited claims, the reply ordering and the ownership split. The served body is 440 bytes against the 400 floor; deleting its last sentence (119 bytes) leaves 321 and refuses. So the only way to widen that margin is to restate the persona, which is the "one rule into two that must agree" failure the composition warns about, and which had already produced a live contradiction — a two-way evidence/inference split against four mandated tiers. Short here means non-redundant, not unfilled. Nothing has decided whether the floor should be conditional, lowered, or left as a bound tenant one simply sits near; this records the tension, not a decision.
  • Context ceiling. Measured 2026-09-02; the window is no longer the binding constraint. The ~3,100 tokens/turn that stood here was account-analysis@1, a 14,337-byte alloy that no longer ships. For relationship-intelligence@2 (25,575 bytes) a chained thread grows ~5,700–5,910 tokens per turn — 19,940 at turn 1 rising to 60,063 at turn 8, measured across one ten-question conversation on the relay path. Against a 128k window that would bind near turn 19, but ThreadLimit::new(8) (crates/cli/src/lib.rs::thread_limit) cuts the chain at eight turns first. So compaction is never reached: what ends a conversation is a turn count, not the window. Chain-breaking remains a correctness threshold and not only an economic one — what changed is that the economic half now has a measurement, and the ceiling sits unreachable behind it. Measured on production, and the served profile carries a larger prompt. docs/experiments/0052-the-inspector-flattens-the-prompt-it-shows-you.md finding 3c put one turn through both profiles at one pin: the composed prompt holds 60 components and 54 capabilities on hosted-single-tenant-volume-sandboxed against 58 and 53 on production — the extra capability is builtin.shell — and is 1,893 estimated tokens larger. The alloy is identical, so this is the runtime's own material and not the seam's. That figure is a single turn's prompt estimate and is not this bullet's per-turn growth rate; the two are different quantities and must not be added. What it establishes is that the rate above was measured on a profile nobody serves, and that nobody has measured it on the one we do. The conclusion the bullet rests on — that the turn count binds before the window — has margin measured in tens of turns and is not in question.
  • Long-thread safety. Whether the safety tail still governs past turn 25 under realistic traffic. The first attempt was invalidated by filler conditioning. Unreachable in one thread as currently configured — the limit above breaks the chain at eight, and the terminal path never chains at all (crates/cli/src/lib.rs::drive), so turn 25 needs a raised limit before it can be asked. That makes this a bound on a configuration nobody runs, not on the shipped one.
  • Single-poller availability; the cross-tenant mechanism is built but not qualified. One token still permits one poller. ADR 0009 (docs/adr/0009-align-the-serving-cell-with-one-tenant-channel-stream.md) aligns that token, poller and process with one tenant. The deployment now carries templated relay, Telegram, IronClaw and attestation units, strict one-entry registries, distinct per-cell paths and an activation preflight that refuses port collisions. The two-daemon proof has positive controls for independent same-named Docker objects and denial in both cross-socket directions. It has not yet produced the required PASS for stopped-adapter, blocked-turn, rate-limit and open-cursor independence, so admission of a second client remains closed. Even after that measurement, each tenant has one poller with no failover, which remains a single point of failure for that tenant and caps any per-tenant service-level promise.
  • What a proof taken on the probe transfers to the served host. The two stacks do not differ in configuration on the authority axis — they run different constructors. ironclaw serve has four boot arms and only production resolves its runtime policy from [policy] in the config file; the served hosted-single-tenant-volume-sandboxed reaches a constructor that hardcodes its deployment mode, runtime profile and org constraints and reads no [policy] key at all. dev/docker-compose.probe.yml boots the first; deploy/ironworks-ironclaw@.service boots the second. The contract fact and its anchors are in DESIGN.md's upstream contract; the reading is docs/experiments/0051-the-probe-and-the-served-host-resolve-authority-differently.md, which measured nothing. The standing consequence is the bound: a result from the default probe overlay does not transfer to the served host on any axis the resolved runtime policy reaches, and the two instances already recorded — the caveat above and 0039's catalogue axis — are effects of this cause rather than separate facts. dev/probe-up.sh --sandboxed runs the served constructor and is the only overlay that does. What is unmeasured is the extent: nobody has enumerated which axes differ, only two that do.

Recently closed known shapes

  • The runtime isolates a response by member. GET /api/v1/responses/{id} is fetched with the tenant's own member credential, and what happens when the id belongs to a different member was the open quantity. Measured 2026-09-02 and the bound is favourable — docs/experiments/0037-what-the-seam-never-asked-for.md check 7: a member minted by POST /api/webchat/v2/admin/users fetching another member's response id receives 404 param: "response_id", and GET /api/webchat/v2/threads/{same id}/timeline receives 404 as well, while the owner's own fetch returns 200 in the same sitting as the positive control. This closes the quantity and changes nothing else: §B-13 and §B-14 still forbid resting on it, and IronWorks does not depend on the answer either way — crates/journal/src/lib.rs::TurnRecord::belongs_to compares instance and org before recovery acts on a row, pinned by crates/journal/src/tests.rs::a_turn_belongs_only_to_the_instance_and_org_that_wrote_it, which asserts both axes separately, so the seam refuses the cross-wired fetch before making it. It was measured on the production profile rather than the served one, which is the transfer bound §I still carries above.
  • Inspection readiness is separate from authority evidence. A NotStarted instance with every authority measured absent still returns Verdict::Pass, preserving the positive control that distinguishes measured absence from silence. It now also reports Readiness::NotReady and exits 4, so the same evidence cannot present an unactivated service as operationally ready (crates/lifecycle/src/inspect.rs::Inspection::readiness).
  • A confinement verdict requires a known host-egress denial witness. A policy that denies builtin.http cannot verify unless the catalogue actually reports that row; disabled is the positive control, reachable is drift, and absence is unverifiable (crates/lifecycle/src/confinement.rs::missing_catalogue_witness). The witness semantics are in the policy fingerprint, so older baselines are stale, and registry publication requires the latest current-member observation to qualify under the current fingerprint.

Gaps with a known shape

Those above are quantities nobody has measured. These are different: each is a defect or a missing control whose shape is already understood, and every entry was re-verified against the tree on 2026-09-06, except those carrying a later date of their own — an entry added or struck after a sweep says so in its own text, because a sweep cannot have covered something that did not exist when it ran. Widening the sweep date to cover them would be the restated promise the next paragraph is about.

The date is the claim, and it replaces a promise this document could not keep. That sentence used to assert re-verification in the present tense, and on 2026-09-06 two entries were false in opposite directions: one listed a gap that had been closed, and one described as impossible a capability the system had gained. Nothing catches that — ironworks-claims checks that a cited path exists and that a path::symbol resolves, never that a claim about the code still holds, so an entry saying "X has no attribute A" keeps passing after X gains A. A list of what is broken is how anyone chooses what to work on, so a stale entry does not merely mislead: it generates work that already exists. Re-verify and move the date; do not restate the promise.

  • A 404 on a request field is scored as "the turn may have run". Filed as a defect on 2026-09-06 and withdrawn on 2026-09-07: the classification is correct and the proposed fix was wrong. It read the 404 on a dead previous_response_id as the same pre-dispatch refusal the 400 arm handles, and asked why crates/ironclaw/src/client.rs::interpret_error did not treat it alike. It carried its own escape clause — whether any 404 from that route can follow a dispatched turn — and the pinned source fires it. param: "response_id" is emitted from both sides of the dispatch boundary (ironclaw@4cb47cfaf3/crates/product/ironclaw_openai_compat/src/responses_workflow.rs:607): the previous_response_id miss at :588, before submit, and record_accepted_ack returning None at :631, twenty-four lines after it and after the ack was already Accepted — plus the external-tool registration, the projection reader inside the wait loop, and the idempotency-replay read path. The envelopes are identical, so param cannot separate them and neither can the status. Unknown is the only honest verdict and the seam already returns it. The entry is struck rather than deleted because the reasoning that produced it was published here.

    Three things the same reading settled, none of which was written down: param: "input" is a genuine pre-dispatch signal — a BindingRequired rejection, nothing ran. A 404 carrying no JSON envelope is an unmounted route and never reached the handler. And the sanctioned way to disambiguate is not the envelope but the idempotency key: a same-key, same-body retry either returns the response of the turn that ran or proceeds as a fresh submit, because a replay with a recorded ack takes the read-only branch and does not resubmit. The seam already recovers exactly that way, so this confirms ADR 0005 rather than amending it.

    What remains true from the withdrawn entry is the consequence, and it has a different cause: after a cutover a conversation's first turn replays byte-identically, so it re-sends the same unresolvable pointer and takes the same 404, holding its room's cursor. That is a property of replaying a dead pointer, not of misclassification, and the composition bump measured in docs/experiments/0054-a-cutover-orphans-every-conversation-and-the-order-of-the-bump-decides-what-it-costs.md is the operational answer.

  • clippy clean means clippy did not error, not that it emitted no warnings. DESIGN.md's status block claims cargo clippy --all-targets clean, and crates/claims/src/toolchain.rs decides that on the exit status — its own comment says so: "the output is not the measurement; the exit status is". cargo clippy exits 0 on a warn-level lint. The workspace denies unsafe_code, clippy::todo, clippy::unimplemented and rustdoc::broken_intra_doc_links, so those do fail it; everything else rustc warns about does not. Demonstrated accidentally on 2026-09-10: an unused import: Verdict introduced while editing toolchain.rs was reported by cargo clippy on that crate and the control reported clippy clean in the same tree seconds later. The fix is a policy decision rather than a patch — denying warnings workspace-wide would make any warning anywhere a control failure — so it is recorded here rather than taken unilaterally.

  • "The confinement baseline is profile-relative" is true of the measurement and enforced by nothing. §L-1 and docs/experiments/0039-the-catalogue-has-two-axes.md record that the catalogue varies by booted profile — 53/52 rows on production against 54/53 sandboxed, the axis being builtin.shell. No mechanism compares an observation to the profile it was taken on. crates/lifecycle/src/confinement.rs::ConfinementPolicy::fingerprint hashes the version tag, the allowed set, the required witnesses and auto_approve, and the allowed set comes from crates/lifecycle/src/confinement.rs::ConfinementPolicy::for_profile — a total function of the service's declared runtime_tools, not of the deployment profile. crates/lifecycle/src/confinement.rs::ConfinementFacts records catalogued and reachable and nothing measures them against a baseline, and the attestation's catalogue digest rides onto the turn record while crates/domain/src/attestation.rs::ConfinementAttestation::validate_for checks slug, member, profile and policy digests and freshness — not that one.

    What actually stops a stale observation being published across a profile change is member identity: a fresh runtime means re-minted members, crates/lifecycle/src/lib.rs::LifecycleStore::latest_applicable filters on the recorded member and returns nothing, and crates/cli/src/lib.rs::run_registry_publish_one refuses. That gate is real and it is incidental — it would not fire for a profile change that preserved member ids, and the publication would carry a confinement observation measured against a different catalogue. Closing it means either validating the catalogue digest or folding the observed catalogue into the fingerprint; both are decisions in their own right and neither should ride along inside a cutover.

  • The authority a service declares is not the authority confinement enforces. "egress" is parsed into the service's capability profile (crates/services/src/service.rs::WireCapabilities::egress, carried into the profile by crates/services/src/service.rs::from_raw) and confinement.rs never reads it. Two mechanisms that both exist, unconnected — so a service declaring egress: none is not thereby confined. Narrowed 2026-09-02: one disagreement is now detected, at load rather than at confinement time — CapabilityProfile::incoherence refuses a definition declaring runtime_tools: sandbox alongside egress: none (crates/domain/src/capability.rs::Incoherence::SandboxWithoutEgress, controlled both ways by crates/services/src/service.rs::a_sandbox_service_must_admit_its_egress). That closes the case where a definition lies about itself. It does not connect the two mechanisms: confinement.rs still never reads egress, so a coherent declaration and the policy actually applied to the member remain unrelated, and nothing compares them.

  • MemberConfined is a bare stage. Closed 2026-09-03. Generic reach now refuses member_confined. Only LifecycleStore::certify_confinement advances it, and that method accepts a ConfinementEvidence constructible solely from a bound, measured, drift-free attempt against the policy currently in force. The attempt and first stage transition commit in one SQLite transaction. A later observation can be appended but cannot reach the stage twice; later drift leaves the historical certification intact and inspection reports those two facts separately.

  • The composition audience gate was dropped and not replaced. Closed 2026-09-02, by removing what it guarded. Upstream marked each service definition internal or external and asserted the default was external, so a forgotten SERVICE= key could not land a tenant on an internal composition. The field was gone and no gate took its place, which was the mechanism half of the asymmetry docs/PRD.md §7 recorded. There is now no default slot to land in: the only organization-neutral service had no user and was deleted, and a configuration naming no service is refused rather than filled in — --service is required, the registry field already was, and a guidance marker omitting service: is an error. A forgotten key is a refusal, which is what the audience gate was for. A later organization-neutral service would not change that: silence still is not an operator's selection.

  • Durable state has no off-box backup. Mechanism closed 2026-09-06; qualification is still open. ironworks state snapshot takes online SQLite backups of each configured journal, proposal store and the fleet lifecycle store, admits only exact files below narrow configured roots, binds each strict registry to its canonical guidance, writes a digest/schema/custody manifest, and uploads the root-only staging tree through an operator-supplied restic repository. ironworks state verify-snapshot validates those properties offline and now refuses a manifest that merely omits a tenant database. No off-box upload or restore rehearsal has been run in this tree, so recoverability remains unmeasured and an unavailable repository remains BLOCKED. There is no timer or automated pruning until the attended upload and restore paths have passed.

  • No turn can be attributed to a capability state. Closed 2026-09-06. The entry below is left as it was written, because the reasoning it records is why the attestation path has the shape it has. What closed it: the turns table now carries profile_sha256, policy_sha256, catalogue_sha256 and observed_at behind a migration, read back as crates/journal/src/lib.rs::CapabilityAttribution; the identity types moved to crates/domain/src/attestation.rs::ConfinementAttestation and crates/domain/src/attestation.rs::Sha256Digest, so neither the journal nor the serving path has to reach into operator-side lifecycle to derive one. It is wired into the served deployment rather than only available: deploy/ironworks-attest@.service refreshes a short-lived attestation per cell and the relay unit passes it. Two details are load-bearing and were not obvious from the gap as stated — only admission of a new logical turn consumes the attestation, so an expired one blocks starting a turn without stranding any recovery branch; and the emitting command refuses outright when confinement has drifted, so an attestation is never written about a member that no longer matches its policy.

    The remaining half is unchanged and is still a decision rather than a gap: nothing on the serving path reads a CapabilityProfile before the model call.

    "Did this turn respect its capability set?" is not a question this architecture can answer. The turns table (crates/journal/src/lib.rs) has no capability, policy or confinement column; confinement observations live in confinement_attempts in a different database (crates/lifecycle/src/lib.rs), keyed by instance slug and member id, with no turn key, no response id and no foreign key to the journal. The strongest external statement available is "instance X was measured confined to policy fingerprint F at time T" beside "instance X ran turn K at time T′" — a turn can be bracketed between two observations, never attributed to one. Nothing on the serving path reads a CapabilityProfile either: it is attenuated once at instance construction and never consulted again before the model call, so the capability model is enforced structurally and out-of-band rather than by a gate in the request path. That is a deliberate design, but the observability half is a gap and not a decision.

  • The append path is a second authority axis the capability model cannot express. Access has deliberately no Write variant (crates/domain/src/capability.rs), yet a write exists and is exercised: crates/records/src/append.rs performs POST /append_activity under a distinct RecordAppend credential, and /confirm appends exactly one activity. The reconciliation the code offers is sound — that write is human-confirmed and never model-driven — but it means a CapabilityProfile cannot say whether a tenant may promote. That authority is carried instead by TenantEntry::append() returning Option<&CredentialRef> (crates/domain/src/registry.rs::TenantEntry::append), which is a second, unmodelled axis. Two places to look for one question.

  • Two config files, two opposite postures on unknown keys. Closed 2026-09-06. The two postures now agree: crates/domain/src/registry.rs::WireEntry, WireContributor and WireRegistry each carry #[serde(deny_unknown_fields)], matching what crates/services/src/service.rs already did, and the registry wire types carry a comment saying the strictness is deliberately local to them and does not change how external API responses are decoded. The reasoning below stands as the argument that closed it.

    A service definition refuses them: #[serde(deny_unknown_fields)] on every struct in crates/services/src/service.rs, so an unknown key is a hard load failure. The tenant registry accepts them silently: crates/domain/src/registry.rs::WireEntry has no deny_unknown_fields and #[serde(default)] on every field, so an unrecognised key in registry.json is discarded without comment. A misspelled required key degrades to an empty string and is caught by the required() guard, so misspelling fails closed — but adding an unknown key is silent, and nothing in either file acknowledges the asymmetry.


J. Which of these should be types, not tests

An invariant enforced by a test catches violations someone already wrote. An invariant enforced by a type prevents them being written. Ranked by what each removes.

Invariant Type-level form Removes
B-11 org verified before thread load typestate: Tenant<Unverified> → Tenant<Verified>, thread loading accepts only the latter an entire ordering hazard — the check cannot be skipped because the value does not exist yet
F-7 REVOKED only from a probe Revoked(ProbeEvidence) where ProbeEvidence is constructible only by the probe the honour system, structurally
E-11 / F-3 journals hold identifiers only the journal accepts OpaqueId, never String a name blacklist that a new field name walks past
A-4 no credential on the runtime request the request builder has no field for one a review item
D-17 bookkeeping only after completion supplied updatable only from a CompletedTurn value the whole class of "marked delivered but wasn't"
F-8 a waiver needs a reason TestResidual { reason: NonEmptyReason } an unaccountable waiver
F-10 expiry is set once no setter; first write wins by construction a moving audit record
B-14 pointer carries its tenant ThreadPointer { tenant, response } cross-wiring, given upstream will not catch it
D-1 routing is three-valued RouteState with no Option the conflation behind E-4
C-6 / G-4 / C-2 / E-6 newtypes with no Default; single-variant enums; no From already done in the existing crates

Status, as of the Rust implementation. Five of these have landed and are recorded in §K: B-11 (typestate — ServiceInstance<Unverified> → <Verified>), A-4 (the request builder has no credential field), D-1 (RouteState, no Option), B-14 (ThreadPointer carries its instance), and E-11 (the journal holds typed identifiers, asserted by reading the file's bytes for sentinels rather than by inspecting the code). D-17, F-7, F-8 and F-10 are lifecycle and remain unbuilt — see the note at the end of §K's introduction about what this implementation does not yet do.

Two the table did not anticipate arrived anyway: the model-visible surface is now gated by the same typestate (K-1, K-3, K-4), and the channel cursor is derived rather than stored, which made crash boundary B6 unrepresentable instead of defended (E-17, ADR 0006).

One tension worth stating rather than resolving here. C-4 requires re-sending the alloy every turn; the context ceiling (§I) measures that at ~5,700–5,910 tokens per turn for the shipped composition, against a 128k window. Both are correct, and together they force a chain-breaking policy — it is not a tuning choice that can be deferred. Whichever way it is set, it is derived from these two invariants and belongs beside them. The policy that was set is a turn count rather than a token bound, for the reason crates/domain/src/continuation.rs gives: instructions dominate the growth and are re-persisted at a constant size, so tokens are proportional to turns for any one composition, and a second knob would be the first knob in different units.

Two independent verification gates exist and should not be merged. smoke_passed (F-2) is provisioning-time, against real credentials, before an entry goes live. organization_verified (B-11) is startup-time, before a persisted conversation is loaded. They answer different questions at different moments; collapsing them would leave a gap on whichever side was dropped.


K. Earned in this implementation

Sections A–J are extracted from the reference: defects it paid for, and what they oblige any replacement to do. This section is different. These are properties this implementation now has, several of which the reference does not — and each is written with what enforces it, because an invariant whose enforcement is unnamed is a wish.

The baseline below is the record of one run, not the state of the tree. It is kept as measured, the way docs/UPGRADE.md keeps a qualification table: editing it to reflect a later run would destroy the evidence that §K was ever earned against something specific. Every figure in it has since moved — the suite is larger, the alloy is 3770e0942624bda4 over 25,575 bytes, and dev/ carries more prove-* scripts than the four named below. Nothing here is a current figure, and no checker gates it; DESIGN.md's status block is where a current figure lives, and it deliberately states passing rather than a total, because a number that moves whenever anyone adds a test is a claim someone has to maintain by hand.

Proven against, at the time: cargo test --workspace 299 passed, cargo clippy --all-targets clean, and the alloy digest 67f80eb2d20172fa over 14,337 bytes unchanged across every commit that claimed to change no model-visible byte. The four proof scripts then existing were last qualified at the 1.4.0 pin — delivery 4/4, lifecycle 5/5, boot 4/4, and dev/prove.sh 6 PASS / 1 BLOCKED, exit 3, the provider having rate-limited the chain check. Read that as blocked, not passed: at that run chain.end_to_end was the only positive control for the two isolation refusals, so it was a run in which nothing distinguished isolation from an empty store (F-14, and §J's rule that a negative check needs a positive one).

Since closed, at the mechanism. The control is now isolation.own_org_control, split out and made model-free — it runs to the crash point before dispatch, so it asserts scope verified, 40 records under the right credential, and 0 create calls without contacting a runtime at all. It has no BLOCKED arm, by construction: a positive control that can be taken out by the same outage as the thing it controls for is not a control. Re-run against a disposable stack at this pin: 8 passed, 0 failed, 0 blocked, both refusals scored beside a control that reported data. docs/UPGRADE.md keeps its table as measured and records the re-run beneath it — a qualification record is the record of one run, and editing it to reflect a later one destroys the evidence that the gap was ever there.

What this implementation does not do, stated here so §K is not read as a completeness claim. Lifecycle is acted on only through narrow explicit operator commands: credential-backed adoption, measured confinement and strict registry publication. Complete export is separate. There is no end-to-end provision or deprovision command: external authority creation, measured activation, destructive administration and the residual-authority ledger are not implemented. Built in the lifecycle model: F-1's authorities — as five variants where F-1's sentence names four, the extra being TenantData, which F-2's own stage list implies with data_initialization_settled and F-1 omits; F-2's ordered stages with resume at the first not reached; F-12's exit-code contract, with lifecycle inspection's orthogonal NOT_READY exit 4 when authority evidence passes before activation; F-14. Partly: F-13 — the mode is set before any content exists, but by create-then-chmod, so the empty-file window F-13 names is narrowed rather than closed; closing it means owning file creation. The deterministic propose-and-confirm approval path is built. No model effect execution: Access has no Write variant. No workflows. Telegram is built and has answered a real question (crates/telegram, 2026-08-31), but no delivery proof runs against it — every crash boundary in dev/ is provoked against the disposable channel fixture, so what is measured is the contract both speak and not the real transport. No offline mode, deliberately: the record store is the only authority on which org a credential authorises, so without one nothing serves.

The governing invariant

K-1 — Every IronWorks-controlled model-visible byte is constructible only through ServiceInstance<Verified>. Enforced by: intra-crate privacy in ironworks-domain, not by a crate boundary. Scope, stated exactly: this covers the instructions and the records envelope — everything IronWorks puts in front of a model. It says nothing about what the runtime prepends; see K-5.

K-2 — Instructions: melt and ModelTurn are private. ServiceInstance::alloy is a private method and ModelTurn has no public constructor, so an alloy is reachable only through build_turn and a turn cannot exist for an instance whose scope was never resolved. Why it stays in domain: a boundary refactor planned to extract composition into its own crate. It was abandoned because extraction forces both constructors public — trading a compiler-enforced boundary for a tidier package one. See K-6. Testable: four compile_fail doctests, plus a positive control first, because a compile_fail that fails for a typo passes vacuously.

K-3 — The envelope is rendered inside build_turn, from typed inputs. build_turn takes an Ask — question, records, RecordsState, timestamp, speaker, fact fields — and renders the envelope itself, using the org from the verified scope. Ask has no field for rendered text and no field for an org. TurnInput::new is crate-private. The defect: the two halves of one model-visible surface had two different enforcement strengths, and the weaker one carried the tenant's business records. Instructions were gated; the envelope was rendered by callers and injected as a finished string through a public constructor. Nothing in the type system made the asymmetry visible. Testable: two compile_fail doctests, each validated by mutation — making TurnInput::new public again breaks exactly one, and adding an org field to the Ask literal breaks exactly the other.

K-4 — Degraded record state carries a typed cause, never a caller's sentence. RecordsState::Degraded(Degradation) with Unavailable and Partial { unreadable: usize }. Adapters report a cause; domain chooses the words. The defect: Degraded { reason: String } had a public field whose contents were rendered verbatim into the envelope, so an adapter or the CLI could put arbitrary text in front of a tenant's model. The same hole as K-3, one level down — the envelope's structure was gated while a sentence inside it was not. A second thing it buys: Partial { unreadable: 3 } renders "3 record(s) … could not be read". The count reaches the model as a count. A model can act on three; it cannot act on "some".

K-5 — Runtime-supplied inputs stay named, never enumerated. ModelInput::RuntimeSupplied names conversation replay and workspace memory as foreign dependencies. K-1 makes no claim about them, deliberately. Why: the foreign share is real and measured — a one-word question on a bare pinned instance consumed 13,171 input tokens before any of our material, and compaction is the runtime's decision. An enumeration that claimed to cover it would be the more dangerous falsehood: it would read as a guarantee where none exists.

The rules those produced

K-6 — Prefer a stronger language-level boundary over a tidier package boundary when the two conflict. Measured, twice. Extracting melt into a model-input crate would have made a private constructor public. Extracting the record types would have done nothing at all, because the duplicate OrgId they were meant to reconcile was reachable only through a conversion that could not fail. Both refactors were planned, both were abandoned after reading the code, and in both cases the package diagram was the thing that looked wrong while the enforcement was already right.

K-7 — domain is effect-free; adapters depend only on domain; the binary composes and owns no network access. domain depends on nothing and names no IO. Every transport dependency is owned by the adapter that speaks it — ureq by the crates that make HTTP calls, rusqlite by the crates that own a store — and services embeds the build-time corpus. The binary composes them and declares no transport dependency of its own. The census belongs in DESIGN.md, which is where crate membership is stated and checked: the enumeration that stood here rotted through a stale count, a stale layer description and two stale dependency lists before crates/claims measured it. Why the binary matters: it briefly owned an HTTP client, on the argument that a single implementation does not need a boundary. That was wrong for a reason worth keeping — the boundary was never about swapping implementations. Adapters being the only crates that touch the network is what makes "who can talk to what" answerable by reading the dependency graph instead of grepping, and one implementation does not change that. Note what effect-free does and does not mean here: no IO, no clock, no credential values. domain carries serde, because record shapes parse from the store's JSON and parsing is not an effect. The alternative — a separate crate domain could not call — is precisely what kept the records envelope outside the gate. Testable: ironworks-channel asserts its own [dependencies] set in a unit test, because its narrowness is a property of the manifest rather than of any .rs file.

A harness fails in both directions

K-8 — A verification harness can invent a match or invent a difference, and both are the same defect. The familiar failure is a check that passes without measuring. The mirror image is a check that reports a difference that is not there, and it is more corrosive, because a green run gets trusted while a spurious red gets investigated — spending exactly the attention the harness was built to save, and training its author to distrust it.

Two concrete instances from one afternoon, both mine:

  • A byte-identity check invented a difference. Comparing three model-visible strings before and after a move, it scanned forward from an anchor that sat inside each string literal — so it captured the closing quote of one string and the opening of the next, and reported CHANGED for all three. The strings were byte-identical. Scanning backward for the opening quote gave the real answer. It was caught only because the diff was obvious nonsense rather than plausible; a subtler version of the same bug would have been believed.
  • A bad one-liner truncated a 446-line source file to zero. open(path, 'w') truncates before writing, and the expression handed to write() evaluated to None, so the write threw after the file was already empty. git checkout restored it exactly — which is the argument for committing a measured baseline before a boundary refactor, demonstrated on the person making the argument, within the hour of making it.

The rule: a harness needs a positive control and a negative one. Assert that it reports a match when there is one, and a difference when there is one. Only the first is habitual, and only the second would have caught the string comparison.


L. Confinement at IronClaw 1.4.0

§A-5 says network authority is removed, not absent by default, and §B-8 that sealed-member status is probed, never inferred. Both survive 1.4.0 — the new managed egress proxy governs the sandbox lane, while builtin.http is a first-party host tool whose policy comes from the grant. What follows is what a measurement pass established about how far that removal can go.

Every figure is measured at 4cb47cfaf3 against a freshly minted member.

L-1 — A fresh member's settings catalogue is fully open; that catalogue is not the model surface. GET /settings/tools returns 53 rows: 51 always_allow, 2 ask_each_time, and agent.auto_approve_tools: true. Confinement is therefore mandatory, not hardening, for the authority that endpoint reports.

The row count has two independent axes, and this table is one of four cells — measured 2026-09-02, docs/experiments/0037-what-the-seam-never-asked-for.md check 3 and docs/experiments/0039-the-catalogue-has-two-axes.md finding 1.

GET /settings/tools operator (WebUI env bearer) minted member
production 53 — the enumeration below 52
hosted-single-tenant-volume-sandboxed 54 53

The caller axis is nearai.web_search; the profile axis is builtin.shell. They do not interact, and the locked pair in §L-4 is the same in all four cells. That resolves the standing disagreement between this section and 0012: both counts were correct for the caller each was read with. Confinement authenticates as the member (§L-6) on whichever profile is served, so the catalogue ConfinementPolicy writes against is the right-hand column. What is corrected here is the label on this enumeration, not its contents.

The enumerations are different layers, measured separately at the pin:

layer measured result
fresh-member settings catalogue 53 rows
host-captured capability surface on the fresh deployment 53 ids
settings-only 8 ids
captured-surface-only 8 ids
fresh member's directly advertised provider definitions 25 names

This section reported 52 rows for a while, against 0012's 53, on the same endpoint at the same pin. The probe 0012 specified was run on 2026-09-02 against a freshly minted member: 53 rows, 51 always_allow, 2 ask_each_time, with dev/prove-confinement.sh independently reporting 53 catalogued, 2 reachable, approval off. The diff was one-sided — this section's enumeration had dropped nearai.web_search, which is also in the host-captured set, so captured-surface-only falls from 9 to 8. 0012 was right and its total stands. No experiment file records that probe.

The 25-name provider-definition row is a table entry with no printed list, and 0012's "named 26 before and 6 after" measures something else — the surface offered to the model, which that file is explicit is not evidence about authority.

The 53 settings rows were:

builtin.admin_configuration_replace
builtin.apply_patch
builtin.attach_workspace_file_to_reply
builtin.document_edit
builtin.echo
builtin.extension_activate
builtin.extension_install
builtin.extension_register_hosted_mcp
builtin.extension_remove
builtin.extension_search
builtin.glob
builtin.grep
builtin.html_to_pdf
builtin.http
builtin.http.save
builtin.ironhub_info
builtin.ironhub_install
builtin.ironhub_search
builtin.json
builtin.list_dir
builtin.notification_channels_set
builtin.operator_config_set_auto_approve
builtin.operator_config_set_tool_permission
builtin.outbound_deliver
builtin.read_file
builtin.skill_auto_activate_learned_set
builtin.skill_auto_activate_set
builtin.skill_install
builtin.skill_list
builtin.skill_remove
builtin.skill_update
builtin.spawn_subagent
builtin.time
builtin.trace_commons.account_login_link
builtin.trace_commons.credits
builtin.trace_commons.onboard
builtin.trace_commons.profile_set
builtin.trace_commons.profile_token
builtin.trace_commons.status
builtin.trigger_create
builtin.trigger_list
builtin.trigger_pause
builtin.trigger_remove
builtin.trigger_resume
builtin.trigger_run
builtin.trigger_status
builtin.write_file
ironclaw.memory.profile_set
ironclaw.memory.read
ironclaw.memory.search
ironclaw.memory.tree
ironclaw.memory.write
nearai.web_search

The 53 host-captured capability ids were:

builtin.apply_patch
builtin.attach_workspace_file_to_reply
builtin.document_edit
builtin.echo
builtin.extension_install
builtin.extension_register_hosted_mcp
builtin.extension_remove
builtin.extension_search
builtin.glob
builtin.grep
builtin.html_to_pdf
builtin.http
builtin.http.save
builtin.ironhub_info
builtin.ironhub_install
builtin.ironhub_search
builtin.json
builtin.list_dir
builtin.notification_channels_set
builtin.outbound_deliver
builtin.outbound_delivery_targets_list
builtin.project_create
builtin.read_file
builtin.result_read
builtin.skill_activate
builtin.skill_auto_activate_set
builtin.skill_install
builtin.skill_list
builtin.skill_remove
builtin.skill_update
builtin.time
builtin.trace_commons.credits
builtin.trace_commons.onboard
builtin.trace_commons.profile_set
builtin.trace_commons.profile_token
builtin.trace_commons.status
builtin.trigger_create
builtin.trigger_list
builtin.trigger_pause
builtin.trigger_remove
builtin.trigger_resume
builtin.trigger_status
builtin.write_file
ironclaw.loop.capability_info
ironclaw.memory.profile_set
ironclaw.memory.read
ironclaw.memory.search
ironclaw.memory.tree
ironclaw.memory.write
ironclaw.tool_call
ironclaw.tool_describe
ironclaw.tool_search
nearai.web_search

The eight settings-only ids were:

builtin.admin_configuration_replace
builtin.extension_activate
builtin.operator_config_set_auto_approve
builtin.operator_config_set_tool_permission
builtin.skill_auto_activate_learned_set
builtin.spawn_subagent
builtin.trace_commons.account_login_link
builtin.trigger_run

The eight captured-surface-only ids were:

builtin.outbound_delivery_targets_list
builtin.project_create
builtin.result_read
builtin.skill_activate
ironclaw.loop.capability_info
ironclaw.tool_call
ironclaw.tool_describe
ironclaw.tool_search

All five API-only capabilities are in the settings-only set. The inspector is operator-only and caller-scoped; tool_search and capability_info control turns on fresh members failed internal_error. Exhaustive fresh-member deferred reachability is therefore BLOCKED, not inferred from either successful enumeration. No one of these lists may be called "the model surface" without naming its layer.

L-2 — The record-only allowed set is empty, and that is derived. IronWorks' request body is {model, instructions, input, previous_response_id}. Its absent tools field means IronWorks registers no caller-hosted function tools; it does not suppress IronClaw's built-in capability catalogue. Records reach the model through gated input rather than by the model fetching them, so every built-in tool grants authority a record-only analytical turn does not need. The burden is on a tool to justify itself against the service that asks for it.

That first sentence is about our body, never about the surface, and the difference was measured rather than assumed. docs/experiments/0031-the-external-tool-lane-is-open.md establishes that /v1/responses does accept tools and tool_choice at the pin, that the host parks such a call rather than executing it, and that the probe deployment has the ports wired. So IronWorks registers no function tools by choice. Two consequences, and they pull in opposite directions:

  • The derivation above is untouched: the empty allow-set is still correct.
  • The two lanes are not independent, and this bullet used to say they were. It read "neither says anything about the other, and enabling one would not confine or unconfine the other." docs/experiments/0035-the-tool-arm-read-the-records-out-of-memory.md measured the opposite on two arms identical but for the field: without tools the runtime answers in one step and invokes nothing; with tools it runs its own agentic loop over its built-in catalogue. Declaring a client tool is a request for agency, not for a function, and it activates precisely the lane the confinement allow-set governs. Enabling one does not confine the other — but it does decide whether the other runs at all.
  • Unconfined, that loop routed around the envelope, measured. On a fresh member it reached ironclaw.memory.*, recovered 63 KB of this tenant's own record content from earlier turns, and answered from it — never calling the declared tool. ironworks_domain::envelope is the gate every model-visible byte is supposed to pass, and the derivation above rests on "records reach the model through gated input rather than by the model fetching them." That is the derivation failing in the one configuration where nothing enforces it. No cross-org boundary was crossed and none is claimed: one runtime credential per tenant means a second tenant is a second member with its own memory. What is demonstrated is the mechanism.
  • So confinement is a precondition of the tool lane, not hardening on top of it. Confined, the same request called the declared tool, matched the baseline answer, caught the injection trap the unconfined arm missed, and cost 20,910 input tokens against a 20,899 baseline — 0.05%. The lane is not merely safer confined; it is functional confined and hijacked otherwise.
  • A service that declares function tools therefore puts a model-visible input path outside ironworks_domain::envelope, which is the property §A and §K rest on. The empty allow-set remains correct and is no longer sufficient. Anything enabling tools owes §A a new accounting of ModelInput before it owes §L anything — and owes a confinement precondition before it serves a turn, which nothing currently enforces because no shipped service declares runtime_tools: "sandbox". no_shipped_service_reaches_a_built_in_tool in crates/cli is the tripwire.

L-3 — Sixteen authority-changing settings rows are denied first; the ordering is conservative, not a claim that all sixteen are model-visible. Installation, MCP, skill and IronHub mutations can add tools; spawn_subagent authorises another actor; notification_channels_set chooses an outbound destination; and three trace_commons rows concern credentials or login links. Five of the sixteen are API-only and were absent from both the captured deployment surface and the fresh member's directly advertised definitions. Exhaustive deferred reachability is BLOCKED (§L-1), so removing them from the deny-first set would replace a cost-free ordering safeguard with an unmeasured conclusion. Ordering invariant: deny these first, then every other disallowed row.

L-4 — Two settings rows are locked and cannot be changed. POST /settings/tools/…{"state":"disabled"} answers 400 validation invalid_value for builtin.operator_config_set_tool_permission and builtin.operator_config_set_auto_approve, and 200 for builtin.echo on the identical request. Both read back state/default_state: ask_each_time, locked: true, mutable: false, and source/effective_source: locked; writing always_allow also answers 400 validation invalid_value and leaves them unchanged. Both are API-only and absent from the measured surfaces in §L-1, so this probe reached no model invocation, approval, or resume path for either row.

Why these two and not others, established 2026-09-02 (docs/experiments/0037-what-the-seam-never-asked-for.md check 2). locked is not a property somebody set; it is computed per read as default_permission == Deny || effects ∩ {Financial, ModifyApproval, ModifyBudget} ≠ ∅ (ironclaw@4cb47cfaf3/crates/product/ironclaw_assistant/src/reborn_services.rs:1951-1962), and both operator_config_set_* capabilities declare EffectKind::ModifyApproval (ironclaw@4cb47cfaf3/crates/extensions/ironclaw_extension_manager/src/operator_config_capability.rs:80,109). So the row set this invariant names is derivable from a field the catalogue already returns and ironworks_reconcile::ironclaw::BoundMember::observe's reader currently reads past. A later capability that gains a hard-floor effect joins the set without anything here changing — which is a drift surface, because ironworks_lifecycle::confinement::plan would queue a write that answers 400 and apply stops at the first failure.

L-5 — For a model-visible capability, ask_each_time is an approval gate, not a denial. The pin's gate admits a matching one-shot lease on resume and otherwise requires approval (ironclaw@4cb47cfaf3/crates/kernel/ironclaw_approvals/src/profile_gate.rs:358-385). Global automatic approval does not change that explicit or hard-floor gate; when the global setting governs an eligible tool, the catalogue reports always_allow, not ask_each_time (ironclaw@4cb47cfaf3/crates/product/ironclaw_assistant/src/reborn_services.rs:1906-1910). That generic mechanism must not be used to infer reachability for the two API-only rows in §L-4.

L-6 — Ordinary confinement is custody-backed; two rows are locked. POST /settings/tools/{cap} writes the caller's own scope, so confinement authenticates as the member — and measured, the member can flip an ordinary row from disabled back to always_allow. Anyone holding the member credential can reverse those ordinary settings. The two operator_config_set_* rows are the measured exception: §L-4's HTTP 400 and unchanged read-back show their locked field is enforced. Never describe the whole confinement policy as immutable, non-overridable, or globally locked; never erase the two locked-row exception either.

Custody-backed is a property of this endpoint, not of the runtime, and there is a measured counter-example. The tenant model-selection policy — PUT /api/webchat/v2/llm/model-policy, {workspace_default, allowed_models} with provider_id derived from the active provider — requires caller.operator_config (ironclaw@4cb47cfaf3/crates/product/ironclaw_operator/src/llm_admin/llm_config_service.rs:841-846) and answers a member 403 Operator WebUI configuration privileges required. Measured 2026-09-02 (docs/experiments/0037-what-the-seam-never-asked-for.md check 8): under a narrow policy a POST /api/v1/responses naming an off-policy model is refused 400, and the identical request succeeds once the policy is widened. That is an upstream control the member credential cannot undo, unlike every write this section governs. It is also outside the settings catalogue entirely, so confinement neither governs nor observes it, and nothing in IronWorks writes it today.

L-7 — The claim is about the settings catalogue only, and the gap now has a walked instance. "Every mutable settings row outside the allowed set is Disabled, with the two locked rows at their measured floor" is not "the model has no other executable authority". The 8-and-8 differences in §L-1 prove the enumerations are not interchangeable — two sets of 53 ids that overlap in 45; exhaustive fresh-member deferred reachability remains BLOCKED.

Measured instance. docs/experiments/0032-a-parked-run-says-completed.md resolved one client-declared function tool and found the runtime rewriting the submitted output into a result_reference and having the model read it back through builtin__result_read — one of §L-1's eight captured-surface-only ids, absent from the catalogue ConfinementPolicy writes against. So a service declaring tools depends on a capability confinement can neither enable, disable, nor observe, on every call. This does not widen or narrow the verified state; it converts this section's caveat from an enumeration gap nobody had walked into one that a tools-enabled service would exercise continuously.

What the confined arm bears on it. docs/experiments/0035-the-tool-arm-read-the-records-out-of-memory.md ran the same two-step loop against a confined member: the call was made immediately, parked in 13.5 s, resumed, and completed at 20,910 input tokens with items [function_call, function_call_output, message]. Per 0032 that resume round trip is what goes through builtin__result_read, so a confined member completed a loop that depends on it — which is evidence the capability survives read_only_analyst() rather than a measurement of it. Neither experiment observed builtin__result_read being invoked in the confined arm directly, and the id is still outside the catalogue the policy writes against, so confinement can neither enable, disable, nor observe it. The question §L owes is now narrower: not whether the lane works confined — 0035 shows it does — but whether that capability is reachable by something other than a resume, which is what an id outside the catalogue would let it be.

L-8 — The achievable settings-catalogue floor is the verified state. On the measured fresh member: 50 disabled + 2 locked at ask_each_time + approval off. Treating the two locked rows as drift makes Verified unreachable. This verdict says nothing about ids outside the settings catalogue and does not turn §L-1's BLOCKED deferred-reachability measurement into a pass. Enforced in ironworks_lifecycle::confinement, mutation-validated, and proven live: fresh → DRIFTED, applied → VERIFIED, one ordinary row re-enabled → DRIFTED.

Which caller this floor belongs to, established 2026-09-02. 50 + 2 is 52 rows — a member minted through POST /admin/users, not the WebUI env bearer, whose catalogue is 53 and whose floor is therefore 51 + 2 (§L-1). dev/prove-confinement.sh used to confine the env bearer and now mints its own member, so this figure is proven against the caller a tenant actually is: measured 52 catalogued, 2 reachable, approval off after 51 written steps, idempotent on a second run.

L-9 — Approval-off is writable once through the settings API, and a re-enabled member cannot be confined again. Two nearby sequences distinguish the invariant. Starting at value: true, source: default, true → false succeeds because it is the first false activity:

sequence A result
write true HTTP 200, value: true, source: override
write false HTTP 200, value: false, source: override — takes

That sequence does not test reversibility after approval has first been disabled and then re-enabled. The discriminating sequence is:

sequence B result
first write false HTTP 200, value: false, source: override — takes
write true HTTP 200, value: true, source: override — takes
second write false HTTP 200, value: true, source: override — does not take
read back HTTP 200, value: true, source: override

The product surface derives this setting's activity id deterministically from tenant, user, agent, project, and the boolean value (ironclaw@4cb47cfaf3/crates/product/ironclaw_assistant/src/reborn_services.rs:1694-1714). The second false therefore reuses the completed false activity instead of executing the underlying store write. The direct store test at ironclaw@4cb47cfaf3/…/auto_approve.rs:372-385 correctly proves that the store can update true → false; it does not pass through product-surface activity replay.

HTTP 200 is not proof that a settings write applied. IronWorks survives the silent no-op only because MemberConfinement::apply checks that the returned entry contains the requested value (crates/reconcile/src/ironclaw.rs::apply). That validation is load-bearing.

Consequence, and it is sharper than §L-6's ordinary-row custody boundary. Approval reversal is one-way: approval-off is step one of the plan (§L-3) and apply stops rather than certifying a write the runtime did not confirm, so a member whose approval has ever been turned back on cannot be re-confined at all. The remedy is to mint a new member, not to re-run confine. An operator recovering from drift must know which of the two they are in.

Consequence for the proof. dev/prove-confinement.sh check 6 sets approval on to prove drift is detected. All six checks pass, then its restore is the second false activity and silently does nothing. Cleanup reads the setting back, reports the residual, and exits with that member still unconfined. Running this proof consumes the member; tear the disposable probe down rather than reusing it.

Near-miss: sequence A, the direct store test, and source inspection all agreed, and almost caused this invariant to be deleted. None exercised the repeated false activity that sequence B asks about. A probe measures the sequence it runs, not the claim it resembles.

L-10 — A client workspace is an explicit different policy, not an exception to L-2. A service declaring capabilities.runtime_tools: sandbox leaves only builtin.shell reachable, with automatic approval off. The declaration is the service's, not the operator's: ConfinementPolicy::for_profile is a total function from the declared ceiling and the lifecycle confine --workspace flag that once chose independently is refused, because two independent answers to "what may this member invoke" is one answer and one thing to keep in sync. The pair sandbox + egress: none does not load, since the allowlist below cannot be emptied. At IronClaw 1.4.0 that is the built-in whose production implementation crosses the user-sandbox process port: writable persistent /workspace, read-only system filesystem, scrubbed credentials, and managed allowlist egress. Every authority-changing and general host tool remains disabled. The deployment must use hosted-single-tenant-volume-sandboxed; allowing shell on the old production profile is not equivalent and must not be described as sandboxed.

Measured 2026-09-02, and the last sentence is stronger than it reads (docs/experiments/0039-the-catalogue-has-two-axes.md). On production there is no builtin.shell row in the settings catalogue at all — the profile decides whether the capability is catalogued, not merely whether it is safe. So sandbox on production is not a weaker sandbox; it is a policy naming a row that does not exist, which assess reports as AllowedToolAbsent. On the sandboxed profile the row is present and the shipped service's runtime_tools: none disables it: dev/prove-confinement.sh reaches 53 catalogued, 2 reachable, approval off. The sandbox itself is still unexercised — dev/prove-workspace.sh had two independent blocks, and only the runtime one is retired; no shipped service declares sandbox, so no turn has run in that workspace.

That sentence is retired (docs/experiments/0059-the-sandbox-works-once-the-model-is-told-the-tools-other-name.md, 2026-09-08). dev/prove-workspace.sh scores 3 PASS against a sandboxed probe using a proof-only service that declares sandbox: a worker wrote /workspace, had no Docker socket, and two later turns on fresh journals — no previous_response_id between them — read the same 64-hex value back. The per-user workspace is sandbox-workspaces/users/<id>/ under the runtime's data directory, not under IRONCLAW_REBORN_WORKSPACE_ROOT.

Three qualifications travel with it, and none is optional. The proof names the tool's wire spelling in its asks, because the runtime otherwise refuses its own model's output (docs/UPSTREAM.md D10) — remove that and the measurement goes back to BLOCKED. The probe needed a Docker daemon new enough for gateway_mode_ipv4=isolated, which the served host's version is not known to satisfy. And this member was never confined: a confined member's sandbox remains unmeasured, so this retires "unexercised" and touches no clause of §L-10's policy claim above.

L-11 — Confinement progress is evidence-bearing and historical. member_confined cannot be written through generic lifecycle progress. ConfinementEvidence::from_attempt admits only a clean measurement for the current policy; the attempt already contains a successfully bound runtime subject, and LifecycleStore::certify_confinement additionally compares its member and service with the instance record. The first qualifying observation and the stage transition are one transaction. A later clean observation appends evidence without adding a second stage row. A later drifted observation does not walk progress backwards: inspection says historically certified and outside policy now as separate facts, because erasing either would turn history into a claim about the present.