test(devnet): add public Context Graph SWM+VM sync proof harness - #1844
Conversation
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>
| .filter((l) => l && !l.startsWith('#')).pop(); | ||
| } | ||
|
|
||
| async function api(node, path, { method = 'GET', bearer, body, timeoutMs = 180_000 } = {}) { |
There was a problem hiding this comment.
🟡 Issue: Fold these ad-hoc runners into the canonical devnet harness instead of duplicating infrastructure
What's wrong
The PR adds a parallel, unregistered devnet test architecture. That increases maintenance surface in exactly the places that tend to drift: auth-token parsing, port derivation, fetch retry behavior, SPARQL binding parsing, polling semantics, and suite discovery. It also leaves a large monolithic proof script outside the normal review and execution path.
Example
A future /api/query response-shape or devnet port-resolution change already has a canonical place to update in _bootstrap/harness.ts; this PR would require separately remembering to update both proof.mjs and verify-fixes.mjs, and neither is covered by the suite manifest guard.
Suggested direction
The code-judo move is to make the new files mostly scenario descriptions and assertions, with HTTP/query/wait/devnet discovery coming from the shared devnet harness. That deletes duplicated mechanics and makes the suite discoverable by the existing manifest checks.
Confidence note
This is based on repository-local conventions: existing devnet suites are wired through Vitest, devnet/suites.json, package scripts, and devnet/_bootstrap/harness.ts. If this directory is intentionally a throwaway forensic artifact rather than maintained devnet coverage, the author should make that boundary explicit.
For Agents
Rework devnet/public-cg-sync-proof as a normal devnet suite: add package.json/vitest.config.ts, wire it into devnet/suites.json and the root script if it is meant to be maintained, and move transport/query/wait/node config helpers behind existing _bootstrap/harness.ts primitives or one small local harness module. Preserve the public/open, public/curated, resilience, and verify-fixes scenarios as scenario-level assertions.
There was a problem hiding this comment.
🟡 Issue: The new scripts duplicate a private devnet harness instead of sharing one boundary
What's wrong
This PR adds two standalone mini-frameworks for the same devnet mechanics. That is exactly the kind of copy-pasted orchestration layer that makes release proof code brittle: the interesting public-CG scenarios are buried under duplicated request wrappers, polling loops, token parsing, result formatting, and agent registration plumbing.
Example
If the daemon auth token parsing, retry policy, JSON parsing, timeout defaults, or port derivation changes, maintainers now have to update the canonical devnet harness plus two private .mjs harnesses in this directory. Those copies already diverge: proof.mjs has CONVERGE_MS, POLL_MS, artifact writing, and stricter empty-token handling; verify-fixes.mjs has fixed 90s waits and returns undefined for an empty token.
Suggested direction
Create one shared harness boundary for this directory, or reuse the existing devnet bootstrap harness, then leave each script with only scenario-specific assertions.
Confidence note
I am treating these scripts as intended to live in the repo rather than as throwaway local artifacts, because the PR commits them under devnet/ with reproducibility docs.
For Agents
Extract a small local module such as devnet/public-cg-sync-proof/harness.mjs, or convert these to Vitest suites that import devnet/_bootstrap/harness.ts patterns. Keep the two scenario scripts behaviorally equivalent, but centralize HTTP calls, token loading, polling, SPARQL result parsing, and result recording.
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>
| const contentQuery = `SELECT (COUNT(*) AS ?n) WHERE { | ||
| GRAPH ?g { ?s ?p ?o } | ||
| FILTER(CONTAINS(STR(?g), "fx-md-${stamp}")) | ||
| FILTER(isLiteral(?o) && CONTAINS(STR(?o), "${stamp}") || CONTAINS(STR(?o), "converge")) |
There was a problem hiding this comment.
🔴 Bug: Markdown visibility check can count metadata as content
What's wrong
The #1779 verification is intended to catch the peer having metadata but zero content triples. Its query is broad enough to match run metadata that includes the same stamp, so it can pass without proving markdown content reached the peer.
Example
If the markdown share again replicated only metadata and no imported heading/body triples, the query can still count the CG name literal FX md ${stamp} and report PEER sees markdown CONTENT triples as PASS.
Suggested direction
Narrow the SPARQL assertion to imported markdown content, for example exact body/heading literals or known imported subjects, and exclude context graph or operation metadata graphs.
Confidence note
This depends on the devnet store exposing the context graph metadata graph to the peer query, which is consistent with the create/subscribe flow but should be confirmed in a run.
For Agents
In verify-fixes.mjs, bind the imported KA subject(s), assertion graph, or exact markdown literals that cannot appear in CG metadata. A regression test should fail when only operation/context metadata exists on the peer and no markdown content triples are present.
zsculac
left a comment
There was a problem hiding this comment.
Review findings for head c621ac1.
Git classifies proof.mjs as binary because it contains a literal NUL byte, so GitHub does not provide normal line anchors for that file. The source line references below are from the committed blob.
-
[P1] Require a genuinely non-host receiver before reporting the VM result as a pass (
proof.mjs:445-507). When no peer outside the storage-ACK set is available, the script records an informational message but continues with the preselectedlatereceiver and evaluatespass: lateVm.done === true, even whenlateWasHostis true. This is guaranteed to be non-decisive with the documented default of four nodes, because the PR itself notes that ACK quorum covers every peer at that size. Require a resolvednonHostNode, requirelateWasHost === false, and otherwise record this assertion as non-decisive rather than counting it as a pass. -
[P1] Make the curated VM negative reach the authorization boundary (
proof.mjs:522-580).badKais intentionally kept out of SWM, thenvm/publishis called for it. A precondition failure such as "No quads in shared memory" therefore satisfiesrefusedHttp || notConfirmedand is reported as proof that curated publication was refused, even though publisher authorization may never have been evaluated. Create a dedicated finalized/SWM-shared asset that is otherwise publishable, invoke publication as the non-curator, and assert the specific authorization status/code rather than accepting every HTTP or non-confirmed failure. -
[P1] Do not count pre-populated receivers as convergence passes (
proof.mjs:655-759). The scale phase detects that a receiver already has the full corpus but still records the subsequent convergence check as a pass. In the outage phase, the supposed pre-subscribe baseline is queried only aftersubscribehas already returned, and a complete baseline still producespass: survived.done === true. This contradicts the PR requirement that pre-populated receivers be reported as NOT DECISIVE. Query the outage baseline before subscribing and exclude both checks from boolean pass totals whenever the exact corpus was already present. -
[P2] Require exact late-SWM convergence (
proof.mjs:472-483). The live assertion compares the receiver digest withauthorSwm, but the late assertion succeeds ond.count > 0. With the two-quad fixture, receiving only one quad is enough to pass. Compare both count and digest with the author snapshot so the cold-backfill result proves complete content rather than mere presence. -
[P2] Reconcile the curated SWM negative with the stated specification (
proof.mjs:509-560). The PR description says that requiring a non-curator SWM write to be refused is a rule that does not exist, yet this block fails if the explicit share is not refused. The committed reports also describe different semantics. Please identify the authoritative rule and align the harness and reports with it; if only VM publication is curated, the SWM refusal should not be a pass/fail gate. -
[P2] Verify the #1780 route contract without an ignored author-looking field (
verify-fixes.mjs:272-283). The #1780 contract says the seal encodes the author andvm/publishmust reject an explicitauthorAgentAddress. This request includesagentAddress: member.address, which the route does not use for author selection, and there is no assertion for the required 400 override rejection. Publish successfully with onlycontextGraphId, then add a separate request usingauthorAgentAddressand assert the 400 response. That makes auto-resolution and override rejection independently observable. -
[P2] Keep the main harness reviewable as text (
proof.mjs:101-108).digestQuadsembeds literal NUL and SOH control bytes as separators. The NUL causes the entire 44.9 KB JavaScript file to appear asBin 0 -> 44929 bytes, hiding its implementation from the normal PR diff. Spell the same runtime separators as escaped text such as\0and\x01, or use an unambiguous serialization, so the source remains line-reviewable without changing digest behavior. -
[P3] Mark the superseded status report as historical (
V10.0.9-STATUS.md:1-7,248-280). This document says public-CG sync is not shippable, #1780 is unverified, and resilience has not run, while the PR description andFINDINGS.mdpresent the later opposite conclusion. If this is intentionally retained as an investigation snapshot, label it with the tested commit and the later report that supersedes it; otherwise update it. As committed, readers cannot tell which release conclusion is current.
The existing discussion about integrating these scripts with the canonical devnet harness already covers duplicated infrastructure and retry behavior, so I have not repeated it here.
lupuszr
left a comment
There was a problem hiding this comment.
The harness is valuable, and the updated head passes the SPARQL scalability lint locally. It still cannot serve as proof that public-CG SWM sync works for two blocking reasons:
-
proof.mjs:474can pass with SWM completely absent. This assertion runs after VM publication, callsreadSubjectwithout a graph filter, and checks onlycount > 0;digestQuadsalso excludes graph identity. A late node that received only durable_verifiable_memorycontent therefore satisfies the claimed SWM check. Restrict the read to the SWM graph family and require exact count plus digest equality withauthorSwm, with a pre-subscribe baseline. -
proof.mjs:804defaults the decisive hold-out to five KAs and 100 content quads, which stays on the one-invocation happy path. It does not force the resumed metadata path that the current #1842 head fails. Add a deterministic case that checkpoints between at least two complete KA descriptor sets, resumes from a non-zero metadata offset, and verifies every expected per-KA SWM graph by exact digest rather than aggregate count.
GitHub could not attach these as inline comments because this file contains a literal NUL byte and is rendered as binary. Removing the control byte would also restore reviewable text diffs.
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>
|
Markdown visibility check can count metadata as content (RED) — fixed in baaed65. The filter matched any literal carrying the run stamp, which includes the CG's own name literal ( Fold into the canonical 🤖 Generated with Claude Code |
lupuszr
left a comment
There was a problem hiding this comment.
Follow-up on the current head after running the combined #1842/#1843/#1844 stack on an isolated six-node devnet (4 core, 2 edge). The normal proof run finished 23/26; an enlarged resilience run with a node stopped before publication and HOLDOUT_KAS=40 finished 32/34. The holdout did reconstruct 800/800 quads in about 3 s, so the public happy path and late corpus arrival are real. However, both public/open and public/curated still left VM reconciliation permanently pending at watermark 0/1, and the first normal run also timed out waiting for the public/open live receiver VM content. More importantly, the harness still cannot prove that SWM and VM are independently complete: readSubject is unfiltered for key VM checks and digestQuads deliberately removes graph identity, so content from one graph family can satisfy the other claim. This is consistent with the separately reproduced #1842 cases where metadata/checkpoint state reports success while an assertion graph is absent. The companion verify-fixes run was also 9/10 (curator publish of a member-shared root returned HTTP 409). Current-head syntax checks pass, but the latest commit introduces the inline SPARQL lint blocker below. Verdict remains REQUEST_CHANGES: the stack demonstrates a useful happy path, but does not prove reliable convergence of both public-CG memory layers.
| // check exists to detect. Bind distinctive body strings that appear only | ||
| // in the document, and exclude the CG/operation metadata graphs. | ||
| const contentQuery = `SELECT (COUNT(*) AS ?n) WHERE { | ||
| GRAPH ?g { ?s ?p ?o } |
There was a problem hiding this comment.
[P1] The latest explanatory block moved the sparql-scan-allow: R2 pragma away from the query it acknowledges, so the current head now fails the repo gate: node scripts/sparql-scale-lint.mjs --diff a6f33e4... baaed650... reports 1 new blocking R2 graph-variable scan here. Keep the acknowledgement immediately adjacent to this query (with the bounded devnet justification), or bind/restructure the graph scan, before merging.
otReviewAgent
left a comment
There was a problem hiding this comment.
Operational Notice: Review Agent could not complete this review.
Synthesizer produced only invalid comment anchors.
What this is
A devnet harness that answers one product question end to end, through the daemon HTTP API a real agent uses — no fixtures, no library shortcuts:
Covers both public policy cells (open + curated), three receiver shapes (author / live / late), curated authorization negatives, and an opt-in
RESILIENCEphase (scale, idempotence, author outage, hold-out).Why it earns its place
It found three real defects that unit tests and code review missed — two of which had been misdiagnosed twice by reading the code:
All three share a shape that author-side tests cannot see: the write succeeds, the author looks correct, and only the peer is wrong.
Confound controls — each added after a green check turned out to measure nothing
Being explicit here because these were all mistakes made during development, and the assertions exist to stop the next person repeating them:
Structural limits it makes visible
Worth knowing when designing any devnet test:
devnet.sh restart-node) is the only construction here that makes corpus reconstruction decidable on one box.Operational traps documented
DEVNET_*andDEVNET_DIR.restart-nodere-derives every port from env. A caller that omitsLIBP2P_PORT_BASEsilently returns a node on the default libp2p port, where no peer can reach it — while its API still answers 200.fetch failed.Usage
Current result on a clean 6-node devnet: 33/35. The two failures are a known, redundant chain-reconcile watermark defect (documented in
FINDINGS.md) — VM is delivered by durable peer sync regardless.Suggested merge order
Consider merging this before #1842 and #1843, so reviewers can reproduce both bugs before and after the fixes.
🤖 Generated with Claude Code