Skip to content

id(world): journal restore never validates Issued scopes — unnamed bits and JOIN-less capabilities restore clean, and a CHAT-/INPUT-only token can act without ever joining #2890

Closed

Description

@dezren39

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; None if any bit names no scope. The admin authority bits round-trip here even though they are outside ALL" — i.e. it is the journal round-trip validator, and restore does not call it.
  • WorldCore::mint refuses any capability whose scopes lack JOIN: "world capability must include join scope". WorldScopes::attenuate likewise returns None unless the requested set keeps JOIN, and every role preset in from_role includes JOIN.

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"):

  1. Unnamed bits are silently carried. scopes = 0x87 (GUEST + bit 0x80, which names no scope) restores Ok, even though WorldScopes::from_bits(0x87) is None. The bit is inert in today's effective_scopes (it intersects from ALL), but the journal now holds a scope set the type itself calls invalid, ready to acquire meaning if a future bit is assigned.
  2. A capability can act without ever joining. scopes = 0b00010 (CHAT only, no JOIN) restores Ok, and its token then passes chat() — which authorizes only WorldScopes::CHAT (core.rs, authorize(token, WorldScopes::CHAT)) — and posts chat into a world it cannot snapshot or join (snapshot() authorizes JOIN and fails). Same shape for INPUT-only (0b00100) through prepare_input/input(), which authorizes only INPUT. No live issuance path can mint such a capability: mint refuses 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: Issued with GUEST scopes (0b00111) restores Ok; its token chats Ok.
  • CONTROL: live issue_capability with CHAT-only scopes on a fresh WorldCore returns Err (the JOIN requirement in mint).
  • CONTROL: restore refuses Issued with uses = Some(0) — restore does validate sibling fields.
  • CASE A: WorldScopes::from_bits(0x87) is None, but restore with scopes = 0x87 returns Ok.
  • CASE B: restore with scopes = 0 returns Ok.
  • CASE C: restore with scopes = 0b00010 (CHAT, no JOIN) returns Ok; core.chat(token, …) is Ok; core.snapshot(token) is Err.
  • CASE D: restore with scopes = 0b00100 (INPUT, no JOIN) returns Ok; core.input(token, …) is Ok.

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 Issued entry whose scopes fail WorldScopes::from_bits (any bit outside ALL_BITS), naming the participant in the error, in the style of the neighbouring restore errors.
  • Restore refuses an Issued entry whose scopes lack JOIN, matching the invariant mint and attenuate enforce 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 (the from_bits admin 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_scopes chain intersection — confirm whether restore should still refuse).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane A, new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a run 2026-10-30 19:35 CDT
    • Found via: filed this run
    • Task: Filed after verifying on laptop probe ~/git/fleet/probe-2026-10-30-laneA-restorescope/ (real id crate at main 690e472): WorldCore::restore accepts Issued scopes unchecked (core.rs:215) — unnamed bit 0x80 restores Ok though from_bits rejects it, and CHAT-/INPUT-only capabilities (no JOIN, unmintable via mint at core.rs:862) restore and can chat/input while snapshot (JOIN) fails. Controls: GUEST restores+chats, live mint CHAT-only errs, restore uses=0 errs.
    • Expectation: effort/xs — triage pins the restore-side validation (from_bits + JOIN, matching mint), then routes a small_fix packet with regression tests beside the existing restore tests.
    • Links: evidence body in issue; probe worktree on laptop (detached at origin/main 690e472, md5-matched core.rs/mod.rs)
  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    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.md in 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:

    • The Issued arm of WorldCore::restore refuses scope bit patterns with unnamed bits and any set without JOIN — exactly the predicate live issuance enforces (core.rs issuance JOIN ensure; WorldScopes named bits in world/mod.rs).
    • Strictness is safe by construction: every capability live code has issued satisfies JOIN + named bits (directory tiers are built from WorldScopes constants), so no legitimately-written journal can be refused.
  4. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    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 by effective_scopes chain intersection — triage confirms whether restore should still refuse).

  5. 88 remaining items

  6. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  9. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  10. added a commit that references this issue on Nov 2, 2026
  11. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions