Replacement assay · necessary-guarantee matrix 30 August 2026

Can Rust IronWorks replace ironworks-py?

Not feature parity — necessary-guarantee parity with minimum legacy baggage. Every Python behaviour reduced to its underlying invariant, then judged on whether we would deliberately build it today.

Specimen
ironworks-rs @ 2753384
Reference
ironworks-py — serving 4 tenants, live
Runtime pin
IronClaw 1.4.0 · 4cb47cfaf3
Verified
285 tests · clippy clean
Proofs
boot 4/4 · delivery 4/4 · lifecycle 5/5 · recovery 6P/1B
VerdictNO

The Python process is serving four tenants over Telegram right now. The Rust binary has no channel to reach them, no registry to route them, no conversation to continue, and no way to confine or provision a member.

This is not a parity shortfall. Five of the six product criteria fail outright — and they fail on plumbing, not on guarantees. What Rust has built is largely stronger than what it replaces: 44 invariants preserved, 23 deliberately rebuilt, 8 that Python never provided. What it has not built is the machinery that carries a message from a room to a model and back.

The repository says so itself. README.md: "It does not provision, deprovision, or talk to Telegram, and it observes confinement rather than applying it."

After gaps 1–5 close, the verdict becomes YES, WITH EXPLICIT MANUAL OPERATIONS — deprovisioning, compensation and destructive teardown are correctly left to an operator, and gaps 6–8 are bounded artifacts rather than Rust code.

44
Keep
23
Redesigned
8
New
2
Superseded
8
Drop
35
Missing — 29 critical
§1

Where the line actually is

8 replacement-critical gaps, ranked

Twenty-nine missing invariants, but only eight things to build. The rest ride along — freshness tracking is meaningless without continuation, a deaf-bot startup check is meaningless without addressing.

1

No channel, and no serving process

relay is one poll cycle, one tenant, one thread, then exit — against devchannel, a semantics fixture. There is no Telegram adapter, no loop, no signal handling, and no markdown stripping. Worse: there is no addressing. run_relay turns every message in the matched channel into a billed model turn, and Disposition::NotAddressed is constructed nowhere in the tree.

39.1 · 39.2 · 39.3 · 39.5 · 19.2 · 19.3 · 38.2

2

No tenant registry, and none of its six refusals

Python refuses the whole registry on any of six uniqueness violations, each with a named production defect behind it. Rust has none — not even the single-tenant half: nothing compares the runtime credential to the record-store credential, and nothing rejects an operator token in a member slot. RouteState is complete, tested, and called by nothing.

2.1 · 2.2 · 2.3 · 2.6 — B-4, B-5, B-6, B-8

3

No conversation continuation

forge::build passes None for the thread pointer, hardcoded. Journal::thread_pointer has no caller outside its own tests. Continuation { Fresh, Chain } appears in DESIGN.md and does not exist in the tree. Every turn is a fresh thread with the whole book re-sent — which makes the product a stateless Q&A, not the group conversation it is sold as.

27.2 · 7.3 · 7.7 — C-4, D-9, D-14, D-16

4

Confinement can be proven, but not established

§L-8 records the apply path proven live once — fresh → DRIFTED, applied → VERIFIED, one tool re-enabled → DRIFTED. But the thing that applied it is examples/confine.rs, explicitly labelled dev scaffolding, which records nothing in the lifecycle store. And MemberConfinement is referenced nowhere in crates/forge, so lifecycle inspect --ironclaw reports member_confinement as Unverifiable(NotObserved) on every run. The measurement exists; the operation does not.

11.1 · 11.2 · 11.3 · 18.2 — A-5, §L-1 through §L-8

5

No lifecycle write path

declare, reach and activate exist, are well-typed and are reachable only from examples/seed.rs. Nothing can bring a tenant into existence or record that it did. The current manual tool — provision.sh — imports five Python modules, so it dies with the language.

15.2 · 15.4 — F-1, F-2

6

Residual authority has no Rust home

Deleting a member does not revoke its bearer — re-measured at this pin. Two unexpired sessions sit in the live ledger today, and if Python retires nothing reports them and nothing can measure their revocation. The §J table already names the type this wants: Revoked(ProbeEvidence).

34.1 · 34.2 · 35.1 · 36.1 — F-4 through F-11

7

The egress boundary lives only in the repo being retired

A-5's outer layer — internal-only networks, a gateway, an image-bound verification stamp derived from the running container rather than from a file — is the last thing between a confinement bypass and the open internet. Rust's only compose file is a disposable probe on an ordinary bridge network. The systemd hardening and the off-box backup go the same way.

12.1 · 12.2 · 38.1 · 38.3

8

The answer-quality claim is unearned

The persona bytes are byte-identical and the envelope framing is present, so this is likely to pass. But "likely" is not the standard this repo holds itself to, and its own README calls the eval corpus the bar for replacement. No grader has ever seen a Rust-produced answer.

6.4, evidence half — multi/eval/cases.py

§2

Three open questions, answered

asked without assuming the answer
A · Activation

Should Activated require positive confinement evidence?

What Python's activation actually guaranteed: a tenant becomes routable only after its own credentials measured own-org read, fail-closed on an unknown token, cross-org non-leakage, a completed model turn, and the staged entry loading through the real registry validator — and only after confine-member.sh re-read the live surface and exited non-zero on any survivor.

Rust covers the first four through SmokeEvidence, constructible only from a passing outcome. The fifth has no registry to validate against. Confinement is covered only as Stage::MemberConfined — and this crate's own doctrine says a stage is "a note about ourselves", not evidence about the world. The asymmetry is real: smoke is evidence, confinement is a claim.

But the honest answer to "can a service become routable in a state we consider unsafe?" is not yet, because nothing can make one routable at all. That is the missing writer, not a defect in Activated.

Not a standalone gap. Do not add a stage. Make reach(Stage::MemberConfined, …) take a &Confinement and accept only Verified — the same pattern SmokeEvidence and Revoked(ProbeEvidence) already prescribe. One parameter, and "confined without measurement" becomes unrepresentable.

B · Applying confinement

Is "can prove" without "can establish" enough to replace?

No — and the gap sits in a narrower place than observes but cannot apply. The policy, the drift model and the achievable floor are all built and mutation-validated, and §L-8 records the apply path exercised live. What is missing is that the only thing which can apply it is a dev example that writes nothing down, and that no operator command can even read the result back.

This is not theoretical. §L-1, measured on a freshly minted member at this pin: 52 catalogued tools, 50 at always_allow, 2 at ask_each_time, agent.auto_approve_tools: true. Confinement is mandatory, not hardening — every tenant minted after Python retires would hold unapproved wildcard outbound HTTP while holding that tenant's book.

Replacement-critical, and small. Promote the example: ironworks lifecycle confine <slug> — authenticate as the member, read the catalogue, deny everything outside the policy authority-changing tools first (§L-3: sixteen of them, not two), set approval off, re-read, assess, and refuse to record the stage unless the verdict is Verified. One file, plus one signature change. Not a policy engine.

C · Verdict rendering

Does PASS make NotStarted look healthier than it is?

Reproduced, not reasoned — an instance that has provisioned nothing:

recorded   reached preflight_passed
  org_registration     absent — measured, nothing there
  tenant_data          absent — measured, nothing there
  member               absent — measured, nothing there
  member_confinement   absent — measured, nothing there
  registry_entry       absent — measured, nothing there

verdict    PASS
EXIT=0

Since F-12 makes the exit code the machine contract, an operator loop over slugs reads this as healthy. Python already paid for this in the mirror: its Report.exit_code returns 3 when every row is SKIPPED, under the comment "A REPORT THAT EVALUATED NOTHING IS NOT A PASS."

But the verdict logic is right — there genuinely is no disagreement here, and turning this into Blocked would make BLOCKED mean two things.

Presentation quality, one line short of operationally misleading. Render the meaning with the verdict — PASS — no disagreement — and append the readiness fact when progress is not Activated: not routable: nothing reached. Keep exit 0 for inspect; let a future doctor own readiness. Do not touch the lattice.

§3

The necessary-guarantee matrix

120 invariants across 43 areas
120 shown
Underlying invariant · current Rust state · classification · replacement-critical. Cites are Rust types, functions and tests; §-refs are docs/INVARIANTS.md.
Ref Invariant Current Rust state Class RC
Organization identity, scope, isolation
1.1A credential resolves to exactly one org, server-side; no caller may assert oneServiceInstance<Unverified>::verifyScopeEvidence; Ask has no org fieldKeepRC
1.2Unverified identity refuses to serve — a control fails closedverify() returns Result; no offline mode in boot(); the_same_outage_before_verification_serves_nothingKeepRC
1.3Tenancy is in the key, not in a WHERE clauseRecordScope; no unscoped Book method existsKeepRC
1.4A store answer for another org is refused, not returnedBookFailure::ScopeDrift on every response; prove.sh check 3New
3.1Every isolation check needs a positive controla_degraded_catalog_still_builds_a_turn beside the refusals; chain check runs firstKeepRC
Registry, uniqueness, routing
2.1The tenant set loads or refuses as a whole — never per fileNone. No registry exists; boot builds one instance from CLI flags and two env varsMissingRC
2.2Six uniqueness refusals: slug · channel · member=operator · member=account · shared member · shared accountNone — not even the single-tenant half; nothing compares the two credentials or rejects an operator tokenMissingRC
2.3Sealed-member status is probed, never inferred from the environmentNone. Admin can reach the surface; nothing asserts the serving credential is refused thereMissingRC
2.4A browser User-Agent on the admin probe is a security control, not cosmeticsAbsent — Admin sends none. Only bites once 2.3 existsMissingRC
2.5No ambient tenant; no single-tenant fallbackTrue — but only because there is one tenant per process, which is the wrong reasonKeepRC
2.6Routing is three-valued; a collision never presents as a matchRouteState + route::resolve — complete, tested, called by nothingKeepRC
2.7Guidance is bound to one slug and one serviceGuidance::parse checks the marker's slug and serviceKeepRC
2.8…and lives at exactly one canonical pathAbsent — there is no registry directory for a path to be canonical to. Returns with 2.1Drop
2.9A misconfigured address name is a startup failure, not runtime silenceNo addressing at all, so no name to be missingMissingRC
Model-visible input, pins, capability
6.1Every IronWorks-controlled model-visible byte is constructible only through a verified instancemelt/ModelTurn private, TurnInput::new crate-private; 6 compile_fail doctests, 2 mutation-validatedRedesignedRC
6.2Order is the product: parts, guidance heading, safety tail lastComposition::new, melt; persona bytes diff-identical to PythonKeepRC
6.3The alloy is re-sent every turn — a once-only injection driftsbuild_turn melts its own composition each callKeepRC
6.4Record text is evidence, never instruction; never called "trusted"envelope.rs provenance lines; vr-006 injection trap carried over; sanitizing it is forbidden in commentKeepRC
6.5A speaker name cannot forge envelope structure, and never resolves recordssanitize_speaker, SPEAKER_MAX=64; no resolver exists to misuse itKeepRC
6.6Operator commentary never reaches the model; length checked on stripped contentstrip_html_comments after marker validation; MIN_CONTENT on the stripped bodyKeepRC
6.7An empty book is declared, never impliedRecordsState::Empty → "ACCOUNT RECORDS STATUS: empty"KeepRC
6.8Degraded record state carries a typed cause, never a caller's sentenceDegradation::{Unavailable, Partial{unreadable}} — adapters report a cause, core chooses the wordsNew
6.9The org renders as its id, never its display nameOrg comes from ScopeEvidence; there is no name to substituteKeepRC
9.1No fallback literal for a pin — a pin is a privacy decision wearing a version numberNo Default on ModelPin/ModelName; no env override; ModelSelection::from_pin is the only constructorRedesignedRC
9.2Which model produced a stored answer is unverifiable after the factMeasured ("reborn" on retrieve); recorded, and no check pretends otherwiseKeepRC
10.1An instance may only ever narrow its service's capabilitiesCapabilityProfile::attenuate is a field-wise min; no widening operation; no Access::Write variantRedesignedRC
10.2Runtime-supplied model inputs are named, never enumeratedModelInput::RuntimeSupplied; the foreign share measured at 13,171 tokensKeepRC
Records: selection, freshness, degradation
7.1The record store degrades; conversation survivesfetch_recordsDegraded(Unavailable); prove.sh check 4KeepRC
7.2A malformed row is dropped, not trusted, and the model is told the book is shortPartial{unreadable: n} rendered as a count; an_unusable_catalog_row_is_dropped_rather_than_failing_the_readKeepRC
7.3Freshness is measured, not asked for — supplied maps id to the version shownAbsent. Rust fetches every catalog entry every turn. Moot today; required by gap 3Missing
7.4An unknown supplied-version re-fetches once, never never-againAbsent — no supplied set to hold a versionMissing
7.5No target means widen, not supply nothingWhole-book prefetch subsumes the resolver: Rust always widens, so the failure mode cannot occurRedesigned
7.6A catalogued-but-unfetchable account gets a versioned negative resultAbsent; every miss re-attempts each turn — which is Rust's normal mode, so the cost bound it protects does not yet existMissing
7.7Data-starvation recovery: never chain to a thread that was never given recordsNothing chains, so nothing to recover from. Rides on gap 3Missing
7.8Bookkeeping only after a confirmed-complete turnStructurally true; finish() writes the pointer only on Outcome::CompletedKeepRC
7.9The seam supplies context; it does not reason — deterministic prefetch onlyNo second agent loop, no model-generated fetch, no scoringKeepRC
Turn durability, idempotency, recovery
24.1One store, one transaction: the response id and the pointer become durable togetherthe_response_id_and_the_pointer_commit_together; a_refused_commit_writes_neither_halfKeepRC
24.2Ambiguity is the default; the flag comes from evidence, never from having built a requestrecord_unknown before dispatch; an_accepted_turn_cannot_regress_to_unknown_or_unsentKeepRC
25.1Never a new logical turn; an idempotent replay is permitted when byte identity is provenRequestDigest, may_replay; the envelope is a pure function of Ask (ADR 0005)RedesignedRC
25.2The window before the response id is durable is unrecoverableMeasured false at both pins — IronClaw's idempotency index owns the recovery. prove.sh check 6 replays and recoversSupersededRC
25.3An idempotency-key collision is an alarm, not a retryCause::IdempotencyConflictOperatorRequired with a NonEmptyReasonKeepRC
26.1Once a response id is durable, fetch; never regenerateRecovery::Fetch; prove.sh check 7 proves a total create-count of 1KeepRC
26.2Poll-deadline exhaustion is not "unsent"Cause::BudgetExhausted keeps the id and advises no retryKeepRC
26.3A response id is not a capabilityNot implemented, and need not be — the runtime returns 404 cross-tenant (measured)Superseded
26.4A thread pointer carries its tenant — upstream will not catch a cross-wireThreadPointer{instance, response}; TurnError::ForeignThread; ThreadScope gates both directionsKeepRC
27.1One wall-clock budget for the whole turnDeadline threaded through create and poll; per-phase HTTP timeouts (ADR 0004)RedesignedRC
27.2A rejected continuity pointer self-heals once, on a derived second keyAbsent — nothing chains. Rides on gap 3Missing
Delivery, cursor, compaction
28.1DELIVERED and ACKED are different factsDerived safe_cursor vs stored cursor_acked; finished_is_not_acknowledgedKeepRC
28.2Delivery evidence is three-valued and never overwritten downwardDeliveryState join-semilattice, joined from disk; the join laws tested exhaustivelyRedesignedRC
28.3HTTP status is not the success signal; "could not read the reply" is not "the reply said no"AttemptOutcome::{Complete, Refused, Ambiguous}; unread bodies map to AmbiguousKeepRC
29.1A resend that may duplicate is an operator decision, never a retryRepair::ResendMayDuplicate; ironworks redeliver is the only way past, and never runs a modelKeepRC
30.1Journal the whole batch before any worker startsnote_batch before the loop; a_journalled_batch_holds_the_cursor_before_any_of_it_is_worked_onKeepRC
30.2The safe cursor is the lowest open update across every tenantcursor::safe_cursor — derived, so crash boundary B6 is unrepresentable (ADR 0006)RedesignedRC
30.3Cross-tenant coordination may order work, never acquire tenant authorityOpenness (two values) through a one-method trait; dependencies_are_the_narrow_set asserts the crate's own manifestNew
30.4An in-flight update is never re-dispositionedAlreadyDispositioned; an_update_that_cost_a_turn_can_never_be_relabelled_as_noiseKeepRC
30.5Compaction is bounded by the acknowledged cursor and never forgets an update that cost a turnDisposition::cost_a_turn() — a predicate, not an enumeration that was wrong twiceRedesignedRC
30.6Settled is not succeeded — a parked turn stops holding the cursor but keeps its alarmOpenness::Settled includes OperatorRequired; operator_required_settles_the_cursor_and_keeps_the_alarmKeepRC
30.7The journal holds identifiers onlythe_journal_holds_no_content greps the store's actual bytesKeepRC
Tool confinement and network containment
11.1Network authority is removed, not absent by default — a fresh member is fully open (§L-1)No production writer. assess() is pure and observe reads; the only applier is examples/confine.rs, which records nothingMissingRC
11.2Deny authority-changing tools first — sixteen of them, not two (§L-3)AUTHORITY_CHANGING + Drift::AuthorityChangingReachable at severity 0; the ordering itself lives only in the exampleRedesignedRC
11.3A confinement verdict must be non-vacuous — prove a real surface was read and acted onEmpty catalogues are refused, but a truncated 3-entry all-disabled catalogue still assesses as Verified. No test covers itMissingRC
11.4"Every catalogued tool is disabled" is not "the model has no other executable authority"confinement.rs states it exactly — custody-backed, not locked, catalogue completeness unprovenKeepRC
11.5The allowed set is derived from the product, not inherited (§L-2)Empty against Python's 11 tools, derived from a request body with no tools field — but no Rust turn has yet been served by a fully-denied memberRedesignedRC
11.6Two operator-config tools cannot be disabled; approval-off is the containmentUNDISABLEABLE + the auto-approve clause; the_best_achievable_state_is_verified_and_approval_off_is_what_makes_it_soKeepRC
12.1The runtime has no unrestricted outbound route, derived from the container and never from a fileNone. The only compose file is a disposable probe on an ordinary bridge networkMissingRC
12.2No automatic fail-open: if the gateway dies, the product breaks and containment holdsAbsentMissingRC
Credential custody and secret storage
13.1The model holds no credential authority; no credential on the runtime requestThe request builder has no field for one; Credential is redacted-Debug with no DisplayRedesignedRC
13.2A credential minted for one purpose cannot be used for anotherCredentialPurpose::{RuntimeMember, RecordScope} inside CredentialRefNew
13.3Nothing in the domain model holds a secret valueCredentialRef{purpose, handle} — a careless {:?} prints a keyring path, not a tokenRedesignedRC
13.4Redaction of text we did not construct, such as an upstream error bodyNo redactor. Lower exposure (auth is a header), but the guarantee is absence-of-a-known-path, not constructionRedesigned
13.5The operator-credential variable list is named once — one reader rejects, the other redactsNeither reader exists. Returns with 2.2Drop
14.1Journals hold stages and opaque ids only, enforced by type and not by a name blacklistOpaqueId + typed methods; the_store_holds_no_credential_material reads the file's bytesRedesignedRC
14.2Private state is written with its mode set before any content existsLifecycleStore::open sets no permissions — and the store names every tenant, org, service, channel and member idMissingRC
Provisioning, compensation, activation
15.1Provisioning creates authority in several systems, and a partial state is a fact on diskLifecycleStore, Stage, stage_history with created_id and adopted — model onlyKeepRC
15.2Every check makeable before creating authority is made firstStage::PreflightPassed exists; no checks are implementedMissingRC
15.3Absence of local evidence is not evidence of absent external authorityplan() asks the inventory before running a stage; NextAction::AdoptExisting — Python fixed only the org caseRedesignedRC
15.4A tenant can be brought into existence at allNo writer. forge exposes only lifecycle inspect and lifecycle membersMissingRC
16.1Resume restarts at the first stage not reached; ordering is the contractStage: Ord by declaration; StageSkipped/StageWouldRegress refusalsKeepRC
17.1A failure compensates exactly what it created and reports the rest as residualA flag with no executor — and with nothing creating, there is nothing to compensateDrop
17.2A failed run never destroys tenant datareversible_by_compensation is false for TenantData; failed_provisioning_never_compensates_tenant_dataKeepRC
18.1A tenant becomes routable only after isolation and reachability were measured against real credentialsactivate() takes SmokeEvidence, constructible only from a passing outcome; refuses before every stageKeepRC
18.2…and only after its member's tool surface was certified confinedMemberConfined sits before Staged, but is a bare stage recordable with no evidence. See question AMissingRC
18.3Activation is a file rename, and the compensator is disarmed in the same instantNo file, no trap — the window does not existDrop
Serving observation and the operator surface
19.1Routable is not serving; "nothing has asked" is not "no"ServingObservation is a new and correct model with no producer — observe_serving has no callerMissingRC
19.2Health is forward progress, not liveness — heartbeat against progress and the in-flight deadlineAbsent. No daemon to be wedged yetMissingRC
19.3A recorded pid separates "running here" from "ran here recently", released compare-and-clearAbsentMissingRC
20.1Inspection separates intent, recorded progress and observed authority; disagreement is surfacedinspect() — pure, four axes, Custody four-valued; prove-lifecycle.sh 5/5 against a real serviceNew
20.2A reporting command cannot mutate what it reports onopen_readonly + require_writable; inspection_leaves_the_store_byte_identical; proof compares file bytesRedesignedRC
31.1BLOCKED is not a PASSVerdict four-valued, exit 0/2/3; an_inspection_with_no_reconciler_is_blocked_not_passingKeepRC
31.2A waiver without a recorded reason is not a waiverVerdict::AcceptedRisk(NonEmptyReason) — constructible only with one, and never inferredRedesignedRC
31.3A report that evaluated nothing is not a PASSViolated in one shape: a NotStarted instance with everything absent renders PASS, exit 0. See question CMissingRC
31.4Ask by exit code, not by parsing a listrun_lifecycle_inspect returns the verdict's exit code — which is exactly why 31.3 mattersKeepRC
32.1One command answers "is this safe and ready", with CONFIG and LIVE never mergedAbsent. Three dev proof scripts needing a disposable stack, plus two per-instance lifecycle commandsMissingRC
Reconciliation
21.1A reconciler reports only what the system it asked is entitled to say; silence is not absenceAccountService reports one fact; unmentioned authorities become Unverifiable(NotObserved)New
21.2A refused credential is evidence about the credential, never about whether the authority existsa_refused_credential_is_unverifiable_never_absent — proven liveNew
22.1Deleting a member is not revoking its tokenStated in ironclaw.rs; the fleet report prints it beside every dangling idKeepRC
22.2The admin API cannot attribute a member to a tenantIronclawMember::observe deliberately ignores the spec; all_member_ids reads past names and emailsRedesignedRC
23.1Fleet reconciliation finds authority no instance accounts forunaccounted_members — both directions, NothingToReconcile as a variant, repeating-cursor refusalRedesignedRC
23.2Exhaustive cursor traversal; a truncated listing is never zero unknownsFleetInventory::Blocked; 102-of-102 traversal measured at 1.4.0KeepRC
Deprovisioning and residual authority
33.1The deletion scope is authenticated from the credential; registry metadata can never redirect deletionAbsent — destructive, rare, and operator-mediated by designMissing
33.2The teardown choreography and its four exit codesDrop the choreography, keep the remainder (34.1)Drop
33.3Route absence is positively established in memory as well as on diskA process that re-reads its registry per cycle makes the whole probe unnecessaryDrop
34.1A residual session is recorded with its expiry and no token material, and never stops being reportedAbsent entirely — two unexpired entries live in the Python ledger todayMissingRC
34.2Validity is measured; meaning is declared; a re-record keeps its classificationAcceptedRisk(NonEmptyReason) is the right shape with no ledger to attach toMissingRC
35.1REVOKED comes only from a probe that measured a rejection, behind a negative controlAbsent. The §J table already names the type: Revoked(ProbeEvidence)MissingRC
36.1An expiry is a fact about a token, not about the call that recorded it — first write winsAbsentMissingRC
36.2Suspension does not remove authority from an issued sessionModelled as a deliberate absence, with the reasoning recorded in the crate docsRedesignedRC
Pins, upgrade, serving host
37.1A pin bump is a qualification pass, not an edit; never infer compatibility from source similaritydocs/UPGRADE.md with a measured contract table; probe-up.sh refuses an unlabelled image; the literal is asserted in two placesRedesignedRC
37.2A boot-path regression is a failing proof, not a silent diffprove-boot.sh 4/4 asserts both capability sets and fresh + existing volume boot; DAC_OVERRIDE measured and rejectedNew
38.1The process holding every tenant's credential runs boxed inAbsent — no unit, no ProtectSystem, no BindPathsMissingRC
38.2Something restarts it, and something alerts a human when it stops making progressAbsentMissingRC
38.3Durable state is backed up off-box, encrypted, with a rehearsed restoreAbsent — and the Rust journal path is in no backup setMissingRC
38.4Restart is routine, so shutdown is gracefulStructurally free from the derived cursor — but no signal handling, because there is no loopRedesignedRC
Channel and concurrency
39.1Messages actually reach and leave the tenantdevchannel is a semantics fixture. There is no TelegramMissingRC
39.2Only a summon causes a turn — mention or reply-to-bot, one shared implementationrun_relay bills a turn for every message in the channel; Disposition::NotAddressed is constructed nowhereMissingRC
39.3Channel formatting is deterministic, not requested; strip rather than convertAbsent — prompt guidance was measured to cut emphasis 6→3 per reply, never to 0MissingRC
39.4Split on line boundaries; hard-split only a single over-long linechunk() is correct and char-boundary safe — but there is nothing to strip before itKeepRC
39.5A client-facing error is one sentence, the same whatever brokeAbsent — failures print to operator stderr; nothing is said in the roomMissingRC
40.1Serial within a tenant, bounded-concurrent across tenantsThe safety half is already a property of the derived cursor; four tenants do not need a poolDrop
40.2A shared connection with check_same_thread=False and a re-entrant lockA Python storage necessity; Send/Sync and Mutex<Connection> replace itDrop
§4

Minimum closeout plan

smallest slice per gap · what not to build
01 · blocks everything

Telegram channel and a serving loop

crates/channel-telegram (new)
crates/core/src/route.rs, address.rs
crates/forge/src/main.rs
crates/lifecycle
Invariant
Only a summon causes a turn; formatting is deterministic; a client is told once when something breaks; health is forward progress, not liveness.
Slice
A channel-telegram crate behind the surface channel-http already implements. addressed()/summoned() as pure functions in core beside route::resolve. to_plain() before chunk(). relay --loop with SIGTERM handling and a progress row feeding ServingObservation.
Not
A worker pool. A JSON-state migration path. parse_mode. A second formatter anywhere in the tree.
Proof
prove-delivery.sh re-run against Telegram for one real group; a unit table for addressed() covering mention, reply-to-bot, case and the missing-username startup failure; a byte test that stripping precedes chunking and no marker spans a boundary.
02

Tenant registry and fail-closed validation

crates/forge
crates/core/src/route.rs
crates/reconcile/src/ironclaw.rs
Invariant
The tenant set loads or refuses as a whole; six uniqueness refusals; every tenant is a sealed member, probed against the runtime and never inferred from this process's environment.
Slice
Registry::load(dir) returning validated specs or one error naming the file and the defect. Wire route::resolve into the relay. A startup probe asserting each member credential is refused on the admin surface, with the browser UA.
Not
The *.env shell-sourceable format. GUIDANCE_FILE overrides. ORG_ID as anything but unverified metadata. Per-tenant MODEL. FACT_FIELDS/NAME_STOPWORDS until a tenant needs them.
Proof
Six refusal tests, one per uniqueness row, each asserting the whole registry refuses; a positive control that a valid two-tenant set loads; a live probe proving a member token gets 401/403 where the operator token does not.
03

Continuation and the supplied set

crates/core/src/instance.rs
crates/journal/src/lib.rs
crates/forge/src/main.rs
Invariant
A group's conversation continues; the chain breaks deliberately on alloy-fingerprint change and on a bounded turn or token count; an account is re-sent only when its catalog version moved; ever_supplied survives restart; a rejected pointer self-heals once on a derived second key.
Slice
Continuation { Fresh, Chain(ThreadPointer) } in core; read Journal::thread_pointer in drive(); a supplied table written inside the existing commit_completed transaction; None version means re-fetch-once; the self-heal key sha256(base ‖ "\0fresh-thread").
Not
Keyword-based target resolution — it failed in both directions and whole-book widening is already correct at this scale. The orphan attempt-count fallback until a book is large enough for it to bite. Anything from handoff.py.
Proof
An exhaustive table over (supplied version, catalog version) including both None cases; a restart test that ever_supplied survives; a live proof that a chained second turn re-sends no record, and that a fingerprint change breaks the chain.
04

Apply, verify and record confinement

crates/reconcile/src/ironclaw.rs
crates/lifecycle/src/{lib,confinement}.rs
crates/forge/src/main.rs
dev/prove-confinement.sh (new)
Invariant
Every catalogued model tool outside the allowed set is Disabled at the recorded member's scope, except the two the runtime refuses, which sit at ask_each_time with auto-approval off — and no stage may claim confinement that nothing measured.
Slice
ironworks lifecycle confine <slug>: seven steps, authority-changing first, refusing to record the stage unless Verified. Add the non-vacuity guard. Wire MemberConfinement into inspect --ironclaw. Reconcile the catalogue count first — UPGRADE.md says 53 entries at this pin and §L-1 says 52, and a counted claim cannot rest on two numbers.
Not
A generic policy engine. A non-empty allowed set. A Locked state — §L-6 measured that 1.4.0 has none and the locked field is inert. A catalogue cache.
Proof
§L-8 already has fresh → DRIFTED, applied → VERIFIED, re-enabled → DRIFTED. What it does not have: a member-authenticated re-read through the operator command, a truncated-catalogue fixture that must not verify, and one real turn served by a fully-denied member — which is what earns the empty allowed set.
05

A lifecycle write path — or an explicit decision not to have one

crates/forge/src/main.rs
crates/lifecycle/src/lib.rs
dev/prove-lifecycle.sh
Invariant
A partial provisioning run is a fact on disk, and authority that already exists is adopted rather than duplicated.
Slice
lifecycle declare | reach | activate as operator commands over the existing store, driven by plan(). The operator performs each external step by hand and records it; NextAction says what is next and AdoptExisting stops them minting a second credential.
Not
An automated provisioner. The compensation trap. The staging-directory rename. data_seeded. The --dry-run/--resume/--status trio — plan() subsumes resume and inspect subsumes status.
Proof
Extend prove-lifecycle.sh with a walk that stops at each stage, crashes, and resumes to the right NextAction; a test that activation is impossible without smoke and without confinement evidence.
06

Residual authority: ledger and probe

crates/lifecycle/src/{lib,authority}.rs
crates/reconcile/src/ironclaw.rs
crates/forge/src/main.rs
Invariant
A residual session is recorded with its expiry and no token material; the expiry is written once; REVOKED comes only from a probe that measured a rejection behind a negative control; a re-record keeps its classification; a gate is never made green by forgetting.
Slice
A residual table in the lifecycle store, first-write-wins on the expiry columns. Revoked(ProbeEvidence) constructible only by the probe. Reuse AcceptedRisk(NonEmptyReason) for the waiver. Migrate the two live entries.
Not
residual drop. The JSON file format. TEST_RESIDUAL as a magic string — it is a waiver with a reason. The exit-2-while-outstanding convention beyond what Verdict::exit_code already gives.
Proof
First-write-wins under repeated recording; a re-record keeps a classification; Revoked unconstructible without a probe, as a compile_fail doctest; a live probe whose deliberately-accepted negative control yields BLOCKED, not REVOKED.
07

Carry the boundary and the host, without carrying Python

deploy/ or ops/ (new, in this repo)
crates/forge (optional)
Invariant
The runtime has no unrestricted outbound route, verified against the running container and never inferred from a file; the process holding every tenant's credential runs boxed in, restarts, and its state is backed up off-box.
Slice
Copy the egress overlay, nginx config, connect-proxy, probe script, systemd unit and backup timer in as operator artifacts. Reimplement only the four-state container read in Rust if the doctor wants it, keeping the image-bound stamp. Add the Rust journal and lifecycle store to the backup set.
Not
The activate/rollback choreography in Rust — leave it shell. The CONFIGURED state, which never existed. The whole egress/proof/ tree.
Proof
probe-egress.sh passing against the Rust stack's runtime container with the stamp bound to the pinned image id; prove-boot.sh still 4/4 under the overlay.
08

Earn the answer-quality claim

dev/prove-eval.sh (new)
crates/corpus (if graders are ported)
Invariant
The analyst surfaces contradictions, refuses injected instructions, and does not present stale facts as current.
Slice
Run the existing graders against answers produced by ironworks turn over fixtures/book and fixtures/eval.guidance.md, three runs per case. Port the graders — about 220 lines of regex with no seam imports — or shell out once and record the result.
Not
A new grading harness. A new corpus. adversarial.py.
Proof
A recorded run with per-case verdicts and the REVIEW count, committed beside docs/experiments/.

Three cheap fixes that are not individually critical: the verdict rendering from question C (one line); a 0600 mode on the lifecycle store before content exists; and the confinement non-vacuity guard, which rides inside gap 4.

§5

Do not port

30 behaviours, with why — a guard against reintroduction

Formats and files

  1. *.env registry filesThe shell-sourceable format itself caused quote-stripping and command-substitution defects. The invariant is "one tenant, one validated record", not "one shell file".
  2. provision-journal/<slug>.jsonSuperseded by a typed store with no k=v surface to smuggle a token through.
  3. deprovision/<slug>.json receiptsPlus the authenticated → account_revoked → complete trio: receipts for a choreography that should not be automated.
  4. residual-authority.jsonThe ledger survives as a table; the JSON file does not.
  5. bridge-threads.json + migrationA legacy source for a store Rust never wrote.
  6. .staging/ and the activation renameThe staged/live split existed because the seam globbed a directory. A registry that validates before it routes needs no unservable half-state.
  7. The journal field-name blacklistThe §J table already says the type replaces it — and a blacklist is exactly what a new field name walks past.

Not dropped: identities.json belongs to the Account Service, not to IronWorks.

Lifecycle states and abstractions

  1. activated as a stageIt is ProvisionProgress::Activated{smoke}, which makes activation without a passing smoke unrepresentable rather than merely disallowed.
  2. data_seeded as a claim about rowsRust records that the step settled, which is all Python's own else-branch ever meant.
  3. The nine-state update enumCollapsed into two orthogonal axes plus a disposition. DeliveryRetry and DeliveryReconcile are repairs a state implies, not states.
  4. ACKED as a stored per-update factDerived from one monotone value, which makes crash boundary B6 unrepresentable rather than defended.
  5. Enumerated compactable statesReplaced by a predicate, because the enumeration was wrong twice — most expensively when an operator alarm turned out to be age-compactable.
  6. Suspension semanticsMeasured not to remove authority from an issued session. Modelling it as revocation would be a lie in the type system.
  7. handoff.py266 lines, explicitly NOT WIRED, no product caller, and three unrelated meanings of the word in one repo.
  8. route_revocation.pyA systemd/ps/kill probe needed only because the registry is loaded once and held forever. Re-reading per cycle removes the module.

Workarounds superseded by measurement

  1. RECOVERY_BLOCKED for the pre-id windowMeasured recoverable at both pins by idempotent replay. Keep the state; drop the belief that this window enters it.
  2. CONFINE_VERIFY_ONLY and the re-POST-everything loopCost one request per tool per client and tripped the rate limiter on the mandatory post-bump re-run.
  3. The non-empty KEEP listInherited from a runtime that offered the model a tool surface regardless. Rust sends no tools field and needs none.
  4. confine-existing.shA one-time back-fill for tenants minted before confinement wiring existed.
  5. BROWSER_UA on /v1/responsesThe instance is UA-blind; the header exists for a hosted edge. Keep it on the admin probe, which is a real control.
  6. Host-migration one-offsmigrate-image, repoint-hostname, enable-device-link, sync-vm, backup-laptop-agency.
  7. The Python quality gategate-coverage.sh, run-quality.py, test_doc_refs.py, test_ci_contracts.py — cargo test, clippy and four proof scripts are the equivalent.
  8. Another product's materialdeploy/secretary, deploy/vidgen, and the SOUL / MULTI.template / MULTIMEDIATOR / operational-tail personas.
  9. adversarial.pyThe traps live in the fixtures the corpus already carries.

Old assumptions and stale concurrency

  1. BRIDGE_MAX_WORKERS and the thread poolThe property it protected is now a consequence of min, not a feature. Four tenants do not need a pool.
  2. check_same_thread=False and a re-entrant lockA Python storage necessity with no analogue.
  3. Import-time-capture defencesLazy path resolvers, module __getattr__, "must precede the import" ceremony. Carry the rule forward, never the mechanisms.
  4. redact.py threaded through every print siteReplaced by a credential type that holds no value. A narrow redactor for upstream error bodies may earn its place later; the threading must not come back.
  5. service.audience / model_policy / guidance:"required" / tool_policyThe internal composition is gone, guidance is non-optional, the model comes only from the pin, and tool policy is a type rather than a path to a shell script.
  6. SALES_GROUP_ID and any ambient single-tenant pathIt hand-built a tenant with no guidance-validated persona. Rust's current one-tenant-per-process shape is a gap, not this design.
§6

What Rust gained

not parity requirements — replacement is judged on preserved guarantees
The model-visible surface is compiler-gatedComposition and turn constructors are private; the envelope is rendered inside the gate from typed materials.6 compile_fail doctests, 2 mutation-validated
Scope drift is refused at every readNot just at the catalog. A re-pointed credential cannot deliver one tenant's records to another mid-flight.BookFailure::ScopeDrift
There is no unscoped readRecordScope is obtainable only from a verified instance, and no store method exists without one.Book::catalog, Book::record
A credential reference holds a location, not a valueAnd a purpose, so a record-store credential cannot reach the runtime.CredentialRef, CredentialPurpose
The channel cursor is derived, not storedCrash boundary B6 has no representation, and two separate mechanisms collapse into one min.ADR 0006
Delivery evidence is a join-semilattice"A failed retry must not lower the evidence" becomes a property of the join rather than a rule to remember.join laws tested exhaustively
Compaction by predicateAsking "did this cost a turn?" cannot be wrong about a state nobody thought of.Disposition::cost_a_turn
The cross-tenant coordinator is denied the vocabularyEnforced twice — by a two-value trait, and by a test asserting the crate's own dependency list.dependencies_are_the_narrow_set
Silence is its own answerThree-valued presence and four-valued custody, so "we could not look" is never read as present or absent.proven live against a real Account Service
Provisioning plans against the worldEvery authority is checked for existence before a stage runs. Python fixed only the org-credential case, by hand.NextAction::AdoptExisting
Fleet reconciliation reports both directionsUnaccounted and dangling; a repeating cursor fails closed; "nothing to compare against" is a variant, not a clean score.FleetInventory
Evidence types, not booleansAn activation nothing measured and a waiver nobody justified are both unconstructible.SmokeEvidence, NonEmptyReason
Adopted versus performed is durable"Did we create this?" stays answerable months later.stage_history.adopted, created_id
Read-only proven by file bytesAfter the defect where a status command silently migrated the operator's live store.inspection_leaves_the_store_byte_identical
A boot-path and capability proofSomething the three behavioural proofs structurally cannot see — plus a documented rejection of DAC_OVERRIDE.prove-boot.sh 4/4
The pin is declared in two placesSo a bump cannot be accidental, and an unlabelled image is refused.the_committed_pins_parse