Skip to content

Run the declared graph matrix over MCP (closes #95, ADR-0026) - #109

Merged
kikashy merged 10 commits into
mainfrom
mcp-test-graphs
Aug 14, 2026
Merged

Run the declared graph matrix over MCP (closes #95, ADR-0026)#109
kikashy merged 10 commits into
mainfrom
mcp-test-graphs

Conversation

@kikashy

@kikashy kikashy commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #95. New ADR-0026.

Material-decision impact: adds one experimental MCP surface and one additive graph option. No graph format, evaluator semantics, disposition comparison, or coverage derivation changes.

ADR-0021 deferred the graph twin explicitly and recorded the condition for revisiting it. That condition is met, so this closes it by exposing graph.TestProject rather than inventing anything.

Cross-vendor review, before implementation

Per the interim review regime and issue #95's own first scope item, the design was reviewed before any code was written — reviewer: codex-cli 0.145.0 / gpt-5.6-sol, ultra, read-only sandbox, reading the real code rather than the note alone. Verdict: "the wrapper's core semantics are sound, and the proposed call path has no hidden write… I would not implement the design unchanged."

All five findings accepted.

# Finding Disposition
F1 (High) The post-marshal response check is not an adequate graph resource bound. Up to 10,000 rows × 64 nodes, each row re-evaluating and retaining canonical dispositions; a 1 MiB outcome id across 10,000 rows retains ~10 GiB before a 16 MiB refusal. ADR-0025 already names this shape a defect. Accepted, and it changed the design. graph.Options.ReportBudget bounds the suite as it accumulates, refusing at the graph that carried it past. CLI leaves it unset (it streams to an interruptible terminal); MCP sets it.
F2 (Med) A plain []string does not enforce the advertised array-of-strings: Go accepts a null collection and null elements. Accepted. supported_extensions is decoded strictly via json.RawMessage. Empty strings and duplicates stay inert, matching the evaluator's set semantics — per the reviewer's answer to my question 1.
F3 (Med) Audit and LawCheck are separate invariants, guarded independently. Accepted. The doc comment names both, and there are two tests: an audit-configured project that writes nothing, and a drifted lock that does not touch the run.
F4 (Med) The claim/documentation inventory is incomplete — seven further sites. Accepted. All updated; CONFORMANCE.md and its mechanical enumeration test move seven → eight. ADR-0021's dated seven-surface consequence is left as written, per the reviewer.
F5 (Low) The oversized fallback cannot blindly copy the packs text; more tests needed. Accepted. Graph-specific message; the extra cases added.

The reviewer also confirmed the thing I could not verify from my own reading: there is no hidden write path — audit append is guarded by Audit != nil, reviewed-set checking independently by LawCheck != nil, and coverage only reads.

Two of my own tests were wrong, and mutation checking found both

  1. The MCP oversized-report test does not discriminate F1's mitigation. Over the small fixture the post-marshal check refuses identically, so it passes with ReportBudget removed. The budget is therefore tested at the graph layer, where removing it fails the test — and the MCP test is labelled for what it actually proves (refusal, not truncation).
  2. My strict decoder still accepted [null]. encoding/json treats null-into-string as a no-op and reports no error — the same accidental null acceptance the review found one level out, reproduced one level in. Now checked explicitly.

Both mutation checks initially failed to compile rather than fail, which proves nothing; each was redone as a cleanly compiling original.

Verified end to end over a real MCP session

isError: False
status: passed | graphs: 1 | summary: {'total': 3, 'passed': 3, 'mismatched': 0}
command: mcp experimental_test_graphs
claim ref: CONFORMANCE.md

Tests cover CLI/MCP payload parity (byte-identical against graph.TestProject), one-graph selection, unknown id, eight bad-argument forms, omitted-vs-empty-vs-duplicated extensions, absent configuration, response bounds, and the two no-write invariants separately.

gofmt clean, go vet clean, full go test ./... green.

The author_graph prompt's closing step no longer says "where a terminal is available" — which was the point.

🤖 Generated with Claude Code

kikashy added 10 commits August 14, 2026 15:29
ADR-0021 deferred the graph twin explicitly and recorded the condition for
revisiting it. That condition is met: the graph CLI and the shared
graph.TestProject / result.GraphSuite model exist, so the gap closes by
exposing what is there rather than inventing graph or evaluator semantics.

experimental_test_graphs takes optional graph_id and supported_extensions and
returns exactly the payload the graph project walk emits. It inherits the
packs tool's disciplines deliberately: a literal JSON null argument refused
rather than decoded into the most expensive run, strict decoding so a
misspelled key is an error, presence kept separate from value so a
present-but-empty id is refused, project.Present so a configuration that is
there and will not load refuses, and an oversized report refused with its size
rather than truncated.

Writing nothing and consulting no reviewed set are two invariants, not one.
Audit nil is what appends no record (ADR-0018); LawCheck nil is what consults
no lock (ADR-0019). They are guarded independently in the graph layer, so each
has its own test.

The one discipline NOT inherited is where the report is bounded, and it is the
substance of the change. A graph matrix multiplies where a pack matrix does
not — up to 10,000 rows, each re-evaluating up to 64 nodes and retaining every
node's canonical disposition — so a suite can reach gigabytes before a check on
the marshaled response could see it, which is the shape ADR-0025 already names
as a defect. graph.Options.ReportBudget bounds the suite AS it accumulates,
refusing at the graph that carried it past. The CLI leaves it unset and streams
to a terminal an operator can interrupt.

The design was reviewed cross-vendor BEFORE implementation, per the interim
review regime and the issue's own first scope item. All five findings accepted;
F1 is why ReportBudget exists and F2 is why supported_extensions is decoded
strictly rather than as a plain []string, which accepts a null collection and
null elements.

Two of my own tests were wrong and mutation checking found both. The MCP
oversized-report test does not discriminate the accumulating budget — over a
small fixture the post-marshal check refuses identically — so the budget is
tested at the graph layer, where removing it fails the test. And my strict
decoder still accepted [null], because encoding/json treats null into a string
as a no-op: the same accidental null acceptance the review found one level out,
reproduced one level in.

Claim surface grows from seven to eight; CONFORMANCE.md, its mechanical
enumeration test, and seven other inventory sites updated together. The
author_graph prompt's closing step no longer says "where a terminal is
available".

Material-decision impact: adds one experimental MCP surface (ADR-0026) and one
additive graph option; no graph format, evaluator semantics, disposition
comparison or coverage derivation change.

Signed-off-by: kikashy <kikashy@hotmail.com>
The design review passed; the implementation did not. Round 2 checked whether
the accepted findings actually landed in code, and three of five had not.

F1 was not fixed. The budget was checked BETWEEN graphs, so a single graph
declaring 10,000 rows still accumulated every one of them before anything
looked — the gigabytes the bound exists to prevent. Worse, ADR-0026 stated the
stronger claim as the substance of its decision. Enforcement moves into the row
loop where the retention happens, the refusal names how many rows were judged
so stopping early is observable, and it escapes as a run failure rather than
being swallowed as that graph's in-band mismatch. The ADR and changelog now say
what the code does, with the rejected first attempt recorded rather than edited
away.

F2 was incomplete. encoding/json matches member names case-insensitively, so
{"GRAPH_ID":"x"} bound to GraphID and passed DisallowUnknownFields against an
advertised additionalProperties:false. Member names are now checked exactly,
with three spelling regressions.

F4 was incomplete: README, the mcp subcommand's Long text, docs/mcp-clients.md
and the tools.go package comment all still said seven surfaces or two
experimental tools, and mcp-clients described graph matrices as instance
matrices run through the packs comparator.

Two claims in the tool description were false. There is no "canonical composite
result" — the composite headline disposition and any named-node dispositions
are canonicalized. And zero rows is not always skipped: a graph or rows
document that cannot be read is a mismatch carrying no rows.

The author_graph prompt recommended experimental_test_graphs at step 6, but the
tool only walks graphs declared in jpack.json and that declaration is proposed
at step 7 — so it could not test the graph just authored. The sequence now says
the CLI takes the document by path on the first pass and the tool closes the
loop afterwards, and ADR-0026's authoring-loop consequence is qualified to
match.

The CLI-parity test never invoked the CLI. It calls graph.TestProject and
supplies this surface's command string, so it is renamed to the layer-parity
test it is.

Both round-2 fixes mutation-checked: reverting either fails its test.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 3 found F1 closed for rows and still open for coverage: the coverage
block is derived per graph and retained across the suite, and it repeats
pack-derived probe strings across up to 64 nodes, so a rows-only bound left the
multiplying component outside it. ADR-0026 and the changelog claimed the whole
report was bounded. Again.

Coverage is now charged as it is derived. The regression is built to separate
the two bounds deliberately: its budget sits above every row's total and below
rows plus coverage, so a rows-only bound does not refuse and this test fails.
Mutation-checked by reverting to the round-3 state.

The suite envelope — ids, paths, summaries — is deliberately not charged, and
the ADR now says so rather than implying everything is. It follows the
configuration's size rather than the matrix's, so it cannot multiply, and the
caller's check on the finished report is what bounds it.

The ADR records both rejected attempts. Each round the code was closer and the
document still described the finished thing.

Also: README said eight surfaces while enumerating seven, and the renamed
layer-parity test still labelled the graph-layer payload "cli" in its failure
output.

Signed-off-by: kikashy <kikashy@hotmail.com>
…ating

Round 4, fourth round on the same claim. Two things were open and both were the
claim rather than the mechanism.

The entry envelope was uncharged, and an entry's detail echoes values a MATRIX
supplies — a rows loader's complaint, a declared matrix version — with no cap.
Several graph entries can name one hostile rows file, so that is graph count
times matrix-derived content, accumulated before anything looked. The ADR said
the envelope could not multiply with the matrix. It could.

The envelope is now charged as whatever the finished entry costs beyond the rows
and coverage already charged while building it, so every retained component is
charged exactly once. Detail is capped at MaxEntryDetailBytes besides, and a
truncated detail says so rather than ending cleanly and misleading a reader.

The residue is now stated instead of claimed away. Each component is metered
AFTER it is composed, so a run can overshoot by at most one component before
refusing: one row, one coverage block, or one envelope. Rows abort early, which
is where a graph matrix actually multiplies. Coverage metering inside probe
construction would eliminate the last of it and is explicitly NOT done, because
it reaches into derivation this change is otherwise careful not to touch — the
bound there is node count and pack content, not row count.

The ADR now records all three rejected attempts. Each described the finished
thing before it existed: between graphs, then rows-only, then rows-and-coverage
with the envelope loose.

Mutation-checked: reverting the envelope charge fails the new regression, which
sets a budget covering exactly rows plus coverage.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 5 found the same claim wrong a fifth time, and one part of it was flatly
false: coverage derivation retains a witness per row and per expectedNodes
entry, so its working memory DOES scale with row count. The ADR said "node count
and pack content, not row count".

Five rounds of trying to assert completeness is evidence that the completeness
claim is the defect, not the mechanism. So the claim is now a mitigation with
its gaps named, in the ADR, the changelog and the option's own comment:

- components are metered AFTER composition, so a run overshoots by whichever
  component crosses the line;
- the suite's outer envelope is never charged;
- coverage derivation's working memory scales with the matrix, and mitigating
  that means restructuring derivation, which is out of scope and is recorded as
  an accepted residue rather than left silent.

What the budget buys is the one thing that motivated it and the one thing tested:
the remaining rows of a large matrix are never evaluated once it trips. The
caller's check on the finished report backstops the rest.

capDetail stated a cap and then exceeded it, appending its marker after filling
the cap, and sliced bytes so it could cut a rune in half. Both fixed; the
regression asserts the total fits and the result stays valid UTF-8, and it fails
against the previous version.

The ADR records all five rejected versions of this paragraph. Every round the
code moved closer and the prose still described a finished thing. The prose was
the defect.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 6 was asked whether the list of named gaps was complete. It was not, and
that was the finding worth having: the row slice is preallocated for the whole
declared matrix before the first row is judged, so even a trip on row one costs
memory proportional to the declared length. MaxRowsBytes bounds that, not this
budget. It is distinct from the coverage witnesses already listed.

Three places still claimed more than the code does. The ADR's own TITLE said
"bound the report as it accumulates" — five words asserting exactly what five
rounds established is not true. The changelog headline said the report "is
bounded". And both the ADR and the option comment called the finished-report
check a backstop for "everything else", when it backstops the response size and
cannot backstop peak working memory: by the time it runs the memory is spent,
which is precisely why the row loop needed its own check.

The one positive claim is narrowed to what round 6 confirmed is true and tested:
the remaining rows are never EVALUATED once the budget trips. That is work
avoided, not memory reclaimed.

Signed-off-by: kikashy <kikashy@hotmail.com>
The heading survived the rewrite that removed the claim from the body.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 7 found a fifth gap: the rows document is fully decoded into Rows.Cases
before the first budget check and stays live through evaluation and coverage,
distinct from the preallocated result-row slice found in round 6.

That is five rounds spent enumerating exceptions and being told the list is
short by one. The enumeration was the defect. A list of gaps is a completeness
claim wearing different clothes, and this change has now failed that claim seven
times.

So the scope is stated as an invariant instead, because an invariant cannot be
short by one:

  ReportBudget constrains only the bytes RETAINED IN THE REPORT as it is
  composed. Everything a run holds in order to PRODUCE that report is outside it
  entirely.

The second category is bounded by MaxRowsBytes and the carrier limits, and the
decoded matrix, the preallocated slice and the coverage witnesses are given as
examples explicitly NOT offered as exhaustive. Two limits inside the part it does
constrain are still named exactly, because they are exact: metering happens after
composition, and the outer envelope is never charged.

Round 7 confirmed no other overclaim survives in the five text surfaces.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 8 asked one question -- is the stated invariant true of the code -- and
the answer was no, for a real reason rather than a wording one. graph.Test
charged its rows and coverage but never its own GraphTest envelope; only
TestProject charged an envelope. So a caller using Test directly with a budget,
which the option's own comment contemplates, had one that escaped, and the
invariant "only the outer suite envelope is uncharged" was false on that path.

Test now charges its envelope as whatever the finished report costs beyond the
components charged while building it. Inside TestProject the arithmetic
self-corrects, because that charges the entry's remaining difference and the
guard drops a non-positive remainder.

Regression asserts a budget covering exactly rows plus coverage refuses on the
direct path, and mutation-checked against the round-8 state.

This is the first round in six where the finding was the mechanism rather than
the prose. Stating the scope as an invariant did what enumerating gaps could
not: it turned "the list is short by one" into a question with a checkable
answer, and the answer found a defect.

Signed-off-by: kikashy <kikashy@hotmail.com>
Round 9 found the round-8 fix wrong in the opposite direction. Test charged its
GraphTest envelope on both paths, but inside TestProject that envelope is
discarded — testEntry copies the status, summary, rows and coverage into a
GraphSuiteEntry and drops the rest. So production-only bytes were consuming the
budget, and the downstream positive-only remainder can neither subtract that nor
undo a refusal it had already caused.

The envelope is now charged only when reportSpent is nil, which is exactly the
direct-Test caller that keeps the report. Inside TestProject the entry envelope
is charged instead, as before.

The invariant is "charge what is RETAINED", and it fails if either side is
over-applied. Both directions are now pinned by their own regression: the
composed path must NOT refuse at a budget covering everything the suite retains,
and must refuse one byte below it; the direct path must refuse at a budget
covering only its rows and coverage. Mutation-checked against both the round-7
state (charged on neither path) and the round-8 state (charged on both).

Nine rounds, and this is the second consecutive one whose finding was the
mechanism rather than the prose. The invariant did what the gap list could not:
it made a wrong answer detectable in both directions instead of only the
direction I happened to be looking.

Signed-off-by: kikashy <kikashy@hotmail.com>
@kikashy
kikashy merged commit a40f473 into main Aug 14, 2026
6 checks passed
@kikashy
kikashy deleted the mcp-test-graphs branch August 14, 2026 19:31
@github-actions

Copy link
Copy Markdown

🎉 Thank you for your contribution to Judgment Pack!

If you’d like another task, choose a path that interests you:

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.

Run declared graph matrices over MCP with experimental_test_graphs

1 participant