fix(sync): reject non-IRI durable-meta subjects at unverified peer-ingest (#1921) - #1936
Conversation
…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>
| * than persisted. Mirrors the responder's `isIriTerm` (graph-plan.ts) so ingest | ||
| * and read agree on the contract. | ||
| */ | ||
| function isIriMetaSubject(term: string): boolean { |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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.
There was a problem hiding this comment.
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.
#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>
…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>
…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>
…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>
Final current-head review —
|
… 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, |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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.
Summary
_metaverification by sanitizing the verification inputs to IRI-only at BOTH entry points —selectVerifiedDurableSyncQuadsandplanBoundedGraphScopedDurableBatchnow filtermetaQuadsthroughisIriMetaSubjectintoiriMetaQuadsbeforeindexIntegrityMetadata/verifyLegacyCandidates. (a) Candidate/self-auth: a non-IRI subject bearingdkg:merkleRootcan no longer entermerkleSubjectsand 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 reachreadIntegrityMetadata's PART_OF scan and falsely invalidate a legitimate graph-scoped UAL (fatalUnscopedFailure→ valid batch rejected → sync pins).selectAdmittedMetadataIndexes,selectSystemOverrideMetadataIndexes) deliberately run on the ORIGINALmetaQuadsand keep anisIriMetaSubjectguard at the top of the loop that drops and counts non-IRI rows intodroppedNonIriSubjectTriples, emitting one aggregatedwarnper 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.updateMetaCheckpointfalse and re-serve the same page forever (a livelock). The worker now exposes a single reason-agnosticconsumedUnpersistedMetaTriples(=droppedSyncControlTriples+droppedNonIriSubjectTriples); when it equalstotalFetchedMetaQuadsthe requester marksdiscardedOnlyMetadataResponseand advances the meta cursor. Per-reason counts remain verifier-side diagnostics only, keeping checkpoint orchestration decoupled from discard policy.consumedUnpersistedMetaTriplesis a required field on the requester's process-result contract — an optional field silently reading0would let the meta cursor pin with no type error, so every producer must set it._metasubject is a conforming IRI"._metasubject 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)._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) —DurableMetaPageFrameErrorreferences 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 classifierisIriMetaSubjectwith the responder read'sisIriTerminto one shared classifier is deferred to Unify the durable-meta IRI term classifier across ingest (durable-integrity) and responder read (graph-plan) #1940.Related
DurableMetaPageFrameError(blank-node reachability of the store-paged subject-atomic lane)_meta-subject SIZE vector (valid IRI subject bearing a giant literal) deferred to Wire-negotiate durable-meta byte-budget pagination (pre-canary short=EOF requester + oversized meta subject) #1923 (SyncPagePolicy)isIriMetaSubjectwith the responder readisIriTerm, and centralize the durable-meta intake filter/drop/count)Diagrams
Flow 1 — Peer
_meta-page verification (durable-integrity)Before: a peer's non-IRI (
_:blank/ literal)_metasubject entersindexIntegrityMetadataover the fullmetaQuads, so it can either become a merkle candidate and self-authenticate its own peer-supplied data, or poison a legitimate graph-scoped UAL viareadIntegrityMetadata'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 endAfter: both verification entry points (
selectVerifiedDurableSyncQuadsandplanBoundedGraphScopedDurableBatch) filter toiriMetaQuads = metaQuads.filter(isIriMetaSubject)beforeindexIntegrityMetadata/verifyLegacyCandidates, so a non-IRI row can neither authenticate nor poison; admission still runs on the ORIGINALmetaQuadsto 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 metaFlow 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=0with no consumed-count, soupdateMetaCheckpointstays 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-PRmain— 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, livelockAfter: the worker exposes a single
consumedUnpersistedMetaTriplesaggregate (=droppedSyncControlTriples+droppedNonIriSubjectTriples); when it equalstotalFetchedMetaQuadsthe requester marksdiscardedOnlyMetadataResponseand 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 livelockFiles changed
packages/agent/src/sync/durable-integrity.tsiriMetaQuadsat BOTH entry points (selectVerifiedDurableSyncQuads+planBoundedGraphScopedDurableBatch);verifyLegacyCandidatesnow takesiriMetaQuads.isIriMetaSubjectguard +droppedNonIriSubjectTriplescount at the top of both admission selectors (running on ORIGINALmetaQuads);logDroppedNonIriMetaSubjectswarn wired at all three selection call sites; newdroppedNonIriSubjectTriplesfield onDurableIntegritySelection; newisIriMetaSubjecthelper.indexIntegrityMetadatano longer filters internally — precondition documented.packages/agent/src/sync/requester/durable-sync.tsconsumedUnpersistedMetaTriplesaggregate fordiscardedOnlyMetadataResponse/ meta-cursor advance (replaces thedroppedSyncControlTriples-only condition); field made required (not optional) on the process-result contract.packages/agent/src/sync-verify-worker.tsdroppedNonIriSubjectTriples(diagnostic) andconsumedUnpersistedMetaTriples(single checkpoint-advance signal) toDurableBatchProcessResult.packages/agent/src/sync-verify-worker-impl.tsdroppedNonIriSubjectTriplesand theconsumedUnpersistedMetaTriplesaggregate (= dropped sync controls + non-IRI) across everyprocessDurableBatch/processDurableBatchForWireresult branch.packages/agent/src/sync/responder/graph-plan.tsDurableMetaPageFrameErroroversized-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.tsmerkleRootdropped without persisting; non-IRI subject withmerkleRootcannot authenticate its data (blank + literal, fail closed); descriptor-less:401branch 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-IRIdkg:partOfpoison row present; system-override path drop.packages/agent/test/sync-durable-worker-wire.test.tsprocessDurableBatchForWirepath: pure-control, all-non-IRI, and mixed pages report the correctconsumedUnpersistedMetaTriples(= sum of per-reason counts).packages/agent/test/sync-requester-progress.test.tsdroppedSyncControlTriplestoconsumedUnpersistedMetaTriples; add cursor-advance tests for an all-non-IRI page and a mixed fully-discarded page.packages/agent/test/durable-sync-since-threading.test.tsconsumedUnpersistedMetaTriplesin the process-result stub.packages/agent/test/rootless-durable-bounded-progress.test.tsdkg:partOfpoison row →planBoundedGraphScopedDurableBatchstill returns the safe prefix (notnull).Test plan
npx vitest run sync-control-metadata-admission— fail-closed auth for BOTH a blank-node and a literaldkg:merkleRootsubject (rejected=1, nothing persisted); a graph-scoped asset STILL verifies with a non-IRIdkg:partOfpoison row present (rejected=0, full descriptor persisted, bad row dropped+counted); system-override-path non-IRI drop; conforming IRI untouchednpx vitest run --config vitest.unit.config.tsdurable/sync blast radius green (durable-integrity, sync-responder, sync-verify, sync-durable-worker-wire, sync-requester-progress, rootless-durable-bounded-progress)rootless-durable-bounded-progress.test.ts): a valid graph-scoped manifest + a non-IRIdkg:partOfrow →planBoundedGraphScopedDurableBatchreturns the safe prefix, notnullrejected=1, the reproduced pre-fix bug); neutralize theconsumedUnpersistedMetaTriplesaggregate → the all-non-IRI + mixed cursor-advance tests fail (the meta cursor pins)tsc --noEmitclean (clean rebuild)localhost_contracts.json/deployments/localhost/*/agent-docs/in the diff