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.
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.
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.
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-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:
- A bound. A-3's why is unbounded authority. A tool loop needs a declared maximum round
count in the type model, the way
ThreadLimitbounds continuation — not a constant in the binary. - A
ModelInputaccounting.ToolOutput(ToolId)is already in the rule's enum and nothing produces one. Tool results are model-visible bytes that did not come throughironworks_domain::envelope, so §A and §K's "the model-visible surface is gated" both need re-earning rather than restating. - 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. - A durable parked state.
TurnOutcomehas no variant betweenAcceptedandCompleted. The journal holds identifiers only, so a parked turn may record the response id and thecall_ids but never the model'sarguments— a recovering process re-reads those from the runtime by response id. - A confinement answer. Resolving a call makes the runtime invoke
builtin__result_read, which is outside the settings catalogueConfinementPolicygoverns (§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-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-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-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-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-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.
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'sis_local_modelname heuristic — a leadingqwen, alongsidellama,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.30MODEL_PINrecords 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-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.
- 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_CONTENTrefuses 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), soscrap/personas/COORDINATOR.mdis 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. Forrelationship-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, butThreadLimit::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 onproduction, and the served profile carries a larger prompt.docs/experiments/0052-the-inspector-flattens-the-prompt-it-shows-you.mdfinding 3c put one turn through both profiles at one pin: the composed prompt holds 60 components and 54 capabilities onhosted-single-tenant-volume-sandboxedagainst 58 and 53 onproduction— the extra capability isbuiltin.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 servehas four boot arms and onlyproductionresolves its runtime policy from[policy]in the config file; the servedhosted-single-tenant-volume-sandboxedreaches a constructor that hardcodes its deployment mode, runtime profile and org constraints and reads no[policy]key at all.dev/docker-compose.probe.ymlboots the first;deploy/ironworks-ironclaw@.serviceboots the second. The contract fact and its anchors are inDESIGN.md's upstream contract; the reading isdocs/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 and0039's catalogue axis — are effects of this cause rather than separate facts.dev/probe-up.sh --sandboxedruns 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.
- 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.mdcheck 7: a member minted byPOST /api/webchat/v2/admin/usersfetching another member's response id receives404 param: "response_id", andGET /api/webchat/v2/threads/{same id}/timelinereceives404as well, while the owner's own fetch returns200in 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_tocompares instance and org before recovery acts on a row, pinned bycrates/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 theproductionprofile rather than the served one, which is the transfer bound §I still carries above. - Inspection readiness is separate from authority evidence. A
NotStartedinstance with every authority measured absent still returnsVerdict::Pass, preserving the positive control that distinguishes measured absence from silence. It now also reportsReadiness::NotReadyand 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.httpcannot 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.
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.
-
AFiled 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 the404on a request field is scored as "the turn may have run".404on a deadprevious_response_idas the same pre-dispatch refusal the400arm handles, and asked whycrates/ironclaw/src/client.rs::interpret_errordid not treat it alike. It carried its own escape clause — whether any404from 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): theprevious_response_idmiss at:588, before submit, andrecord_accepted_ackreturningNoneat:631, twenty-four lines after it and after the ack was alreadyAccepted— plus the external-tool registration, the projection reader inside the wait loop, and the idempotency-replay read path. The envelopes are identical, soparamcannot separate them and neither can the status.Unknownis 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 — aBindingRequiredrejection, nothing ran. A404carrying 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 indocs/experiments/0054-a-cutover-orphans-every-conversation-and-the-order-of-the-bump-decides-what-it-costs.mdis the operational answer. -
clippy cleanmeans clippy did not error, not that it emitted no warnings.DESIGN.md's status block claimscargo clippy --all-targets clean, andcrates/claims/src/toolchain.rsdecides that on the exit status — its own comment says so: "the output is not the measurement; the exit status is".cargo clippyexits0on a warn-level lint. The workspace deniesunsafe_code,clippy::todo,clippy::unimplementedandrustdoc::broken_intra_doc_links, so those do fail it; everything else rustc warns about does not. Demonstrated accidentally on 2026-09-10: anunused import: Verdictintroduced while editingtoolchain.rswas reported bycargo clippyon that crate and the control reportedclippy cleanin 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.mdrecord that the catalogue varies by booted profile — 53/52 rows onproductionagainst 54/53 sandboxed, the axis beingbuiltin.shell. No mechanism compares an observation to the profile it was taken on.crates/lifecycle/src/confinement.rs::ConfinementPolicy::fingerprinthashes the version tag, the allowed set, the required witnesses andauto_approve, and the allowed set comes fromcrates/lifecycle/src/confinement.rs::ConfinementPolicy::for_profile— a total function of the service's declaredruntime_tools, not of the deployment profile.crates/lifecycle/src/confinement.rs::ConfinementFactsrecordscataloguedandreachableand nothing measures them against a baseline, and the attestation's catalogue digest rides onto the turn record whilecrates/domain/src/attestation.rs::ConfinementAttestation::validate_forchecks 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_applicablefilters on the recorded member and returns nothing, andcrates/cli/src/lib.rs::run_registry_publish_onerefuses. 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 bycrates/services/src/service.rs::from_raw) andconfinement.rsnever reads it. Two mechanisms that both exist, unconnected — so a service declaringegress: noneis not thereby confined. Narrowed 2026-09-02: one disagreement is now detected, at load rather than at confinement time —CapabilityProfile::incoherencerefuses a definition declaringruntime_tools: sandboxalongsideegress: none(crates/domain/src/capability.rs::Incoherence::SandboxWithoutEgress, controlled both ways bycrates/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.rsstill never readsegress, so a coherent declaration and the policy actually applied to the member remain unrelated, and nothing compares them. -
Closed 2026-09-03. GenericMemberConfinedis a bare stage.reachnow refusesmember_confined. OnlyLifecycleStore::certify_confinementadvances it, and that method accepts aConfinementEvidenceconstructible 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 definitioninternalorexternaland asserted the default wasexternal, so a forgottenSERVICE=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 asymmetrydocs/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 —--serviceis required, the registry field already was, and a guidance marker omittingservice: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 snapshottakes 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-snapshotvalidates 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: theturnstable now carriesprofile_sha256,policy_sha256,catalogue_sha256andobserved_atbehind a migration, read back ascrates/journal/src/lib.rs::CapabilityAttribution; the identity types moved tocrates/domain/src/attestation.rs::ConfinementAttestationandcrates/domain/src/attestation.rs::Sha256Digest, so neither the journal nor the serving path has to reach into operator-sidelifecycleto derive one. It is wired into the served deployment rather than only available:deploy/ironworks-attest@.servicerefreshes 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
CapabilityProfilebefore the model call."Did this turn respect its capability set?" is not a question this architecture can answer. TheNothing on the serving path reads aturnstable (crates/journal/src/lib.rs) has no capability, policy or confinement column; confinement observations live inconfinement_attemptsin 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.CapabilityProfileeither: 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.
Accesshas deliberately noWritevariant (crates/domain/src/capability.rs), yet a write exists and is exercised:crates/records/src/append.rsperformsPOST /append_activityunder a distinctRecordAppendcredential, and/confirmappends exactly one activity. The reconciliation the code offers is sound — that write is human-confirmed and never model-driven — but it means aCapabilityProfilecannot say whether a tenant may promote. That authority is carried instead byTenantEntry::append()returningOption<&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,WireContributorandWireRegistryeach carry#[serde(deny_unknown_fields)], matching whatcrates/services/src/service.rsalready 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:A misspelled required key degrades to an empty string and is caught by the#[serde(deny_unknown_fields)]on every struct incrates/services/src/service.rs, so an unknown key is a hard load failure. The tenant registry accepts them silently:crates/domain/src/registry.rs::WireEntryhas nodeny_unknown_fieldsand#[serde(default)]on every field, so an unrecognised key inregistry.jsonis discarded without comment.required()guard, so misspelling fails closed — but adding an unknown key is silent, and nothing in either file acknowledges the asymmetry.
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.
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.
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.
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.
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 towrite()evaluated toNone, so the write threw after the file was already empty.git checkoutrestored 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.
§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.mdmeasured the opposite on two arms identical but for the field: withouttoolsthe runtime answers in one step and invokes nothing; withtoolsit 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::envelopeis 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 enablingtoolsowes §A a new accounting ofModelInputbefore it owes §L anything — and owes a confinement precondition before it serves a turn, which nothing currently enforces because no shipped service declaresruntime_tools: "sandbox".no_shipped_service_reaches_a_built_in_toolincrates/cliis 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.