Repository navigation
Description
Problem
WorldCore::restore (pkgs/id/src/world/core.rs, the JournalEntry::Issued arm) inserts the journaled Participant verbatim — including its display_name. The struct's own contract is "Caller-provided display name, sanitized and bounded at issue time" (src/world/mod.rs, Participant), and the one live producer enforces it:
WorldCore::mint(core.rs) runs every name throughsanitize_display_name(src/world/util.rs: strip control characters, cap at 48 chars, trim) and refuses the result if it is empty: "participant display name must not be empty".
Restore does neither. A journal no live code path can produce (hand-edited, corrupted, or fork-copied — restore's contract is "a bad journal is refused, never partially applied") therefore restores participants whose names are:
- Empty — a participant no join flow could ever create, shown as a blank entry in every snapshot's participant list and in the presence "left" lines restore itself synthesizes from the name (
presence_event(participant, &name, false)at the end of restore). - Control-bearing and unbounded — names with newlines / BEL / other control characters, and names far past the 48-char bound, carried verbatim into snapshots (
WorldSnapshot.participants), the players list a world program reads (players_listinsrc/world/actor.rs), and presence events. Every other name producer in the system sanitizes first; a restored name is the one that reaches those consumers raw.
This is the restore-side gap, distinct from #2112: that issue is about the sanitizer itself letting format characters (Cf) through on the live path. Here even the sanitizer's existing guarantees (no control characters, ≤48 chars, non-empty) are skipped entirely by the restore path.
Evidence (verified this run, main 51033658)
Probe on the laptop (~/git/fleet/probe-2026-10-31-laneA-restorecap/, a detached worktree of origin/main at 51033658f73a; integration test pkgs/id/tests/probe_restorecap.rs against the real id crate, token digests built exactly as mint builds them):
- CONTROL: live
issue_capabilitywith a control-only name ("\u{1}\u{2}", which sanitizes to empty) returnsErr. - CASE B: restore of
Created + Issued(1, display_name="")returnsOk; a snapshot taken with participant 1's legitimate token lists the participant with name"". - CASE C: restore of
Created + Issued(1, display_name="a\u{7}\n" + 98 more chars)returnsOk; the snapshot participant's name is verbatim (verbatim=true, 101 chars,has_control=true) — no stripping, no 48-char cap, no trim.
Code points (main 51033658): restore's Issued arm at src/world/core.rs:168-222 inserts participant unchecked at :212-221; mint's sanitize + empty refusal at core.rs:864-868; sanitize_display_name at src/world/util.rs:69-76; the Participant doc contract at src/world/mod.rs:307-313.
Why it matters
Display names are the human-facing identity of a participant: they render in participant lists, presence join/leave lines, and program-visible player lists. The system treats sanitization as an issue-time invariant — the type documents it, mint enforces it — so downstream consumers reasonably trust names to be single-line, bounded, and non-empty. Restore silently breaks that trust for exactly the journals that deserve the most suspicion, and once restored, the name is re-journaled verbatim by the next checkpoint (checkpoint_entries re-emits the stored Participant), so the unsanitized name becomes permanent.
Acceptance criteria
- Restore either refuses an
Issuedentry whosedisplay_nameis empty after sanitization, or sanitizes journaled names on restore exactly asmintdoes — triage pins which (refuse matches restore's "bad journal is refused" contract for the neighbouring fields; sanitize-and-keep matches fork/legacy tolerance). Whichever is chosen, a restored name must never be empty, control-bearing, or over the 48-char bound. - Regression tests beside the existing restore tests: empty name case; control-char name case; >48-char name case; a normal minted journal (names already sanitized) still restores byte-identical names.
- Triage checks historical/compacted journals:
checkpoint_entriesre-emits stored participants, and all stored participants came throughmint, so legitimately produced journals always carry already-sanitized names — confirm no legitimate shape is broken by the pinned rule. - Out of scope: strengthening the sanitizer itself against format characters — that is id(world): sanitize_display_name / sanitize_chat filter Cc only — Cf format characters (U+202E, U+200B, bidi isolates) pass into participant names and broadcast chat, and a zero-width-only name passes the join empty check #2112, on the live path.
Dedup checked this run: open+closed search for restore / display name / sanitize — #2112 (open; the sanitizer's own Cf gap on the live path — different mechanism), #1833 (collab cursor names, different surface), #2890/#2894/#2958 (restore Issued scopes / event authors / participant limit — same function, different fields). No filing covers display-name validation in restore.
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · state: filed
idcrate (probe_restorecap.rs, same run as id(world): journal restore never enforces the participant limit — a journal with more Issued entries than WorldLimits.participants restores fully, and the excess participants act #2958): restore accepts an empty Issued display_name and a 101-char control-bearing name verbatim, while live mint refuses a control-only name. Deduped against id(world): sanitize_display_name / sanitize_chat filter Cc only — Cf format characters (U+202E, U+200B, bidi isolates) pass into participant names and broadcast chat, and a zero-width-only name passes the join empty check #2112 (sanitizer's own Cf gap, live path), id(web/collab): a client's cursor name is broadcast verbatim — no length cap and no control-character filtering on the frame path, and identity sanitize_name does not remove control characters either #1833 (collab cursor names), 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/id(world): journal restore never validates Committed/Checkpoint event authors — inputs and chat from never-issued participants restore, replay, and project #2894/id(world): journal restore never enforces the participant limit — a journal with more Issued entries than WorldLimits.participants restores fully, and the excess participants act #2958 (other restore arms/fields).dezren39 commentedon Nov 1, 2026
🤖 Agent note · state: picked_up
61 remaining items
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
dezren39 commentedon Nov 2, 2026
🤖 Agent note · parked
dezren39 commentedon Nov 2, 2026
agent-note:v1
event: picked_up
task: 5500 (repo-health-code-pilot, repo-worker-code; OpenChamber scheduled lane; session ses_e5e6382f58effeYYkQqmW0WWvF)
state: claimed; resumed from awaiting_ci checkpoint. Revalidating #2960 and PR #4279 metadata only. Drew-owned PR contents, branch, and worktree are not inspected.
dezren39 commentedon Nov 2, 2026
agent-note:v1
event: parked
task: 5500 (repo-health-code-pilot, repo-worker-code; OpenChamber scheduled lane; session ses_e5e6382f58effeYYkQqmW0WWvF)
state: parked until 2026-11-02T14:22:04Z (30 minutes). Fresh metadata-only check: #2960 OPEN/Drew-assigned; PR #4279 OPEN non-draft Drew-authored. PR contents, branch, and worktree were not inspected.
resume: after PR closure, explicit ownership change, or rerouting; revalidate fresh before continuing.
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
Merge pull request #4279 from developing-today/queue/5500-2960-restor…
dezren39 commentedon Nov 2, 2026
🤖 Agent note · done
idworkflow is the confirmation, fix-forward if red.