Skip to content

fix(agent): preserve SWM head operation identity across catch-up (GH#2273 2/3) - #2286

Open
Jurij89 wants to merge 20 commits into
fix/2273-pr1-head-cardinalityfrom
fix/2273-pr2-public-lane-identity
Open

fix(agent): preserve SWM head operation identity across catch-up (GH#2273 2/3)#2286
Jurij89 wants to merge 20 commits into
fix/2273-pr1-head-cardinalityfrom
fix/2273-pr2-public-lane-identity

Conversation

@Jurij89

@Jurij89 Jurij89 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Part 2/3 of the bug(publisher): restart recovery can invalidate same-job quorum retry via rotated SWM operation ID #2273 fix chain (base: fix(publisher): fail closed on multi-valued SWM heads and classify as retryable (GH#2273 1/3) #2281 — chained; retarget to testnet-canary when part 1 merges). This part fixes the public SWM catch-up lane so it preserves the workspace head's operation identity for semantically identical content — the Core Rule: when the locally stored operation rows and an incoming descriptor's operation rows are equivalent over an explicit operation-identity allow-list, the identity the local store already carries wins. Catch-up may retain the remote operation subject as immutable history, but must not add, remove, or replace the head's shareOperationId. Any genuine difference (content digest, counts, private root, access policy, allowed peers, ownership, version) routes to today's behavior — remote authority.

  • The mechanism being fixed, both stages. A queued async VM-publish job freezes the head's shareOperationId at admission. A peer — typically a storage-ACKing core — legitimately holds the SAME share under a DIFFERENT deterministic id. Stage 1: the per-KA path correctly skipped the already-materialized KA, but the round's bulk verified-meta union-insert then stacked the peer's head-id row beside the local one, leaving a multi-valued head. Stage 2: the next round's needsRepair deleted the head AND the local operation subject, re-inserting only the remote identity — after which the preflight terminally failed the job as publish_intent_stale for content that never changed.

  • Equivalence is an allow-list, not a deny-list: operationIdentityKey compares content commitment (digest, counts, private root), access envelope (policy, allowed peers), scope (UAL, version, subgraph) and ownership (prov:wasAttributedTo) — and deliberately excludes shareOperationId, publishedAt (per-node clocks), publisherPeerId (per-node), and the snapshot pointers (publicSnapshotGraph embeds the operation id in its value, so any deny-list keying on raw rows silently never matches and the whole fix degrades to a no-op). Wire-vs-stored normalization (xsd:string, integer canonical form) is pinned by a dedicated round-trip test against real Oxigraph.

  • Every head rewrite goes through one decision. selectRepairIdentity (under the held per-KA write lock) decides preservation; repairHeadPreservingIdentity heals a multi-valued head to the winning stored identity without ever deleting the winner's operation rows (they may be the only durable copy a queued job references). Suppression of the loser's head-id row is row-level on the commit coordinator — descriptor-level suppression would withhold the four required head rows after a repair and manufacture a permanently corrupt head. The version-superseded exit now also withholds a stale descriptor's head rows from the bulk append (a two-VERSION head is the same hazard arriving via the metadata side).

  • What stays exactly as today: non-equivalent operations (genuine change) rewrite to remote authority; graph-backed KAs (no snapshot ref) keep the bulk insert as their only head writer — suppression is decision-driven, never blanket (the SWM catch-up can leave a partial unrecovered tail after phase timeouts under backpressure #2050 G7 invisibility class is pinned by a control test).

  • Review-driven hardening (post-initial rounds): the preserved-winner gate stack is named validators (identity key, resolver decodability, responder type row, snapshot-locator serveability, usable access envelope); ref-form locators must equal the committed digest (readers follow explicit refs) and malformed locators rank non-serveable instead of throwing; operationIdentityKey compares the EFFECTIVE access policy (absent row == publisher default derived from privateTripleCount) while a policy-less stored winner still refuses preservation (queued preflight requires a defined policy); the descriptor carries every lexical form of its selected id so withhold plans are value-complete; the kept-head branch ENACTS the preserving head rewrite (residue purged) rather than suppress-only; deterministic sorted tie-break and the dirty-head sync-lane repair path are regression-pinned. Every fix carries a fail-before-proven test row.

Related

Diagrams

Catch-up offering an equivalent operation id

Before:

sequenceDiagram
    participant Peer as Peer with storage-ack id B
    participant Sync as Catch-up round
    participant Store as Local store with head id A
    Peer->>Sync: verified meta with head row B
    Sync->>Store: per-KA check finds content identical
    Note over Sync: per-KA path skips, head untouched
    Sync->>Store: bulk union-insert of all verified meta
    Note over Store: head now carries A and B
    Sync->>Store: next round sees needsRepair
    Sync->>Store: repair deletes head and operation A, installs B
    Note over Store: queued job frozen on A dies publish_intent_stale
Loading

After:

sequenceDiagram
    participant Peer as Peer with storage-ack id B
    participant Sync as Catch-up round
    participant Store as Local store with head id A
    Peer->>Sync: verified meta with head row B
    Sync->>Store: per-KA check finds content identical
    Sync->>Store: selectRepairIdentity compares operation rows under the KA lock
    Note over Sync: equivalent, so stored identity A wins
    Sync->>Sync: suppress descriptor head-id row from the bulk append
    Sync->>Store: bulk insert lands operation B rows as immutable history only
    Note over Store: head stays single-valued on A, job preflight still authorizes
Loading

Files changed

File What
packages/agent/src/sync/graph-scoped-swm-recovery.ts OPERATION_IDENTITY_PREDICATES allow-list + operationIdentityKey with wire-vs-stored object normalization (xsd:string, canonical integers); shipped resolveEquivalentHeadOperation untouched
packages/agent/src/sync/requester/swm-snapshot-materializer.ts readStoredHead exposes the single unambiguous shareOperationId (null when absent or multi-valued — SAMPLE over ambiguity would be the arbitrary pick this chain exists to kill); selectRepairIdentity (preservation decision under the held lock); repairHeadPreservingIdentity (heals to the winning stored identity, never deletes the winner's operation rows; kaUal ownership guard preserved)
packages/agent/src/sync/requester/shared-memory-sync.ts Row-level suppressRows on the commit coordinator; repairOrReplaceHead at BOTH replaceHeadMetadata call sites; prefer-stored branch at the healthy-skip exit; version-superseded exit withholds stale head rows from the bulk append
packages/agent/test/swm-snapshot-materializer.test.ts New operation identity preservation (GH#2273) describe: round-trip identity-key spike (the assumption the fix rests on), two-stage repro, materialize-path repro, prefer-stored polarity row (non-equivalent still rewrites), heal rows, version-superseded row, graph-backed control; readStoredHead rows updated for the new field
packages/agent/test/swm-public-snapshot-materialization.test.ts Event-recording fake extended for the two new materializer methods; stub head literals gain the new field
packages/agent/test/e2e-memory-layers.test.ts End-to-end row: a REAL agent share + queued intent survives a catch-up round offering an equivalent id, verified by the REAL preflight; peer batch built by reading the local rows back and re-labeling (equivalence by construction); anti-vacuity pinned via insertedMetaTriples > 0

Test plan

All behavior rows have fail-before evidence (lane src reverted, rows run, restored):

  • Two-stage repro: pre-fix round 1 leaves a two-valued head and round 2 deletes operation A — post-fix the head stays single-valued on A across both rounds and the resolver returns A
  • Materialize-path repro (head present, graph absent): pre-fix rotated to B — post-fix graph fills AND identity stays A
  • Polarity: a genuinely changed share (different digest) still adopts the remote identity; a non-equivalent stored operation makes selectRepairIdentity return null (descriptor wins)
  • Version-superseded: an older-version descriptor's head rows no longer stack onto the live head
  • Graph-backed KA control: head still lands via the bulk insert (suppression is decision-driven — G7 guard)
  • Round-trip spike: operationIdentityKey byte-equal between wire quads and Oxigraph read-back; equivalent twin equal, changed share different
  • E2E: real share → real enqueue → catch-up with equivalent op B → head single-valued on A → real preflightQueuedKnowledgeAssetVmPublishExecution returns {action: 'execute'} (pre-fix: head carried both ids after one round)
  • Agent unit lanes green on the final base (materializer, recovery, materialization-ordering, sync-requester-progress: 119 tests); e2e row green (Hardhat lane)
  • pnpm build:packages (turbo + tsc) green

🤖 Generated with Claude Code

GH#2273 part 2/3 - the Core Rule: when the locally stored operation rows and
an incoming descriptor's operation rows are equivalent over the operation-
identity allow-list, the identity the local store already carries wins.
Catch-up may retain the remote operation subject as immutable history, but
must not add, remove, or replace the head's shareOperationId. Any genuine
difference (content digest, counts, private root, access policy, allowed
peers, ownership, version) routes to today's behavior - remote authority.

Mechanism fixed (both stages): the per-KA skip for already-materialized
content let the round's bulk verified-meta union-insert stack the peer's
head-id row beside the local one (stage 1); the next round's needsRepair
then deleted the head AND the local operation subject, re-inserting only
the remote identity (stage 2) - terminally killing queued VM-publish jobs
whose preflight froze the local id at admission.

- graph-scoped-swm-recovery: operationIdentityKey over an explicit
  ALLOW-LIST (deny-listing fails: publicSnapshotGraph embeds the operation
  id in its VALUE; publisherPeerId/publishedAt are legitimately per-node),
  with wire-vs-stored normalization (xsd:string, integer canonical form).
- swm-snapshot-materializer: readStoredHead exposes the single unambiguous
  shareOperationId (null when absent or multi-valued); selectRepairIdentity
  decides preservation under the held KA lock; repairHeadPreservingIdentity
  heals a (possibly multi-valued) head to the winning stored identity
  WITHOUT ever deleting the winner's operation rows.
- shared-memory-sync: row-level suppressRows on the commit coordinator
  (descriptor-level would withhold the four required head rows after a
  repair and manufacture a permanently corrupt head); prefer-stored at the
  healthy-skip exit; repairOrReplaceHead at BOTH replaceHeadMetadata call
  sites; the version-superseded exit now withholds the stale descriptor's
  head rows from the bulk append (two-VERSION heads were the same hazard
  arriving via the metadata side).

Tests (all fail-before proven against the old lane): round-trip identity-key
spike (wire vs Oxigraph read-back - the comparison the whole fix rests on),
two-stage repro, materialize-path repro, prefer-stored polarity row,
version-superseded row, graph-backed-KA control (bulk insert remains their
only head writer - suppression is decision-driven, never blanket), repair
rows, and an e2e row in e2e-memory-layers where a REAL agent share + queued
intent survives a catch-up round offering an equivalent id, verified by the
REAL preflight (peer batch built by reading the local rows back and
re-labeling, so equivalence is by construction; anti-vacuity pinned via
insertedMetaTriples > 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts Outdated
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts Outdated
* The loser id row is suppressed so neither the per-KA meta insert nor
* the round's bulk append re-stacks it onto the repaired head.
*/
const repairOrReplaceHead = async (

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: Identity preservation is split across orchestration and row-suppression side effects

What's wrong
This PR embeds GH#2273-specific head identity policy into an already large sync orchestration function. The materializer owns store state, but the requester owns part of the decision by selecting identities, calling repair, suppressing only certain rows, and then inserting verified metadata. That makes the behavior depend on scattered sequencing rather than a single head-commit contract, and it worsens the readability and extensibility of the sync loop.

Example
A future head-write path can call replaceHeadMetadata or repairHeadPreservingIdentity and forget to also suppress the losing descriptor head row, allowing the final bulk append to re-stack the rejected shareOperationId onto the head. The current code already has to remember this coupling in two separate branches.

Suggested direction
Move the whole head identity decision into one explicit abstraction, such as a materializer/commit-coordinator method that returns a HeadMetadataDisposition including the rows to suppress. The sync loop should not need to know which exact metadata row must be withheld after a preserved repair.

For Agents
Look at shared-memory-sync.ts around descriptorHeadIdRows, repairOrReplaceHead, and the already-materialized branch, plus the new materializer interface methods. Preserve behavior: equivalent stored IDs win and descriptor head-id rows are suppressed; genuine changes still adopt descriptor metadata. Prove both repair and already-materialized paths share the same disposition/helper.

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.

Applied in b6c1f98 in the contained form: withholdDescriptorIdentity is now the ONE preserve step owning suppress+log, and both preserve paths (healthy-skip and repair) go through it — deciding without withholding is no longer expressible in this loop. I kept the requester/materializer split itself: the materializer owns store mutations, the requester owns round-scoped commit bookkeeping (the suppression ledger is intrinsically a property of THIS round's bulk append, not of the store), and a single head-commit object crossing that boundary is the kind of restructure that belongs with the post-chain module extraction agreed on the part-1 PR.

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: Collapse preserve/repair/suppress into one operation

What's wrong
The comment says there is one preserve decision, but the implementation spreads that decision across the sync loop, materializer, and row-suppression ledger. This adds feature-specific branching to an already large orchestration function and makes the invariant dependent on every caller remembering the same sequence.

Example
A future materialization branch could call selectRepairIdentity and repair/preserve the head but forget to call snapshotCommit.suppressRows(...); the round's bulk insert would then re-stack the rejected descriptor id. The current design relies on call choreography rather than a cohesive operation.

Suggested direction
Expose a single operation that returns the preserved identity and the exact metadata rows to suppress, or let the commit coordinator own the preserve decision directly. The sync loop should only choose high-level states like skip, repair, or materialize, not coordinate identity-ledger internals.

For Agents
Work in shared-memory-sync.ts and swm-snapshot-materializer.ts. Move identity selection, preserved-head rewrite decision, and suppression-row reporting behind one materializer/commit-coordinator abstraction. Preserve both current paths: repairing a bad head and suppressing a foreign equivalent id on a healthy head.

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.

Applied in f69045edecideAndWithholdStoredIdentity is now the one preserve operation: it selects the winner AND suppresses the losing descriptor id row in the same call, so deciding without withholding is no longer expressible; the sync loop's two branches just call it and act on the returned winner (repair vs nothing). The materializer keeps store mutations, the coordinator keeps the round-scoped ledger — moving the decision fully into the coordinator crosses the store/round boundary and belongs with the post-chain module extraction agreed on the part-1 PR.

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: Identity preservation is wired into the sync loop as scattered orchestration

What's wrong
This adds feature-specific control flow to an already large orchestration function instead of reducing the concept to a single reusable head-write policy. The comments are compensating for an implicit protocol, and the unused helper is a symptom that the local abstraction did not settle cleanly.

Example
A future head-rewrite branch must know to call decideAndWithholdStoredIdentity, maybe call repairHeadPreservingIdentity, maybe suppress rows, and maybe insert descriptor metadata. That is an implicit protocol spread across the sync loop instead of a single head-write abstraction.

Suggested direction
Make the identity-preserving head rewrite a first-class operation owned by the materialization layer or commit coordinator. The sync loop should select between high-level outcomes, not manually coordinate winner selection, suppression ledger updates, logging, repair, replacement, and metadata insertion in several branches.

For Agents
Focus on runSharedMemorySync, GraphScopedSnapshotCommitCoordinator, and the materializer interface. Preserve the current row suppression and head-repair behavior, but collapse the decision/write/suppression protocol behind one operation such as a materializer-returned head write plan or applyDescriptorHeadMetadata. Add or keep tests covering both existing call sites and the bulk-append suppression.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Head preservation is bolted into the large sync loop

What's wrong
This adds another special-case orchestration layer inside an already sprawling sync function. The amount of explanatory commenting is a symptom: the invariants are not captured by the abstraction, so the reader must reconstruct them from closures over snapshotCommit, snapshotMaterializer, pid, and summary.

Example
The materialized-content branch calls repairOrReplaceHead, which also inserts descriptor metadata. The healthy-but-different-id branch calls decideAndWithholdStoredIdentity and repairHeadPreservingIdentity directly. A future call site has to know which helper writes metadata, which only suppresses rows, and which ordering constraints are implicit.

Suggested direction
Extract a focused head-reconciliation workflow so the sync loop stays at the level of superseded/materialized/replace-graph decisions. The preservation-specific suppression, repair, and metadata-write ordering should be centralized behind one cohesive API.

For Agents
Refactor the new preservation orchestration out of runSharedMemorySync around materializeReadySnapshot. Preserve lock ordering, graph-before-head ordering, suppression-before-bulk-append, and summary counting. A small workflow object or pure reconciler should own the preserve/replace decision and expose one call per 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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Collapse identity preservation into a single repair abstraction

What's wrong
The new flow is guarded by comments rather than by the abstraction. Three consecutive docblocks explain that deciding and withholding are inseparable, but the implementation still separates decision, ledger mutation, and repair across two objects and several branches in runSharedMemorySync. That makes the hot sync path harder to scan and leaves a fragile protocol for future maintenance.

Example
The intended atomic idea is "preserve stored identity and withhold the losing head row", but callers have to perform selectRepairIdentity -> suppressRows -> repairHeadPreservingIdentity in the right order. A future call site can easily preserve without withholding, or withhold without repairing, because the type boundary still exposes the pieces separately.

Suggested direction
Have the materializer or a dedicated head-repair coordinator return/apply an explicit HeadRepairPlan that includes the winner and rows to suppress, or expose one repairOrReplaceHeadMetadata operation that owns the preservation workflow. The sync loop should orchestrate phases, not manually coordinate the invariants of the repair protocol.

For Agents
Look at GraphScopedSnapshotCommitCoordinator, SharedMemorySnapshotMaterializer.selectRepairIdentity, and repairHeadPreservingIdentity. Preserve current ordering and counters, but collapse the decision, suppression plan, and head rewrite into a single typed repair plan or higher-level head repair operation. Add/update a test proving the sync loop cannot select a preserved identity without also withholding the losing descriptor row.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

Comment thread packages/agent/src/sync/graph-scoped-swm-recovery.ts
Comment thread packages/agent/src/sync/requester/shared-memory-sync.ts
Comment thread packages/agent/src/sync/graph-scoped-swm-recovery.ts
Jurij89 and others added 2 commits August 16, 2026 19:51
…preserve step

PR2 review round 1 (otReviewAgent), both reds + two yellows applied:

- repairHeadPreservingIdentity rewrites the HEAD first and deletes loser
  operation subjects AFTER: a crash between the two now leaves a healthy
  single-valued head plus benign identity-equivalent residue, where the
  previous order left a multi-valued head naming operations whose rows were
  already gone - readers failed closed on a half-repaired state.
- selectRepairIdentity refuses winners that are identity-equivalent but NOT
  resolvable operations (id-echo missing, or publisherPeerId absent /
  multi-valued - both deliberately outside the identity key): preserving
  such a winner would write a head the resolver permanently fails as
  corrupt, and the next round's prefer-stored decision would preserve it
  again, wedging the KA. Descriptor-wins is the fallback. Fail-before
  proven (new sub-row fails against the unguarded selection).
- one withholdDescriptorIdentity step owns suppress+log for BOTH preserve
  paths - deciding without withholding is no longer expressible.
- new sync-loop row for the r26 state (head + local op present, content
  absent): the graph materializes from the equivalent peer snapshot AND the
  local identity survives - pins the materialize-path call site a revert to
  replaceHeadMetadata would silently break.
- new allow-list polarity rows: same-digest descriptors adding an allowList
  envelope or a different author are NOT identity-equivalent.

Agent SWM lanes 121 green + e2e catch-up-survival row green; turbo build
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… preserve operation

PR2 review round 2 (otReviewAgent), all three applied:

- the red was correct again: publishedAt is outside the identity key
  (per-node clocks) AND was outside the resolvability guard, so a stored
  winner with a corrupt timestamp would be preserved, fail every reader as
  corrupt, and be preserved again next round. selectRepairIdentity now
  mirrors the resolver's timestamp rule (every stored publishedAt must
  parse to a safe non-negative epoch). Fail-before proven (new sub-row).
- normalizeIdentityObject now delegates literal-term semantics to
  @origintrail-official/dkg-rdf-utils (parseRdfLiteralTerm +
  formatCanonicalRdfLiteralTerm: plain==xsd:string, escaping, language
  tags, datatype brackets - the same rules storage/hash paths recognize),
  keeping only the identity-specific integer-value canonicalization as a
  wrapper. Corrects my earlier claim that no shared helper existed - the
  reviewer was right and the package was there. New workspace dep wired
  (agent package.json + lockfile).
- decideAndWithholdStoredIdentity is the ONE preserve operation: deciding
  IS withholding (the losing id row is suppressed in the same call that
  selects the winner), so no call site can preserve and forget the ledger.

Agent SWM lanes 121 green; e2e catch-up-survival row green; turbo build
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jurij89
Jurij89 requested a review from branarakic as a code owner August 16, 2026 18:18
* author) under different operation ids — the residue storage-ACK persistence
* and originator persistence legitimately produce for one share.
*/
export function operationIdentityKey(rows: readonly Quad[]): string | null {

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: Avoid maintaining two operation-equivalence models

What's wrong
This adds a second definition of when two share operations are “the same” while the existing resolver keeps its own equivalence construction. That makes the most delicate invariant in this change easy to drift: one path can be updated while the other silently keeps different rules.

Example
A future operation metadata predicate now has two places to reason about equivalence: add it to OPERATION_IDENTITY_PREDICATES.compared for repair decisions, and separately decide whether the parser's raw equivalenceKey should include or ignore it.

Suggested direction
Move operation identity into a dedicated helper/module and use it for both multi-head descriptor resolution and stored-vs-descriptor repair comparison. If the semantics truly differ, encode that as a named mode rather than two independent key builders.

For Agents
Look in graph-scoped-swm-recovery.ts. Extract one shared operation-identity/equivalence abstraction and route both resolveEquivalentHeadOperation and materializer repair selection through it, with any intentional parser-vs-repair differences made explicit and covered by existing identity tests.

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, with the concrete reason: unifying the two relations means changing the SHIPPED intra-payload equivalence (resolveEquivalentHeadOperation's deny-list key includes publicSnapshotGraph, whose value embeds the operation id — which is why byte-identical snapshot-graph-backed ops currently throw 'ambiguous shareOperationId' on snapshot-disabled nodes). Unification on the allow-list would silently start ACCEPTING payloads that are rejected today — a behavior change to the shipped parser that deserves its own review, and it is exactly the F3 follow-up recorded on the part-1 PR alongside the module extraction where both relations will live together. The drift risk in the interim is bounded: both relations are pinned by fail-closed tests in both polarities.

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: Avoid introducing a second operation-identity model

What's wrong
This adds a new semantic identity key beside an existing equivalence calculation. The codebase now has two places that define what “same share under a different operation id” means, which is a structural drift risk for a very sensitive path.

Example
If a future metadata field affects share identity, maintainers now have to update OPERATION_IDENTITY_PREDICATES and the older equivalenceKey construction separately. If they drift, parser-time multi-head resolution and repair-time identity preservation can disagree about whether two operation ids represent the same share.

Suggested direction
Move the identity logic into a dedicated operation identity module or typed decoder, and have the existing head resolver delegate to it instead of maintaining two comparison schemes.

For Agents
Look in packages/agent/src/sync/graph-scoped-swm-recovery.ts around operationIdentityKey and resolveEquivalentHeadOperation. Preserve current behavior, but extract one canonical operation identity/decoded-operation model and make both descriptor parsing and materializer repair use it. Add a focused regression around a multi-id head to prove both paths make the same identity decision.

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.

Standing position (fourth iteration of this finding), now final for this PR: the two relations are DELIBERATELY different today — the parser's deny-list key includes publicSnapshotGraph (op-id-embedding, the F3 self-ambiguity) and requires a single publishedAt, while the identity key excludes per-node rows precisely so the storage-ACK residue compares equal. Unifying them changes the SHIPPED parser's acceptance behavior and is exactly the F3 follow-up recorded on the part-1 PR, where both relations move into one module and get one owner. Both are pinned in both polarities in the meantime. If you consider this blocking for the bugfix, flag 🔴 with the failure scenario.

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: Consolidate the operation identity model instead of adding a second one

What's wrong
The PR adds a second definition of operation equivalence beside an existing one. Even if both are individually defensible, the codebase now has multiple places where identity semantics are encoded with different predicate sets and normalization rules, which is a long-term maintainability trap.

Example
A future operation row such as another access-envelope predicate, locator shape, or per-node metadata field now has to be classified consistently in several places: OPERATION_IDENTITY_PREDICATES, resolveEquivalentHeadOperation's equivalence key, isResolvableWorkspaceOperationRows, and the materializer's manual locator checks. Missing one of those spots changes which identities are considered equivalent in one path but not another.

Suggested direction
Introduce one canonical helper/model that decodes operation rows into stable identity fields, per-node fields, and locator fields, then use that from both the recovery parser and the preservation decision. The code should express mode differences explicitly instead of relying on parallel string-key implementations.

For Agents
Look at packages/agent/src/sync/graph-scoped-swm-recovery.ts and packages/agent/src/sync/requester/swm-snapshot-materializer.ts. Preserve current behavior, but extract a single typed workspace-operation identity/validation model that both descriptor parsing and repair selection consume. Tests should keep the current equivalent-id, changed-content, envelope, subgraph, and locator cases green.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Operation identity policy is split across ad hoc key builders

What's wrong
The new identity-preservation logic introduces a second operation-equivalence model beside the parser's existing byte-equivalence model. The comments explain why they differ, but the structure leaves future maintainers to synchronize two implicit policies manually, which is exactly the kind of hidden coupling this change should be deleting.

Example
If a new operation predicate is added, the author must remember whether it participates in same-payload byte ambiguity, cross-store identity preservation, both, or neither. Today that policy is encoded in two separate loops plus long comments instead of one canonical model.

Suggested direction
Extract the equivalence policy into a dedicated module with named modes and typed predicate classification. That would make the intentionally different comparison rules discoverable without duplicating policy in parser and materializer code paths.

For Agents
Create a focused SWM operation-equivalence module. Preserve current behavior by exposing two explicit modes, for example samePayloadEquivalenceKey and crossStoreIdentityKey, backed by shared predicate classification and literal normalization helpers. Update the parser and materializer to import that module.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Unify the operation identity policy before adding another equivalence model

What's wrong
The PR adds a second operation-equivalence model next to the existing parser equivalence and relies on long comments to explain why they differ. That preserves behavior, but it increases the number of concepts future changes must hold in their head and makes predicate evolution error-prone.

Example
A future operation predicate now has to be classified across two hidden policies: the parser byte key includes it automatically, while cross-store preservation ignores it unless someone also updates OPERATION_IDENTITY_PREDICATES. The comment notes this split as follow-up F3, which is a sign the PR is knowingly landing the design before the abstraction exists.

Suggested direction
Create a small workspace-operation-equivalence/identity-policy module that owns both key builders and the predicate classification rules. The parser and repair selector should consume that module instead of carrying parallel explanations and implicit drift points.

For Agents
Extract the operation-equivalence policy out of graph-scoped-swm-recovery.ts into a focused module, with explicit modes for same-payload byte equivalence and cross-store identity equivalence. Preserve the existing two behaviors, but make predicate classification and normalization live in one place and update the current tests to call that module directly.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

* any stored operation's rows are missing or non-equivalent, or when either
* side's identity key is unprovable. Callers MUST hold the KA write lock.
*/
selectRepairIdentity(

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: Collapse the temporal coupling in head identity repair

What's wrong
The new API splits one invariant across layers: the materializer decides identity equivalence and performs preserving repair, while the sync loop must know which rows to withhold from later appends. The result is a fragile call-order contract enforced by comments rather than types.

Example
A future call site can call selectRepairIdentity, get a winning stored id, but forget to suppress the losing descriptor head row from the bulk append; the comments describe exactly that as invalid, but the API still permits it.

Suggested direction
Make the materializer own the complete head-repair decision and return the exact suppression/write plan needed by the sync commit coordinator. That would remove the public “select, then maybe repair, and separately suppress rows” protocol.

For Agents
Look at SharedMemorySnapshotMaterializer and the nested helpers in runSharedMemorySync. Replace the split select/repair/suppress protocol with one typed materializer operation or plan, e.g. reconcileHeadMetadata(...) -> { rowsToSuppress, action }, and keep the sync loop responsible only for orchestration and commit-ledger application.

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.

Applied the type-level half in afb4b00: selectRepairIdentity now returns { winnerShareOperationId, withholdRows } — the decision carries its complete suppression plan in one value, and the sync loop's single decideAndWithholdStoredIdentity operation consumes that plan (no re-derivation, no second lookup to forget). Full coordinator ownership of the ledger crosses the store/round boundary and stays with the post-chain module extraction, same position as the prior round.

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: Collapse the split identity-preservation protocol

What's wrong
The new API encodes one logical operation as a multi-step caller protocol spread across the materializer, the sync loop, and the commit coordinator. That makes the flow harder to reason about and relies on comments to enforce ordering instead of the type boundary.

Example
The comments say deciding and withholding must be inseparable, but the API makes them separable: a future caller can select a winner without suppressing the losing head row, or repair with a winner that was selected under a stale read.

Suggested direction
Make the materializer return/apply a single head-rewrite plan, or move the whole decision and repair behind one method, so callers cannot forget half of the protocol.

For Agents
Refactor SharedMemorySnapshotMaterializer and the call sites in shared-memory-sync.ts. Preserve the current preserve-vs-descriptor behavior, but expose one operation such as planHeadRewrite/applyHeadRewrite or repairOrReplaceHeadMetadata that owns decision, mutation, and returned suppression rows as one typed result. Existing tests around identity preservation should still pass.

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.

Standing position (fourth iteration), final for this PR: the current shape is one decision API that returns its complete plan ({winner, withholdRows}), one loop-side operation that consumes it atomically, and a materializer that owns every store mutation — the stale-read concern is bounded by the KA write lock both the decision and the repair run under. Fusing decision+repair+ledger into one method requires the materializer to own the commit coordinator's round state, which is the module-extraction boundary move. If blocking, flag 🔴 with a failure scenario.

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: Collapse the identity-preserving repair protocol into one explicit plan

What's wrong
The preservation invariant is not represented as one abstraction. It is split across two materializer methods, a nested helper inside a 1.8k-line sync function, and several comments saying callers must hold locks and must suppress rows. That is a brittle protocol boundary rather than a maintainable model.

Example
A future head-rewrite branch could call selectRepairIdentity, then forget snapshotCommit.suppressRows(preserved.withholdRows), or call repairHeadPreservingIdentity with a winner that did not come from the selector. The current implementation relies on comments and a local closure to keep that protocol intact.

Suggested direction
Replace the split selectRepairIdentity / repairHeadPreservingIdentity protocol with a discriminated HeadRepairPlan or a single materializer operation that owns the selection and exposes exactly the suppression rows plus the allowed rewrite. The sync loop should consume a plan, not reassemble an invariant spread across methods and comments.

For Agents
Look at SharedMemorySnapshotMaterializer, decideAndWithholdStoredIdentity, and the two call sites in runSharedMemorySync. Preserve the same repair/replace behavior, but collapse the multi-step protocol into one typed operation or repair plan so selection, suppression rows, and the permitted write action are produced together and cannot be recombined incorrectly.

New materializer contract is not verified against all typed implementations

What's wrong
The PR expands the materializer interface, but at least one existing typed materializer wrapper in the tests was left with the old method set. That leaves the interface change only partially validated and can hide breakage in callers that provide their own materializer object.

Example
A type-aware validation of that test file would fail with the object missing selectRepairIdentity and repairHeadPreservingIdentity. If tests are not typechecked, this also means custom materializer implementations can drift from the new contract without validation.

Suggested direction
Add the new methods to the remaining typed materializer wrapper and include a validation step that typechecks or otherwise exercises these test doubles.

Confidence note
I could not run the stated pnpm/tsc command because pnpm is unavailable here, but the repository text shows a concrete typed implementation that was not updated for the new interface methods.

For Agents
Update every in-repo SharedMemorySnapshotMaterializer test double/wrapper, especially packages/agent/test/sync-requester-progress.test.ts, to delegate or stub selectRepairIdentity and repairHeadPreservingIdentity; then run the relevant typecheck/test validation that covers test helpers and fakes.

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.

Position (final): with e9ae36e the decision and its enactment are now one call-site pairing in BOTH lanes (the private lane inside the materializer method; the kept-head branch decide-then-enact under the same held KA lock), and the withhold plan is produced only by the materializer. Collapsing the public lane's remaining choreography into a single method is the recorded cross-lane follow-up — the pattern to migrate to already exists in this chain. If blocking, flag 🔴 with a failure scenario.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Bug: Preservation can leave an unsuppressed losing head id row

What's wrong
The new preserve decision withholds only the exact head id row carried in descriptor.metadataQuads. When the incoming metadata contains multiple accepted RDF terms for the same descriptor operation id, one unselected losing row can remain in the bulk insert and re-stack the remote operation id onto the head that was just repaired.

Example
A peer can send an equivalent descriptor whose head has both "op-remote" and "op-remote"^^<http://www.w3.org/2001/XMLSchema#string> as dkg:shareOperationId. The parser collapses both to the same descriptor id and preservation chooses the local winner, but only one remote id row is withheld. The final bulk append can still add the other remote id row beside the preserved local id, leaving the head multi-valued/corrupt again.

Suggested direction
Build the withhold plan from the actual verified/canonicalized rows, or make the plan value-based so every descriptor head id row for the losing operation is suppressed before the bulk append.

For Agents
Look at selectRepairIdentity and the suppression ledger in shared-memory-sync.ts. Suppress all verified head shareOperationId rows that semantically denote the losing descriptor id, or reject duplicate lexical head id rows before preservation. Add a sync-level test with duplicate same-value descriptor head id rows in different RDF literal forms and assert the repaired head remains single-valued with the stored winner.

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.

Applied in eae96df — verified real end-to-end first: a served duplicate typed-variant id row ("storage-ack-2273b"^^xsd:string beside the plain literal) reproduced exactly your scenario — the head ended [op-v1, storage-ack-2273b] after the preserve round. Root cause was one layer below the suppression ledger: the parser's metadataQuads kept only the ONE selected id row, so the plan built from those rows missed the variant while the value-based insert canonicalization passed it. Fixed at the parser: metadataQuads now carries EVERY head id row whose value is the selected id, so byte-keyed withhold plans cover all lexical forms in both lanes. The new sync-level row pins the single-valued preserved head; the now-unused selected-row property is removed (its presence validation stays). All 143 tests across the six affected suites green.

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: The materializer API leaks the sync write ledger

What's wrong
selectRepairIdentity is a store/materialization concern, but it now returns exact descriptor rows for the caller's suppression ledger. That couples persistence selection to the sync loop's verified-key bookkeeping and makes future changes to metadata canonicalization or suppression semantics require changes in the materializer boundary.

Example
The fake materializer in swm-public-snapshot-materialization now has to implement selectRepairIdentity and repairHeadPreservingIdentity even when the test only cares about sync ordering, because the store adapter interface now includes caller-ledger concepts.

Suggested direction
Separate the store-backed identity decision from the per-round verified-meta suppression plan. The materializer should expose stored state and perform store mutations; the sync commit coordinator or extracted workflow should own which descriptor rows are withheld from later writes.

For Agents
Rework selectRepairIdentity so it returns a domain decision, such as the winning stored id and losing descriptor id, or move the whole repair plan into a dedicated reconciliation workflow. Let GraphScopedSnapshotCommitCoordinator own row suppression, for example through suppressDescriptorShareId(descriptor, losingId), so ledger keying stays in one layer.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

}

/** realHarness's shape (see the end-to-end describe) without the counters. */
function identityHarness(store: TripleStore, served: typeof v1) {

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: Extract the duplicated sync harness before this test file sprawls further

Why it matters
The new block adds another near-copy of a large orchestration fixture in a file that is already just under 1k lines. A shared harness would make the tests smaller and reduce incidental churn when the sync context changes.

Suggestion
Factor the repeated runSharedMemorySync fixture into one configurable helper and use it from the existing real-materializer tests, partial-round tests, and the new identity-preservation block.

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.

Acknowledged — deferring the harness consolidation to the same test-structure pass as the module extraction: hoisting the fixture means touching the wiring of five green suites in a file that three in-flight chained PRs assert against, and the two copies are behavior-pinned by the suites themselves in the meantime.

…d plan

PR2 review round 3 (otReviewAgent):

- selectRepairIdentity also refuses a stored winner with NO publishedAt row:
  the plain resolver tolerates it, but the published-head wrapper (RFC64
  inventory ordering) fails it as corrupt and every production writer
  stamps one - descriptor-wins installs a canonically stamped operation
  instead of preserving the anomaly forever. New sub-row.
- new private-commitment polarity sub-row: same public digest/counts but a
  different privateMerkleRoot is NOT identity-equivalent (pins the private
  half of the allow-list against dropped/mis-normalized commitment rows).
- selectRepairIdentity returns { winnerShareOperationId, withholdRows }:
  the decision carries its complete suppression plan in one value, and the
  sync loop's decide-and-withhold consumes that plan rather than
  re-deriving the rows.

Agent SWM lanes 121 green; turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts
Comment thread packages/agent/test/swm-snapshot-materializer.test.ts Outdated
…d-id pins

PR2 review round 4 (otReviewAgent):

- the GH#2273 identity-preservation suite moves to
  swm-head-identity-preservation.test.ts (the materializer file returns to
  ~612 lines; the small harness copies are deliberate - consolidating the
  shared sync fixture is queued with the post-chain test-structure pass).
  Registered in the unit config; CI shards auto-discover it.
- new regression pin: repairHeadPreservingIdentity's kaUal ownership guard
  spares another KA's operation subject referenced by a corrupted head
  (re-proves on the NEW method what the replaceHeadMetadata rows pin).
- new regression pin: a dirty head with one equivalent AND one
  non-equivalent stored id refuses preservation - the contract quantifies
  over EVERY referenced operation, and a first-match-return regression
  passes the single-id rows and fails only here. (Both rows pin properties
  the implementation has had since its first commit - regression pins, not
  fail-before rows.)
- dead descriptorHeadIdRows helper removed (unused since the selection
  started returning its own withhold plan).

Agent SWM lanes 123 green across 5 files; turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/test/swm-head-identity-preservation.test.ts
…echo-guard pin

PR2 review round 5 (otReviewAgent), all three applied:

- the mirrored resolver rules in selectRepairIdentity are GONE: publisher
  now exports isResolvableWorkspaceOperationRows, built ON the resolver's
  own operation decoder, and the preservation gate calls it - the gate can
  no longer drift from what readers actually accept. (This round's reuse
  ask was implementable where round 4's was not: the resolver's decoder
  tolerates duplicate publishedAt, unlike the parser's requireSingle, so
  reuse preserves the benign duplicate-stamp state.) requirePublishedAt
  additionally enforces the published-head wrapper's rule. Every existing
  unresolvable-winner sub-row stays green against the unified gate.
- ONE runSharedMemorySync fixture (test/_helpers/swm-sync-harness.ts) now
  serves the identity suite; the copied in-file harness is deleted.
- new echo-guard regression pin: a stored operation whose own
  shareOperationId row does not echo the head's reference refuses
  preservation (isolates the id-echo conjunct of the resolvability gate).

Agent SWM lanes 124 green across 5 files; e2e catch-up-survival row green;
turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/test/e2e-memory-layers.test.ts
…ow; e2e via harness

PR2 review round 6 (otReviewAgent), the three substantive items applied:

- collectOwnedHeadOperationSubjects is the ONE discovery of which operation
  subjects a head references and this KA owns (subject format + ownership
  ASK), consumed by BOTH replaceHeadMetadata and the identity-preserving
  repair - the delicate cleanup semantics can no longer drift between the
  paths. excludeShareOperationId spares a preserved winner.
- new sync-LEVEL polarity row: a same-digest descriptor whose operation
  adds an allowList envelope is NOT suppressed by the healthy-skip branch
  (head goes two-valued, next round converges to remote authority) - a
  regression that suppresses whenever ids differ passes every
  decision-level row and fails only here.
- the e2e catch-up-survival scenario drops its bespoke sync wiring for the
  shared makeSwmSyncHarness (the assertion is now most of the test).

The identity-model unification and the single-plan protocol asks keep their
standing positions (F3 / post-chain module extraction), stated in-thread.

Agent lanes 50 green (identity/materializer/recovery) + e2e row green;
turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* correct outcome (e.g. an added accessPolicy or allowedPeer IS a change the
* stale-intent machinery must see).
*/
export const OPERATION_IDENTITY_PREDICATES = {

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: Avoid defining workspace-operation identity twice

What's wrong
The new identity key is a parallel schema model in the agent: it hard-codes which RDF predicates define operation identity, how literals canonicalize, and which fields are ignored. That makes the codebase harder to maintain because the reader contract lives in publisher while the preservation equivalence contract now lives in agent, with comments warning about exactly the drift this structure invites.

Example
If a future workspace operation adds another resolver-significant envelope field, the resolver decoder and this allow-list both need to change. Missing the agent-side allow-list update would make identity preservation compare against a stale projection, even though the publisher already has the typed operation model.

Suggested direction
Use the publisher resolver layer as the canonical owner of operation-row semantics. That should let this PR delete the agent-side predicate allow-list, literal normalizer, and new direct dkg-rdf-utils dependency from packages/agent.

For Agents
Move the identity projection next to decodeWorkspaceOperationRows in packages/publisher/src/workspace-resolution.ts, expose a typed/canonical workspaceOperationIdentityKey or decodeWorkspaceOperationIdentity, and have the agent call that for descriptor and stored rows. Preserve the current equality semantics, including integer normalization and ignored per-node fields; add a unit asserting the agent no longer owns a separate predicate allow-list.

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.

Standing position, fifth iteration, final: the reader contract IS now consumed from the publisher (isResolvableWorkspaceOperationRows wraps the resolver's own decoder), so the drift your example describes fails closed — an allow-list omission makes preservation REFUSE (descriptor-wins), never silently preserve. What remains agent-side is the equivalence allow-list, whose unification with the parser's relation is the F3 follow-up where both move into one module. If blocking, flag 🔴 with a failure scenario.

Jurij89 and others added 2 commits August 16, 2026 21:47
PR2 review round 7 (otReviewAgent). The red was correct a third time, and
in the same family: the snapshot LOCATOR sits outside both the identity key
(its graph form embeds the operation id) and the head decoder (which never
consumes snapshot pointers), so a stored winner with a stale or empty
node-local snapshot graph could be preserved while the sync responder can
no longer serve its public quads to peers.

- selectRepairIdentity now also requires locator coherence on the winner:
  at most one locator form; a graph-form locator must actually hold the
  operation's public-quad count. Ref-form or absent locators are
  content-addressed (no worse than the descriptor's own) and pass.
  Fail-before proven: the stale-locator sub-row selects the winner against
  the ungated code.
- rows: stale-graph refuse / populated-graph preserve / dual-locator
  refuse; plus key-level subGraphName rows (same lane equal, cross-lane and
  one-sided different) pinning lane isolation in the identity predicate.

Agent lanes 52 green (identity/materializer/recovery); turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ive)

Completing the reader-contract family from PR2 rounds 2/3/7 proactively: the
sync responder's serving join (readFreshSwmMeta* and both legacy plans)
requires rdf:type WorkspaceOperation on the operation subject - a predicate
outside both the identity key and the head decoder, so a typeless preserved
winner would resolve locally but be unservable to peers. selectRepairIdentity
now requires the type row; new sub-row pins it. This closes the audit of
every identity-key exclusion against the reader contracts (publishedAt,
publisherPeerId, id echo, snapshot locator, rdf:type - each now gated or
covered by the resolver decoder).

Agent lanes 53 green; turbo build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Jurij89

Jurij89 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Reader-contract audit closed (12c68bc): every predicate the identity key deliberately excludes has now been checked against every reader that consumes a preserved operation, and each is either covered by the resolver decoder (isResolvableWorkspaceOperationRows) or has an explicit gate with its own test row:

Excluded predicate Reader that cares Gate
publishedAt (malformed/absent) head resolver + RFC64 published wrapper decoder + requirePublishedAt (rounds 2–3)
publisherPeerId head resolver decoder (round 1)
id echo head resolver + responder join decoder (rounds 1/5)
publicSnapshotGraph/Ref sync responder snapshot serving locator-coherence gate (round 7 🔴)
rdf:type WorkspaceOperation responder serving join (readFreshSwmMeta* + legacy plans) type gate (preemptive, this push)

The preserved-winner contract is now: identity-equivalent under the allow-list AND acceptable to every reader that will consume it — with descriptor-wins as the fallback for anything unprovable.

Comment thread packages/agent/src/sync/requester/swm-snapshot-materializer.ts Outdated
* published-head wrapper's rule (RFC64 inventory ordering requires a stamp,
* and every production writer emits one).
*/
export function isResolvableWorkspaceOperationRows(

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: The new publisher export exposes a partial internal validation boundary

What's wrong
The new export leaks the resolver’s internal row-shape decoder as a generic boolean helper, but it does not actually cover the full operation usability contract needed by the caller. The optional requirePublishedAt flag also mixes two layers of validation into a low-level rows API, which makes the boundary harder to reason about.

Example
A future caller can see isResolvableWorkspaceOperationRows and assume it fully answers whether operation rows are usable, while GH#2273 still needs separate snapshot locator coherence checks in the agent. That is an unclear boundary for a public publisher export.

Suggested direction
Prefer a typed decoder/validation result or a cohesive publisher-owned preservation validator over a public boolean wrapper with caller-specific options. If this is only for the agent, keep the API narrow and clearly internal rather than adding a broad-looking export.

Confidence note
This is a boundary/design concern rather than a behavioral defect; the helper may be expedient, but it still exposes a low-level internal contract across packages.

For Agents
Look at packages/publisher/src/workspace-resolution.ts and the call in packages/agent/src/sync/requester/swm-snapshot-materializer.ts. Preserve decoder reuse, but avoid exporting a partial boolean wrapper as a general publisher API. Consider exporting a typed internal validator/result that includes the complete preservation-relevant contract, or moving the preservation decision behind a publisher-owned function.

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.

Applied in e373703 — renamed to isDecodableWorkspaceOperationRows with the doc stating exactly what it answers: head-decoder acceptance ONLY, with serving-side gates (locator coherence, responder-join type) explicitly named as the caller's responsibility. requirePublishedAt stays as the documented published-head-wrapper addendum — splitting it into a second export would recreate the two-halves-of-one-contract problem this thread family keeps flagging.

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: Avoid exporting a narrow boolean wrapper around the workspace decoder

What's wrong
This turns a private decoder into public surface area while hiding all detail behind true/false and a caller-specific requirePublishedAt flag. It gives the agent just enough internals to depend on the resolver shape, but not enough abstraction to stop the agent from duplicating adjacent publisher/responder rules around it.

Example
The agent asks isDecodableWorkspaceOperationRows(..., requirePublishedAt: true), then separately checks rdf:type WorkspaceOperation, then separately checks snapshot locator coherence. Those are all parts of whether a preserved operation is usable, but the public API only exposes one boolean fragment of that concept.

Suggested direction
Replace the public boolean wrapper with a clearer boundary: either export the actual decoded operation/validation result with named failure modes, or add a publisher-owned helper that answers the full “can this operation be preserved and served?” question. That keeps resolver invariants from leaking into agent-side ad-hoc checks.

Confidence note
This is a structural API concern rather than a behavior defect; the current helper may work, but it makes the package boundary more brittle.

For Agents
Look at packages/publisher/src/workspace-resolution.ts and the call site in swm-snapshot-materializer.ts. Preserve the resolver’s existing validation semantics, but prefer exporting a typed validator/decoded result for the full published-operation contract or keep this helper private and move the preservation validation to a canonical publisher-owned helper.

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.

Standing position (boundary family), final: the boolean decoder wrapper is intentionally narrow — the publisher exposes exactly its own reader contract and nothing about serving-side rules it does not own (the responder's join and snapshot serving are agent/responder concerns). A richer publisher abstraction that also encodes responder rules would centralize knowledge the publisher does not have. The full-usability contract gets one home in the module extraction. Converging; if blocking, flag 🔴 with a failure scenario.

Comment thread packages/publisher/src/index.ts Outdated
KnowledgeAssetOperationPublicSnapshotNotFoundError,
KnowledgeAssetWorkspaceHeadCorruptError,
isKnowledgeAssetWorkspaceHeadCorruptError,
isResolvableWorkspaceOperationRows,

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: Avoid publishing an agent repair hook as root publisher API

What's wrong
The PR solves an internal agent boundary problem by widening the publisher package's public surface. That leaks implementation details from the workspace resolver and makes future cleanup of the decoder harder.

Example
An external user can now import isResolvableWorkspaceOperationRows from @origintrail-official/dkg-publisher and depend on a low-level row-bag boolean with repair-specific options. Later refactoring decodeWorkspaceOperationRows or changing the published-head rule becomes a public API compatibility concern.

Suggested direction
Do not expose this narrow boolean from the package root unless it is intentionally part of the public SDK. Prefer an internal subpath/shared package for the repair decision, or expose a higher-level stable validator whose contract is not coupled to the agent's preservation workflow.

Confidence note
If the project intentionally treats every package index export as internal-only despite the package being publishable, this can be downgraded; otherwise this is public API surface area.

For Agents
Review the intended boundary between publisher and agent. If the helper is only for agent repair, keep it off the root index or move the shared validator to an internal/shared module with an explicit non-public contract. If it is meant to be public, rename and document it as a stable domain API rather than a catch-up hook.

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.

Applied the honest-marking half in 2410231 and holding the export location by repo precedent: swmKaWriteLockKey/withKeyedLocks are exactly this class of agent-facing plumbing already root-exported from the publisher (there is no subpath-export convention in this repo — every package maps only '.'), and the doc now states explicitly: internal cross-package plumbing, not SDK surface, shape may change without compatibility notice. A dedicated internal subpath or shared package is a packaging-convention change that belongs with the module extraction.

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: Do not publish an internal resolver hook from the package root

What's wrong
The PR solves an agent-side reuse problem by adding an internal decoder probe to the publisher package’s main export surface. That creates architectural drift: an implementation detail that is expected to change without compatibility notice is now exposed exactly where SDK users discover stable APIs.

Example
A downstream consumer can now import isDecodableWorkspaceOperationRows from @origintrail-official/dkg-publisher; if the resolver decoder shape changes, this supposedly internal helper has become part of the practical package API.

Suggested direction
Either make this an intentional public API with a stable name and contract, or keep it internal by exporting from a clearly internal entrypoint used only by the agent. The current root export contradicts the comment and leaks implementation detail across the package boundary.

For Agents
Look at packages/publisher/src/index.ts line 55 and packages/publisher/src/workspace-resolution.ts lines 422-437. Preserve the agent’s ability to reuse the decoder, but expose it through an explicit internal module/path or move the shared decoder contract to a more appropriate internal package instead of exporting it from the publisher root.

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.

Standing position, third statement, final: the repo has no internal-entrypoint convention (every package's exports map is only '.'), so 'clearly internal entrypoint' is a packaging-convention change, and root-exported agent-facing plumbing is the established pattern (swmKaWriteLockKey, withKeyedLocks — same file class). The helper is explicitly doc-marked internal/no-compat. Making the decoder contract a deliberate public API — or introducing an internal subpath convention repo-wide — belongs to the module extraction; doing either as a side effect of a bugfix chain is how accidental API happens.

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: Internal decoder plumbing is being exported through the public publisher barrel

What's wrong
This change solves an agent implementation need by leaking publisher internals through the SDK-facing index. That makes the resolver’s private row-decoder shape look like stable public API, so future refactors of decodeWorkspaceOperationRows will have to account for external consumers or risk a hidden breaking change.

Example
A downstream SDK consumer importing from @origintrail-official/dkg-publisher now sees isDecodableWorkspaceOperationRows as part of the same public barrel as resolveKnowledgeAssetWorkspaceHead, even though the comment says the helper may change without compatibility notice.

Suggested direction
Move this behind an internal boundary instead of adding it to the package’s main export surface. If cross-package reuse is required, make that boundary explicit as an internal module/shared package rather than relying on a public export plus a warning comment.

For Agents
Keep the resolver decoder reuse, but avoid publishing this as a top-level SDK API. Look at packages/publisher/src/index.ts and packages/publisher/src/workspace-resolution.ts; either move the shared decoder contract into an internal/shared module consumed by agent and publisher, or expose it through an explicitly internal subpath that is not part of the public barrel. Preserve the agent preservation gate behavior and add/adjust a compile-level import test if this repo has one.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Internal decoder plumbing should not be exported as publisher API

What's wrong
The PR adds an explicitly internal, agent-specific decoder hook to the public package index. That makes a private resolver implementation detail look like supported API, so future resolver cleanup now has to worry about external consumers and raw-row compatibility.

Example
A downstream SDK consumer can now import isDecodableWorkspaceOperationRows from @origintrail-official/dkg-publisher and rely on its raw row shape or requirePublishedAt option, even though the comment says it may change with the resolver decoder.

Suggested direction
Keep the package root focused on stable SDK surface. Either move this helper to an internal shared module consumed by both publisher and agent, or move the agent's validation behind a publisher-owned domain method that does not expose decoder internals.

For Agents
Move the decoder acceptance helper out of the public root export. Preserve the agent's ability to validate rows against the workspace-head decoder, but expose it through an internal module/subpath or a higher-level publisher-owned preservation predicate. Run the TypeScript build after changing imports.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

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: Don't expose internal decoder plumbing through the public publisher API

What's wrong
This change says the helper is internal, but then exports it from the package's main surface. That makes a partial implementation detail look supported and also spreads the resolver contract: the publisher owns the decoder, while the agent owns several additional gates needed to make the decoded operation usable. That is a brittle boundary for future resolver changes.

Example
The new helper is public enough for import { isDecodableWorkspaceOperationRows } from '@origintrail-official/dkg-publisher', but it only answers one slice of resolver acceptability; the agent then has to remember extra responder type, access envelope, and snapshot locator checks separately.

Suggested direction
Move this behind an internal subpath/shared package, or expose a higher-level typed operation validation API that genuinely owns the resolver-side contract. The top-level publisher export should not become a grab bag for agent-only internals.

For Agents
Look at packages/publisher/src/workspace-resolution.ts, packages/publisher/src/index.ts, and the materializer import. Preserve the current preservation behavior, but keep this decoder plumbing off the public barrel or replace it with one canonical internal validator that covers the preservation contract. Existing preservation tests should still pass through the new boundary.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

Jurij89 and others added 3 commits August 16, 2026 22:01
…est export name

PR2 review round 8 (otReviewAgent), both reds + two yellows applied:

- the red pair was correct: count equality does not prove content, and a
  SAME-SIZE stale snapshot graph was the remaining integrity hole. The
  locator gate now uses the count-then-digest ladder (the same shape as
  isGraphAssetMaterialized): the graph's content digest must equal the
  committed publicQuadsDigest. New same-count-wrong-content row
  (remoteChanged.payload deliberately equals the committed quad count);
  fail-before proven against the count-only gate.
- candidate loading is ONE bounded query (head-join pulls every referenced
  operation's rows; identity, ownership and reader-contract validation run
  over the in-memory model) - the per-candidate ASK/read fan-out under the
  lock is gone.
- the publisher export is renamed isDecodableWorkspaceOperationRows and its
  doc states exactly what it answers (head-decoder acceptance ONLY, serving-
  side gates remain the caller's) - the old name overpromised.

Agent lanes 53+ green incl. all locator sub-rows; e2e row green; turbo
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR2 review round 9. Key-level rows: a zero-padded integer lexical form
("02" vs "2" on publicQuadsCount) keys EQUAL (value comparison), while a
genuinely different count keys different - pins the normalization the
wire-vs-store round-trip relies on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lumbing

PR2 review round 9 follow-up: isDecodableWorkspaceOperationRows is the same
class of agent-facing plumbing as swmKaWriteLockKey/withKeyedLocks - root-
exported by repo convention for workspace-internal consumers, explicitly not
SDK surface, no compatibility notice on shape changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR2 post-convergence round (otReviewAgent): selectRepairIdentity is now an
orchestration of NAMED validators - operationIdentityMatches,
storedWinnerIsDecodable, storedWinnerHasResponderType,
snapshotLocatorIsServeable - each naming one invariant of the
preserved-winner contract and each pinned by its own polarity row. Behavior
byte-identical (all 54 lane tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/test/swm-public-snapshot-materialization.test.ts Outdated
Jurij89 and others added 3 commits August 16, 2026 23:51
Review follow-up on GH#2273 PR2:

- snapshotLocatorIsServeable no longer free-passes ref-form locators: the
  resolver's read-both rule makes an explicit publicSnapshotRef row WIN
  over the digest fallback, so a stale ref on a preserved winner would be
  FOLLOWED by readers while the identity key (locators excluded) still
  matches. A ref is now serveable only when single-valued and equal to the
  committed public digest (putSnapshot's ref === digest convention);
  absent locators keep the digest-fallback pass. Two new rows (stale ref,
  multi-valued refs) verified failing under the pre-fix free pass.
- Harness override for selectRepairIdentity typed against the real
  materializer return shape (withholdRows included) instead of a partial
  object no production caller can receive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…licies

Review follow-up on GH#2273 PR2:

- New row: two identity-equivalent stored ids on one dirty head (insertion
  order reversed from the expected winner) must select the sorted-first id
  and repairHeadPreservingIdentity must leave only that id — verified
  failing with the sort replaced by reverse().
- The parser's byte-level key is renamed samePayloadByteEquivalenceKey and
  both key builders carry cross-referencing policy docs: same-payload byte
  comparison (throws on ambiguity, predicate-agnostic) vs cross-store
  normalized allow-list identity; unification recorded as follow-up F3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up on GH#2273 PR2: snapshotLocatorIsServeable now (1) rejects
graph locators failing isSafeIri — the exact rule the public snapshot
resolver applies before following a locator, so a winner readers would
reject cannot be certified serveable — and (2) treats a locator-query
failure as non-serveable instead of letting assertSafeIri (or the store)
THROW out of the preservation check, which pre-fix stalled the sync round
on exactly the corrupt rows descriptor-wins repair exists for. New row: a
literal-valued publicSnapshotGraph (the malformed shape a store can
actually hold — Oxigraph refuses schemeless IRI terms at insert) must
yield a clean null; verified failing pre-fix (throws "Unsafe or empty IRI
value"), passing after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/test/swm-head-identity-preservation.test.ts
* ABSENT locators (no graph, no ref) fall back to the digest convention —
* content-addressed, no worse than the descriptor's own — and pass.
*/
const snapshotLocatorIsServeable = async (

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: Snapshot locator policy is duplicated outside its canonical resolver

What's wrong
The agent now owns a parallel implementation of publisher snapshot-resolution semantics. This is exactly the kind of cross-layer policy duplication that drifts: the code even names reader-specific rules from another module, but encodes them locally with bespoke SPARQL and digest logic.

Example
If resolveKnowledgeAssetOperationPublicQuads changes the read-both rule for publicSnapshotRef versus publicSnapshotGraph, this agent-side validator must be updated manually or it can approve/refuse preserved identities under different rules than the actual reader.

Suggested direction
Push this serveability check behind a publisher-owned internal helper, or have the materializer call a canonical resolver/validator that already owns snapshot locator semantics. The materializer should orchestrate store state, not carry a second copy of reader rules.

For Agents
Look in packages/publisher/src/workspace-resolution.ts for the canonical public snapshot resolution rules and expose a narrow internal validator/resolver that the materializer can call. Preserve the current null-on-unserveable behavior in the sync decision, but remove the duplicate locator interpretation from swm-snapshot-materializer.ts.

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.

Position (final): the agent gate does not re-implement snapshot RESOLUTION — it applies the reader's acceptance predicates (isSafeIri from the same dkg-core module the resolver imports; digest equality against the descriptor's committed digest) to decide serveability BEFORE preserving, on the agent's own store. The resolution semantics live only in the publisher; what's shared is the two predicates, both imported from their canonical homes. Extracting a joint locator-coherence contract is part of the recorded workspace-resolution module extraction. If blocking, flag 🔴 with a failure scenario.

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: Do not duplicate snapshot locator semantics in the materializer

What's wrong
The materializer now reimplements reader-specific snapshot locator policy locally. The large explanatory comment is a sign that the logic is living outside its natural owner, and it creates a second place to keep in sync with the publisher resolver.

Example
If resolveKnowledgeAssetOperationPublicQuads later changes how multi-valued refs, graph locators, or a new locator form are interpreted, this private snapshotLocatorIsServeable copy must be updated by hand or preservation can diverge from the reader contract.

Suggested direction
Push this into the canonical workspace-resolution layer, or make preservation call a shared resolver/validator that already knows how operation public snapshots are located and validated.

For Agents
Move the locator/readability check beside the publisher resolver, or expose one internal validator used by both the resolver and the materializer. Keep the materializer focused on store orchestration and keep the existing stale/wrong/ambiguous locator tests passing.

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.

Position unchanged (final, repeated): recorded follow-up owns this boundary. If blocking, flag 🔴.

…c repair path

Review follow-up on GH#2273 PR2, two reds:

- operationIdentityKey now keys accessPolicy by its EFFECTIVE value under
  the publisher's own default rule (absent => privateTripleCount > 0 ?
  ownerOnly : public, per async-lift-publish-options/dkg-publisher):
  an older stored operation without the row and a peer's explicit-default
  row are the SAME share, and raw-presence keying refused preservation for
  exactly the old-metadata interop case — reintroducing stale-intent
  rotation. Non-default/allowList changes still differ; multi-valued
  policy rows or unparsable counts key to null (fail toward remote
  authority). Count compared by VALUE, not lexical form. New row (absent
  vs explicit default equal; ownerOnly polarity) verified failing under
  raw-presence keying.
- New sync-lane row seeds the PRE-upgrade two-valued head residue and runs
  the full harness once: the storedHead.needsRepair branch of
  runSharedMemorySync must preserve the stored id (verified failing with
  the repair branch bypassed to replaceHeadMetadata). The two-stage row's
  round-2 narrative is corrected to idempotent convergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jurij89 and others added 2 commits August 17, 2026 00:58
… rows

Review follow-up on GH#2273 PR2:

- The kept-head prefer-stored branch now ENACTS the preserving repair when
  a winner is decided (repairHeadPreservingIdentity: descriptor head rows
  + stored winner id) instead of suppress-only. Version/id cardinality is
  all the branch checks, but the resolver validates more head rows — a
  stale extra assertionGraph row was invisible here yet corrupt to
  readers, and suppress-only froze it in place round after round. Same
  decide-and-enact shape as the private recovery lane. New row (residue
  head row + equivalent foreign descriptor => resolver returns preserved
  local id, single assertionGraph row) verified failing under
  suppress-only.
- Access-envelope isolation rows: allowedPeer set participates in
  identity independently of the policy row (order-independent, refusal on
  set change); private content's absent policy row equals explicit
  ownerOnly (BigInt path), with explicit-public polarity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up on GH#2273 PR2: metadataQuads kept only the ONE selected
head id row, so a same-value different-lexical-form row (plain vs
xsd:string-typed literal — RDF 1.1 admits both) escaped the byte-keyed
withhold plan, passed the value-based insert canonicalization, and
re-stacked the losing id beside a just-preserved head. The parser now
includes every head id row whose VALUE is the selected id, so downstream
plans cover all forms. New sync-level row serves a duplicate typed-variant
id row and asserts the preserved head stays single-valued — reproduced
failing exactly as reported (head ended [op-v1, storage-ack-2273b]) before
the fix. The now-unused selected-row candidate property is removed (the
presence VALIDATION stays).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/agent/src/sync/graph-scoped-swm-recovery.ts
Review follow-up on GH#2273 PR2:

- New reader-contract validator storedWinnerHasUsableAccessEnvelope: the
  VM-publish preflight requires the LIVE head's accessPolicy to be DEFINED,
  so preserving a policy-less stored winner parks the KA on an operation
  queued publishes reject as stale — descriptor-wins instead converges to
  the peer's explicit-policy op. Identity-KEY equality (absent == effective
  default, kept for old-metadata comparison) and envelope USABILITY are now
  separate concerns. Fail-before: the new gate row (policy-less stored
  winner + equivalent explicit-default descriptor => refuse) failed before
  the gate landed.
- Fixture now stamps accessPolicy 'public' on operation rows — production
  share paths always write the effective policy row (verified on a live
  testnet op dump); policy-less ops are the old-metadata shape, built
  per-row where a test needs one. Two envelope-difference rows updated to
  REPLACE the default row rather than append (parser rejects multi-valued
  policies).
- New key-level row pins plain-vs-xsd:string canonicalization on
  string-valued identity rows (passed immediately — the rdf-utils
  canonicalization path covers it; now regression-pinned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants