@basein/runner 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/mcpmark.md CHANGED
@@ -1,752 +1,752 @@
1
- # MCPMark — running one task against BaseInstRunner
2
-
3
- A concrete plan for one task, end to end:
4
-
5
- ```
6
- tasks/postgres/easy/employees/hiring_year_summary/
7
- ```
8
-
9
- Companion to [t-bench.md](t-bench.md). That document explains why τ²-bench was hard to
10
- integrate. This one exists because **MCPMark is structurally easier and scores our product
11
- more honestly**, and §1 is the reason.
12
-
13
- Source: [github.com/eval-sys/mcpmark](https://github.com/eval-sys/mcpmark) ·
14
- [paper](https://huggingface.co/papers/2509.24002) · [mcpmark.ai](https://mcpmark.ai/)
15
-
16
- ---
17
-
18
- ## 1. Why this benchmark, and why this task
19
-
20
- Three structural facts, each of which τ²-bench got wrong for us.
21
-
22
- **MCPMark grades the world, not the transcript.** `verify.py` opens a psycopg2 connection
23
- and queries the actual database. τ²-bench's `evaluator_env.py` builds a *fresh* environment
24
- and replays the trajectory's `(tool_call, tool_result)` pairs onto it — which is why direct
25
- mode was structurally penalised there (t-bench.md §3.1). Here, direct mode executes real
26
- SQL through a real connection, the table really exists, and `verify.py` passes. **Direct
27
- mode is fully scoreable on MCPMark.** This is the single most important line in this
28
- document.
29
-
30
- **The agent already speaks MCP.** MCPMark spawns `postgres-mcp` over stdio
31
- (`src/agents/mcpmark_agent.py:1183`). τ²-bench has no MCP interface at all, which is why it
32
- needed `_shadow_tools`, `mcp-stdio-bridge.mjs` and two copies of the domain. Here
33
- `bir-proxy` just wraps the server MCPMark was going to spawn anyway. **No bridge.**
34
-
35
- **Every task runs four times against a reset state.** That is our replay shape handed over
36
- for free: trial 1 records, trials 2–4 replay. And the metric MCPMark leads with, `pass^4`,
37
- collapses for every model (best is 52.56% pass@1 → 33.86% pass^4) purely from run-to-run
38
- inconsistency — which is exactly what a deterministic replay removes.
39
-
40
- ### Why `hiring_year_summary` specifically
41
-
42
- | | |
43
- |---|---|
44
- | Service | `postgres` — pure MCP, no built-in substitute the model would prefer |
45
- | Difficulty | `L1` / `easy` bucket |
46
- | Template DB | `employees` |
47
- | Writes | one `CREATE TABLE` + populate — enough to be a real CRUD test, small enough to debug |
48
- | Verification | ground truth computed in SQL at verify time, compared row-by-row with 0.1 tolerance on decimals |
49
- | Prompt | 4 short sections, ~200 words |
50
-
51
- It is the smallest task in the repo that still exercises write operations and per-step
52
- parameter threading. Start here, then widen.
53
-
54
- ---
55
-
56
- ## 2. The prompt
57
-
58
- The user prompt is `description.md` **verbatim**, plus one fixed suffix appended by
59
- `BaseTaskManager._format_task_instruction`:
60
-
61
- ```
62
- <description.md>
63
-
64
- Note: Based on your understanding, solve the task all at once by yourself,
65
- don't ask for my opinions on anything.
66
- ```
67
-
68
- Plus MCPMark's system prompt (`src/agents/mcpmark_agent.py:48`, `MAX_TURNS = 100`):
69
-
70
- > You are a helpful agent that uses tools iteratively to complete the user's task, and when
71
- > finished, provides the final answer or simply states "Task completed" without further
72
- > tool calls.
73
-
74
- **No templating, no randomisation, no timestamps.** All four trials send byte-identical
75
- text. Our similarity gate sees 1.0 against a 0.92 threshold, so replay arms on trials 2–4
76
- every time. Nothing in this plan depends on embedding luck.
77
-
78
- Corollary: `ANTHROPIC_API_KEY` is **not needed**. `doctor` reporting
79
- `derive=recorded sample values` is correct behaviour here, not a degradation — for an
80
- identical prompt the recorded values *are* the right ones.
81
-
82
- ---
83
-
84
- ## 3. Topology
85
-
86
- ```
87
- claude -p (cwd = the run dir)
88
- ├── mcp__bir__run_scenario ─────────────▶ bir-scenario ──┐ direct-mode delivery
89
- │ │ (§4.4 — needs a trusted
90
- └── mcp__postgres__* ──▶ bir-proxy ──▶ postgres-mcp │ workspace, or it is denied)
91
- │ (uvx, │
92
- reports every call mcp<2) │
93
- ▼ │ ▼
94
- bir-hooks :53411 │ POST /scenario/run
95
- │ │ │
96
- ▼ ▼ │
97
- BaseIn service :8080 PostgreSQL 17 ◀┘
98
- (native, :5432)
99
- ▲
100
- verify.py ────┘
101
- (grades the real database)
102
- ```
103
-
104
- The proxy holds the open connection, and that is what makes direct mode free: on
105
- `/scenario/run` all eight steps execute over it for zero model tokens. `bir-scenario` is
106
- how the results get back into the transcript as a genuine `tool_result` rather than an
107
- injected denial — which is why §4.4 matters as much as it does.
108
-
109
- ---
110
-
111
- ## 4. Setup
112
-
113
- ### 4.1 Postgres, seeded
114
-
115
- MCPMark runs `pgvector/pgvector:0.8.0-pg17-bookworm` in Docker. **This machine has no
116
- Docker, no Docker Desktop and no WSL**, so we install PostgreSQL 17 natively instead —
117
- the same major version. This task is plain SQL, so the missing `pgvector` extension is
118
- irrelevant; note the deviation in any published result.
119
-
120
- ```powershell
121
- winget install --id PostgreSQL.PostgreSQL.17 -e `
122
- --accept-package-agreements --accept-source-agreements `
123
- --custom "--mode unattended --superpassword password --serverport 5432 --unattendedmodeui none"
124
- ```
125
-
126
- Needs one UAC approval. The superuser password is set to `password` to match MCPMark's own
127
- `run-task.sh` default (`POSTGRES_PASSWORD:-password`) and the `DATABASE_URI` in §4.3.
128
- Binaries land in `C:\Program Files\PostgreSQL\17\bin`, which is where `psql` and
129
- `pg_restore` come from below.
130
-
131
- Then seed the template DB — `./seed.sh` in the run directory does this:
132
-
133
- ```bash
134
- curl -o employees.backup https://storage.mcpmark.ai/postgres/employees.backup # 33 MB
135
- psql -U postgres -d postgres -c "CREATE DATABASE employees;"
136
- pg_restore -U postgres -d employees --no-owner --no-privileges employees.backup
137
- ```
138
-
139
- MCPMark's own `postgres_state_manager.py::_setup_database` does exactly this for five
140
- templates (`employees`, `chinook`, `dvdrental`, `sports`, `lego`). We only need one.
141
-
142
- ### 4.2 A per-trial database
143
-
144
- MCPMark isolates each run with `CREATE DATABASE … WITH TEMPLATE …` and drops it afterwards.
145
- Reproduce that — it is what makes trials independent and `pass^4` meaningful. `./reset-db.sh`:
146
-
147
- ```sql
148
- SELECT pg_terminate_backend(pid) FROM pg_stat_activity
149
- WHERE datname = 'hys_trial' AND pid <> pg_backend_pid();
150
- DROP DATABASE IF EXISTS hys_trial;
151
- CREATE DATABASE hys_trial WITH TEMPLATE employees;
152
- ```
153
-
154
- The `pg_terminate_backend` line is not optional: `postgres-mcp` holds a pooled connection,
155
- and `DROP DATABASE` fails while any session is attached. `reset-db.sh` then asserts
156
- `hiring_year_summary` is absent and exits non-zero if it is not.
157
-
158
- **Do not skip this.** Without a reset, trial 2 finds `employees.hiring_year_summary`
159
- already present and the task is no longer the task.
160
-
161
- ### 4.3 The run directory
162
-
163
- ```bash
164
- mkdir -p ~/Desktop/BasIns/mcpmark-hys && cd ~/Desktop/BasIns/mcpmark-hys
165
- ```
166
-
167
- Write `.mcp.json` **unwrapped** — `bir install` wraps it:
168
-
169
- ```json
170
- {
171
- "mcpServers": {
172
- "postgres": {
173
- "command": "uvx",
174
- "args": ["--with", "mcp<2", "postgres-mcp==0.3.0", "--access-mode=unrestricted"],
175
- "env": {
176
- "DATABASE_URI": "postgresql://postgres:password@localhost:5432/hys_trial"
177
- }
178
- }
179
- }
180
- }
181
- ```
182
-
183
- `--access-mode=unrestricted` is required — the task creates a table.
184
-
185
- **`--with "mcp<2"` is mandatory, and MCPMark's own invocation is broken without it.**
186
- `postgres-mcp==0.3.0` imports `mcp.server.fastmcp`, which no longer exists in the `mcp`
187
- 2.x SDK (`FastMCP` was renamed to `MCPServer`). A bare
188
- `pipx run postgres-mcp==0.3.0` — MCPMark's canonical command at
189
- `src/agents/mcpmark_agent.py:1183` — resolves `mcp` 2.x today and dies at import:
190
-
191
- ```
192
- ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where
193
- FastMCP was renamed to MCPServer … or pin 'mcp<2' to keep running v1 code.
194
- ```
195
-
196
- This is upstream drift, not our bug, but it means any MCPMark postgres result published
197
- today used a pinned resolution. `uvx` is used in place of `pipx` because this machine has
198
- no `pipx`; both are equivalent here.
199
-
200
- Then:
201
-
202
- ```bash
203
- bir install --replay --local
204
- ```
205
-
206
- That wraps `postgres` with `bir-proxy`, installs the hooks into `.claude/settings.json`, and
207
- adds the `bir` scenario server that direct mode delivers through. Note the port it reports —
208
- it picks a free one (**53411** here, not the 53455 the incident demo uses), and the hook
209
- URLs in `settings.json` are written for that port.
210
-
211
- ### 4.4 Deny the built-ins — this is the whole ballgame
212
-
213
- Claude Code will reach for `Bash` + `psql` if you let it. One built-in in the recording and
214
- `modeFor` ([coverage.ts:52](../src/replay/coverage.ts#L52)) returns `steer`, and we already
215
- measured what steer mode is worth: nothing. On Windows `PowerShell` is a separate built-in
216
- from `Bash` and reaches `psql` just as easily, so it must be denied too. The subagent tool is
217
- `Agent` in current Claude Code; `Task` is its old name, kept for older builds. Add to
218
- `.claude/settings.json`:
219
-
220
- ```json
221
- {
222
- "permissions": {
223
- "deny": ["Bash", "PowerShell", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
224
- "WebFetch", "WebSearch", "Agent", "Task"],
225
- "allow": ["mcp__postgres__*", "mcp__bir__*"]
226
- }
227
- }
228
- ```
229
-
230
- **The workspace must also be trusted, or the `allow` half is silently discarded** and
231
- direct mode cannot work at all. Claude Code prints it once and then carries on:
232
-
233
- ```
234
- Ignoring 2 permissions.allow entries from .claude/settings.json:
235
- this workspace has not been trusted.
236
- ```
237
-
238
- This is not cosmetic, and it fails *asymmetrically* in the worst possible way:
239
-
240
- - `mcp__postgres__*` keeps working, because bir's own `PreToolUse` answers
241
- `permissionDecision: "allow"` on the correlation path ([server.ts:859](../src/control/server.ts#L859)).
242
- - `mcp__bir__run_scenario` is the **one** tool bir deliberately passes through
243
- ([controller.ts:301](../src/replay/controller.ts#L301) → `{kind:"passthrough"}`), so it gets
244
- no hook-granted allow. With the allow list gone it is denied, and in `-p` mode a denial is
245
- final:
246
-
247
- ```
248
- Claude requested permissions to use mcp__bir__run_scenario,
249
- but you haven't granted it yet.
250
- ```
251
-
252
- The plan then arms in `direct` mode, the model cannot reach the delivery tool, it does the
253
- task by hand, and the first manual call triggers divergence. Everything *looks* healthy —
254
- the task passes and the audit log says `mode=direct` — while the saving is negative. §11 is
255
- the measurement.
256
-
257
- Fix before the first replay, either by running `claude` interactively in the directory once
258
- and accepting the trust dialog, or by setting in `~/.claude.json`:
259
-
260
- ```json
261
- "projects": { "C:/Users/Admin/Desktop/BasIns/mcpmark-hys": { "hasTrustDialogAccepted": true } }
262
- ```
263
-
264
- ### 4.5 Start the control server, in this directory
265
-
266
- ```bash
267
- export BIR_AUTH_URL=http://127.0.0.1:8080
268
- export BIR_REPLAY=1
269
- bir-hooks
270
- ```
271
-
272
- Then, in another shell **in the same directory**:
273
-
274
- ```bash
275
- bir doctor
276
- ```
277
-
278
- Required before going further:
279
-
280
- ```
281
- Wrapped in config : postgres
282
- Registered proxies: postgres ← needs claude running
283
- ```
284
-
285
- `Registered proxies: (none)` means no proxy has bound. Launch `claude` in this directory
286
- first, then re-check. **Do not send the prompt until this line is right** — that is the exact
287
- mistake that made the incident-demo run cost more than the original.
288
-
289
- ---
290
-
291
- ## 5. Run protocol
292
-
293
- The run directory is `C:\Users\Admin\Desktop\BasIns\mcpmark-hys`, and it carries four
294
- scripts and a prompt file so a trial is one command:
295
-
296
- | Script | Does |
297
- |---|---|
298
- | `seed.sh` | one-time: create the `employees` template DB and `pg_restore` the backup |
299
- | `reset-db.sh` | per-trial: recreate `hys_trial` from the template, assert the target table is gone |
300
- | `verify.sh` | MCPMark's unmodified `verify.py` plus the env it needs |
301
- | `trial.sh N` | reset → `claude -p "$(cat prompt.txt)"` → verify → verdict |
302
- | `prompt.txt` | the assembled prompt from §2, byte-exact |
303
-
304
- `trial.sh` drives the agent with `claude -p`, non-interactive, one call per trial. That is
305
- the same shape the τ²-bench adapter uses and it keeps the prompt byte-identical across
306
- trials without a human retyping it.
307
-
308
- ### Trial 1 — record
309
-
310
- ```bash
311
- ./trial.sh 1
312
- ```
313
-
314
- `trial.sh` ends by running `./verify.sh`, which is MCPMark's unmodified `verify.py` plus the
315
- environment it needs:
316
-
317
- ```bash
318
- PYTHONIOENCODING=utf-8 \
319
- POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DATABASE=hys_trial \
320
- POSTGRES_USERNAME=postgres POSTGRES_PASSWORD=password \
321
- uv run --with psycopg2-binary python verify.py
322
- ```
323
-
324
- Exit 0 is a pass.
325
-
326
- **`PYTHONIOENCODING=utf-8` is required on this machine.** `verify.py` prints `✅`, `❌` and
327
- `🎉`, and this console's codepage is cp1255 — without it Python raises
328
- `UnicodeEncodeError: 'charmap' codec can't encode character '❌'` *while formatting its
329
- own result*, on both the PASS and FAIL paths. A run graded through a crashed reporter is a
330
- run with no grade.
331
-
332
- **A failed trial 1 is a stop, not a retry.** There is no point calculating a scenario from a
333
- run that did not solve the task.
334
-
335
- ### Calculate the scenario
336
-
337
- ```bash
338
- bir scenario list # find the runId
339
- bir scenario calc <runId>
340
- bir scenario show <runId> # read the intent, params and steps before trusting it
341
- ```
342
-
343
- Read the step logic. Specifically check that `tool_output_logic` navigates
344
- `data.content[0].text` and that the steps thread through `respParams` rather than every step
345
- carrying a hardcoded literal. In the incident scenario every step had a baked-in fallback
346
- (`?? "inventory-svc"`), which is why it looked healthy while threading nothing.
347
-
348
- ### Trials 2–4 — replay
349
-
350
- Reset the DB, send the identical prompt, verify. Between each, confirm:
351
-
352
- ```bash
353
- curl -s -H "Authorization: Bearer $(...)" http://127.0.0.1:53411/health
354
- ```
355
-
356
- Wanted: `mode: "direct"`, `stepsPinned == stepsPlanned`, `outcome: "steered_full"`.
357
-
358
- You can also drive a replay without a session at all, which is the fastest way to debug the
359
- step logic:
360
-
361
- ```bash
362
- bir replay --scenario <scnId> --prompt "<the prompt>" --dry # recorded outputs, no real SQL
363
- bir replay --scenario <scnId> --prompt "<the prompt>" # real SQL through the proxy
364
- ```
365
-
366
- `--dry` books no execution and invents no saving.
367
-
368
- ---
369
-
370
- ## 6. What to measure
371
-
372
- | Metric | Where from | Expectation |
373
- |---|---|---|
374
- | pass@1 | `verify.py` exit code, trial 1 | unchanged by us |
375
- | pass^4 | all four trials pass | this is the headline |
376
- | `session_cost_usd` per trial | `scenario_executions` | trials 2–4 ≪ trial 1 |
377
- | `saved_usd` | `scenario_executions` | **must be positive** |
378
- | `duration_ms` vs `baseline_ms` | same table | secondary |
379
- | `steps_pinned` / `steps_planned` | same table | must be 8/8-shaped, not 1/8 |
380
-
381
- **Baseline honesty.** `baseline_method` is `single` and only `not_steered`/`failed` outcomes
382
- feed new samples, so a scenario that keeps succeeding never widens its baseline. One sample
383
- with ±8% run variance cannot support a claim to two significant figures. Run trial 1 several
384
- times with `BIR_REPLAY=0` first and take a median, or state the sample size.
385
-
386
- **`steps_pinned` is currently wrong in steer mode** —
387
- [controller.ts:326](../src/replay/controller.ts#L326) uses `pinned.size`, which
388
- [controller.ts:383](../src/replay/controller.ts#L383) has already decremented, so sequential
389
- pins report 1. Direct mode uses a cumulative count and is fine. If this task lands in direct
390
- mode the number is trustworthy; if it lands in steer mode, do not read it.
391
-
392
- ---
393
-
394
- ## 7. The honesty section
395
-
396
- `pass^4` exists to measure whether a *model* is consistent. Raising it by not re-deciding is
397
- real product value and a real user benefit — but it is not a model-capability result, and
398
- publishing it as one would be indefensible.
399
-
400
- Report it as **cost and latency at held-constant `pass^4`**, with replay disclosed as a
401
- system-level intervention and trial 1 shown separately as the recording cost. That is the
402
- same standard t-bench.md §3.2 already holds itself to, and it is the framing that survives
403
- review.
404
-
405
- ---
406
-
407
- ## 8. Known risks
408
-
409
- | Risk | Mitigation |
410
- |---|---|
411
- | Model uses `Bash`+`psql` → steer mode → zero saving | §4.4 deny list; verify `mode: "direct"` on `/health` |
412
- | Recording made without the proxy → output logic authored against the hook's envelope, threads `{}` | `bir doctor` must show `Registered proxies: postgres` **before** trial 1 |
413
- | No DB reset between trials | §4.2, every trial |
414
- | Windows long paths — the repo will not check out | `git clone -c core.longpaths=true` |
415
- | `pipx` unavailable | `uvx postgres-mcp==0.3.0`, disclosed as a deviation |
416
- | Scenario expiry | `scenarios.expires_at` — re-`calc` if trials span days |
417
-
418
- ## 9. Next, if this works
419
-
420
- Widen within `postgres` first — 31 prompts, same server, same reset mechanism, so the
421
- marginal cost per task is near zero. `tasks/postgres/standard/**` is where the paper's
422
- numbers come from.
423
-
424
- Hold `filesystem` (40 prompts) back: the model prefers built-in `Read`/`Edit` over the
425
- filesystem MCP server, so those tasks fight §4.4 rather than benefiting from it.
426
-
427
- ---
428
-
429
- ## 10. Repo facts worth keeping
430
-
431
- - Layout: `tasks/<service>/<difficulty>/<initial-state>/<task-name>/{description.md,meta.json,verify.py}`
432
- - 177 prompts total: **127 `standard`** (the paper's number) + 50 `easy`
433
- - Per service: filesystem 40 · notion 38 · github 33 · playwright_webarena 31 · postgres 31 · playwright 4 · insforge/supabase 0
434
- - The repo has grown past the paper's five servers — `insforge`, `supabase` and `playwright_webarena` are new
435
- - `meta.json` carries an `"mcp": ["postgres"]` array — use it to select only tasks that can reach direct mode
436
- - Small upstream bug: the §2 suffix's docstring says "Notion-specific additions" but the
437
- method is on `BaseTaskManager` with no override, so it is appended for every service
438
-
439
- ---
440
-
441
- ## 11. First run: measured results (2026-09-04)
442
-
443
- Six trials on `hiring_year_summary`. Setup as in §4, on PostgreSQL 17 native.
444
-
445
- ### Grading
446
-
447
- **6/6 PASS.** `verify.py` reported `✅ 16 records correct` every time, against ground truth
448
- it recomputes in SQL at verify time. `pass^6` on this task.
449
-
450
- Do **not** compare that to the paper's 33.86% `pass^4`. This is an `easy`-bucket task; the
451
- paper's number is over the 127 `standard` tasks. It says the harness works, not that we beat
452
- anybody.
453
-
454
- ### Cost
455
-
456
- Baseline: **$0.22401525**, 25,705 ms, `baseline_samples=1`, method `single`.
457
-
458
- | trial | outcome | cost | saved | duration | steps |
459
- |---|---|---|---|---|---|
460
- | 1 | *recorded* | $0.22402 | — | 25,705 ms | 8 |
461
- | 2 | `diverged` | $0.23484 | **−$0.01083** | 36,734 ms | 8/8 |
462
- | 3 | `diverged` | $0.24438 | **−$0.02037** | 40,768 ms | 8/8 |
463
- | 4 | `diverged` | $0.24277 | **−$0.01876** | 36,243 ms | 8/8 |
464
- | 5 | `diverged` | $0.23937 | **−$0.01536** | 43,078 ms | 8/8 |
465
- | 6 | `diverged` | $0.24512 | **−$0.02110** | 35,810 ms | 8/8 |
466
-
467
- Mean saving **−$0.01728 (−7.7%)**, and every replay was *slower* than the recording.
468
- Five for five negative — this is not variance.
469
-
470
- ### What worked
471
-
472
- - **Arming is correct.** `mode=direct`, `coverage=direct,direct,direct,direct,direct,direct,direct,direct`,
473
- `similarity=1.000` on every trial. This is the piece the incident demo never reached.
474
- - **The recording is correct.** `wrapped=["postgres"]` at run start, outputs captured in the
475
- proxy's `{"content":[...]}` envelope, 8/8 tool calls `mcp__postgres__*` and zero built-ins.
476
- - **State genuinely threads.** Steps 2–4 read `respParams.userSchemas` / `respParams.tableNames`;
477
- the audit log shows `emitted=allSchemas,userSchemas`, `emitted=schemaObjects,tableNames`,
478
- `emitted=objectDetails,employeeColumns`. Not the incident scenario's hardcoded fallbacks.
479
- - **Derivation is free.** `replay.derived params=5 costUsd=0.0000 source="recorded samples"`.
480
- No `ANTHROPIC_API_KEY` needed for an identical prompt, as §2 predicted.
481
- - **`steps_pinned` is right here** — 8/8 on every trial. The `pinned.size` bug in §6 is
482
- specific to steer mode; the direct and divergence paths use a cumulative count.
483
-
484
- ### Why the saving is negative
485
-
486
- `mcp__bir__run_scenario` was **denied by the permission system** on every trial — see §4.4.
487
- The workspace was untrusted, so the `allow` list was discarded, and that tool is the only one
488
- bir passes through rather than auto-allowing. The model could not reach the plan, so it did
489
- the task by hand, and its first call diverged.
490
-
491
- **Divergence recovery then performed exactly as designed** — and this is worth stating
492
- plainly, because it is the safety net doing its job:
493
-
494
- ```
495
- replay.compose remaining=8 executed=8 recorded=0 skipped=0 bytes=10259 costUsd=0.00
496
- replay.done mode=direct steps=8/8 outcome=diverged ms=7591
497
- ```
498
-
499
- All eight steps ran for real through the already-open proxy connection in ~370 ms of tool
500
- time, nothing fell back to a recorded output, nothing was skipped, and the fallback cost was
501
- zero. The task passed. But the model had already paid for the wasted `run_scenario` turn,
502
- then paid again to read a 10,259-byte injected bundle — so the session cost landed at
503
- roughly the cost of just doing the task, plus overhead.
504
-
505
- **The mechanism is sound; the delivery channel was blocked.** Nothing in these six trials
506
- measures direct mode's actual saving, because direct delivery never once happened.
507
-
508
- ### Next
509
-
510
- 1. Trust the workspace (§4.4). Without it every replay measures the divergence path.
511
- 2. Re-record after trusting — trials 2–6 were `diverged`, which books a ledger row but
512
- never contributes a baseline sample, so the baseline is still a lone measurement of one
513
- run at `$0.22401525` with ±8% run-to-run variance. Take a median of 3–5 `BIR_REPLAY=0`
514
- runs before quoting any saving.
515
- 3. Then re-run and expect `replay.done mode=direct outcome=steered_full` with no
516
- `replay.diverge` line. That is the only configuration in which a positive saving is
517
- even possible.
518
-
519
- ---
520
-
521
- ## 12. Second run: direct mode reached, and the real blocker
522
-
523
- After trusting the workspace (§4.4) and restarting `bir-hooks` with a clean session map,
524
- trial 7 finally reached direct delivery:
525
-
526
- ```
527
- tool.pre mcp__bir__run_scenario
528
- replay.step n=0..7 all ok=true 09:53:18.924 → 09:53:19.289 (435 ms total)
529
- replay.done mode=direct steps=8/8 outcome=steered_full ms=4236
530
- tool.post mcp__bir__run_scenario failed=false
531
- ```
532
-
533
- No `replay.diverge`. Every step executed for real over the already-open proxy connection in
534
- 435 ms of tool time, `fallbackCostUsd=0`, `deriveCostUsd=0`. `verify.py`: PASS.
535
-
536
- ### And it was the most expensive run yet
537
-
538
- | # | outcome | cost | saved | duration |
539
- |---|---|---|---|---|
540
- | 1 | *recorded* | $0.22402 | — | 25,705 ms |
541
- | 2 | `diverged` | $0.23484 | −$0.01083 | 36,734 ms |
542
- | 3 | `diverged` | $0.24438 | −$0.02037 | 40,768 ms |
543
- | 4 | `diverged` | $0.24277 | −$0.01876 | 36,243 ms |
544
- | 5 | `diverged` | $0.23937 | −$0.01536 | 43,078 ms |
545
- | 6 | `diverged` | $0.24512 | −$0.02110 | 35,810 ms |
546
- | 7 | **`steered_full`** | **$0.31426** | **−$0.09025** | 35,921 ms |
547
-
548
- 7/7 PASS. Direct mode cost **+40% over baseline** — roughly five times the mean loss of the
549
- divergence path it replaced.
550
-
551
- ### Why: the model re-did the whole task
552
-
553
- The bundle arrived as a 10,751-byte `tool_result` on turn 3. The model read it — and then
554
- ran this:
555
-
556
- ```sql
557
- DROP TABLE IF EXISTS employees.hiring_year_summary;
558
- CREATE TABLE employees.hiring_year_summary AS SELECT ... -- rebuilt from scratch
559
- SELECT column_name, data_type FROM information_schema.columns WHERE ...
560
- SELECT * FROM employees.hiring_year_summary ORDER BY hire_year;
561
- ```
562
-
563
- It **dropped the table the replay had just created and built it again**. So the session paid
564
- for the bundle *and* for doing the task, plus a longer final answer. The plan was already
565
- retired by then, so nothing logged a divergence — the audit log says `steered_full` and looks
566
- perfect.
567
-
568
- ### The cause is one sentence of prompt text
569
-
570
- [bundle.ts:54](../src/replay/bundle.ts#L54):
571
-
572
- ```ts
573
- "[BaseInstRunner calculated replay] A known-good tool sequence was previously " +
574
- "recorded for this request. Below are the tool calls and their results, in order. " +
575
- "Use them to answer the user's request now — do NOT call any tools."
576
- ```
577
-
578
- That header is unconditional. It says **"previously recorded"** even when all eight steps
579
- executed live against the real database 400 ms earlier. `anyRecorded` correctly appends a
580
- staleness caveat when some step *did* fall back to a recorded output — but it never says the
581
- opposite when nothing did.
582
-
583
- For a read-only scenario that framing is harmless: the bundle contains the answer, and the
584
- model just answers. For a **write** scenario it is actively counterproductive. The model is
585
- told side effects are historical, cannot verify the live database without calling a tool, and
586
- so reasonably re-runs the work. Every CRUD task in MCPMark — which is to say the whole
587
- benchmark, its own README calls the tasks CRUD-heavy — lands in this case.
588
-
589
- **Proposed fix:** make the header state what actually happened. When `recorded === 0`, say
590
- the sequence *has just been executed against the live system and its side effects are already
591
- in place*; keep the current wording only for entries that really are replayed from a
592
- recording. This is the single highest-leverage change available to the product, and it is one
593
- string.
594
-
595
- *Revised by §13:* the scenario underneath these runs was broken, and that is the larger
596
- problem. This header fix drops to fourth in §13's list.
597
-
598
- Until it lands, `direct` mode's measured saving on a write task is **negative**, and no
599
- amount of correct plumbing changes that — trial 7 had perfect plumbing.
600
-
601
- ### Second bug, independent of the above
602
-
603
- `onScenarioRun` ([server.ts:1109](../src/control/server.ts#L1109)) resolves the run with:
604
-
605
- ```ts
606
- const run = [...this.sessions.values()].find((s) => s.run && !s.run.finished)?.run;
607
- ```
608
-
609
- Insertion order. A `claude -p` session's run is not sealed when the process exits, so the
610
- *previous* trial's session is still first in the map, its plan retired. `runArmed` hits its
611
- `state.retired` guard and answers `no_plan`, and the live armed plan at index 1 is never
612
- reached. Caught on trial 4 by polling `/health` during the turn:
613
-
614
- ```
615
- 09:42:04 n=2 1a1b140c:direct,armed=false | 705f3cc5:direct,armed=true
616
- └ picked, retired └ the actual plan, ignored
617
- ```
618
-
619
- Workaround used here: restart `bir-hooks` before every trial. Proper fix: select the session
620
- whose run carries an *armed* plan, not merely the first unfinished one.
621
-
622
- ### Also latent
623
-
624
- `runToCompletion` calls `assembleBundle(entries, Number.MAX_SAFE_INTEGER)`
625
- ([plan.ts:502](../src/replay/plan.ts#L502)) — the direct-mode bundle is **uncapped**, where
626
- the divergence path caps at `MAX_REPLAY_REASON` (60 KB). 10,751 bytes was fine here; a
627
- scenario with large tool outputs would push an unbounded payload into the model's context.
628
-
629
- ---
630
-
631
- ## 13. The scenario was broken all along
632
-
633
- Trial 8 is the most informative run of the set, because it is the first one where the model
634
- *trusted* the bundle. It failed:
635
-
636
- ```
637
- replay.done mode=direct steps=8/8 outcome=steered_full ms=3629
638
- execution.reported outcome=steered_full session=0.0791 savedUsd=+0.14489850 measured=true
639
- verify.py: ❌ relation "employees.hiring_year_summary" does not exist → FAIL
640
- ```
641
-
642
- **A +$0.145 saving (−65% cost) booked on a run that produced nothing.**
643
-
644
- ### Root cause: a prose sample value interpolated into SQL
645
-
646
- `params_object` for `scn_9832f19f`:
647
-
648
- | parameter | sampleValue |
649
- |---|---|
650
- | `schema_name` | `"employees"` |
651
- | `summary_table_name` | `"hiring_year_summary"` |
652
- | `active_to_date` | `"9999-01-01"` |
653
- | `group_by_dimension` | **`"hire_year (EXTRACT(YEAR FROM hire_date))"`** |
654
-
655
- Step 5's input logic:
656
-
657
- ```js
658
- const dim = parameters.group_by_dimension || 'hire_date';
659
- ... "EXTRACT(YEAR FROM e." + dim + ")::int AS hire_year," ...
660
- ```
661
-
662
- which produces:
663
-
664
- ```sql
665
- EXTRACT(YEAR FROM e.hire_year (EXTRACT(YEAR FROM hire_date)))::int AS hire_year,
666
- ```
667
-
668
- A syntax error. `CREATE TABLE` never runs, step 6 returns `[]`, step 7 returns
669
- `Error: relation "employees.hiring_year_summary" does not exist`. The SQL template itself is
670
- correct — run it with `dim = 'hire_date'` and it produces the right 16 rows, verified
671
- directly against psql.
672
-
673
- The analyser wrote a **description** where a literal tool-input value was required. The
674
- declared `examples` carry the same string, so nothing downstream could have caught it.
675
-
676
- ### Why six trials hid it
677
-
678
- | trial | outcome | replay's SQL | model's behaviour | grade |
679
- |---|---|---|---|---|
680
- | 2–6 | `diverged` | broken | saw the errors in the bundle, did the task itself | PASS |
681
- | 7 | `steered_full` | broken | `DROP TABLE` + rebuilt it correctly | PASS |
682
- | 8 | `steered_full` | broken | trusted the bundle, only *proposed* SQL | **FAIL** |
683
-
684
- **The replay has never once produced the task's result.** Every PASS came from the model
685
- working around a broken scenario — which is also the real reason costs were high in §11–12,
686
- and it means §12's "the model re-did the task" was the *symptom*, not the disease. The
687
- model re-did the task because the bundle it received was full of SQL errors.
688
-
689
- ### Three things made this invisible from inside bir
690
-
691
- **1. A step's verdict does not mean its work succeeded.** `executeStep` rejects only when a
692
- tool could not be *run*; a tool that ran and returned an error resolves normally, so
693
- `replay.step ... ok=true` is logged for a failed `CREATE TABLE`. That is a deliberate and
694
- correct distinction ([controller.ts](../src/replay/controller.ts) — "a tool that ran and
695
- failed resolves with its failure as the response"), but it means the audit log cannot be read
696
- as evidence of success.
697
-
698
- **2. Step 5's output logic hardcodes success:**
699
-
700
- ```js
701
- return { summaryTableCreated: true, summaryTableName: ... };
702
- ```
703
-
704
- It never inspects `toolOutput`. So `emitted=summaryTableCreated` appears in the log whether
705
- or not the table exists. An output logic that asserts a fact it did not check is worse than
706
- no output logic, because it manufactures corroboration.
707
-
708
- **3. `saved_usd` is computed from cost alone.** `steered_full` means "the plan ran to
709
- completion", not "the task succeeded" — and bir cannot know the benchmark grade. So the
710
- cheapest possible outcome (a replay that does nothing) books the largest possible saving.
711
- The signed saving in the BaseIn service's `src/scenarios/repo.ts` (a separate repo)
712
- catches replays that cost *more*; nothing catches replays that cost less by doing less.
713
-
714
- ### What to fix, in order
715
-
716
- 1. **Sample values must be literal tool inputs.** The analyser prompt has to forbid prose in
717
- `paramsObject.sampleValue` — it is interpolated into tool arguments verbatim. A validation
718
- pass that re-evaluates each `toolInputLogic` against the recorded input and diffs the
719
- result would have caught this at calculate time, for free.
720
- 2. **Judge a step by its output, not its resolution.** At minimum, detect an MCP result whose
721
- text begins `Error:` and mark the step `failed` rather than `ok`. Today a scenario can
722
- report `steered_full` while every step errored.
723
- 3. **Do not book a saving for a scenario whose steps errored.** Combined with (2), a step
724
- marked `failed` should force `outcome: "failed"`, which already books a baseline sample
725
- instead of a saving.
726
- 4. Then the §12 bundle-header wording, which is a real problem but a smaller one.
727
-
728
- Until (1) is fixed, re-deriving is the immediate unblock:
729
-
730
- ```bash
731
- BIR_AUTH_URL=http://127.0.0.1:8080 node <bir>/dist/bin/bir.js scenario calc <runId> --force
732
- ```
733
-
734
- and then **check `params_object` before trusting the result** — `bir scenario show <runId>`,
735
- and read every `sampleValue` as though it were about to be pasted into a shell.
736
-
737
- ---
738
-
739
- ## 14. Status (2026-09-04): every fix in §12–13 has landed
740
-
741
- | # | Finding | Fix |
742
- |---|---|---|
743
- | §13 (1) | prose `sampleValue` spliced into SQL | BaseIn `analysis.ts`: the parameter prompt now requires literal example values, and `repairMismatchedInputLogic` re-evaluates every `toolInputLogic` over the sample values at calculate time and replays the recorded input verbatim when the result differs |
744
- | §13 (2) | a step whose tool returned `Error:` logged `ok=true` | [tool-error.ts](../src/replay/tool-error.ts): an MCP result with `isError` or a leading `Error` is a `failed` step at stage `tool_call`, in direct, divergence and steer mode alike |
745
- | §13 (3) | `steered_full` booked a saving on errored steps | [controller.ts](../src/replay/controller.ts): any errored step forces `outcome: "failed"`, which books a baseline sample |
746
- | §12 header | bundle said "previously recorded" for live work | [bundle.ts](../src/replay/bundle.ts): a bundle with no recorded entry says the work was just executed and its side effects are in place |
747
- | §12 session | `/scenario/run` picked the first unfinished session | [server.ts](../src/control/server.ts): it picks the session whose plan is armed |
748
- | §12 latent | direct-mode bundle uncapped | [plan.ts](../src/replay/plan.ts): `runToCompletion` caps at `MAX_REPLAY_REASON` |
749
- | §6 | `steps_pinned` reported 1 for sequential pins | [controller.ts](../src/replay/controller.ts): counted as each pin threads, not from `pinned.size` |
750
-
751
- Not yet re-measured. Trials 9+ need a re-`calc` (the scenario must be regenerated for the
752
- validation pass to run) and then the §5 protocol again from a fresh baseline.
1
+ # MCPMark — running one task against BaseInstRunner
2
+
3
+ A concrete plan for one task, end to end:
4
+
5
+ ```
6
+ tasks/postgres/easy/employees/hiring_year_summary/
7
+ ```
8
+
9
+ Companion to [t-bench.md](t-bench.md). That document explains why τ²-bench was hard to
10
+ integrate. This one exists because **MCPMark is structurally easier and scores our product
11
+ more honestly**, and §1 is the reason.
12
+
13
+ Source: [github.com/eval-sys/mcpmark](https://github.com/eval-sys/mcpmark) ·
14
+ [paper](https://huggingface.co/papers/2509.24002) · [mcpmark.ai](https://mcpmark.ai/)
15
+
16
+ ---
17
+
18
+ ## 1. Why this benchmark, and why this task
19
+
20
+ Three structural facts, each of which τ²-bench got wrong for us.
21
+
22
+ **MCPMark grades the world, not the transcript.** `verify.py` opens a psycopg2 connection
23
+ and queries the actual database. τ²-bench's `evaluator_env.py` builds a *fresh* environment
24
+ and replays the trajectory's `(tool_call, tool_result)` pairs onto it — which is why direct
25
+ mode was structurally penalised there (t-bench.md §3.1). Here, direct mode executes real
26
+ SQL through a real connection, the table really exists, and `verify.py` passes. **Direct
27
+ mode is fully scoreable on MCPMark.** This is the single most important line in this
28
+ document.
29
+
30
+ **The agent already speaks MCP.** MCPMark spawns `postgres-mcp` over stdio
31
+ (`src/agents/mcpmark_agent.py:1183`). τ²-bench has no MCP interface at all, which is why it
32
+ needed `_shadow_tools`, `mcp-stdio-bridge.mjs` and two copies of the domain. Here
33
+ `bir-proxy` just wraps the server MCPMark was going to spawn anyway. **No bridge.**
34
+
35
+ **Every task runs four times against a reset state.** That is our replay shape handed over
36
+ for free: trial 1 records, trials 2–4 replay. And the metric MCPMark leads with, `pass^4`,
37
+ collapses for every model (best is 52.56% pass@1 → 33.86% pass^4) purely from run-to-run
38
+ inconsistency — which is exactly what a deterministic replay removes.
39
+
40
+ ### Why `hiring_year_summary` specifically
41
+
42
+ | | |
43
+ |---|---|
44
+ | Service | `postgres` — pure MCP, no built-in substitute the model would prefer |
45
+ | Difficulty | `L1` / `easy` bucket |
46
+ | Template DB | `employees` |
47
+ | Writes | one `CREATE TABLE` + populate — enough to be a real CRUD test, small enough to debug |
48
+ | Verification | ground truth computed in SQL at verify time, compared row-by-row with 0.1 tolerance on decimals |
49
+ | Prompt | 4 short sections, ~200 words |
50
+
51
+ It is the smallest task in the repo that still exercises write operations and per-step
52
+ parameter threading. Start here, then widen.
53
+
54
+ ---
55
+
56
+ ## 2. The prompt
57
+
58
+ The user prompt is `description.md` **verbatim**, plus one fixed suffix appended by
59
+ `BaseTaskManager._format_task_instruction`:
60
+
61
+ ```
62
+ <description.md>
63
+
64
+ Note: Based on your understanding, solve the task all at once by yourself,
65
+ don't ask for my opinions on anything.
66
+ ```
67
+
68
+ Plus MCPMark's system prompt (`src/agents/mcpmark_agent.py:48`, `MAX_TURNS = 100`):
69
+
70
+ > You are a helpful agent that uses tools iteratively to complete the user's task, and when
71
+ > finished, provides the final answer or simply states "Task completed" without further
72
+ > tool calls.
73
+
74
+ **No templating, no randomisation, no timestamps.** All four trials send byte-identical
75
+ text. Our similarity gate sees 1.0 against a 0.92 threshold, so replay arms on trials 2–4
76
+ every time. Nothing in this plan depends on embedding luck.
77
+
78
+ Corollary: `ANTHROPIC_API_KEY` is **not needed**. `doctor` reporting
79
+ `derive=recorded sample values` is correct behaviour here, not a degradation — for an
80
+ identical prompt the recorded values *are* the right ones.
81
+
82
+ ---
83
+
84
+ ## 3. Topology
85
+
86
+ ```
87
+ claude -p (cwd = the run dir)
88
+ ├── mcp__bir__run_scenario ─────────────▶ bir-scenario ──┐ direct-mode delivery
89
+ │ │ (§4.4 — needs a trusted
90
+ └── mcp__postgres__* ──▶ bir-proxy ──▶ postgres-mcp │ workspace, or it is denied)
91
+ │ (uvx, │
92
+ reports every call mcp<2) │
93
+ ▼ │ ▼
94
+ bir-hooks :53411 │ POST /scenario/run
95
+ │ │ │
96
+ ▼ ▼ │
97
+ BaseIn service :8080 PostgreSQL 17 ◀┘
98
+ (native, :5432)
99
+ ▲
100
+ verify.py ────┘
101
+ (grades the real database)
102
+ ```
103
+
104
+ The proxy holds the open connection, and that is what makes direct mode free: on
105
+ `/scenario/run` all eight steps execute over it for zero model tokens. `bir-scenario` is
106
+ how the results get back into the transcript as a genuine `tool_result` rather than an
107
+ injected denial — which is why §4.4 matters as much as it does.
108
+
109
+ ---
110
+
111
+ ## 4. Setup
112
+
113
+ ### 4.1 Postgres, seeded
114
+
115
+ MCPMark runs `pgvector/pgvector:0.8.0-pg17-bookworm` in Docker. **This machine has no
116
+ Docker, no Docker Desktop and no WSL**, so we install PostgreSQL 17 natively instead —
117
+ the same major version. This task is plain SQL, so the missing `pgvector` extension is
118
+ irrelevant; note the deviation in any published result.
119
+
120
+ ```powershell
121
+ winget install --id PostgreSQL.PostgreSQL.17 -e `
122
+ --accept-package-agreements --accept-source-agreements `
123
+ --custom "--mode unattended --superpassword password --serverport 5432 --unattendedmodeui none"
124
+ ```
125
+
126
+ Needs one UAC approval. The superuser password is set to `password` to match MCPMark's own
127
+ `run-task.sh` default (`POSTGRES_PASSWORD:-password`) and the `DATABASE_URI` in §4.3.
128
+ Binaries land in `C:\Program Files\PostgreSQL\17\bin`, which is where `psql` and
129
+ `pg_restore` come from below.
130
+
131
+ Then seed the template DB — `./seed.sh` in the run directory does this:
132
+
133
+ ```bash
134
+ curl -o employees.backup https://storage.mcpmark.ai/postgres/employees.backup # 33 MB
135
+ psql -U postgres -d postgres -c "CREATE DATABASE employees;"
136
+ pg_restore -U postgres -d employees --no-owner --no-privileges employees.backup
137
+ ```
138
+
139
+ MCPMark's own `postgres_state_manager.py::_setup_database` does exactly this for five
140
+ templates (`employees`, `chinook`, `dvdrental`, `sports`, `lego`). We only need one.
141
+
142
+ ### 4.2 A per-trial database
143
+
144
+ MCPMark isolates each run with `CREATE DATABASE … WITH TEMPLATE …` and drops it afterwards.
145
+ Reproduce that — it is what makes trials independent and `pass^4` meaningful. `./reset-db.sh`:
146
+
147
+ ```sql
148
+ SELECT pg_terminate_backend(pid) FROM pg_stat_activity
149
+ WHERE datname = 'hys_trial' AND pid <> pg_backend_pid();
150
+ DROP DATABASE IF EXISTS hys_trial;
151
+ CREATE DATABASE hys_trial WITH TEMPLATE employees;
152
+ ```
153
+
154
+ The `pg_terminate_backend` line is not optional: `postgres-mcp` holds a pooled connection,
155
+ and `DROP DATABASE` fails while any session is attached. `reset-db.sh` then asserts
156
+ `hiring_year_summary` is absent and exits non-zero if it is not.
157
+
158
+ **Do not skip this.** Without a reset, trial 2 finds `employees.hiring_year_summary`
159
+ already present and the task is no longer the task.
160
+
161
+ ### 4.3 The run directory
162
+
163
+ ```bash
164
+ mkdir -p ~/Desktop/BasIns/mcpmark-hys && cd ~/Desktop/BasIns/mcpmark-hys
165
+ ```
166
+
167
+ Write `.mcp.json` **unwrapped** — `bir install` wraps it:
168
+
169
+ ```json
170
+ {
171
+ "mcpServers": {
172
+ "postgres": {
173
+ "command": "uvx",
174
+ "args": ["--with", "mcp<2", "postgres-mcp==0.3.0", "--access-mode=unrestricted"],
175
+ "env": {
176
+ "DATABASE_URI": "postgresql://postgres:password@localhost:5432/hys_trial"
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ `--access-mode=unrestricted` is required — the task creates a table.
184
+
185
+ **`--with "mcp<2"` is mandatory, and MCPMark's own invocation is broken without it.**
186
+ `postgres-mcp==0.3.0` imports `mcp.server.fastmcp`, which no longer exists in the `mcp`
187
+ 2.x SDK (`FastMCP` was renamed to `MCPServer`). A bare
188
+ `pipx run postgres-mcp==0.3.0` — MCPMark's canonical command at
189
+ `src/agents/mcpmark_agent.py:1183` — resolves `mcp` 2.x today and dies at import:
190
+
191
+ ```
192
+ ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where
193
+ FastMCP was renamed to MCPServer … or pin 'mcp<2' to keep running v1 code.
194
+ ```
195
+
196
+ This is upstream drift, not our bug, but it means any MCPMark postgres result published
197
+ today used a pinned resolution. `uvx` is used in place of `pipx` because this machine has
198
+ no `pipx`; both are equivalent here.
199
+
200
+ Then:
201
+
202
+ ```bash
203
+ bir install --replay --local
204
+ ```
205
+
206
+ That wraps `postgres` with `bir-proxy`, installs the hooks into `.claude/settings.json`, and
207
+ adds the `bir` scenario server that direct mode delivers through. Note the port it reports —
208
+ it picks a free one (**53411** here, not the 53455 the incident demo uses), and the hook
209
+ URLs in `settings.json` are written for that port.
210
+
211
+ ### 4.4 Deny the built-ins — this is the whole ballgame
212
+
213
+ Claude Code will reach for `Bash` + `psql` if you let it. One built-in in the recording and
214
+ `modeFor` ([coverage.ts:52](../src/replay/coverage.ts#L52)) returns `steer`, and we already
215
+ measured what steer mode is worth: nothing. On Windows `PowerShell` is a separate built-in
216
+ from `Bash` and reaches `psql` just as easily, so it must be denied too. The subagent tool is
217
+ `Agent` in current Claude Code; `Task` is its old name, kept for older builds. Add to
218
+ `.claude/settings.json`:
219
+
220
+ ```json
221
+ {
222
+ "permissions": {
223
+ "deny": ["Bash", "PowerShell", "Read", "Write", "Edit", "NotebookEdit", "Glob", "Grep",
224
+ "WebFetch", "WebSearch", "Agent", "Task"],
225
+ "allow": ["mcp__postgres__*", "mcp__bir__*"]
226
+ }
227
+ }
228
+ ```
229
+
230
+ **The workspace must also be trusted, or the `allow` half is silently discarded** and
231
+ direct mode cannot work at all. Claude Code prints it once and then carries on:
232
+
233
+ ```
234
+ Ignoring 2 permissions.allow entries from .claude/settings.json:
235
+ this workspace has not been trusted.
236
+ ```
237
+
238
+ This is not cosmetic, and it fails *asymmetrically* in the worst possible way:
239
+
240
+ - `mcp__postgres__*` keeps working, because bir's own `PreToolUse` answers
241
+ `permissionDecision: "allow"` on the correlation path ([server.ts:859](../src/control/server.ts#L859)).
242
+ - `mcp__bir__run_scenario` is the **one** tool bir deliberately passes through
243
+ ([controller.ts:301](../src/replay/controller.ts#L301) → `{kind:"passthrough"}`), so it gets
244
+ no hook-granted allow. With the allow list gone it is denied, and in `-p` mode a denial is
245
+ final:
246
+
247
+ ```
248
+ Claude requested permissions to use mcp__bir__run_scenario,
249
+ but you haven't granted it yet.
250
+ ```
251
+
252
+ The plan then arms in `direct` mode, the model cannot reach the delivery tool, it does the
253
+ task by hand, and the first manual call triggers divergence. Everything *looks* healthy —
254
+ the task passes and the audit log says `mode=direct` — while the saving is negative. §11 is
255
+ the measurement.
256
+
257
+ Fix before the first replay, either by running `claude` interactively in the directory once
258
+ and accepting the trust dialog, or by setting in `~/.claude.json`:
259
+
260
+ ```json
261
+ "projects": { "C:/Users/Admin/Desktop/BasIns/mcpmark-hys": { "hasTrustDialogAccepted": true } }
262
+ ```
263
+
264
+ ### 4.5 Start the control server, in this directory
265
+
266
+ ```bash
267
+ export BIR_AUTH_URL=http://127.0.0.1:8080
268
+ export BIR_REPLAY=1
269
+ bir-hooks
270
+ ```
271
+
272
+ Then, in another shell **in the same directory**:
273
+
274
+ ```bash
275
+ bir doctor
276
+ ```
277
+
278
+ Required before going further:
279
+
280
+ ```
281
+ Wrapped in config : postgres
282
+ Registered proxies: postgres ← needs claude running
283
+ ```
284
+
285
+ `Registered proxies: (none)` means no proxy has bound. Launch `claude` in this directory
286
+ first, then re-check. **Do not send the prompt until this line is right** — that is the exact
287
+ mistake that made the incident-demo run cost more than the original.
288
+
289
+ ---
290
+
291
+ ## 5. Run protocol
292
+
293
+ The run directory is `C:\Users\Admin\Desktop\BasIns\mcpmark-hys`, and it carries four
294
+ scripts and a prompt file so a trial is one command:
295
+
296
+ | Script | Does |
297
+ |---|---|
298
+ | `seed.sh` | one-time: create the `employees` template DB and `pg_restore` the backup |
299
+ | `reset-db.sh` | per-trial: recreate `hys_trial` from the template, assert the target table is gone |
300
+ | `verify.sh` | MCPMark's unmodified `verify.py` plus the env it needs |
301
+ | `trial.sh N` | reset → `claude -p "$(cat prompt.txt)"` → verify → verdict |
302
+ | `prompt.txt` | the assembled prompt from §2, byte-exact |
303
+
304
+ `trial.sh` drives the agent with `claude -p`, non-interactive, one call per trial. That is
305
+ the same shape the τ²-bench adapter uses and it keeps the prompt byte-identical across
306
+ trials without a human retyping it.
307
+
308
+ ### Trial 1 — record
309
+
310
+ ```bash
311
+ ./trial.sh 1
312
+ ```
313
+
314
+ `trial.sh` ends by running `./verify.sh`, which is MCPMark's unmodified `verify.py` plus the
315
+ environment it needs:
316
+
317
+ ```bash
318
+ PYTHONIOENCODING=utf-8 \
319
+ POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DATABASE=hys_trial \
320
+ POSTGRES_USERNAME=postgres POSTGRES_PASSWORD=password \
321
+ uv run --with psycopg2-binary python verify.py
322
+ ```
323
+
324
+ Exit 0 is a pass.
325
+
326
+ **`PYTHONIOENCODING=utf-8` is required on this machine.** `verify.py` prints `✅`, `❌` and
327
+ `🎉`, and this console's codepage is cp1255 — without it Python raises
328
+ `UnicodeEncodeError: 'charmap' codec can't encode character '❌'` *while formatting its
329
+ own result*, on both the PASS and FAIL paths. A run graded through a crashed reporter is a
330
+ run with no grade.
331
+
332
+ **A failed trial 1 is a stop, not a retry.** There is no point calculating a scenario from a
333
+ run that did not solve the task.
334
+
335
+ ### Calculate the scenario
336
+
337
+ ```bash
338
+ bir scenario list # find the runId
339
+ bir scenario calc <runId>
340
+ bir scenario show <runId> # read the intent, params and steps before trusting it
341
+ ```
342
+
343
+ Read the step logic. Specifically check that `tool_output_logic` navigates
344
+ `data.content[0].text` and that the steps thread through `respParams` rather than every step
345
+ carrying a hardcoded literal. In the incident scenario every step had a baked-in fallback
346
+ (`?? "inventory-svc"`), which is why it looked healthy while threading nothing.
347
+
348
+ ### Trials 2–4 — replay
349
+
350
+ Reset the DB, send the identical prompt, verify. Between each, confirm:
351
+
352
+ ```bash
353
+ curl -s -H "Authorization: Bearer $(...)" http://127.0.0.1:53411/health
354
+ ```
355
+
356
+ Wanted: `mode: "direct"`, `stepsPinned == stepsPlanned`, `outcome: "steered_full"`.
357
+
358
+ You can also drive a replay without a session at all, which is the fastest way to debug the
359
+ step logic:
360
+
361
+ ```bash
362
+ bir replay --scenario <scnId> --prompt "<the prompt>" --dry # recorded outputs, no real SQL
363
+ bir replay --scenario <scnId> --prompt "<the prompt>" # real SQL through the proxy
364
+ ```
365
+
366
+ `--dry` books no execution and invents no saving.
367
+
368
+ ---
369
+
370
+ ## 6. What to measure
371
+
372
+ | Metric | Where from | Expectation |
373
+ |---|---|---|
374
+ | pass@1 | `verify.py` exit code, trial 1 | unchanged by us |
375
+ | pass^4 | all four trials pass | this is the headline |
376
+ | `session_cost_usd` per trial | `scenario_executions` | trials 2–4 ≪ trial 1 |
377
+ | `saved_usd` | `scenario_executions` | **must be positive** |
378
+ | `duration_ms` vs `baseline_ms` | same table | secondary |
379
+ | `steps_pinned` / `steps_planned` | same table | must be 8/8-shaped, not 1/8 |
380
+
381
+ **Baseline honesty.** `baseline_method` is `single` and only `not_steered`/`failed` outcomes
382
+ feed new samples, so a scenario that keeps succeeding never widens its baseline. One sample
383
+ with ±8% run variance cannot support a claim to two significant figures. Run trial 1 several
384
+ times with `BIR_REPLAY=0` first and take a median, or state the sample size.
385
+
386
+ **`steps_pinned` is currently wrong in steer mode** —
387
+ [controller.ts:326](../src/replay/controller.ts#L326) uses `pinned.size`, which
388
+ [controller.ts:383](../src/replay/controller.ts#L383) has already decremented, so sequential
389
+ pins report 1. Direct mode uses a cumulative count and is fine. If this task lands in direct
390
+ mode the number is trustworthy; if it lands in steer mode, do not read it.
391
+
392
+ ---
393
+
394
+ ## 7. The honesty section
395
+
396
+ `pass^4` exists to measure whether a *model* is consistent. Raising it by not re-deciding is
397
+ real product value and a real user benefit — but it is not a model-capability result, and
398
+ publishing it as one would be indefensible.
399
+
400
+ Report it as **cost and latency at held-constant `pass^4`**, with replay disclosed as a
401
+ system-level intervention and trial 1 shown separately as the recording cost. That is the
402
+ same standard t-bench.md §3.2 already holds itself to, and it is the framing that survives
403
+ review.
404
+
405
+ ---
406
+
407
+ ## 8. Known risks
408
+
409
+ | Risk | Mitigation |
410
+ |---|---|
411
+ | Model uses `Bash`+`psql` → steer mode → zero saving | §4.4 deny list; verify `mode: "direct"` on `/health` |
412
+ | Recording made without the proxy → output logic authored against the hook's envelope, threads `{}` | `bir doctor` must show `Registered proxies: postgres` **before** trial 1 |
413
+ | No DB reset between trials | §4.2, every trial |
414
+ | Windows long paths — the repo will not check out | `git clone -c core.longpaths=true` |
415
+ | `pipx` unavailable | `uvx postgres-mcp==0.3.0`, disclosed as a deviation |
416
+ | Scenario expiry | `scenarios.expires_at` — re-`calc` if trials span days |
417
+
418
+ ## 9. Next, if this works
419
+
420
+ Widen within `postgres` first — 31 prompts, same server, same reset mechanism, so the
421
+ marginal cost per task is near zero. `tasks/postgres/standard/**` is where the paper's
422
+ numbers come from.
423
+
424
+ Hold `filesystem` (40 prompts) back: the model prefers built-in `Read`/`Edit` over the
425
+ filesystem MCP server, so those tasks fight §4.4 rather than benefiting from it.
426
+
427
+ ---
428
+
429
+ ## 10. Repo facts worth keeping
430
+
431
+ - Layout: `tasks/<service>/<difficulty>/<initial-state>/<task-name>/{description.md,meta.json,verify.py}`
432
+ - 177 prompts total: **127 `standard`** (the paper's number) + 50 `easy`
433
+ - Per service: filesystem 40 · notion 38 · github 33 · playwright_webarena 31 · postgres 31 · playwright 4 · insforge/supabase 0
434
+ - The repo has grown past the paper's five servers — `insforge`, `supabase` and `playwright_webarena` are new
435
+ - `meta.json` carries an `"mcp": ["postgres"]` array — use it to select only tasks that can reach direct mode
436
+ - Small upstream bug: the §2 suffix's docstring says "Notion-specific additions" but the
437
+ method is on `BaseTaskManager` with no override, so it is appended for every service
438
+
439
+ ---
440
+
441
+ ## 11. First run: measured results (2026-09-04)
442
+
443
+ Six trials on `hiring_year_summary`. Setup as in §4, on PostgreSQL 17 native.
444
+
445
+ ### Grading
446
+
447
+ **6/6 PASS.** `verify.py` reported `✅ 16 records correct` every time, against ground truth
448
+ it recomputes in SQL at verify time. `pass^6` on this task.
449
+
450
+ Do **not** compare that to the paper's 33.86% `pass^4`. This is an `easy`-bucket task; the
451
+ paper's number is over the 127 `standard` tasks. It says the harness works, not that we beat
452
+ anybody.
453
+
454
+ ### Cost
455
+
456
+ Baseline: **$0.22401525**, 25,705 ms, `baseline_samples=1`, method `single`.
457
+
458
+ | trial | outcome | cost | saved | duration | steps |
459
+ |---|---|---|---|---|---|
460
+ | 1 | *recorded* | $0.22402 | — | 25,705 ms | 8 |
461
+ | 2 | `diverged` | $0.23484 | **−$0.01083** | 36,734 ms | 8/8 |
462
+ | 3 | `diverged` | $0.24438 | **−$0.02037** | 40,768 ms | 8/8 |
463
+ | 4 | `diverged` | $0.24277 | **−$0.01876** | 36,243 ms | 8/8 |
464
+ | 5 | `diverged` | $0.23937 | **−$0.01536** | 43,078 ms | 8/8 |
465
+ | 6 | `diverged` | $0.24512 | **−$0.02110** | 35,810 ms | 8/8 |
466
+
467
+ Mean saving **−$0.01728 (−7.7%)**, and every replay was *slower* than the recording.
468
+ Five for five negative — this is not variance.
469
+
470
+ ### What worked
471
+
472
+ - **Arming is correct.** `mode=direct`, `coverage=direct,direct,direct,direct,direct,direct,direct,direct`,
473
+ `similarity=1.000` on every trial. This is the piece the incident demo never reached.
474
+ - **The recording is correct.** `wrapped=["postgres"]` at run start, outputs captured in the
475
+ proxy's `{"content":[...]}` envelope, 8/8 tool calls `mcp__postgres__*` and zero built-ins.
476
+ - **State genuinely threads.** Steps 2–4 read `respParams.userSchemas` / `respParams.tableNames`;
477
+ the audit log shows `emitted=allSchemas,userSchemas`, `emitted=schemaObjects,tableNames`,
478
+ `emitted=objectDetails,employeeColumns`. Not the incident scenario's hardcoded fallbacks.
479
+ - **Derivation is free.** `replay.derived params=5 costUsd=0.0000 source="recorded samples"`.
480
+ No `ANTHROPIC_API_KEY` needed for an identical prompt, as §2 predicted.
481
+ - **`steps_pinned` is right here** — 8/8 on every trial. The `pinned.size` bug in §6 is
482
+ specific to steer mode; the direct and divergence paths use a cumulative count.
483
+
484
+ ### Why the saving is negative
485
+
486
+ `mcp__bir__run_scenario` was **denied by the permission system** on every trial — see §4.4.
487
+ The workspace was untrusted, so the `allow` list was discarded, and that tool is the only one
488
+ bir passes through rather than auto-allowing. The model could not reach the plan, so it did
489
+ the task by hand, and its first call diverged.
490
+
491
+ **Divergence recovery then performed exactly as designed** — and this is worth stating
492
+ plainly, because it is the safety net doing its job:
493
+
494
+ ```
495
+ replay.compose remaining=8 executed=8 recorded=0 skipped=0 bytes=10259 costUsd=0.00
496
+ replay.done mode=direct steps=8/8 outcome=diverged ms=7591
497
+ ```
498
+
499
+ All eight steps ran for real through the already-open proxy connection in ~370 ms of tool
500
+ time, nothing fell back to a recorded output, nothing was skipped, and the fallback cost was
501
+ zero. The task passed. But the model had already paid for the wasted `run_scenario` turn,
502
+ then paid again to read a 10,259-byte injected bundle — so the session cost landed at
503
+ roughly the cost of just doing the task, plus overhead.
504
+
505
+ **The mechanism is sound; the delivery channel was blocked.** Nothing in these six trials
506
+ measures direct mode's actual saving, because direct delivery never once happened.
507
+
508
+ ### Next
509
+
510
+ 1. Trust the workspace (§4.4). Without it every replay measures the divergence path.
511
+ 2. Re-record after trusting — trials 2–6 were `diverged`, which books a ledger row but
512
+ never contributes a baseline sample, so the baseline is still a lone measurement of one
513
+ run at `$0.22401525` with ±8% run-to-run variance. Take a median of 3–5 `BIR_REPLAY=0`
514
+ runs before quoting any saving.
515
+ 3. Then re-run and expect `replay.done mode=direct outcome=steered_full` with no
516
+ `replay.diverge` line. That is the only configuration in which a positive saving is
517
+ even possible.
518
+
519
+ ---
520
+
521
+ ## 12. Second run: direct mode reached, and the real blocker
522
+
523
+ After trusting the workspace (§4.4) and restarting `bir-hooks` with a clean session map,
524
+ trial 7 finally reached direct delivery:
525
+
526
+ ```
527
+ tool.pre mcp__bir__run_scenario
528
+ replay.step n=0..7 all ok=true 09:53:18.924 → 09:53:19.289 (435 ms total)
529
+ replay.done mode=direct steps=8/8 outcome=steered_full ms=4236
530
+ tool.post mcp__bir__run_scenario failed=false
531
+ ```
532
+
533
+ No `replay.diverge`. Every step executed for real over the already-open proxy connection in
534
+ 435 ms of tool time, `fallbackCostUsd=0`, `deriveCostUsd=0`. `verify.py`: PASS.
535
+
536
+ ### And it was the most expensive run yet
537
+
538
+ | # | outcome | cost | saved | duration |
539
+ |---|---|---|---|---|
540
+ | 1 | *recorded* | $0.22402 | — | 25,705 ms |
541
+ | 2 | `diverged` | $0.23484 | −$0.01083 | 36,734 ms |
542
+ | 3 | `diverged` | $0.24438 | −$0.02037 | 40,768 ms |
543
+ | 4 | `diverged` | $0.24277 | −$0.01876 | 36,243 ms |
544
+ | 5 | `diverged` | $0.23937 | −$0.01536 | 43,078 ms |
545
+ | 6 | `diverged` | $0.24512 | −$0.02110 | 35,810 ms |
546
+ | 7 | **`steered_full`** | **$0.31426** | **−$0.09025** | 35,921 ms |
547
+
548
+ 7/7 PASS. Direct mode cost **+40% over baseline** — roughly five times the mean loss of the
549
+ divergence path it replaced.
550
+
551
+ ### Why: the model re-did the whole task
552
+
553
+ The bundle arrived as a 10,751-byte `tool_result` on turn 3. The model read it — and then
554
+ ran this:
555
+
556
+ ```sql
557
+ DROP TABLE IF EXISTS employees.hiring_year_summary;
558
+ CREATE TABLE employees.hiring_year_summary AS SELECT ... -- rebuilt from scratch
559
+ SELECT column_name, data_type FROM information_schema.columns WHERE ...
560
+ SELECT * FROM employees.hiring_year_summary ORDER BY hire_year;
561
+ ```
562
+
563
+ It **dropped the table the replay had just created and built it again**. So the session paid
564
+ for the bundle *and* for doing the task, plus a longer final answer. The plan was already
565
+ retired by then, so nothing logged a divergence — the audit log says `steered_full` and looks
566
+ perfect.
567
+
568
+ ### The cause is one sentence of prompt text
569
+
570
+ [bundle.ts:54](../src/replay/bundle.ts#L54):
571
+
572
+ ```ts
573
+ "[BaseInstRunner calculated replay] A known-good tool sequence was previously " +
574
+ "recorded for this request. Below are the tool calls and their results, in order. " +
575
+ "Use them to answer the user's request now — do NOT call any tools."
576
+ ```
577
+
578
+ That header is unconditional. It says **"previously recorded"** even when all eight steps
579
+ executed live against the real database 400 ms earlier. `anyRecorded` correctly appends a
580
+ staleness caveat when some step *did* fall back to a recorded output — but it never says the
581
+ opposite when nothing did.
582
+
583
+ For a read-only scenario that framing is harmless: the bundle contains the answer, and the
584
+ model just answers. For a **write** scenario it is actively counterproductive. The model is
585
+ told side effects are historical, cannot verify the live database without calling a tool, and
586
+ so reasonably re-runs the work. Every CRUD task in MCPMark — which is to say the whole
587
+ benchmark, its own README calls the tasks CRUD-heavy — lands in this case.
588
+
589
+ **Proposed fix:** make the header state what actually happened. When `recorded === 0`, say
590
+ the sequence *has just been executed against the live system and its side effects are already
591
+ in place*; keep the current wording only for entries that really are replayed from a
592
+ recording. This is the single highest-leverage change available to the product, and it is one
593
+ string.
594
+
595
+ *Revised by §13:* the scenario underneath these runs was broken, and that is the larger
596
+ problem. This header fix drops to fourth in §13's list.
597
+
598
+ Until it lands, `direct` mode's measured saving on a write task is **negative**, and no
599
+ amount of correct plumbing changes that — trial 7 had perfect plumbing.
600
+
601
+ ### Second bug, independent of the above
602
+
603
+ `onScenarioRun` ([server.ts:1109](../src/control/server.ts#L1109)) resolves the run with:
604
+
605
+ ```ts
606
+ const run = [...this.sessions.values()].find((s) => s.run && !s.run.finished)?.run;
607
+ ```
608
+
609
+ Insertion order. A `claude -p` session's run is not sealed when the process exits, so the
610
+ *previous* trial's session is still first in the map, its plan retired. `runArmed` hits its
611
+ `state.retired` guard and answers `no_plan`, and the live armed plan at index 1 is never
612
+ reached. Caught on trial 4 by polling `/health` during the turn:
613
+
614
+ ```
615
+ 09:42:04 n=2 1a1b140c:direct,armed=false | 705f3cc5:direct,armed=true
616
+ └ picked, retired └ the actual plan, ignored
617
+ ```
618
+
619
+ Workaround used here: restart `bir-hooks` before every trial. Proper fix: select the session
620
+ whose run carries an *armed* plan, not merely the first unfinished one.
621
+
622
+ ### Also latent
623
+
624
+ `runToCompletion` calls `assembleBundle(entries, Number.MAX_SAFE_INTEGER)`
625
+ ([plan.ts:502](../src/replay/plan.ts#L502)) — the direct-mode bundle is **uncapped**, where
626
+ the divergence path caps at `MAX_REPLAY_REASON` (60 KB). 10,751 bytes was fine here; a
627
+ scenario with large tool outputs would push an unbounded payload into the model's context.
628
+
629
+ ---
630
+
631
+ ## 13. The scenario was broken all along
632
+
633
+ Trial 8 is the most informative run of the set, because it is the first one where the model
634
+ *trusted* the bundle. It failed:
635
+
636
+ ```
637
+ replay.done mode=direct steps=8/8 outcome=steered_full ms=3629
638
+ execution.reported outcome=steered_full session=0.0791 savedUsd=+0.14489850 measured=true
639
+ verify.py: ❌ relation "employees.hiring_year_summary" does not exist → FAIL
640
+ ```
641
+
642
+ **A +$0.145 saving (−65% cost) booked on a run that produced nothing.**
643
+
644
+ ### Root cause: a prose sample value interpolated into SQL
645
+
646
+ `params_object` for `scn_9832f19f`:
647
+
648
+ | parameter | sampleValue |
649
+ |---|---|
650
+ | `schema_name` | `"employees"` |
651
+ | `summary_table_name` | `"hiring_year_summary"` |
652
+ | `active_to_date` | `"9999-01-01"` |
653
+ | `group_by_dimension` | **`"hire_year (EXTRACT(YEAR FROM hire_date))"`** |
654
+
655
+ Step 5's input logic:
656
+
657
+ ```js
658
+ const dim = parameters.group_by_dimension || 'hire_date';
659
+ ... "EXTRACT(YEAR FROM e." + dim + ")::int AS hire_year," ...
660
+ ```
661
+
662
+ which produces:
663
+
664
+ ```sql
665
+ EXTRACT(YEAR FROM e.hire_year (EXTRACT(YEAR FROM hire_date)))::int AS hire_year,
666
+ ```
667
+
668
+ A syntax error. `CREATE TABLE` never runs, step 6 returns `[]`, step 7 returns
669
+ `Error: relation "employees.hiring_year_summary" does not exist`. The SQL template itself is
670
+ correct — run it with `dim = 'hire_date'` and it produces the right 16 rows, verified
671
+ directly against psql.
672
+
673
+ The analyser wrote a **description** where a literal tool-input value was required. The
674
+ declared `examples` carry the same string, so nothing downstream could have caught it.
675
+
676
+ ### Why six trials hid it
677
+
678
+ | trial | outcome | replay's SQL | model's behaviour | grade |
679
+ |---|---|---|---|---|
680
+ | 2–6 | `diverged` | broken | saw the errors in the bundle, did the task itself | PASS |
681
+ | 7 | `steered_full` | broken | `DROP TABLE` + rebuilt it correctly | PASS |
682
+ | 8 | `steered_full` | broken | trusted the bundle, only *proposed* SQL | **FAIL** |
683
+
684
+ **The replay has never once produced the task's result.** Every PASS came from the model
685
+ working around a broken scenario — which is also the real reason costs were high in §11–12,
686
+ and it means §12's "the model re-did the task" was the *symptom*, not the disease. The
687
+ model re-did the task because the bundle it received was full of SQL errors.
688
+
689
+ ### Three things made this invisible from inside bir
690
+
691
+ **1. A step's verdict does not mean its work succeeded.** `executeStep` rejects only when a
692
+ tool could not be *run*; a tool that ran and returned an error resolves normally, so
693
+ `replay.step ... ok=true` is logged for a failed `CREATE TABLE`. That is a deliberate and
694
+ correct distinction ([controller.ts](../src/replay/controller.ts) — "a tool that ran and
695
+ failed resolves with its failure as the response"), but it means the audit log cannot be read
696
+ as evidence of success.
697
+
698
+ **2. Step 5's output logic hardcodes success:**
699
+
700
+ ```js
701
+ return { summaryTableCreated: true, summaryTableName: ... };
702
+ ```
703
+
704
+ It never inspects `toolOutput`. So `emitted=summaryTableCreated` appears in the log whether
705
+ or not the table exists. An output logic that asserts a fact it did not check is worse than
706
+ no output logic, because it manufactures corroboration.
707
+
708
+ **3. `saved_usd` is computed from cost alone.** `steered_full` means "the plan ran to
709
+ completion", not "the task succeeded" — and bir cannot know the benchmark grade. So the
710
+ cheapest possible outcome (a replay that does nothing) books the largest possible saving.
711
+ The signed saving in the BaseIn service's `src/scenarios/repo.ts` (a separate repo)
712
+ catches replays that cost *more*; nothing catches replays that cost less by doing less.
713
+
714
+ ### What to fix, in order
715
+
716
+ 1. **Sample values must be literal tool inputs.** The analyser prompt has to forbid prose in
717
+ `paramsObject.sampleValue` — it is interpolated into tool arguments verbatim. A validation
718
+ pass that re-evaluates each `toolInputLogic` against the recorded input and diffs the
719
+ result would have caught this at calculate time, for free.
720
+ 2. **Judge a step by its output, not its resolution.** At minimum, detect an MCP result whose
721
+ text begins `Error:` and mark the step `failed` rather than `ok`. Today a scenario can
722
+ report `steered_full` while every step errored.
723
+ 3. **Do not book a saving for a scenario whose steps errored.** Combined with (2), a step
724
+ marked `failed` should force `outcome: "failed"`, which already books a baseline sample
725
+ instead of a saving.
726
+ 4. Then the §12 bundle-header wording, which is a real problem but a smaller one.
727
+
728
+ Until (1) is fixed, re-deriving is the immediate unblock:
729
+
730
+ ```bash
731
+ BIR_AUTH_URL=http://127.0.0.1:8080 node <bir>/dist/bin/bir.js scenario calc <runId> --force
732
+ ```
733
+
734
+ and then **check `params_object` before trusting the result** — `bir scenario show <runId>`,
735
+ and read every `sampleValue` as though it were about to be pasted into a shell.
736
+
737
+ ---
738
+
739
+ ## 14. Status (2026-09-04): every fix in §12–13 has landed
740
+
741
+ | # | Finding | Fix |
742
+ |---|---|---|
743
+ | §13 (1) | prose `sampleValue` spliced into SQL | BaseIn `analysis.ts`: the parameter prompt now requires literal example values, and `repairMismatchedInputLogic` re-evaluates every `toolInputLogic` over the sample values at calculate time and replays the recorded input verbatim when the result differs |
744
+ | §13 (2) | a step whose tool returned `Error:` logged `ok=true` | [tool-error.ts](../src/replay/tool-error.ts): an MCP result with `isError: true` is a `failed` step at stage `tool_call`, in direct, divergence and steer mode alike. Since 2026-09-19 a leading `Error` text with `isError: false` no longer counts: replay judges a step by the same rule the recorder does |
745
+ | §13 (3) | `steered_full` booked a saving on errored steps | [controller.ts](../src/replay/controller.ts): any errored step forces `outcome: "failed"`, which books a baseline sample |
746
+ | §12 header | bundle said "previously recorded" for live work | [bundle.ts](../src/replay/bundle.ts): a bundle with no recorded entry says the work was just executed and its side effects are in place |
747
+ | §12 session | `/scenario/run` picked the first unfinished session | [server.ts](../src/control/server.ts): it picks the session whose plan is armed |
748
+ | §12 latent | direct-mode bundle uncapped | [plan.ts](../src/replay/plan.ts): `runToCompletion` caps at `MAX_REPLAY_REASON` |
749
+ | §6 | `steps_pinned` reported 1 for sequential pins | [controller.ts](../src/replay/controller.ts): counted as each pin threads, not from `pinned.size` |
750
+
751
+ Not yet re-measured. Trials 9+ need a re-`calc` (the scenario must be regenerated for the
752
+ validation pass to run) and then the §5 protocol again from a fresh baseline.