Skip to content

id(replication): ReplicationTracker::accept never checks the payload's own journal header — a snapshot with no header, a foreign world's header, a version-99 header or a duplicate header is verified and advances the cursor, though WorldCore::restore refuses every one #4486

Closed

Description

@dezren39

Summary

ReplicationTracker::accept in pkgs/id/src/world/replication.rs (F12.13 replication contract, issue #806) verifies that every payload line decodes as some JournalEntry — but it never checks the payload's own journal header against the frame or the world. A correctly signed frame from the pinned source is accepted, and the tracker's verified cursor advances, for payloads WorldCore::restore refuses wholesale:

  • Snapshot with no header at all — payload is a single CapsGranted entry. Restore: "world journal does not start with a header" (world/core.rs:221).
  • Snapshot whose Created entry names a different world — the envelope's world_id matches the tracker, the payload's header does not. Restore: "journal belongs to world …" (world/core.rs:210-215).
  • Snapshot whose Created entry claims version 99 while the envelope's header_version is 1 — the envelope field passes the <= MAX_JOURNAL_VERSION check (replication.rs:626), the entry's own version is never read. Restore accepts only 1..=3 (world/core.rs:217).
  • Snapshot with a duplicate header — two Created entries in one payload. Restore: "duplicate world journal header" (world/core.rs:232).
  • Tail carrying a Created entry — appending it to an already-headed journal is the same duplicate-header refusal at restore.

The only payload-shape check in the chain path is decodability: chain_payload (replication.rs:367, decode at :384) maps each line through serde_json::from_slice::<JournalEntry> and otherwise treats its bytes as chain arithmetic. Its own comment states the intended guarantee — "refuse it here so a verified cursor never covers data the restore path cannot read" — and PR #4447 added the decode check for exactly that reason. The header rules are the part of restore's contract that check does not cover: restore's first-entry and no-second-header rules (core.rs:210-232) have no counterpart in accept's Snapshot branch (replication.rs:651-666) or Tail branch (:667-686), and the payload header's world_id/version are never compared with envelope.world_id / envelope.header_version.

Evidence (verified this run, laptop probe)

Probe: ~/git/fleet/probe-2026-11-02-laneA-replpayload/ on the laptop — replication.rs copied verbatim from current main 5c49f4807 (md5 f807510cedc0884c513f5a49ea81bfe6, matched against the sandbox clone), with only a minimal JournalEntry stub (the three variants the module's own tests use, same serde tagging) around it. cargo test: 7/7 probe tests pass, plus the module's own 10 tests (probe-output.txt).

  • probe1_snapshot_without_any_header_entry_is_accepted — signed Snapshot, payload [CapsGranted] only, header_version=1: accept returns Ok, tracker cursor advances to ordinal 1.
  • probe2_snapshot_header_entry_version_99_is_accepted — signed Snapshot, payload [Created{world, version: 99}, CapsGranted], envelope header_version=1: Ok, cursor ordinal 2.
  • probe3_snapshot_header_entry_for_other_world_is_accepted — signed Snapshot, payload [Created{"someone-elses-world", 1}, CapsGranted], envelope world matches tracker: Ok, cursor ordinal 2.
  • probe4_snapshot_with_duplicate_header_entry_is_accepted — payload [Created, Created, CapsGranted]: Ok, cursor ordinal 3.
  • probe5_tail_carrying_a_header_entry_is_accepted — after a good snapshot (cursor ordinal 2), a signed Tail whose payload is [Created{world, 1}]: Ok, cursor ordinal 3.
  • Controls pass: the well-formed snapshot ([Created{world, 1}, CapsGranted]) is accepted, and a hand-crafted signed frame with an undecodable entry line is refused (MalformedPayload from the chain step) — so the gap is exactly the header semantics, not the decode or signature machinery.

The signing key is the pinned source, so this is not a forgery scenario: it is a writer bug or a confused peer producing frames the contract says the receiver verifies — and the receiver's verified state then describes a journal no binary can restore.

Why it matters

The tracker is the trust boundary for replication (issue #806): later phases build the writer feed, the durable receiver, and spectator serving on the guarantee that accepted frames form a restorable journal. Today that guarantee holds only for line decodability. A snapshot that rebases the chain onto a headerless / foreign-world / version-99 / double-headed journal poisons the replica at the exact moment it is supposed to replace state wholesale, and the failure only surfaces later — at restore — far from the frame that caused it, with the verified cursor already advanced past it.

Acceptance criteria

  • ReplicationTracker::accept refuses a Snapshot whose payload does not begin with exactly one JournalEntry::Created header whose world_id equals the envelope's world_id and whose version equals the envelope's header_version (which is itself in 1..=MAX_JOURNAL_VERSION per issue id(replication): ReplicationTracker accepts journal header_version 0 — accept checks only the upper bound, so a signed Snapshot rebases a v1-v3 chain onto a version WorldCore::restore refuses (1..=3) #4471), with a named ReplicationError variant; tracker state unchanged on refusal.
  • A Snapshot or Tail payload containing a Created entry anywhere other than a Snapshot's first entry is refused (duplicate-header rule, mirroring core.rs:232); a Tail payload never carries a header.
  • Regression tests in replication.rs for all five probe cases above (no header, foreign-world header, entry version ≠ envelope header version, duplicate header in a snapshot, header in a tail), each asserting refusal and unchanged cursor/header version; the existing snapshot/tail round-trip and rebase tests still pass.
  • cargo test -p id --lib world::replication passes.

Distinct from: issue #4471 (the envelope header_version field's missing lower bound — this issue is the payload's own header entry, which is never checked at all; the two fixes are complementary and both belong in accept), issue #4454/#4455 (references.rs), and the PR #4447 fixes already on main (trailing frame bytes, undecodable entries).

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/sSmall: expected under 2 hours of work
    on Nov 2, 2026
  2. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  3. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · handed off

  4. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · triage-verified

  5. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · triaged

  6. added
    task/fixhas a fix task queued
    and removed
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 2, 2026
  7. 2 remaining items

  8. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    status/triagedTriaged and routed (see agent note)
    on Nov 2, 2026
  9. added
    queue/needs-reviewQueue task finished and is awaiting review
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  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

No one assigned

    Labels

    app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours 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 jobtask/fixhas a fix task queued

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions