Skip to content

Codex/v10.0.9 testnet canary - #1845

Merged
lupuszr merged 55 commits into
testnet-canaryfrom
codex/v10.0.9-testnet-canary
Jul 20, 2026
Merged

Codex/v10.0.9 testnet canary#1845
lupuszr merged 55 commits into
testnet-canaryfrom
codex/v10.0.9-testnet-canary

Conversation

@lupuszr

@lupuszr lupuszr commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Changes

Test Plan

  • Tests pass locally (pnpm test)
  • Build succeeds (pnpm build)
  • Manually tested with dkg start (if applicable)

Related Issues

Bojan131 and others added 30 commits July 16, 2026 16:47
Move the root package and all 20 packages/* workspaces to 10.0.8 in
lockstep, per the single-version release set rule in RELEASE_PROCESS.md
§3, and add the 10.0.8 CHANGELOG entry.

Version-only bump: pnpm-lock.yaml records third-party versions only and
is untouched, so `pnpm install --frozen-lockfile` stays valid.
`pnpm release:verify-versions --version 10.0.8` passes (21/21).

No Solidity source, ABI, or deployment-registry changes since v10.0.7,
so this release requires no contract deployment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(release): bump version set to 10.0.8
Expose StorageACK peers for reliable remote queries
GH#1778 reported that a sealed rootless named KA shared member -> curator
over SWM is "unsealed" on the curator and cannot be published to VM, and
proposed adding the seal to the SWM wire protobuf.

Investigation (incl. executing the real durable-sync + seal modules)
showed the seal never rode SWM gossip in any version, so there is nothing
to restore there. The seal already reaches the curator via the durable
`_meta` sync lane. The real failure is two stacked off-chain defects, so
no wire/protobuf change (and no metadata mirror) is needed:

- Defect A: VM publish built the seal-lookup URI from the CALLER (curator)
  address, not the KA author. The seal sits at .../assertion/<member>/<name>;
  publish queried .../assertion/<curator>/<name>, found nothing, and reported
  "is not finalized" (surfaced as "unsealed"). Fix: resolveAssertionAuthor()
  reads the author from local `_meta` (prefer the caller's own KA; else the
  sole other author; else 409 AMBIGUOUS_ASSERTION_AUTHOR). The caller hint is
  the effective publish identity, so tokenless self-publish is unchanged. The
  route's existing "author cannot be supplied on vm/publish" 400-gate stays.

- Defect B: the durable-sync integrity filter stripped dkg:assertionVersion
  from a not-yet-published seal subject (13/14 quads), making
  parseAssertionSealQuads throw "Partial graph-scoped assertion seal" once
  Defect A was fixed. Fix: admit assertionVersion for a self-consistent seal
  subject (carries assertionMerkleRoot, contentScopeVersion=2, single kaUal
  whose author == single authorAddress == the /assertion/<addr>/ segment).
  The seal's 13 sibling quads already sync unauthenticated; the real integrity
  boundary is the publish-time Merkle recheck, which stays authoritative.

No receiver/wire/ACK changes; the receiver still writes no context-graph
`_meta`. Adds unit + resolution + adversarial durable-sync regression tests.
Also corrects two stale comments about `_meta` replication / seal transport.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on PR #1780:

- BUG (otReviewAgent 🔴): `opts.agentAddress` was reused as the #1778 caller
  hint, but for direct programmatic callers it is an AUTHORITATIVE author
  selector. A caller requesting an author with no seal could be silently
  served a different same-named author. Split the roles: `agentAddress` stays
  authoritative (no substitution); the daemon publish routes now pass
  `callerAgentAddress` (token/caller identity) and only that hint drives
  resolution. Extracted the shared `resolveFinalizedAssertionPublishAuthor`
  helper so the sync and async publish paths cannot drift (🟡).

- Centralised the assertion-coordinate shape in one core helper
  `parseContextGraphAssertionUri` (inverse of `contextGraphAssertionUri`),
  used by both the publish author resolver and the durable-sync seal identity
  check, replacing the bespoke prefix/suffix and regex parsing (🟡).

- Tests: explicit-agentAddress-not-substituted; async intent auto-resolution;
  route-level 409 `AMBIGUOUS_ASSERTION_AUTHOR` { candidates } for sync and
  async publish; core round-trip for the new parser. Updated two existing
  route tests to the callerAgentAddress contract.

core 1238 / agent 1120 green; CLI publish/route suites green (the remaining
Windows-only CLI failures are pre-existing Hardhat-context / native-binary /
unix-permission env issues, unrelated to this change).

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

Follow-ups on PR #1780 (otReviewAgent round 2):

- BUG (🔴): `parseContextGraphAssertionUri` counted URI segments, but
  `validateContextGraphId` permits `/` (wallet-scoped CG ids like
  `0xabc…/project`), so such ids were mis-split into a subgraph and #1778
  author resolution fell back to "is not finalized". Reworked the parser to
  anchor on the `/assertion/<addr>/<name>` suffix from the RIGHT and return the
  raw `scope` (cgId or cgId/subGraph, not split — a slash-containing cgId
  cannot be separated from an optional subgraph by the URI alone). Consumers
  compare `scope` to the known cg. Added core round-trip tests for slash cgIds
  (with/without subgraph) and agent resolver + durable-sync tests.

- Moved the graph-seal self-consistency check into core as
  `graphScopedSealAuthor` (beside `ASSERTION_SEAL_PREDICATES` /
  `parseAssertionSealQuads`); durable-sync now calls it and no longer
  re-declares seal predicate constants (🟡).

- Extracted the store/URI/EVM author lookup out of the large publish mixin into
  a focused `finalized-assertion-author.ts` module; the mixin method delegates (🟡).

- Deduplicated the ambiguous-author 409 mapping behind
  `respondAmbiguousAssertionAuthor`, used by both publish routes; centralised
  the route caller-hint via `publishCallerHintLane` (🟡).

- Enforced the selector-vs-hint contract: `resolveFinalizedAssertionPublishAuthor`
  now rejects supplying both `agentAddress` and `callerAgentAddress`
  (PUBLISH_AUTHOR_SELECTION_CONFLICT) (🟡).

- Added subgraph author-resolution coverage (root/subgraph do not cross-match) (🟡).

core 1238 / agent 1124 green (relay.test.ts flakes only under full-suite load;
passes in isolation — unrelated to this change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jurij89 and others added 21 commits July 17, 2026 23:47
…l version

Follow-ups on PR #1780 (otReviewAgent round 7):

- BUG (🔴): the round-6 seal-version classifier admitted `dkg:assertionVersion`
  whenever the seal IDENTITY was self-consistent, but never checked the admitted
  field itself. A peer could append a second, conflicting `assertionVersion`
  (e.g. "1" and "999") and both rows were admitted, letting the full parser's
  last-writer-wins pick the tampered value. Now the seal predicate requires
  exactly ONE distinct `assertionVersion` (alongside kaUal/authorAddress), so a
  conflicting version drops ALL version rows and publish stays fail-closed. New
  durable-integrity test: two conflicting versions ⇒ none admitted, parse throws.

- Renamed the core helper `graphScopedSealAuthor` → boolean
  `isSelfConsistentGraphScopedAssertionSeal` (🟡): it is a narrow durable-sync
  admission predicate, not a general author extractor — the boolean contract
  matches its only use and no longer reads like a reusable core author resolver.

- System-override coverage (🟡): added a test that routes selection through
  `selectSystemOverrideMetadataIndexes` (acceptUnverified + orphan control) and
  proves the self-consistent seal keeps `assertionVersion` while the orphan
  control is dropped — the second admission site the classifier touches.

core (isolation) / agent 1127 green; probe 14/14. (Full-suite core failures are
flaky libp2p relay/networking tests, unrelated — they pass in isolation.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… default identity

Round-8 follow-up on PR #1780 (otReviewAgent). Round 6 made async CG
auto-registration use only `agent.getDefaultAgentAddress()` (collapse-proof,
never the KA author). But a node with NO default identity then registered with
no EVM actor at all, so a self-authored async publish (author == caller) to a
fresh CG could fail at registration — a regression from the pre-PR
`request.agentAddress` fallback.

Fix: `getDefaultAgentAddress() ?? request.agentAddress`. The node identity still
wins whenever present (preserving the round-6 collapse/curator fixes), and the
degenerate no-default deployment falls back to the request author (correct on
the self-publish path where author == caller). New test covers the no-default
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-9 follow-up on PR #1780 (otReviewAgent).

- BUG (🔴): author resolution let ANY `_meta` subject carrying
  `dkg:assertionMerkleRoot` participate in selection, but durable `_meta` can
  hold stale/partial rows or unauthenticated fragments. A partial `OTHER/name`
  (merkleRoot only) alongside a valid `MEMBER/name` produced a false
  `AMBIGUOUS_ASSERTION_AUTHOR`, and a partial-only subject was selected and then
  failed at publish as a corrupt seal (500) instead of "not finalized". Fix: the
  resolver now CONSTRUCTs each candidate subject's full `_meta` rows and admits
  only those that `parseAssertionSealQuads` accepts as a complete graph-scoped
  v2 seal — the exact check publish performs — so a resolved author is always
  publishable and a partial subject is treated as not-finalized. New tests:
  valid + partial-only ⇒ resolves the valid author (no false ambiguity);
  partial-only ⇒ undefined (no unusable author).

- Nit (🔵): reordered `respondAmbiguousAssertionAuthor` above the
  `respondAssertionError` JSDoc so that doc-comment reattaches to its function.

core / agent 1129 / cli routes green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…andidate check

Round-10 follow-up on PR #1780 (otReviewAgent).

- BUG (🔴): the round-9 resolver validated that a candidate's rows parse as a
  graph-scoped seal, but then read the author from the SUBJECT URI without
  checking it against the seal's own `authorAddress`/`kaUal`. A malformed `_meta`
  with a complete seal at `.../assertion/0xMember/report` whose fields name
  `0xOther` would resolve `0xMember` while the attestation/scope belong to
  `0xOther` — a mismatched-identity publish. Fixed by requiring the subject
  coordinate author, `authorAddress`, and `kaUal` author to all agree.

- Unified the two seal-candidate definitions (🟡): introduced ONE canonical core
  helper `parseGraphScopedAssertionSealCandidate(rows, subject)` — complete v2
  seal + single-valued identity/version fields + coordinate/author/kaUal
  alignment — returning the parsed seal + coordinate. Both VM-publish author
  resolution (`finalized-assertion-author.ts`) and durable-sync admission
  (`durable-integrity.ts`) now consume it, replacing the previous
  `isSelfConsistentGraphScopedAssertionSeal`, so the definition of "publishable
  graph-scoped seal" lives in one place and cannot drift.

New resolver tests: coordinate-mismatched seal is ignored (alone and alongside
an aligned seal). Durable-sync + probe unchanged (14/14).

core 104 / agent green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ane consistency)

Human review (branarakic) Major concern: async VM-publish CG auto-registration
stamped the curator with the NODE default identity (round-6/8 change), while the
sync `vm/publish` lane stamps it with the token holder (`callerAgentAddress:
requestAgentAddress`). `callerAgentAddress` becomes the CG curator via
`stampAddressCurator`, so the same CG got a DIFFERENT curator depending on which
lane registered it — a multi-tenant inconsistency.

Fix: persist the enqueuing caller on the queued intent and register the async
lane under it, matching sync.
- `KnowledgeAssetVmPublishRequest.callerAgentAddress` re-added (type + persisted
  parser), distinct from the resolved-author `agentAddress`.
- `resolveFinalizedAssertionVmPublishIntent` sets it to the enqueuing caller
  (`opts.callerAgentAddress ?? opts.agentAddress`), kept OUT of intentKey so it
  never forks dedup.
- lifecycle.ts registers with `request.callerAgentAddress`; when absent
  (tokenless / pre-#1778 job) it passes none and `stampAddressCurator` falls back
  to the node default — exactly as the sync lane does when requestAgentAddress is
  undefined. Deduped jobs stamp under the first enqueuer, matching sync's
  first-writer-wins.

Also: moved the dangling `parseAssertionSealQuads` doc block down to its export
(review nit), and added an adversarial durable-sync test pinning the fail-closed
behavior of a seal-plus-planted-`merkleRoot` subject.

Rebased onto origin/main. agent 1132 / publisher 165 / cli routes green; probe 14/14.

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

The round-10 CONSTRUCT { ?s ?p ?o } over the CG _meta graph tripped the SPARQL
scalability lint (R4: unbounded scan over a fleet-growing graph). Split into a
bound-predicate SELECT that finds the ~1 candidate subject by name, then an
EXACT-subject CONSTRUCT per candidate (a bounded per-subject read, lint-exempt).
Behaviour unchanged - same complete/self-consistent seal validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Answers one product question end to end, through the daemon HTTP API a real
agent uses — no fixtures, no library shortcuts:

  "Can any agent subscribing to a public Context Graph reliably converge to
   both the shared working memory and the finalized verifiable memory corpus?"

Covers both PUBLIC policy cells (open + curated), three receiver shapes
(author / live / late), curated authorization negatives, and an opt-in
RESILIENCE phase (scale, idempotence, author outage, hold-out).

WHY THIS EXISTS. It found three real defects that unit tests and code review
missed, two of which had been misdiagnosed twice by reading the code:

1. Markdown KAs replicating to peers with metadata but EMPTY entities/triples.
2. Member->curator SWM permanently dropped on public/curated CGs — the share
   reported success while the write went nowhere.
3. Public SWM catch-up fetching and verifying every snapshot, then never
   materializing it: a node that missed the live gossip stayed empty forever
   ("0 data + N meta triples"). Matches a live production symptom.

CONFOUND CONTROLS, each added after a green check turned out to measure nothing:

- Content presence is asserted SEPARATELY from sync-mechanism progress. A
  receiver can hold content while the chain-reconcile watermark is stuck at 0.
- The decisive receiver is chosen AFTER the publish, from nodes provably outside
  the storage-ACK set, and must differ from the live receiver's node.
- Corpus counts bind the published content subjects and require EXACT equality.
  Counting every quad in the CG's graphs sweeps in metadata and passes while the
  content is incomplete (observed 1450 against a 1000-quad target).
- Pre-subscribe baselines are recorded, and a receiver that already held the
  corpus is reported NOT DECISIVE instead of passing.
- The hold-out proves reconstruction by ORDERING (the CG is created after the
  node is stopped), because once catch-up works it converges faster than a
  baseline read.

STRUCTURAL LIMITS THIS MAKES VISIBLE. A single-box devnet cannot produce a
non-host subscriber below ~5 nodes (ACK quorum is 3, so every peer must ACK),
and public gossip pre-populates every RUNNING node regardless of subscription.
Both hid real defects. The hold-out (stop a node before publication, publish,
restart via `devnet.sh restart-node`) is the only construction here that makes
corpus reconstruction decidable.

Also documents two operational traps: default devnet ports collide with a
locally-running production node, and `restart-node` re-derives every port from
env, so a caller that omits LIBP2P_PORT_BASE silently returns a node on the
default port where no peer can reach it while its API still answers 200.

Usage:
  node devnet/public-cg-sync-proof/proof.mjs
  RESILIENCE=1 NUM_NODES=6 node devnet/public-cg-sync-proof/proof.mjs
  node devnet/public-cg-sync-proof/verify-fixes.mjs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… graphs

Sender and receiver decided the SWM encryption requirement from different
authorities, and disagreed on exactly one set: accessPolicy=0 AND agent-gated —
the public/curated cell.

SENDER (packages/agent, resolveWorkspaceRecipientsGated) short-circuits on a
LIVE on-chain accessPolicy of 0 and gossips PLAINTEXT, deliberately ignoring the
agent gate. That is correct and deliberate: on a public CG the allowlist governs
PUBLISH AUTHORITY, not READ ACCESS, so there is nothing to keep confidential.
Encrypting instead bootstraps a sender-key handshake that non-gated recipients
reject, which previously surfaced as HTTP 500 on promote — the bug
packages/agent/test/swm-public-cg-plaintext.test.ts exists to prevent.

RECEIVER (workspace-handler) required encryption whenever agentGateAddresses was
non-null, derived from local allowedAgent/participantAgent triples — intent, not
authority. It therefore dropped the sender's plaintext with retryable:false
while the sender reported success, so every member->curator SWM share on a
public/curated CG failed permanently and silently. Reproduced on a 6-node
devnet: member share returns status=swm-shared, curator logs "Sender Key
encrypted workspace payload required ... (permanent rejection)", content never
converges, and the curator's subsequent vm/publish 409s as not finalized.

FIX: the receiver now consults the SAME live on-chain predicate the sender uses,
injected as publicAccessPolicyOnChainOracle alongside the existing
chainAgentGateOracle and wired to isContextGraphPublicOnChain. An agent gate
forces encryption UNLESS the CG is proven public on-chain. Fail-closed
throughout — absent oracle, false, or a throw all mean "not proven public" and
keep the requirement, so a stale mapping or RPC flake can never become a
plaintext-acceptance hole.

A sender-side fix was tried first and reverted: it broke 2 of 38 tests in
swm-public-cg-plaintext.test.ts, whose assertion `store.query.calls == []`
requires the public path not to touch the store at all.

TEST COVERAGE. New packages/publisher/test/swm-public-gated-plaintext-accept.test.ts
covers public+gated accept, gated-not-public require, probe-throws fail-closed,
no-oracle fail-closed, and ungated unaffected. It was MUTATION-TESTED: restoring
the pre-fix behaviour makes the public+gated case fail.

CI GAP CLOSED. Both vitest.unit.config.ts files use explicit include lists, and
neither the new test NOR swm-public-cg-plaintext.test.ts was listed — the latter
guards a previously-fixed production bug and was not running in CI at all. Both
added: publisher 37->38 files / 422->427 tests, agent 90->91 / 1132->1170.

Mixed-fleet note: un-upgraded receivers keep rejecting plaintext on agent-gated
public CGs until they take this build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ch-up

A node that missed the live gossip never obtained any SWM content. Reproduced
deterministically: hold a node out of a publication cycle, restart it, subscribe
— 0 of 100 quads after 180s, from five healthy holders. Now 100/100 in ~3s.

"0 data + N meta triples" was a red herring: it is CORRECT responder behaviour.
Graph-scoped (contentScopeVersion 2) KAs carry no dkg:rootEntity, so the
aggregate data phase legitimately returns nothing for them — their content
travels as immutable snapshots instead. The catch-up lane fetched and VERIFIED
those snapshots and cached them, then never wrote them to the triple store. The
held-out node was already holding
swm-public-snapshots/81/98/8198388b...nq — the exact 20 quads for KA 27 — on
disk, unmaterialized.

The asymmetry: live gossip materializes (gossip-publish-handler), durable/VM sync
materializes (materializeVerifiedGraphScopedAsset), and PRIVATE CG recovery
materializes (swm-recovery.ts materializeReadySnapshot) — but the PUBLIC catch-up
lane omitted the step. syncPublicSnapshotsForMeta already exposed an
onSnapshotReady hook; the public caller simply never passed it.

FIX mirrors the private lane: parse graph-scoped descriptors from verified meta,
pass onSnapshotReady, and materialize each verified snapshot via replaceGraph.

Deliberate properties:
- replaceGraph, NOT insert. A KA graph is all-or-nothing and digest-verified;
  union-insert risks partial or duplicated graph state across retries, and would
  bypass per-KA digest verification.
- Gated on wsMetaResult.completed. parseGraphScopedSwmRecoveryDescriptors throws
  on incomplete metadata and this lane pages meta, so an ungated parse would
  abort the whole context-graph fanout on a timed-out page.
- Per-KA error isolation: one unmaterializable snapshot must not take down the
  rest of the corpus; the phase stays incomplete so the scheduler retries.
- storeReplaceGraph is optional on the context so existing callers and test rigs
  compile unchanged; when absent, materialization is skipped rather than
  half-applied.

TRAP AVOIDED: the tempting fix is to make the data lane work — resurrect
dkg:rootEntity, or make readFreshSwmRoots match graph-scoped heads. That
reintroduces the O(#KA) aggregate scan the graph-scoped design exists to
eliminate and double-transports content. The defect is in materialization, not
in the data lane.

Because onSnapshotReady fires for 'cache' as well as 'network', nodes that
already cached snapshots materialize them on the next pass without refetching.
This matches the production symptom on the operator's Base node (fifa CG:
query-remote returns 4606 quads while catch-up reports data=0), which may
therefore be recoverable locally once this ships.

Devnet, clean 6-node, hold-out decisive (baseline 0):
  before  INCOMPLETE — 0/100 after 180788ms
  after   100/100 quads in 3019ms from remaining holders
Full gate 33/35; the two remaining failures are the known redundant
chain-reconcile watermark. Agent unit suite 1170 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ip graphs

The materialization fix in d0373b704 replaced KA graphs unconditionally. Live
gossip may already hold a RICHER version of the same graph, and replaceGraph is
destructive, so catch-up silently DESTROYED content the node already had. Caught
end-to-end: a peer that previously converged at 76 quads regressed to 27. Unit
tests were green and the build was clean — only the devnet run showed it.

The private recovery lane has a guard this port dropped:
isGraphAssetMaterialized (an ASK for the head's dkg:assertionGraph marker) skips
replacement when the graph is already present. Now wired on both sides, and
materialization refuses to run at all when the guard is unavailable rather than
proceeding blind.

Also corrects the hold-out's decisiveness gate. It required a 0-quad baseline
after restart, but that is a PROXY for being held out, and once catch-up works it
loses the race: the node can materialize the corpus between restart and the
measurement. The real proof is ORDERING — the context graph is created AFTER the
node is stopped, so any content it holds must have arrived post-restart, because
it did not exist before. Baseline is now INFO; the convergence assertion stands
on ordering alone.

Verified on a clean 6-node devnet, both directions of the trade-off:
  #1779 markdown peer population  author=78 / peer=76   (was 78/27 while unguarded)
  hold-out reconstruction         100/100 in 3023ms, baseline 0
  full gate                       33/35 — the 2 remaining failures are the known
                                  redundant chain-reconcile watermark
  agent unit suite                1170 passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The SPARQL scalability lint blocked this PR with 5 findings of R2 graph-var-scan
— "all-variable triple inside GRAPH ?var", the #1597 listGraphs-storm shape. The
lint was right: the corpus counts were `GRAPH ?g { ?s ?p ?o }` filtered by a
string CONTAINS, which enumerates every graph x every triple.

BOUNDED (the real fix, 3 queries): the scale and hold-out counts now bind the
exact subjects this run published with `VALUES ?s { ... }` instead of scanning
and filtering. Cost becomes proportional to what the run published rather than
to store size. These still trip the lint statically because the VALUES list is a
template interpolation the scanner cannot resolve, so each carries a pragma
stating precisely that.

ACKNOWLEDGED (2 queries in verify-fixes.mjs): the markdown checks cannot be
bound the same way — markdown import SKOLEMIZES its subjects
(urn:dkg:ka-skolem:cN), so they are not knowable in advance. Scope is one
purpose-built devnet store holding only this run's fixtures, and this file is
never executed by node runtime code.

Note for future harness work: pragmas must sit within 4 lines of the query
literal (collectPragmas scans literalStartLine-4), so a longer justification
comment silently fails to register.

sparql-scale-lint: 0 unacknowledged findings across 2 files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed unit

Review found the first cut of this fix inverted the failure instead of
removing it: driving requiresEncryptedPayload to false for a public+agent-gated
CG also flipped the later validation from "encrypted required" to "encrypted
not supported", so any ENCRYPTED write for the same CG was rejected with
retryable:false. Sender and receiver chain probes legitimately disagree during
RPC flakes, stale mappings, rollout skew, or older clients — and a sender that
cannot prove public FAILS CLOSED and encrypts, which this receiver would then
permanently drop: the mirror image of the plaintext drop being fixed.

The two questions are now separate, in one exported decision the handler
itself consumes (resolveWorkspaceEncryptionRequirement):

  requiresEncryptedPayload  MUST it be encrypted?  private, or gated and not
                            proven public on-chain (policy, chain-derived)
  supportsEncryptedPayload  MAY it be encrypted?   private, or gated at all
                            (structure, local)

Both encodings are admitted during any skew window; an ungated public CG
still refuses Sender-Key payloads exactly as before. Applied to BOTH encoding
branches — the review flagged the senderKeyMessage arm, and the same flaw was
in the encryptedPayload arm.

Also from review: the regression test mirrored the fix instead of exercising
it — it reimplemented the boolean against a stubbed private helper, so
reverting the production decision would have kept it green. The decision is
now an exported function precisely so the test covers shipped logic; the suite
asserts the public/curated accept, the fail-closed unproven case, that a
public proof never downgrades a PRIVATE CG, the unchanged ungated behaviour,
and the skew invariant (never REQUIRE without SUPPORT) across all eight input
combinations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review found the #1779 verification could pass without proving markdown
content reached the peer: its filter matched any literal carrying the run
stamp, which includes the context graph's own name ("FX md <stamp>"). A peer
holding metadata and ZERO imported content — the exact #1779 signature the
check exists to detect — could still count that literal and report PASS.

The query now binds distinctive body strings that appear only inside the
imported document and excludes the _meta graphs, so it can only be satisfied
by the markdown content itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…content

Closes the remaining review blockers on the catch-up materializer. The theme
of all of them: the marker could exist without the graph, and the destructive
replace could run against state it had not re-verified.

LOCK (review P1). materializeReadySnapshot now runs inside the SAME per-KA
write lock the live-gossip path takes: swmKaWriteLockKey() is a new shared
helper in keyed-lock.ts consumed by BOTH call sites (a hand-rolled copy of the
key format would fail silently — an unequal key does not error, it just stops
serializing), and the agent passes withKeyedLocks over this.writeLocks — the
exact map it already injects into SharedMemoryHandler. The key lowercases the
UAL segment: address case varies by source, an under-merged key recreates the
race, an over-merged one merely coarsens serialization.

VERSION ORDERING (review P1). A lock prevents interleaving, not
overwriting-with-older: gossip may advance the KA while catch-up waits.
All decisions moved INSIDE the lock, starting with a stored-head
assertionVersion read — stored newer than descriptor => skip; unparseable =>
skip, because state whose ordering we cannot establish must not be destroyed.

CONTENT-PROVING GUARD (review P1). isGraphAssetMaterialized now counts the
assertion graph and requires exact equality with the descriptor's
publicQuadsCount. The prior marker ASK classified the PRE-FIX broken state
(head metadata written, graph never written — the observed "0 data + N meta")
as materialized, so the repair skipped exactly the nodes that need it, and a
partially-fetched metadata round could strand an asset forever behind its own
marker. Content-equality also makes multi-round metadata self-healing: a
marker without its graph no longer blocks anything.

COHESIVE DEPENDENCY (review). The loose optional trio becomes one
snapshotMaterializer object — a caller can no longer half-configure
materialization silently.

TESTS, in CI's include list, driving the REAL runSharedMemorySync with the
REAL lock functions:
 - held-out node materializes a cached snapshot (the production repair path)
 - the gossip race, deterministically: the test holds the actual lock as
   "gossip" (the hold IS the pause), commits version 2 while catch-up is
   provably blocked, releases, and asserts replace never fires — with a
   checksummed-case UAL on the gossip side so key normalization is exercised
 - pre-fix broken state heals (marker present, content absent => replaced)
 - already-materialized asset untouched
 - failed replace withholds the meta insert and fails the phase
Both load-bearing behaviours mutation-tested: disabling the version re-check
kills exactly the race test; swallowing failures kills exactly the
meta-withholding test.

Agent and publisher suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… binding

Catch-up's meta tail is a union insert outside the lock, so a stale head
row can coexist with gossip's newer one. An unordered SELECT taking
bindings[0] could then return the older version, defeating the in-lock
ordering guard and re-enabling overwrite-with-older on a later pass.
Reading the maximum is always the conservative direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	packages/publisher/src/workspace-handler.ts
# Conflicts:
#	packages/agent/vitest.unit.config.ts
// identity — again exactly as the sync lane does when `requestAgentAddress`
// is undefined. Deduped jobs stamp under the first enqueuer's caller, which
// is the same first-writer-wins the sync lane already has.
await agent.ensureRegisteredForPublish(request.contextGraphId, {

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: Old async publish jobs can auto-register a Context Graph under the wrong curator after upgrade

What's wrong
The async retry path no longer falls back to request.agentAddress when callerAgentAddress is missing. That is correct for new foreign-author jobs, but it changes the meaning of already-persisted jobs created before this field existed. If such a job is the one that triggers publish-time CG auto-registration, the chain registration can be stamped with the node default rather than the agent that queued the job.

Example
A multi-agent node running 10.0.7 enqueues an async VM publish for token agent 0xAAA against an unregistered CG, then upgrades before the worker retries. The persisted request has agentAddress=0xAAA and no callerAgentAddress. On CG_NOT_REGISTERED, the new worker calls ensureRegisteredForPublish(contextGraphId, {}) and stamps the node default curator instead of 0xAAA.

Suggested direction
Keep the new caller-vs-author split for new jobs, but add an explicit compatibility path for persisted requests that predate callerAgentAddress so their original request agent is not lost.

Confidence note
This depends on existing persisted async publish jobs that were enqueued before this PR added callerAgentAddress, but those jobs are a normal upgrade state for a release bump.

For Agents
In packages/cli/src/daemon/lifecycle.ts, preserve upgrade compatibility for persisted pre-#1778 KnowledgeAssetVmPublishRequest rows. Add a migration or fallback that distinguishes old requests from new curator-publishes-member requests, and test an old queued request with agentAddress set and callerAgentAddress absent through the CG_NOT_REGISTERED retry path.

expiresAt: Date.now() + DURABLE_DATA_SYNC_SESSION_TTL_MS,
}
: responderSession;
persistUnfinishedSyncResponderSession(

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: Failed durable verification can retry against the same rejected responder snapshot

What's wrong
The requester now persists the responder session before the durable verification/store layer knows whether the fetched row list was usable. When that later layer rejects the batch and leaves the offset unchanged, the next retry can reuse the same stale immutable responder row list at offset zero, delaying recovery from a responder-side race or healed metadata until the session expires.

Example
A durable data fetch completes at offset 0 with a responder session, but processDurableBatchInWorker rejects one graph because the responder snapshot was missing matching metadata. The requester does not advance the checkpoint, but page-fetch has persisted the responderSessionId. The next retry sends offset=0 with the same syncSessionId, so the responder reuses the same immutable bad row list instead of rebuilding from its now-healed store.

Suggested direction
Avoid persisting, or explicitly clear, the responder session when the higher durable layer rejects the fetched batch or cannot materialize it. Persisting the token should be tied to a verified safe checkpoint, not just successful transport completion.

Confidence note
The stale-session loop lasts until the responder session TTL rather than forever, but it directly undermines retry behavior for failed durable verification/materialization attempts.

For Agents
Look at fetchSyncPages session persistence and runDurableSync's rejected/failed verification branches. Preserve session tokens only after the caller has either committed a safe prefix or can still verify against that same immutable list; clear the responder session when verification/materialization rejects the fetched snapshot. Add a test where a completed fetch is rejected, the responder data changes, and the next retry must mint a fresh session.

// incomplete metadata, and this lane pages meta, so a timed-out page would
// otherwise abort the whole CG fanout. A parse failure here must degrade to
// "no materialization this round" — never take down the sync.
const snapshotDescriptorsByRef = new Map<string, GraphScopedSwmRecoveryDescriptor[]>();

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: Public snapshot materialization is embedded too deeply in sync orchestration

What's wrong
This adds a policy-heavy subsystem to an already central sync loop. Transport completion, snapshot cache behavior, destructive store replacement, race avoidance, and phase-completion semantics are now tied together through closure state instead of a clear boundary.

Example
A maintainer trying to reason about whether metadata insertion is safe now has to trace meta paging, public snapshot sync, a nested materializer, in-lock stale-version checks, replacement side effects, and the later snapshotPhaseUsable gate inside one function.

Suggested direction
Extract the materialization policy into a dedicated helper with an explicit input/output contract.

For Agents
In packages/agent/src/sync/requester/shared-memory-sync.ts, preserve current behavior but move the graph-scoped public SWM snapshot materialization into a focused helper/module, e.g. materializeVerifiedPublicSwmSnapshots(...), returning { materializedGraphs, materializedQuads, failures }. Keep runSharedMemorySync responsible for phase orchestration and summary merge. Existing swm-public-snapshot-materialization tests should continue proving the helper behavior.

* store/URI/EVM lookup so it lives beside the coordinate helpers rather than
* in this publish mixin. See that function for the full resolution rule.
*/
async resolveAssertionAuthor(this: DKGAgent,

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 adding a public thin delegate to the publish mixin

What's wrong
This expands an already very large agent mixin without reducing complexity. It adds API surface and another concept for maintainers to understand, while the focused module already owns the actual store/URI lookup.

Example
The publish lane now has three names for one resolution path: the standalone resolveFinalizedAssertionAuthor, the pass-through resolveAssertionAuthor, and resolveFinalizedAssertionPublishAuthor. Only the standalone resolver and publish-specific resolver appear to earn their keep.

Suggested direction
Delete the wrapper layer unless it is a deliberate API surface.

For Agents
In packages/agent/src/dkg-agent-publish.ts, preserve the selector-vs-hint behavior. Collapse the pass-through method by calling resolveFinalizedAssertionAuthor directly from resolveFinalizedAssertionPublishAuthor, or make the wrapper private/internal if the class boundary truly needs it. Update tests to exercise the module or publish resolver rather than a new public delegating method.

// permanently dropped the plaintext writes the sender is supposed to
// send on a public CG — silently breaking member->curator SWM shares on
// every public/curated context graph.
publicAccessPolicyOnChainOracle: (cgId: string) =>

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: Public/curated plaintext acceptance is only tested as a pure helper decision

What's wrong
The changed behavior depends on the agent wiring the public-access oracle into the receiver and the receiver applying it while handling a real SWM envelope. The current added test pins the helper’s truth table, but it does not prove plaintext member-to-curator SWM writes are accepted by the actual handler on a public agent-gated Context Graph.

Example
A regression that removes publicAccessPolicyOnChainOracle from getOrCreateSharedMemoryHandler, or leaves the handler ignoring the oracle result, would still keep the pure resolveWorkspaceEncryptionRequirement(publicGated) tests green while a real public/curated plaintext SWM share is dropped again.

Suggested direction
Cover the receiver path that actually failed, not just the extracted boolean policy helper.

For Agents
Add a handler-level regression around SharedMemoryHandler: seed/return agent-gate metadata, provide publicAccessPolicyOnChainOracle: async () => true, submit a plaintext graph-scoped workspace message, and assert applied: true; also assert the same plaintext is rejected when the oracle returns false/throws. If practical, add a small DKGAgent wiring test that getOrCreateSharedMemoryHandler passes the oracle through.

@lupuszr
lupuszr merged commit 87f5301 into testnet-canary Jul 20, 2026
3 checks passed
@branarakic

Copy link
Copy Markdown
Contributor

Heads-up for testers: the canary carries a receive-path latency regression (~3ms → ~33ms per SWM gossip receive — an unconditional chain RPC from the #1843 review fix). Symptom: slow SWM/VM arrival on receiving nodes; not data loss. Fix PR against testnet-canary: see fix/testnet-canary-lazy-probe. Note #1848 (subscription catch-up) addresses the same symptom area — worth confirming whether it remains needed, is complementary, or overlaps once the lazy-probe fix lands.

@branarakic

Copy link
Copy Markdown
Contributor

Step-1 canary composition is up as #1876 (this change included): current testnet-canary + #1871 + #1852, gated green on the 6-node devnet sync suite (verify-fixes 8/10 excepted-only; proof 33/35 and 32/34, watermark-only failures — full numbers in #1876). Merging #1876 supersedes #1852. Big-CG pair #1868+#1842 follows as step 2.

@branarakic

Copy link
Copy Markdown
Contributor

Canary composition for the fifa-class fixes is up as #1880 (this change carried in full): current testnet-canary + #1868 + #1842, hand-resolved against the #1879 merge, triple-gated green — holdout reconstructs 100/100 quads in ~3s on the exact PR head. Numbers in #1880.

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.

5 participants