The egress declaration (Trekk B3) says what a run MAY contact. It cannot say what
it DID: after the run, nothing distinguished "the agents queried the price
register" from "the agents ignored it", and a proposal resting on an external
service should be traceable to it.
ToolCallRecorder(FunctionMiddleware) mirrors BudgetMiddleware(ChatMiddleware) one
layer down — that one observes the debate's chat calls, this one its tool calls.
It observes only: call_next is always awaited, so a trace can never alter the run
it traces. The record lands on ProvenanceStamp.external_calls, read AFTER the
debate so it is a record rather than an intention.
MEASURED, not assumed, before any of it was written: FunctionMiddleware fires for
a tool served over a REAL MCP stdio subprocess, and context.function.name carries
the BARE tool name with no server prefix. That measurement decided the design —
MAF cannot tell us which server a tool came from, so attribution comes from our own
config, and a name allowed by two servers is recorded UNATTRIBUTED (server="")
rather than credited to the first match. Naming a service that may never have been
contacted is the one place a guess must not go.
Only CONFIGURED tools are recorded. The middleware fires for every function the
agents invoke, including the in-process retrieve_cost_docs on the road path;
logging those would turn the record into a false egress claim. An empty list is a
positive statement — nothing outside this process was contacted — which is why it
is always serialized rather than omitted.
Honesty limit, written on ExternalCall itself: this is the call and its source. It
is NOT evidence that the service's answer reached the proposal, nor a verified
rendering of that answer.
One finding, and it is the reason for measuring rather than trusting green: the
road-path negative test was VACUOUS. Its scripted tool call named an argument the
tool does not declare (code vs query), MAF rejected the call before invocation, and
the test asserted an empty record against a run where no tool ran at all — green
under the exact mutation it existed to catch. It now spies on the recorder and
asserts the invocation genuinely reached it before asserting it was not recorded.
This is last session's lesson again: a scenario that cannot distinguish two
implementations proves nothing.
The tool-call double is registered in test_scripted_client_consolidation.py's
_DELEGATING_OVERRIDES — it cannot live in the reply_selector seam, which returns a
reply STRING, and a response that is not text is its whole subject.
Load-bearing MEASURED (tests/test_b4_mcp_call_trace_loadbearing.py) against the
whole 755-test suite, four mutations all red: detach the recorder from the debate
middleware · record every function invocation · attribute an ambiguous name to the
first server · stop reading the recorder into provenance. Control: a run with no
configured servers records nothing, so the empty record is a real answer and not
the only one the seam can produce.
Ran it, not just tested it: the real recorder against a real MCP server subprocess
returns ExternalCall(server='prisregister', tool='lookup_unit_price'), and a
scripted CLI run's outbox artefact carries the empty list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtRd8y1PDPGwkrRXFhubqr
Three items on one seam — what a FAILED project does to the wave loop — plus the
snapshot copy they sit next to.
(v) The catch is BaseException, not Exception, and that width was ungated. The
existing collect-and-continue test raises RuntimeError, so it stays green when
the handler is narrowed: measured, the whole of test_portfolio_concurrent_
loadbearing.py (13 tests) passes under the narrowing. asyncio.CancelledError is
the one realistic vector that separates the two — probed first, gather(
return_exceptions=True) COLLECTS it, while KeyboardInterrupt propagates
regardless and could never be helped by a wider catch. Narrowed, a cancelled
member is cast into runs as a fake RunResult and the pass dies in _aggregate,
pointing away from its cause. RED measured.
(t) sum_token_usage excludes a failed project's spend, and that is the honest
answer, not a bug: a run that died before producing a stamp has no provenance,
and inventing one is the fabrication RunFailure exists to avoid. What needed
gating is that those tokens still reach the ledger the global cap is enforced
against — otherwise a repeatedly-failing project burns budget while the meter
reads clean. Pins meter.spent as the pass's real cost, sum_token_usage as the
completed-run subtotal, and their difference as exactly the failed spend. RED
measured against the likely "fix" (sourcing sum_token_usage from the meter),
which is wrong because a seeded meter also carries EARLIER passes' spend; 21
existing budget/portfolio tests stay green under it.
(s) _wave_snapshot uses dataclasses.replace, so a field added later is carried
without touching the function. Not cosmetic: measured, dropping retriever by
hand-enumerating left all 585 tests green — the Step-2 coverage its docstring
credited no longer existed, so the S3.1 retriever seam could be downgraded
mid-pass in silence. Now gated by a property test derived from
dataclasses.fields (not a field count, the shape rejected earlier). The explicit
verdicts copy is retained and separately gated: replace(store) alone shares the
caller's list and takes the byte-identical determinism test RED.
strict=True on the zip is documented as deliberately untested — measured green
when dropped, since gather is built from exactly snapshots, so a test could only
go red by manufacturing a mismatch and would exercise zip rather than this pass.
The new double is registered in the S2.5 consolidation guard's delegating-
overrides list rather than the guard being weakened; it already delegates via
super()._inner_get_response, which test_delegating_overrides_call_super now
enforces on it.
583 -> 586 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbgTCEZma764i1rHTrzceU
Replaces run_portfolio's sequential loop with a wave loop over _waves(ids, k):
each wave takes a per-project snapshot of the shared store, runs the wave under
one asyncio.gather in a single event loop, then crosses a merge barrier that
folds each project's NEW verdicts back in wave-submission order. Step 2's
contract goes GREEN; runs stays in project_ids order because gather resolves in
argument order, not completion order.
TWO PLAN CORRECTIONS, both found by the RED-first test rather than by reading:
1. The plan specified sorting the merged verdicts on `project_id`. Measured, that
produces a deterministic order which is the WRONG one: lexicographic gives
BRU/FV42/RV13 while the sequential pass gives FV42/RV13/BRU. It satisfies
"deterministic" while breaking "identical to concurrency=1" — and the second is
the actual contract. The merge preserves submission order instead.
2. The plan named the barrier's sort as the load-bearing seam. It is not — with
per-project snapshots the wave list is never reordered by completion, so a
sorted() there would re-sort an already-ordered list and read as a guard while
guarding nothing. The SNAPSHOT is the half that carries the load. Rather than
ship a decorative sort, both halves were measured (scratchpad-restore, never
git checkout):
detach _wave_snapshot -> RED (store lands in completion order)
detach merge ordering -> RED (reversed wave order diverges)
Both restored byte-identical (sha 42b01d46).
The snapshot carries `retriever` across deliberately: dropping it would silently
downgrade a caller-owned store's S3.1 semantic-retrieval opt-in mid-pass.
Also strengthens the scripted-client consolidation guard, which the probe broke by
being a legitimate third _inner_get_response def-site. It pinned a literal count
of 2 — the wrong shape: it failed on any new legitimate subclass while still
passing if someone pasted a duplicated body into an already-listed file. It now
pins the property (registered sites, scripted-lineage overrides must delegate via
super(), foreign-lineage doubles must genuinely be foreign). Verified load-bearing:
removing both delegation sites turns it RED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr