Skip to content

id(world): journal restore never sanitizes Issued display names — empty, control-bearing, and over-length names restore verbatim, though mint refuses or strips all three #2960

Closed

Description

@dezren39

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 through sanitize_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:

  1. 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).
  2. 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_list in src/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_capability with a control-only name ("\u{1}\u{2}", which sanitizes to empty) returns Err.
  • CASE B: restore of Created + Issued(1, display_name="") returns Ok; 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) returns Ok; 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 Issued entry whose display_name is empty after sanitization, or sanitizes journaled names on restore exactly as mint does — 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_entries re-emits stored participants, and all stored participants came through mint, 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

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    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 · state: filed

  3. added
    priority/lowNice to have; do when convenient
    and removed
    priority/normalOrdinary priority (the default)
    on Oct 31, 2026
  4. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · state: picked_up

  5. removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  6. 61 remaining items

  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  8. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  9. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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.

  10. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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.

  11. 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
  12. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  13. added a commit that references this issue on Nov 2, 2026
  14. 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/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator 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