Repository navigation
Description
Problem
WorldCore::restore (pkgs/id/src/world/core.rs, the JournalEntry::Issued arm) rebuilds a participant's scopes with a direct tuple construction — scopes: WorldScopes(scopes) — and never validates the raw journaled u8. Every other path that creates a scope set validates it:
WorldScopes::from_bits(src/world/mod.rs) exists for exactly this: "Scopes from raw bits;Noneif any bit names no scope. The admin authority bits round-trip here even though they are outsideALL" — i.e. it is the journal round-trip validator, and restore does not call it.WorldCore::mintrefuses any capability whose scopes lackJOIN: "world capability must include join scope".WorldScopes::attenuatelikewise returnsNoneunless the requested set keepsJOIN, and every role preset infrom_roleincludesJOIN.
Restore validates the neighbouring Issued fields — duplicate participant id, parent must already exist, uses >= 1, a delegate's expiry may not outlive its parent's (there is a dedicated test, restore_refuses_a_delegate_outliving_its_parent) — but the scopes byte gets none of that: no from_bits check, no JOIN requirement, nothing.
Consequences, both from a journal no live code path can produce (a hand-edited, corrupted, or fork-copied journal — restore's own contract is "a bad journal is refused, never partially applied"):
- Unnamed bits are silently carried.
scopes = 0x87(GUEST + bit0x80, which names no scope) restoresOk, even thoughWorldScopes::from_bits(0x87)isNone. The bit is inert in today'seffective_scopes(it intersects fromALL), but the journal now holds a scope set the type itself calls invalid, ready to acquire meaning if a future bit is assigned. - A capability can act without ever joining.
scopes = 0b00010(CHAT only, no JOIN) restoresOk, and its token then passeschat()— which authorizes onlyWorldScopes::CHAT(core.rs,authorize(token, WorldScopes::CHAT)) — and posts chat into a world it cannot snapshot or join (snapshot()authorizesJOINand fails). Same shape for INPUT-only (0b00100) throughprepare_input/input(), which authorizes onlyINPUT. No live issuance path can mint such a capability:mintrefuses it outright (verified control below).
Evidence (verified this run, main 690e4722)
Probe on the laptop (~/git/fleet/probe-2026-10-30-laneA-restorescope/, a detached worktree of origin/main at 690e4722; core.rs/mod.rs md5-identical to the sandbox clone) — an integration test against the real id crate (tests/probe_restorescope.rs), token digests built exactly as mint builds them ("<id>.<hex secret>", SHA-256):
- CONTROL:
Issuedwith GUEST scopes (0b00111) restoresOk; its token chatsOk. - CONTROL: live
issue_capabilitywith CHAT-only scopes on a freshWorldCorereturnsErr(the JOIN requirement inmint). - CONTROL: restore refuses
Issuedwithuses = Some(0)— restore does validate sibling fields. - CASE A:
WorldScopes::from_bits(0x87)isNone, but restore withscopes = 0x87returnsOk. - CASE B: restore with
scopes = 0returnsOk. - CASE C: restore with
scopes = 0b00010(CHAT, no JOIN) returnsOk;core.chat(token, …)isOk;core.snapshot(token)isErr. - CASE D: restore with
scopes = 0b00100(INPUT, no JOIN) returnsOk;core.input(token, …)isOk.
Code points (main 690e4722): restore's Issued arm validates digest shape, duplicate id, parent existence, uses >= 1, expiry-vs-parent, then inserts WorldScopes(scopes) unchecked at src/world/core.rs:215; WorldScopes::from_bits is at src/world/mod.rs:189; the JOIN requirement in mint is at core.rs:862; chat() authorizes only CHAT at core.rs:965 and prepare_input() only INPUT at core.rs:1004, while snapshot() authorizes JOIN at core.rs:1042 — so the JOIN invariant is the only thing keeping a non-join capability from acting, and restore is the one producer that skips it.
Why it matters
Restore is the trust boundary for persisted world state: its whole design is to refuse internally inconsistent journals rather than partially apply them, and it already enforces the delegate-narrowing rule for expiry. Scopes are the authorization substance of the entry — the one field where an inconsistent value changes what a bearer token may do — yet they are the field restored without a check. A journal produced by a bug, a partial write outside the journal format's own protections, or a future writer that trusts restore to catch bad scopes yields capabilities that act in a world they never joined, in a shape no operator ever issued.
Acceptance criteria
- Restore refuses an
Issuedentry whose scopes failWorldScopes::from_bits(any bit outsideALL_BITS), naming the participant in the error, in the style of the neighbouring restore errors. - Restore refuses an
Issuedentry whose scopes lackJOIN, matching the invariantmintandattenuateenforce for live issuance. - Regression tests beside the existing restore tests (cf.
restore_refuses_a_delegate_outliving_its_parent): unnamed-bit scopes refused; JOIN-less scopes refused; a valid GUEST/builder/moderator journal still restores (thefrom_bitsadmin bits must keep round-tripping). - Triage confirms there is no legitimate historical journal shape with JOIN-less or unnamed-bit scopes that must keep restoring; if one exists, name it explicitly instead of loosening the check silently.
- Triage also rules on the adjacent gap in the same arm: a delegated entry's scopes are not checked against its parent's implied scopes the way expiry is (partly mitigated at use time by
effective_scopeschain intersection — confirm whether restore should still refuse).
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · filed
dezren39 commentedon Oct 31, 2026
Agent note (design-architect, 2026-10-30): this issue is Phase 1 of design #2898 ("reconstruction is admission", design doc
files/designs/code-id-reconstruction-validation.mdin the keep-the-four-repos-healthy goal workspace), together with #2894.Recorded decisions for triage — do not re-file, re-scope, or split this issue; record the phase structure instead:
Issuedarm ofWorldCore::restorerefuses scope bit patterns with unnamed bits and any set without JOIN — exactly the predicate live issuance enforces (core.rsissuance JOIN ensure;WorldScopesnamed bits inworld/mod.rs).WorldScopesconstants), so no legitimately-written journal can be refused.dezren39 commentedon Oct 31, 2026
Correction/addendum (design-architect, same pass): two precisions from this issue's acceptance, now recorded in design #2898's doc — (1) the unnamed-bits check is
WorldScopes::from_bits(ALL_BITS, with the admin authority bits continuing to round-trip), not a hand-rolled mask; (2) this phase also carries the triage ruling on the adjacent gap in the same arm: a delegated entry's scopes vs its parent's implied scopes (partly mitigated at use time byeffective_scopeschain intersection — triage confirms whether restore should still refuse).88 remaining items
dezren39 commentedon Nov 2, 2026
🤖 Agent note · parked
queue/5459-2890-restore-scopes-validate, head 2e03b81) OPEN. The earlier protected-owner framing was the shared-account pattern again — PR fix(id): validate Issued scopes in WorldCore::restore (#2890) #4254 is on this task's queue/ branch with a task-bound body.dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
WorldScopes::from_bits+ JOIN ensure with participant-naming errors, and tests cover the unnamed-bit refusal, JOIN-less refusals, and GUEST/admin-bit round-trips — matching the acceptance criteria. The Codex P1 (test-name prefix) is fixed in the head commit and replied to; I am resolving that thread and merging under the standing merge authority (PRs do not wait for CI). Theidrun 41408150312 has sat queued since 2026-11-02T07:43Z in the runner backlog, and the laptop load (~147) blocks the local cargo test.idworkflow confirms afterwards (fix-forward if red).Merge pull request #4254 from developing-today/queue/5459-2890-restor…
dezren39 commentedon Nov 2, 2026
🤖 Agent note · done
WorldCore::restore's Issued arm validates journaled scopes withWorldScopes::from_bits(participant-naming refusal for unnamed bits) and ensures JOIN before insert, with the threetest_restore_*regression tests in pkgs/id/src/world/tests.rs. The Codex P1 was fixed in the merged head and its thread is now resolved. Merged under the standing merge authority: theidCI run sat queued since 2026-11-02T07:43Z in the runner backlog and laptop load (~147) blocked the local cargo test — main'sidworkflow is the confirmation, fix-forward if red.