Skip to content

fix(sync): reject non-IRI durable-meta subjects at unverified peer-ingest (#1921) - #1936

Merged
Jurij89 merged 9 commits into
testnet-canaryfrom
fix/1921-iri-only-durable-meta
Jul 23, 2026
Merged

fix(sync): reject non-IRI durable-meta subjects at unverified peer-ingest (#1921)#1936
Jurij89 merged 9 commits into
testnet-canaryfrom
fix/1921-iri-only-durable-meta

Conversation

@Jurij89

@Jurij89 Jurij89 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Closes two peer-ingest attack vectors on durable _meta verification by sanitizing the verification inputs to IRI-only at BOTH entry points — selectVerifiedDurableSyncQuads and planBoundedGraphScopedDurableBatch now filter metaQuads through isIriMetaSubject into iriMetaQuads before indexIntegrityMetadata / verifyLegacyCandidates. (a) Candidate/self-auth: a non-IRI subject bearing dkg:merkleRoot can no longer enter merkleSubjects and self-authenticate its own peer-supplied data (the claimed root is peer-supplied, not on-chain-anchored). (b) Poison/DoS: a _:bad dkg:partOf "<valid-ual>" row can no longer reach readIntegrityMetadata's PART_OF scan and falsely invalidate a legitimate graph-scoped UAL (fatalUnscopedFailure → valid batch rejected → sync pins).
  • Admission still drops + counts. Both meta selectors (selectAdmittedMetadataIndexes, selectSystemOverrideMetadataIndexes) deliberately run on the ORIGINAL metaQuads and keep an isIriMetaSubject guard at the top of the loop that drops and counts non-IRI rows into droppedNonIriSubjectTriples, emitting one aggregated warn per selection. Nothing non-IRI is ever persisted, and the drop is still accounted for the meta cursor. Verification is subject-keyed, so no positional remap is needed across the sanitized/original split.
  • Livelock cursor-advance via one aggregate. An all-non-IRI meta-only page verifies zero meta; a naive drop that discarded these rows without counting them would leave updateMetaCheckpoint false and re-serve the same page forever (a livelock). The worker now exposes a single reason-agnostic consumedUnpersistedMetaTriples (= droppedSyncControlTriples + droppedNonIriSubjectTriples); when it equals totalFetchedMetaQuads the requester marks discardedOnlyMetadataResponse and advances the meta cursor. Per-reason counts remain verifier-side diagnostics only, keeping checkpoint orchestration decoupled from discard policy.
  • Aggregate is REQUIRED, not optional. consumedUnpersistedMetaTriples is a required field on the requester's process-result contract — an optional field silently reading 0 would let the meta cursor pin with no type error, so every producer must set it.
  • REJECT, never skolemize. A peer-injected blank node has no authenticatable, stable identity; skolemizing would mint a fabricated IRI over attacker-shaped metadata. Dropping is the only fail-closed option that preserves the invariant "every durable _meta subject is a conforming IRI".
  • Incoming peer quads only — no store sweep. The guard operates on incoming quads; it never reads, DROPs, or sweeps the store, so there is zero data-loss risk to already-persisted durable data. A blank-node _meta subject written by a pre-fix peer may still reside in the store, so fix(sync): keep a graph-scoped seal's _meta subject-atomic across durable pages (#1788) #1916's per-query-relabel subject-atomic accommodation stays as defense-in-depth (docstring updated).
  • Scope boundaries. The oversized-_meta-subject SIZE vector (a valid IRI subject bearing a giant literal) is orthogonal to this IRI-only guard and is deferred to Wire-negotiate durable-meta byte-budget pagination (pre-canary short=EOF requester + oversized meta subject) #1923 (SyncPagePolicy) — DurableMetaPageFrameError references repointed from Enforce IRI-only durable-meta subjects at unverified peer-ingest (root fix behind #1788 fail-loud) #1921 to Wire-negotiate durable-meta byte-budget pagination (pre-canary short=EOF requester + oversized meta subject) #1923. Unifying the ingest classifier isIriMetaSubject with the responder read's isIriTerm into one shared classifier is deferred to Unify the durable-meta IRI term classifier across ingest (durable-integrity) and responder read (graph-plan) #1940.

Related

Diagrams

Flow 1 — Peer _meta-page verification (durable-integrity)

Before: a peer's non-IRI (_:blank / literal) _meta subject enters indexIntegrityMetadata over the full metaQuads, so it can either become a merkle candidate and self-authenticate its own peer-supplied data, or poison a legitimate graph-scoped UAL via readIntegrityMetadata's PART_OF scan (a valid batch is rejected and sync pins).

sequenceDiagram
    participant Peer
    participant Ver as Verifier durable-integrity
    participant Sel as AdmissionSelectors
    participant Store
    Peer->>Ver: metaQuads incl non-IRI _bad subject
    Ver->>Ver: indexIntegrityMetadata over FULL metaQuads
    Note over Ver: _bad merkleRoot enters merkleSubjects as a candidate
    Ver->>Ver: readIntegrityMetadata PART_OF scan sees _bad partOf valid-ual
    alt _bad bears merkleRoot
        Note over Ver: peer-supplied root self-authenticates _bad data
        Ver->>Sel: selectAdmittedMetadataIndexes over metaQuads
        Sel-->>Ver: admitted indexes incl _bad-authenticated data
        Ver->>Store: persist attacker data and meta
    else _bad bears partOf valid-ual
        Note over Ver: fatalUnscopedFailure rejects the VALID batch, sync pins
    end
Loading

After: both verification entry points (selectVerifiedDurableSyncQuads and planBoundedGraphScopedDurableBatch) filter to iriMetaQuads = metaQuads.filter(isIriMetaSubject) before indexIntegrityMetadata / verifyLegacyCandidates, so a non-IRI row can neither authenticate nor poison; admission still runs on the ORIGINAL metaQuads to drop and count it.

sequenceDiagram
    participant Peer
    participant Ver as Verifier durable-integrity
    participant Sel as AdmissionSelectors
    participant Store
    Peer->>Ver: metaQuads incl non-IRI _bad subject
    Ver->>Ver: filter isIriMetaSubject to iriMetaQuads
    Note over Ver: _bad removed before any indexing
    Ver->>Ver: indexIntegrityMetadata over iriMetaQuads
    Ver->>Ver: readIntegrityMetadata PART_OF scan never sees _bad
    Note over Ver: _bad can neither authenticate data nor poison the valid UAL
    Ver->>Sel: selectAdmittedMetadataIndexes over ORIGINAL metaQuads
    Note over Sel: isIriMetaSubject guard drops and counts _bad as droppedNonIriSubjectTriples
    Sel-->>Ver: admitted indexes without _bad
    Ver->>Store: persist only verified IRI data and meta
Loading

Flow 2 — Meta checkpoint advance on a fully-discarded meta-only page (durable-sync)

Before (naive drop): dropping the non-IRI rows from Flow 1 without counting them makes an all-non-IRI meta-only page yield verifiedMeta=0 with no consumed-count, so updateMetaCheckpoint stays false, the meta cursor pins, and the responder re-serves the same page — a livelock. (This is the naive form of the drop, not pre-PR main — where the page had no guard and was simply persisted; the diagram shows why the drop must be counted.)

sequenceDiagram
    participant Peer
    participant Req as Requester DurableSync
    participant Ver as Verifier
    participant CP as Checkpoint
    Peer->>Req: meta-only page all non-IRI subjects
    Req->>Ver: processDurableBatch
    Ver-->>Req: verifiedMeta 0 and no consumed-count
    Note over Req: naive drop -- non-IRI rows discarded but NOT counted, discardedOnlyMetadataResponse false
    Note over Req: updateMetaCheckpoint false, meta cursor pins
    Req->>Peer: re-fetch same meta offset
    Peer-->>Req: responder re-serves the same page, livelock
Loading

After: the worker exposes a single consumedUnpersistedMetaTriples aggregate (= droppedSyncControlTriples + droppedNonIriSubjectTriples); when it equals totalFetchedMetaQuads the requester marks discardedOnlyMetadataResponse and advances the meta cursor.

sequenceDiagram
    participant Peer
    participant Req as Requester DurableSync
    participant Ver as Verifier
    participant CP as Checkpoint
    Peer->>Req: meta-only page all non-IRI subjects
    Req->>Ver: processDurableBatch
    Ver-->>Req: verifiedMeta 0 and consumedUnpersistedMetaTriples equals controls plus non-IRI
    Note over Req: consumedUnpersistedMetaTriples equals totalFetchedMetaQuads, discardedOnlyMetadataResponse true
    Note over Req: updateMetaCheckpoint true
    Req->>CP: setCheckpoint cg meta nextOffset
    Note over CP: meta cursor advances, page consumed once, no livelock
Loading

Files changed

File What
packages/agent/src/sync/durable-integrity.ts Filter verification inputs to iriMetaQuads at BOTH entry points (selectVerifiedDurableSyncQuads + planBoundedGraphScopedDurableBatch); verifyLegacyCandidates now takes iriMetaQuads. isIriMetaSubject guard + droppedNonIriSubjectTriples count at the top of both admission selectors (running on ORIGINAL metaQuads); logDroppedNonIriMetaSubjects warn wired at all three selection call sites; new droppedNonIriSubjectTriples field on DurableIntegritySelection; new isIriMetaSubject helper. indexIntegrityMetadata no longer filters internally — precondition documented.
packages/agent/src/sync/requester/durable-sync.ts Depend on the required consumedUnpersistedMetaTriples aggregate for discardedOnlyMetadataResponse / meta-cursor advance (replaces the droppedSyncControlTriples-only condition); field made required (not optional) on the process-result contract.
packages/agent/src/sync-verify-worker.ts Add droppedNonIriSubjectTriples (diagnostic) and consumedUnpersistedMetaTriples (single checkpoint-advance signal) to DurableBatchProcessResult.
packages/agent/src/sync-verify-worker-impl.ts Populate droppedNonIriSubjectTriples and the consumedUnpersistedMetaTriples aggregate (= dropped sync controls + non-IRI) across every processDurableBatch / processDurableBatchForWire result branch.
packages/agent/src/sync/responder/graph-plan.ts Repoint DurableMetaPageFrameError oversized-subject docstring + message from #1921 to #1923; update the #1916 subject-atomic docstring — NEW ingest now enforces IRI-only but does not sweep the store, so the per-query blank-node relabel stays as defense-in-depth for pre-fix persisted data.
packages/agent/test/sync-control-metadata-admission.test.ts New tests: forged blank-node merkleRoot dropped without persisting; non-IRI subject with merkleRoot cannot authenticate its data (blank + literal, fail closed); descriptor-less :401 branch drop; all-non-IRI page counted as fully consumed so the cursor advances; conforming IRI untouched; literal subject dropped; graph-scoped asset still verifies with a non-IRI dkg:partOf poison row present; system-override path drop.
packages/agent/test/sync-durable-worker-wire.test.ts New wire tests over the real selection → processDurableBatchForWire path: pure-control, all-non-IRI, and mixed pages report the correct consumedUnpersistedMetaTriples (= sum of per-reason counts).
packages/agent/test/sync-requester-progress.test.ts Switch stubs from droppedSyncControlTriples to consumedUnpersistedMetaTriples; add cursor-advance tests for an all-non-IRI page and a mixed fully-discarded page.
packages/agent/test/durable-sync-since-threading.test.ts Set the now-required consumedUnpersistedMetaTriples in the process-result stub.
packages/agent/test/rootless-durable-bounded-progress.test.ts New regression for the second (bounded-planner) poison path: a valid graph-scoped manifest + a non-IRI dkg:partOf poison row → planBoundedGraphScopedDurableBatch still returns the safe prefix (not null).

Test plan

  • npx vitest run sync-control-metadata-admission — fail-closed auth for BOTH a blank-node and a literal dkg:merkleRoot subject (rejected=1, nothing persisted); a graph-scoped asset STILL verifies with a non-IRI dkg:partOf poison row present (rejected=0, full descriptor persisted, bad row dropped+counted); system-override-path non-IRI drop; conforming IRI untouched
  • npx vitest run --config vitest.unit.config.ts durable/sync blast radius green (durable-integrity, sync-responder, sync-verify, sync-durable-worker-wire, sync-requester-progress, rootless-durable-bounded-progress)
  • Bounded-planner poison regression (rootless-durable-bounded-progress.test.ts): a valid graph-scoped manifest + a non-IRI dkg:partOf row → planBoundedGraphScopedDurableBatch returns the safe prefix, not null
  • Mutation-proven: neutralize the verification-input sanitize → the poison batch is rejected (rejected=1, the reproduced pre-fix bug); neutralize the consumedUnpersistedMetaTriples aggregate → the all-non-IRI + mixed cursor-advance tests fail (the meta cursor pins)
  • tsc --noEmit clean (clean rebuild)
  • No localhost_contracts.json / deployments/localhost/* / agent-docs/ in the diff

Jurij89 and others added 2 commits July 23, 2026 12:22
…gest (#1921)

Durable-sync _meta ingest funnels every peer-supplied metadata quad
through selectVerifiedDurableSyncQuads, but neither meta selector
(selectAdmittedMetadataIndexes, selectSystemOverrideMetadataIndexes)
checked that the metadata SUBJECT is an IRI. A peer could inject a
blank-node _meta subject -- including one bearing an integrity predicate
such as dkg:merkleRoot -- and have it persisted into a durable _meta
graph, where it has no trustworthy, stable identity.

Add an isIriMetaSubject guard at the top of BOTH selector loops (before
the merkle/marker admission branch), dropping non-IRI (blank-node /
literal) subjects and counting them. REJECT, never skolemize: a
peer-injected blank node has no authenticatable identity. The guard
operates on incoming peer quads only -- no store read/DROP/sweep -- so
there is zero data-loss risk to already-persisted data. A single
aggregated warn is emitted per selection at all three guarded call
sites, and the drop count is surfaced as droppedNonIriSubjects on
DurableIntegritySelection (mirroring droppedSyncControlTriples).

Also repoint the DurableMetaPageFrameError oversized-subject references
from #1921 to #1923 (SyncPagePolicy): #1921 is IRI-only; a valid-IRI
subject bearing a giant literal is an orthogonal size vector. The #1916
responder subject-atomic docstring is updated to note ingest now enforces
IRI-only for NEW peer ingest, while the store-paged blank-node
accommodation stays as defense-in-depth for pre-fix persisted data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment-only. Expand the loop-top guard comment in
selectAdmittedMetadataIndexes to spell out why the IRI-only check must run
BEFORE (not inside) the merkle/marker admission branch: indexIntegrityMetadata
adds any merkleRoot-bearing subject to merkleSubjects without validating the
term, and a persisted blank-node subject has no stable identity — the
responder must later serve it back, where #1916's store-paged subject-atomic
lane only stays sound via Oxigraph's per-query relabel. No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent/src/sync/durable-integrity.ts Outdated
* than persisted. Mirrors the responder's `isIriTerm` (graph-plan.ts) so ingest
* and read agree on the contract.
*/
function isIriMetaSubject(term: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Centralize the durable metadata term classifier instead of mirroring it by comment

What's wrong
This PR makes the IRI-only durable _meta invariant span ingest and read paths, but implements the same string-prefix classifier in two private places. That is a boundary-cleanliness problem: future changes to RDF term encoding, skolemization, or literal handling now have to update two large files in lockstep, and reviewers have to remember that these private helpers are semantically coupled.

Example
isIriMetaSubject('') returns false, while the responder's private isIriTerm('') returns true. Even if an empty term is not expected, this already shows the two copies can drift because there is no single term-boundary helper.

Suggested direction
Extract a canonical isIriTerm/isDurableMetaIriSubject helper in the layer that owns the string-encoded Quad contract, then import it from both ingest and responder code. The important invariant should be shared by code, not by a comment pointing at a private function.

For Agents
Move RDF string-term classification into a shared sync/storage utility, use it from durable-integrity and graph-plan, preserve the current blank-node/literal handling, and cover the shared helper with small tests for IRI, blank-node, literal, and empty-string cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring as a tracked follow-up: #1940.

The two helpers share the same _: / " prefix contract; the empty-string divergence you flagged (isIriMetaSubject('') → false vs the responder's isIriTerm('') → true) is a deliberate semantic rule, not drift: the ingest guard fails closed on a malformed empty _meta subject, while the responder read path never sees an empty term post-ingest. Unifying the two would change the responder read-path's empty-string handling and warrants its own review/tests, so I filed #1940 to track extracting a single canonical classifier rather than widen #1921's scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: Clarify the RDF term boundary instead of calling this a full IRI check

Why it matters
The helper is now part of the durable ingest contract, so an imprecise name and duplicated implementation make future boundary work easy to misread or accidentally diverge.

Suggestion
Either extract/reuse a shared isNamedNodeTermString-style helper with the responder, or rename/comment this helper to match the actual predicate. If durable _meta truly requires validated IRIs, use the canonical IRI validator at this boundary instead of a prefix-only test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring the shared-classifier extraction / naming to #1940 (durable-meta IRI term-classifier unification across ingest + responder). The prefix-only isIriMetaSubject predicate is intentional here: it matches the responder's isIriTerm contract (_:/" prefixes), and the empty-string divergence is a deliberate fail-closed hardening at ingest. Extracting a canonical shared helper / using a full IRI validator at this boundary is a boundary-cleanliness change that touches the responder read path too, so it deserves its own review + tests rather than widening this security PR. Tracked in #1940.

Comment thread packages/agent/src/sync/durable-integrity.ts Outdated
Comment thread packages/agent/test/sync-control-metadata-admission.test.ts
#1921)

Addresses the otReviewAgent review of PR #1936.

Bug (liveness): the durable requester advances the meta checkpoint for a
metadata-only page that was ENTIRELY discarded only when the discarded rows
are counted as deliberately consumed — durable-sync.ts gated that on
droppedSyncControlTriples === totalFetchedMetaQuads. A page consisting solely
of non-IRI `_meta` rows dropped by the #1921 ingest guard produced
droppedSyncControlTriples=0, so updateMetaCheckpoint stayed false and the meta
cursor pinned: the same page is re-fetched and re-dropped every round. This is
also a regression vs pre-#1921 (those rows used to be persisted, so
verifiedMeta>0 advanced the cursor). Reachable when a responder holds pre-fix
persisted non-IRI rows or a malicious responder injects them on the wire.

Fix: thread droppedNonIriSubjectTriples through the worker boundary
(DurableBatchProcessResult -> wire result -> requester) and generalize the
requester check to deliberatelyDroppedMeta = droppedSyncControlTriples +
droppedNonIriSubjectTriples, compared to totalFetchedMetaQuads. This also
fixes a MIXED all-discarded page (some controls + some non-IRI) that pins today
because neither count alone equals the fetched total.

Also folds in the review's other points:
- Rename droppedNonIriSubjects -> droppedNonIriSubjectTriples (it counts dropped
  rows, mirroring droppedSyncControlTriples).
- Add a literal-subject ingest-drop test (guard rejects blank-node AND literal).
- Add requester-path regression tests (sync-requester-progress.test.ts) proving
  the meta cursor advances on an all-non-IRI page and a mixed all-discarded page;
  verified to fail without the propagate fix.

Deferred: unifying the ingest/responder IRI term classifier -> #1940.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent/src/sync/durable-integrity.ts
Comment thread packages/agent/test/sync-requester-progress.test.ts Outdated
Comment thread packages/agent/src/sync/requester/durable-sync.ts Outdated
…ta (#1921)

Addresses the otReviewAgent review of PR #1936 (two 🔴).

🔴 Data-integrity: the #1921 admission-only guard dropped non-IRI metadata
from PERSISTENCE but not from VERIFICATION. indexIntegrityMetadata added any
subject bearing dkg:merkleRoot to merkleSubjects with no IRI check, so a
peer's blank-node/literal subject became a verification candidate,
self-consistently authenticated its bound DATA (the claimed root is
peer-supplied, not on-chain-anchored), and got that data admitted — after
which the selector guard dropped only the metadata, leaving orphaned,
peer-forged data in the store (an injection vector). Empirically: a blank-node
legacy envelope over matching data yielded dataIndexes=[0], metaIndexes=[].

Fix (candidate-gate): skip non-IRI subjects at the top of
indexIntegrityMetadata's merkle/marker loop so they never become verification
candidates and cannot authenticate data. A page bound only by a non-IRI
envelope then has no verified descriptor and fails closed (rejected on a normal
CG). The admission-selector guards + droppedNonIriSubjectTriples counting are
kept unchanged (still load-bearing for persist-drop and meta-cursor advance).
isIriMetaSubject is now null-safe: the requester's bounded-snapshot planner
(planBoundedGraphScopedDurableBatch) runs indexIntegrityMetadata on raw fetched
meta, which must tolerate malformed input rather than throw.

Tests:
- Flip the former "persisted blank-node" case to FAIL-CLOSED for BOTH a
  blank-node and a literal merkle-subject (rejected=1, dataIndexes=[],
  metaIndexes=[]); mutation-proven (removing the gate reverts to
  data-persists/meta-dropped and the tests fail).
- Livelock-fix-intact: an acceptUnverified all-non-IRI metadata-only page is
  fully consumed (droppedNonIriSubjectTriples === total, not rejected) so the
  cursor still advances.
- Worker-wire propagation (🔴#2): real processDurableBatchForWire on an
  all-non-IRI page reports the dropped count across selection->wire.

Deferred: consolidating the requester's per-reason consumed-metadata counters
into one worker-owned aggregate touches shipped sync-control checkpoint
semantics -> tracked as a follow-up, kept out of this security PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jurij89 and others added 2 commits July 23, 2026 16:14
…ne worker-owned count (#1921)

Addresses the otReviewAgent review of PR #1936 (🟡: requester progress depended
on per-reason verifier discard counters).

The requester's discardedOnlyMetadataResponse summed reason-specific counters
(droppedSyncControlTriples + droppedNonIriSubjectTriples) to decide whether a
fully-discarded metadata-only page still advances the meta cursor. That coupled
checkpoint orchestration to verifier discard policy, so each new deliberate-drop
category would need another optional field threaded through the worker result
plus another addend here.

Consolidate: the WORKER (processDurableBatch) now emits a single reason-agnostic
consumedUnpersistedMetaTriples = droppedSyncControlTriples +
droppedNonIriSubjectTriples on DurableBatchProcessResult, and the requester's
checkpoint predicate compares that one field to totalFetchedMetaQuads. The
per-reason counters are kept as verifier-side diagnostics only and removed from
the requester's structural contract. The already-shipped sync-control semantics
are preserved bit-for-bit: for a pure-sync-control page nonIri=0 so the aggregate
equals droppedSyncControlTriples exactly.

Tests:
- Worker-level: consumedUnpersistedMetaTriples === droppedSyncControlTriples +
  droppedNonIriSubjectTriples for pure-sync-control, all-non-IRI, and mixed pages.
- Requester cursor-advance for pure-sync-control (regression guard for the shipped
  path), all-non-IRI, and mixed pages, all via the single aggregate; pin case when
  the aggregate < total.
- Mutation-proven: neutralizing the worker sum breaks the all-non-IRI + mixed
  aggregate tests; neutralizing the requester's aggregate read breaks all three
  cursor-advance tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st admission (#1921)

Addresses the otReviewAgent 14:03 review of PR #1936 (a 🔴 nested on the resolved
durable-integrity thread) + follow-up 🟡s.

🔴 Non-IRI metadata could still POISON verification. The candidate-gate stopped
non-IRI subjects from becoming merkle/marker candidates, but metaBySubject was
still built from ALL rows, and readIntegrityMetadata scans it for PART_OF
(line ~604) — so a peer's `_:bad dkg:partOf "<valid-ual>"` row falsely
invalidated the valid graph-scoped UAL → fatalUnscopedFailure → a VALID batch
was rejected → durable sync pinned (a poison/DoS vector, opposite direction to
the earlier authenticate-then-drop hole). Reproduced: valid descriptor + one
non-IRI partOf row → rejected=1, dataIndexes=[], metaIndexes=[].

Fix (Option B, boundary sanitize): filter metaQuads to IRI-only ONCE at each
verification entry and feed that to indexIntegrityMetadata + verifyLegacyCandidates,
so non-IRI subjects can neither authenticate data (never a candidate) nor poison
verification (never in metaBySubject or the raw legacy scan). This is applied in
BOTH selectVerifiedDurableSyncQuads AND planBoundedGraphScopedDurableBatch — both
call readIntegrityMetadata and were vulnerable. Admission (selectVerifiedQuads /
the no-descriptor selectAdmittedMetadataIndexes) deliberately stays on the
ORIGINAL metaQuads: the selectors still drop + count non-IRI rows (persist-drop
and meta-cursor advance) and index into the original array. The verification
outcome is subject-keyed, so no positional index remap is needed. The
now-subsumed candidate-gate inside indexIntegrityMetadata is removed (single
boundary invariant; addresses the "centralize the drop" 🟡).

Also (net-new 🟡): make consumedUnpersistedMetaTriples REQUIRED (non-optional)
on the requester's processed contract and drop the `?? 0` — it is the single
checkpoint-advance signal, so every producer must set it (tsc-enforced) rather
than silently reading 0 and pinning the cursor.

Tests:
- Valid graph-scoped descriptor + `_:bad dkg:partOf <ual>` → asset STILL verifies
  (rejected=0, dataIndexes=[0], full descriptor persisted), bad row dropped +
  counted; mutation-proven (neutralize the sanitize → rejected=1).
- Direct selectSystemOverrideMetadataIndexes non-IRI test (rejected IRI candidate
  forces the accept-unverified override path + non-IRI rows → dropped + counted).
- Updated the since-threading mock for the now-required aggregate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent/src/sync/durable-integrity.ts
Jurij89 and others added 2 commits July 23, 2026 16:43
…x stale gate comment (#1921)

Comment-only. Two follow-ups to the Option B boundary-sanitize:
- indexIntegrityMetadata now documents an explicit PRECONDITION — callers MUST
  pass IRI-sanitized meta (selectVerifiedDurableSyncQuads + planBounded do; a new
  caller must too) — since the internal candidate-gate was removed.
- Rewrite the selectAdmittedMetadataIndexes non-IRI guard comment: it claimed
  indexIntegrityMetadata "adds ANY merkleRoot-bearing subject" (the bot's cited
  drift), which is no longer true under the boundary sanitize. The guard's real
  job is admission drop+count on the ORIGINAL metaQuads (persist-drop + cursor
  accounting); non-IRI subjects can't reach the merkle/marker branch anymore.

No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the otReviewAgent :181 🟡 — Option B's sanitize was added to the
second verification boundary (planBoundedGraphScopedDurableBatch) but only the
main selector path had a poison regression. Add a bounded-planner test: a valid
3-graph manifest + `_:bad dkg:partOf "<valid-ual>"` → the planner still projects
the same safe prefix (safeNextOffset=8, 2 complete graphs) as the clean case,
rather than returning null. Mutation-proven: removing planBounded's iriMetaQuads
filter makes the plan null (poison invalidates the valid UAL) and this test fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent/src/sync-verify-worker-impl.ts Outdated
@lupuszr

lupuszr commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Final current-head review — c12e6c3

I completed another review of the current head and tested it as a virtual merge with current testnet-canary (3ca9212c).

No blocking findings. Recommendation: merge promptly.

The security/correctness path is now coherent:

  • Non-IRI peer _meta rows are removed before both verification entry points (selectVerifiedDurableSyncQuads and planBoundedGraphScopedDurableBatch), so they can neither become a Merkle candidate that admits attacker-shaped data nor poison a valid UAL through a forged dkg:partOf row.
  • Admission still runs over the original metadata array, preserving source indexes while dropping and counting every non-IRI row before persistence.
  • consumedUnpersistedMetaTriples is required across the worker boundary. The requester advances a fully discarded metadata-only page only when the batch is otherwise clean and the aggregate exactly equals totalFetchedMetaQuads; partially explained or verification-rejected pages remain pinned. This closes the new drop-induced cursor livelock without creating an unsafe checkpoint advance.

Verification performed

  • Clean virtual merge with current canary despite the PR branch being 7 base commits behind.
  • Full agent/dependency build passed, including TypeScript type tests and package-root checks.
  • Changed test files: 74/74 passed.
  • Adjacent durable verifier tests: 49/49 passed.
  • Responder admission parity: 12/12 passed.
  • Total focused tests: 135 passed.
  • git diff --check passed.
  • GitHub currently reports MERGEABLE / CLEAN; Windows SQLite lifecycle and SPARQL scalability checks passed.

Nonblocking follow-ups

Given the verified attack-path closure, green merged-state tests, and absence of a current correctness blocker, I recommend merging #1936 promptly rather than waiting for the follow-up refactors.

… aggregate (#1921)

Addresses the otReviewAgent :392 🟡 — consumedUnpersistedMetaTriples (the single
signal the requester uses for meta-checkpoint advance) was computed one layer up
in the worker by re-summing the verifier's diagnostic counters, so the worker had
to know which discard reasons count toward requester progress.

Move ownership to the verifier that classifies the drops: add
consumedUnpersistedMetaTriples (REQUIRED) to DurableIntegritySelection, set at
every return site as droppedSyncControlTriples + droppedNonIriSubjectTriples; the
worker now TRANSPORTS verifiedSelection.consumedUnpersistedMetaTriples on the main
path (no recompute), and its early-return branches (empty page / data-without-meta)
still set 0 explicitly (tsc-enforced at every branch). The per-reason counts stay
as diagnostics. Value-identical: same sum, computed where the drops are classified.

Scope: only the count's OWNERSHIP moves. The larger prepareDurableMeta intake
centralization (filter/drop/count of the verification-flow) stays deferred in #1943.

Tests: added a durable-integrity assertion that
selection.consumedUnpersistedMetaTriples === droppedSyncControlTriples +
droppedNonIriSubjectTriples across pure-control / all-non-IRI / mixed pages;
mutation-proven (drop the +nonIri addend → the all-non-IRI + mixed cases fail).
Existing worker-aggregate + requester cursor-advance tests stay green (behavior
unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rejected: 0,
droppedSyncControlTriples: selectedMetadata.droppedControls,
droppedNonIriSubjectTriples: selectedMetadata.droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples: selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Stop hand-maintaining the consumed metadata aggregate in every return branch

What's wrong
The PR introduces a useful aggregate boundary, but the implementation keeps the invariant as repeated arithmetic across several branches. That preserves incidental complexity inside the verifier and makes the next discard reason or branch edit more fragile than necessary.

Example
The invariant is currently repeated as selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples at lines 449, 1075, and 1116, while early returns manually spell out all-zero counters. Adding another consumed-but-unpersisted reason means every return branch has to be audited again.

Suggested direction
Have the metadata-selection helper return a drop-count object with a derived consumedUnpersistedMetaTriples, or build DurableIntegritySelection through a small constructor. The caller should pass counts, not restate the aggregate formula branch by branch.

For Agents
In packages/agent/src/sync/durable-integrity.ts, introduce a DurableMetaDropCounts/MetadataAdmissionSelection helper or result constructor that computes consumedUnpersistedMetaTriples exactly once. Preserve the existing public fields and requester behavior; the existing aggregate tests should prove the helper is wired through every branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to #1943 — this is the same centralization theme it already tracks. #1943 covers a prepareDurableMeta / DurableMetaDropCounts helper (or a DurableIntegritySelection constructor) that computes consumedUnpersistedMetaTriples exactly ONCE and threads it through every return branch, so a new discard reason won't require auditing each branch.

The aggregate-OWNERSHIP move (the verifier owns the field, single requester signal) shipped in this PR (b7710dc). Computing-it-once across the 7 return sites is the deferred helper refactor: it reworks the durable-meta verification INTAKE on the security-critical path, so it belongs in #1943 with its own design + mutation-proof + review rather than widening this security fix. Tracked in #1943; leaving this thread open.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants