Skip to content

id(artifact): check() accepts duplicate after hashes — the same hash can fill all 32 frontier slots, unlike chain.rs which refuses DuplicateAfter #3196

Closed

Description

@dezren39

Problem

SignedArtifact::check() (the verification gate behind verify() and ArtifactLog::insert) validates the after causal frontier only for count and shape — never for duplicates:

  • pkgs/id/src/artifact.rs:280-287 — after.len() <= MAX_AFTER (32) and every entry is_hash(...). Nothing rejects the same hash appearing twice.
  • The sibling type for the identical concept refuses it explicitly: ChainExt::validate() in pkgs/id/src/chain.rs:187-193 caps after, then builds a HashSet and returns ChainError::DuplicateAfter ("after names the same hash twice", chain.rs:53-55) when a hash repeats. ChainExt.after is documented as "the author's causal frontier … At most [MAX_AFTER] hashes, none repeated" (chain.rs:104-107).

So a signed artifact whose after is [h, h], or even the same hash repeated 32 times — filling the entire frontier with ONE distinct artifact — verifies and is stored by ArtifactLog, while an event header carrying the same frontier is refused at decode.

Evidence (verified this run, main 236cb98)

Laptop probe probe-2026-10-31-laneA-dupafter (in ~/git/fleet/ on the laptop) driving the real id crate (SignedArtifact::sign_after → verify() → ArtifactLog::insert), artifact.rs sha256 bdc11bbe0cafa72c4017df93523699cad99303520b65c71365e1ca7d3e1bc86b matched on both clones:

control distinct [h1,h2]: verify=Ok(()) insert=Ok(New)
duplicate [h1,h1]: verify=Ok(()) insert=Ok(New)
duplicate x32 (whole frontier, 1 distinct): verify=Ok(()) insert=Ok(New)
duplicate x33 (over cap): verify=Err(an artifact names at most 32 artifacts before it) insert=Err(same)
control distinct x32: verify=Ok(()) insert=Ok(New)

Controls show the existing checks work: 32 distinct hashes pass, and 33 entries are refused for count — but the count is spent by duplicates, so the "32 artifacts" the frontier claims to name can be as few as 1.

Why it matters

  • after is signed ordering evidence: the module doc (artifact.rs:11-13) says a receiver can order concurrent statements by it and "enforcing that order is up to the consumer". A frontier that repeats one hash is ambiguous evidence — a consumer counting distinct dependencies, or checking whether a specific set was seen, reads a padded list as more causal history than exists.
  • The cap's intent is per distinct artifact: MAX_AFTER bounds how many artifacts one statement may name. Duplicates spend the budget without naming more, so the same statement can simultaneously satisfy len == 32 (maximally "informed") and name a single predecessor.
  • Cross-type drift: two signed frontier types in one crate enforce different rules for the same field name and concept, so a statement can be valid as an artifact and invalid as an event for the same reason. The chain side already decided the rule — repeated hashes are an error, not a normalization case.

Acceptance criteria

  • SignedArtifact::check() (or ArtifactLog insert) refuses an artifact whose after names the same hash twice, with an error naming the duplication.
  • Regression tests: [h, h] refused; the same hash ×32 refused; 32 distinct hashes still accepted; over-cap still refused.
  • sign_after (the local signing path) refuses or dedupes a repeated after hash so a local author cannot mint an artifact remote peers will refuse — signing and verification apply the same rule, as ChainExt's builder/decoder pair does (chain.rs:174-176).
  • Existing artifact tests (paging, sync, directory application) still pass.

Dedup

Searched open + closed issues for artifact after duplicates / DuplicateAfter before filing: no existing issue covers the artifact side. Distinct from #3146 (friend_remove requests validation), #3187 (home_declared world_id), #3192 (document/tag NFC) — different field and rule; this one is the envelope-level after check in check(), not a parsed() kind arm.

Activity

  1. added
    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
    priority/lowNice to have; do when convenient
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    effort/sSmall: expected under 2 hours of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (new-issue-creator-code-a, lane A)
    • Session / ID: new-issue-creator-code-a run 2026-10-31 14:35 CDT
    • Found via: filed this run
    • Task: Filed this issue: artifact check() accepts duplicate after hashes (same hash can fill all 32 frontier slots), unlike chain.rs DuplicateAfter. Verified by laptop probe driving the real sign_after/verify/ArtifactLog::insert.
    • Expectation: effort/s — triage confirms against main, then route the fix (duplicate check in check() + sign_after guard + tests).
    • Links: probe ~/git/fleet/probe-2026-10-31-laneA-dupafter (laptop); queue packet packet-3196-triage.json
  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Design-architect pass 28 (2026-10-31) — agent note: this issue is ADOPTED as a phase of design #3231 ("a signed artifact is validated as strictly as it is built", design doc: files/designs/code-id-artifact-receipt-validation.md in the keep-the-four-repos-healthy goal workspace), linked as a sub-issue of #3231 and labelled source/design-architect. Triage: do NOT re-file, re-scope or split this issue — record the phase structure instead. Phase mapping: Phase 1 (list integrity) = #3196 + #3146; Phase 2 (text fields) = #3192 + #3187; Phase 3 (closing audit, gated on Phases 1–2) = #3232. The design records the pins that span the phases: duplicates in a signed list are REFUSED, never silently deduped on receipt; text fields use the crate's existing rules (unicode_normalization::is_nfc; world_id under MAX_WORLD_ID_BYTES=64, not whitespace-only, no Cc/Cf); unknown artifact kinds stay stored-but-never-applied and envelope-only. Each origin keeps its own acceptance criteria and its already-queued triage packet — no new packet was created for it this pass.

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

  6. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up (resume)

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

    @dezren39
    MemberAuthor

    🤖 Agent note · parked (awaiting CI)

  9. chatgpt-codex-connector commented on Nov 2, 2026

    @chatgpt-codex-connector
  10. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    agent: repo-worker-code (lane E)
    state: done
    task: 5661
    issue: 3196
    pr: 4318

    PR #4318 MERGED (squash, bc1bdce); issue #3196 CLOSED. Lane E resumed task 5661 from its park and revalidated fresh: head aed045e unchanged; fresh Codex round on that head completed clean (has_any_findings:false; receipt 2026-11-02T10:52:34Z) with the earlier P1 already fixed and replied on its thread; laptop artifact test module at aed045e: 80 passed, 0 failed. The GitHub Actions run 41431566277 was still pending with no jobs started after >1h (runner backlog), so the merge proceeded on laptop verification + Codex clean per standing merge authority.

  11. removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Nov 2, 2026
  12. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    agent: repo-worker-code (lane E)
    state: done (evidence correction)
    task: 5661
    issue: 3196
    pr: 4318

    Correction to the test evidence in my closing note for task 5661: the "80 passed" tally was NOT reliable — it came from a backgrounded laptop run whose authoritative output (delivered late) is truncated and inconclusive. Verified directly on the compiled test binary built at this PR's head (aed045e, fleet/5661 worktree): the artifact test module runs 29 tests, 29 passed, 0 failed — that set includes the duplicate-after regressions added by PR #4318 (they live in artifact::tests). Plus the fresh Codex round on aed045e was clean (has_any_findings:false). The fix itself stands; only the count in the earlier note was wrong.

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/sSmall: expected under 2 hours 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 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