Skip to content

WT-2-metric-extract: eval-metrics extractor (Phase 2 D2)#68

Merged
yzs15 merged 31 commits into
paper/v3-integrationfrom
paper/v3/p2-metric-extract
Jul 3, 2026
Merged

WT-2-metric-extract: eval-metrics extractor (Phase 2 D2)#68
yzs15 merged 31 commits into
paper/v3-integrationfrom
paper/v3/p2-metric-extract

Conversation

@yzs15

@yzs15 yzs15 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Phase 2 #6 · 12号 §D2 · Owner: WT-2-metric-extract

Scope

  • New Python package multi-agent/tools/eval/metrics/ (module eval_metrics).
  • Reads observer SQLite (runs, route_reasons, capability_snapshots, task_contracts, …) → emits paper-metric CSV or JSON.
  • No Go code touched. Diff scope: multi-agent/tools/eval/metrics/ + docs/specs/wt2-metric-extract.{spec,plan}.md.

Metric coverage (41)

Union of 08 号 §Metrics + §Experiments E1–E6 + 12 号 §A/§B/§C/§D + 11 号 §6 (E4 Stage-A HumanEditCount / token):

Cross-section membership (per spec §3.2 subset table): TaskSuccessRate / TimeToCompletion / HumanContextSelectionCount / WrongContextFailureRate / ManualSetupStepCount / ConfigTouchCount / CapabilityReuseRate / RepeatedGenerationRate are cross-listed so --metric-set semantic|overhead|user-promoted|lifecycle all pick them up per 08:140 / 08:87 / 08:37-38.

Metrics whose upstream data source has not landed (12号 §A3 / §A4 / §A6 / §B / §C4 / §D6c / §D7 / §D8) emit null with _notes: \"upstream data missing\" and a stderr warning naming the owner. Metrics with landed upstream but empty cohort emit null with \"denominator zero\". The two reasons are a closed set — every null cell has exactly one.

CLI

eval-metrics extract \\
  --observer-db <path>          # required; SQLite (§7 (a)–(b))
  --runs-filter '<fragment>'    # optional; WHERE fragment (§7 (c))
  --format csv|json             # required; no default (silent-typo hazard)
  --out <path>                  # default stdout (§7 (d))
  --metric-set full|lifecycle|contracted|user-promoted|semantic|overhead   # default full (§7 (e))

Exit codes: 0 success / 2 validation / 3 write-time I/O.

Security (spec §7 (a)–(h))

Defense-in-depth on --runs-filter (the flagship attack: --runs-filter \"1=1; UPDATE runs SET success_oracle_result='pass'\" would silently corrupt the paper's evidence base):

Layer Guards against
Denylist scan ;, --, /*, */, 20 DDL/DML keywords case-insensitive
Parametric extraction Every string literal → ? placeholder
sqlglot AST parse (<27) Structured node walk on the parsed expression
Column whitelist Only run_id, workload_id, claim_id, experiment_id, baseline_or_ablation
Tree-shape walker Every AND/OR/NOT/Paren leaf must be a predicate (rejects bare TRUE, run_id='x' OR TRUE)
Tautology guard Every predicate must reference a whitelisted column (rejects 1=1)
IS-predicate ban Rejects workload_id IS NOT NULL (schema-tautology on NOT NULL columns)
Predicate-shape enforcement LHS bare Column; RHS bare literal/placeholder — rejects col = LOWER(col), col = col || '', col = COALESCE(col,''), col = -workload_id, col = -(-col), function-wrapped LHS

--observer-db: realpath scrub → /etc /proc /sys /dev reject → O_NOFOLLOW magic-byte probe → /proc/self/fd/<fd> handoff to sqlite3 on Linux (Codex round-6 P0 tripwire) → PRAGMA query_only=ON + read-back verification.

--out: parent realpath (must exist, no mkdir -p) → forbidden-subtree reject → O_RDONLY|O_DIRECTORY|O_NOFOLLOW dir_fd pin → dir_fd-relative fstatat/lstat symlink/overwrite refusal → dir_fd-relative O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW open at mode 0600. Parent swap between validate and open lands the file in the ORIGINAL inode, not the attacker's replacement.

CSV formula-injection escape covers 7 prefix chars (= + - @ \\t \\r \\n), matching cmd/evalrun-export/main.go:283.

Fixtures + tests

  • 3 golden fixtures (fixture_{1,2,3}.db + expected_{1,2,3}.json + build_fixture_{1,2,3}.py) exercise the full 41-metric matrix. Fixture-builder canonicalises short IDs to sha256 to satisfy evalrun/schema.go:42 ^[a-f0-9]{64}$.
  • 122 pytest tests pass (17 test files); 1 perf test skipped by default (runs with -m perf or CI=true).
  • Percentile math uses Fraction to avoid IEEE-754 dust (76.5 not 76.49999999999997; 3700000.0 not 3699999.999999999).

Handoff carve-out

14号 §5 handoff item 1 (internal/evalrun.NewSQLWriter Go-side wiring) is reassigned to a follow-up Go-only worktree; this Python-only worktree consumes whatever the SQLWriter writes to runs, whether via PR #53's stub CSV or the future real SQL writer.

Codex review trajectory

  • Spec: 14 rounds (P0(7)→P1(6)→P1(6)→P1(5)→P1(6)→P1(5)→P1(2)→P1(3)→P1(4)→P0(1)→P0(1)→P1(3)→P1(2)→CLEAN)
  • Plan: 2 rounds (P1(2)→CLEAN)
  • Code: 7 rounds (P0(1)+P1(2)→P0(1)→P2(4)→P0(1)→P0(1)→P0(2)→CLEAN)

Every code-review P0 covered a real security bypass in the --runs-filter guard or --observer-db/--out TOCTOU handling; each was fixed with a targeted regression test.

Verification

cd multi-agent/tools/eval/metrics
PYTHONPATH=. python -m pytest -q             # 122 passed, 1 skipped
python -m eval_metrics extract --observer-db /tmp/empty.db --format csv | head -2   # header + 1 data row

🤖 Generated with Claude Code

claude added 30 commits July 2, 2026 22:46
Covers CLI, 36 metric catalog from 12号 §A/§B/§C/§D + 08号
§Lifecycle/Contracted/User-promoted/Semantic/Overhead, 3+ golden
fixtures with hand-computed values, security (a)-(h) with SQL
injection defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0:
- Add StateContinuityRate (#9), TimeToFirstTask (#38), SetupFailureRate
  (#39); catalog now 39 metrics.

P1:
- Scope line: CSV single row or JSON single object, not JSON-Lines.
- Aggregation contract: one output record per invocation, even for
  empty cohort (row_count=0); §5.1 + §4.4 aligned.
- RoutingLatencyP50P95 join: time window, not conversation_id (runs
  has no conversation_id column).
- Fixture 3 null-count arithmetic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Scope §1 acknowledges some data sources not landed.
- Metric counts 36 -> 39 propagated everywhere; §8 file manifest
  corrected (lifecycle 9, overhead 9).
- §7 (d) --out policy fully rewritten (parent-exists,
  overwrite/symlink refusal, no /dev/stdout).
- Fixture 3 route_reasons keying uses time-window timestamps.
- Fixture 3 null-count reconciled to 31 cells (was drifting).
- §2.2 #10 ContractCompleteness cohort projection added with
  fallback warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §2.2 #11/#12/#13 reclassified as upstream-missing (12号 §A3/§A4
  not landed); fixture 2 values now null.
- §2.1 #9 StateContinuityRate denominator = count(runs) per 08:39;
  numerator closed-form, populated today.
- Fixture 1 StateContinuityRate = 6/10.
- Fixture 3 rewritten: 9 populated + 30 null = 39 arithmetic.
- '36-column' → '39-metric' in §7 (d).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §2.1: metric-set membership override for #7 #8 (both lifecycle+overhead).
- §2.2 #10 ContractCompleteness: fallback removed; null when join
  key missing (was silently aggregating over all rows).
- §2.2 #15 RecoverySuccessRate: null (needs 12号 §A6).
- §2.1 #9 StateContinuityRate: null (needs 12号 §A6).
- §3.3 empty-DB: count-metrics with landed upstream = 0, others = null.
- §3.1 filter grammar: dropped col = ? (no user placeholders).
- Fixtures updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- WrongContextFailureRate: forbidden-cred + stale-capability added.
- LifecycleClosureRate: capability_snapshot_hash != '' added.
- Metric-set membership: CapabilityReuseRate/RepeatedGenerationRate
  are lifecycle+user-promoted (08:37-38).
- Empty-cohort contract harmonized: count with landed upstream = 0,
  count with unlanded = null.
- Fixture 3 ContractCompleteness null-reason aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §3.2 metric-set subset table added; §5.1 acceptance criterion #3
  aligned so cross-listed metrics don't contradict each other.
- §7 (c) AST parse tightened: every Comparison/In/Like/Between/Is
  node MUST reference a whitelisted column (closes OR-broadening
  attack via 'run_id = x OR 1 = 1').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §2.1 membership + §3.2 subset table: #4 HumanContextSelectionCount
  and #5 WrongContextFailureRate cross-listed into semantic (08:140).
- Scope §1: carves out ContractCompleteness (upstream landed but
  cohort join key missing → null).
- §7 (d) --out: atomic O_CREAT|O_EXCL|O_NOFOLLOW open (mode 0600)
  as the actual TOCTOU-close; earlier checks are the belt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §3.3 _notes: two-value closed set (upstream-missing OR
  denominator-zero); §5.1 + §7 (f) aligned.
- §4.4 empty-DB fixture: applies FULL observer schema (was: only
  WT-1-run-schema DDL, would fail on route_reasons); missing-table
  branch = null + upstream-missing.
- §7 (d) --out step 4: anchor O_CREAT|O_EXCL|O_NOFOLLOW on dir_fd
  opened O_DIRECTORY|O_NOFOLLOW from resolved parent.
- ConfigTouchCount cite 08:237 not 08:91.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- P0: HumanEditCount added as #40 (08:185 / 11号 §6); catalog 39 → 40.
- P1: §7 (g) notes emit for both upstream-missing AND denominator-zero.
- P1: §4.4 empty-DB fixture: missing-table (upstream) vs empty-table
  (denominator zero) semantics distinct.
- P1: RegistryLookupHitRate denominator = count(runs) per 08:70.
- P1: GeneratedCapabilityDefectRate narrowed to reuse+tool-defect
  per 08:76.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- P0: TokenUsage added as #41 (08:185 token collection line); catalog 40 → 41.
- P1: --metric-set user-promoted emits #40 + #41 too.
- P1: §2.5 all 6 non-routing overhead metrics carry {p50, p95, count}
  structured shape per 12号 §D7; §3.3 flatten list lists all 10 structured.
- P1: fixture 3 arithmetic: 33 nulls + 8 populated = 41.
- P1: empty-DB JSON example shows both null-note reasons.
- P1: fixture short IDs canonicalized to sha256 by fixture-builder
  (evalrun/schema.go:42 validator).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Stale 40 refs corrected to 41 (§3.2, §5.1, §8 manifest, fixture 3).
- Empty-DB JSON example cites both null-note reasons.
- §7 (b) --observer-db TOCTOU tightened: fd-based /proc/self/fd
  handoff on Linux; documented plain-path fallback on other OSes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Scope carve-out: NewSQLWriter Go-wiring (14号 §5 item 1) reassigned
  to follow-up Go-only worktree; this Python worktree stays Python-only.
- Metric-set: #1 TaskSuccessRate + #3 TimeToCompletion cross-listed
  into user-promoted per 08:185-186; §3.2 subset table updated.
- §2.5 structured shapes: #35 ObserverOverhead = 5-key
  (latency p50/p95 + CPU delta + mem delta + count per 08:88);
  #36 ModelProxyOverhead = 6-key (first-token p50/p95 + tokens/sec
  p50/p95 + e2e p95 + count per 08:89).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §2.5 structured shapes acknowledge per-metric variable sub-column
  counts (3/3/3/3/5/6/3 = 26 total).
- §7 (c) tautology guard: column-column comparisons rejected
  (col1 = col2 → ErrRunsFilterColumnCompare); only col = literal,
  col IN (literals), col LIKE 'pat' accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test matrix covering §7 (a)-(h) security items 1:1, plus §2 metric
catalog coverage on 3 golden fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Plan §2 explicit test-file list including conftest.py + all splits.
- Spec §8 two-directory rule (multi-agent/tools/eval/metrics/ +
  docs/specs/) — the wt1-* precedent already places specs there.

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

Package skeleton for the WT-2-metric-extract paper-metric extractor.
Establishes:

- pyproject.toml with sqlglot<27 pin + pytest markers (perf, linux_only)
  per plan §4.
- paths.py — spec §7 (b) --observer-db validators (realpath scrub,
  forbidden-prefix reject, magic-bytes probe, O_NOFOLLOW handoff,
  /proc/self/fd Linux TOCTOU closure) + spec §7 (d) --out validators
  (parent-realpath, overwrite refuse, symlink refuse, dir_fd-anchored
  O_CREAT|O_EXCL|O_NOFOLLOW).
- db.py — read-only sqlite open helper (PRAGMA query_only belt) +
  companion_table_status three-way MISSING/EMPTY/PRESENT branch for
  spec §4.4.
- filter.py — spec §7 (c) sanitizer (denylist + parametric extraction
  + sqlglot AST walk + tautology guard + column-compare guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the metric registry per spec §2 (41 metrics) with §3.2 subset-
selection helper:

- metrics/__init__.py — Metric / MetricResult / Context dataclasses;
  ALL_METRICS list; metrics_for_set() cross-list resolver.
- lifecycle.py — §2.1 (#1..#9): TaskSuccessRate, LifecycleClosureRate,
  TimeToCompletion (numpy-compat linear percentile), HumanContext-
  SelectionCount, WrongContextFailureRate (5-tag D4 taxonomy),
  ArtifactCorrectnessRate populate today; ManualSetupStepCount /
  ConfigTouchCount / StateContinuityRate emit null + upstream-missing.
- contracted.py — §2.2 (#10..#16) all emit null + upstream-missing.
- user_promoted.py — §2.3 (#17..#27, #40, #41) all emit null; #40/#41
  probe PRAGMA table_info so a future §D8 column landing flips them
  automatically.
- semantic.py — §2.4 (#28..#30): RoutingAccuracy populates from runs;
  CapabilityRecall / CapabilityPrecision emit null.
- overhead.py — §2.5 (#31..#39): only RoutingLatencyP50P95 (#37)
  populates today (route_reasons time-window join per §2.5 clause).
  Distinguishes MISSING (upstream-missing) vs EMPTY (denominator-zero)
  companion-table branch per §4.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- csv_out.py — spec §3.3 CSV shape (header + 1 data row per invocation,
  structured metrics flattened to <metric>.<subkey>); spec §7 (d)
  formula-injection escape for cells starting with = + - @ tab CR LF;
  _notes companion column with semicolon-separated <metric>: <reason>
  pairs from the two-value closed set.
- json_out.py — spec §3.3 single JSON object; fixed key order
  {metric_set, row_count, metrics, notes}; notes ALWAYS present
  (even when empty {}).
- cli.py — spec §3 extract subcommand; exit codes 0/2/3; per-metric
  stderr warnings per spec §7 (g) template; owner-hint table for
  upstream-missing warnings; --format required with no default
  (silent-typo hazard).

Empty-DB smoke: header row (71 cols) + 1 data row with row_count=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three golden fixtures per spec §4:

- fixture_1.db (§4.1): 10 lifecycle runs, experiment E1/FullLoom;
  hand-computed TaskSuccessRate=0.6, LifecycleClosureRate=0.6,
  TimeToCompletion.p50=19.0/p95=76.5/mean=29.2/count=10,
  HumanContextSelectionCount=8, WrongContextFailureRate=0.3,
  ArtifactCorrectnessRate=1.0.
- fixture_2.db (§4.2): 6 E3 runs + 6 task_contracts with 7-field
  bitmap; all §2.2 metrics null today.
- fixture_3.db (§4.3): 5 E2 runs + 4 route_reasons with time-window
  alignment so 4 dispatched runs each contain one decision; hand-
  computed RoutingAccuracy=0.75, RoutingLatencyP50P95={p50:1.5M,
  p95:3.7M, count:4}.

Fixture builders apply the FULL observer schema.sql (spec §4.4
rationale — WT-1-run-schema DDL alone would leave route_reasons
absent). Short artifact/contract IDs are canonicalized to sha256
before insertion per spec §4.1 hash-format note. Timestamps derive
from a fixed BASE_TS + offset so rebuilds are deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test files map 1:1 to plan §2 test matrix, covering the 41-metric
catalog (§2.1..§2.5) and every spec §7 (a)-(h) security item:

- test_cli_help.py, test_cli.py — CLI help / subcommand / --format /
  --metric-set (cross-listed via §3.2 subset table).
- test_paths.py — §7 (b) --observer-db (forbidden prefixes, magic
  bytes, TOCTOU) + §7 (d) --out (parent-must-exist, overwrite refuse,
  symlink refuse, O_EXCL / dir_fd binding).
- test_db_readonly.py — §7 (a) read-only pragma + §4.4 companion-
  table three-way branch (MISSING / EMPTY / PRESENT).
- test_filter.py — §7 (c) denylist, whitelist, tautology, column-
  compare, parametric binding, accepted grammar.
- test_metrics_lifecycle.py — fixture 1 golden (6 populated + 3 null).
- test_metrics_contracted.py — fixture 2: all 7 §2.2 metrics null.
- test_metrics_semantic.py — fixture 3: RoutingAccuracy + 2 null.
- test_metrics_user_promoted.py — fixture 3: all 13 §2.3 metrics null.
- test_metrics_overhead.py — fixture 3: RoutingLatencyP50P95 populated;
  #35/#36 subkey-count sanity; #38/#39 null.
- test_metrics_golden_fixture_3.py — 8 populated + 33 null = 41 total.
- test_csv_out.py — formula-injection escape (=/+/-/@/tab/CR/LF);
  header-order stability; _notes shape.
- test_json_out.py — key order + notes always present.
- test_metrics_null_contract.py — §7 (f) null-not-NaN + closed-set
  reasons + count-zero-vs-null.
- test_empty_db.py — §5.1 empty-DB contract (header + 1 data row,
  landed count = 0, unlanded count = null).
- test_integration.py — end-to-end fixture 1/2/3 + --runs-filter
  cohort narrowing + stderr warning template.
- test_perf_gated.py — @Perf mark, skipped by default (§7 (h)).

Test count: 100 passing + 1 perf-skipped. Empty-DB smoke:
`python -m eval_metrics extract --observer-db <empty> --format csv`
emits header + 1 data row with row_count=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-11 spec change gave the wrong hex expansion for the illustrative
sha256(b'a1'); the formula-of-record (hashlib.sha256(short.encode()).hexdigest())
was correct, only the example literal was off. Fixture-builder used the
formula, so no code change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0: --runs-filter tautology guard bypassed by bare TRUE / FALSE and
'run_id = x OR TRUE'. Fixed by adding _reject_non_predicate_leaves
tree-shape walker: every leaf of the AND/OR/NOT/Paren boolean tree
must be a predicate node (EQ/NEQ/GT/GTE/LT/LTE/In/Like/ILike/Between/
Is). Boolean literals and bare numeric literals at boolean positions
now raise ErrRunsFilterTautology. Added 6 regression tests.

P1: linear_percentile emitted binary-float dust (e.g. 76.499999...
instead of 76.5); did the math in Fraction-space and round the final
float to 9 decimals. Emitted values now match spec §4 exact decimals.

P1: TOCTOU tests test_observer_db_symlink_swap_after_resolve and
test_out_dir_fd_bind were happy-path only. Rewrote to actually swap
inodes / rename parents between resolve and open; verify the
O_NOFOLLOW / dir_fd rejection actually fires (OSError / ELOOP).
Added test_out_dir_fd_bind_happy_path for the non-race path.

107 passed, 1 skipped (perf mark).

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

workload_id IS NOT NULL was a schema-tautology bypass (the runs DDL
declares workload_id NOT NULL, so IS NOT NULL selects the full cohort
while looking like a narrowing filter). Spec §3.1 grammar only names
=, IN, LIKE, AND/OR — IS was never part of it. Removed exp.Is from
_PREDICATE_TYPES; the tautology walker rejects any IS predicate at
the tree-shape check. Added 2 regression tests.

109 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- test_perf_gated: -m perf now enables via os.sys.argv sniff (not just CI=true).
- cli.py: --out open failure returns exit 2 (validation) not exit 3 (write); spec §3 exit codes.
- metrics/__init__.py: catalog order = §2 section-order (2.1, 2.2, 2.3 incl #40+#41, 2.4, 2.5) not tail-appended.
- filter.py: added rationale for accepting !=/BETWEEN/ILIKE beyond spec §3.1 grammar (convenience; tautology/column-compare walkers still apply uniformly).

109 passed (default), 1 passed (-m perf), 1 skipped (-m perf default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bypass class: 'run_id = run_id || ""' / 'run_id = COALESCE(run_id, "")'
/ 'run_id = LOWER(run_id)' all evaluate to schema-tautologies while
looking like narrowing filters. Prior tautology walker only rejected
Column-Column direct comparisons and let function-wrapped columns
slip through.

Fix: added _enforce_predicate_shape — LHS must be a bare Column,
RHS must be a bare Literal/Placeholder/Null/Boolean/Neg (or list
of these for IN). No functions, no concatenation, no expressions.
Applied to EQ/NEQ/GT/GTE/LT/LTE + In + Like/ILike + Between.

7 regression tests added covering: || concat, COALESCE, LOWER(col),
LOWER(literal), IN subquery, BETWEEN with column bound, LHS function.

116 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bypass: 'run_id = -workload_id' / 'run_id = -(-run_id)' — the round-4
_RHS_ATOMIC_TYPES allowed exp.Neg as atomic without recursing into
its child, so Neg(Column) slipped through. For numeric-only run_ids
this becomes a self-tautology on the negated pair.

Fix: replaced isinstance(rhs, _RHS_ATOMIC_TYPES) with _is_bare_rhs
helper that recurses through Neg and Paren wrappers and only accepts
if the innermost node is a bare Literal/Placeholder/Null/Boolean.
Neg-of-literal ('run_id = -1', 'run_id = -(1)') still works;
Neg-of-column / Paren-of-column rejected.

4 regression tests added.

120 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P0-1: PRAGMA query_only was set but never verified. A silent PRAGMA
failure would leave the connection writable. open_observer_db now
reads PRAGMA query_only after setting it and refuses to return a
connection where the value is not 1. Regression test added.

P0-2: --out parent-inode was resolved at validate time but re-opened
by pathname at open time — a regular-directory parent swap between
validate and open would land the file in the attacker's replacement
dir. Refactored: validate_out_path now returns an OutTarget carrying
a pinned dir_fd (opened O_RDONLY|O_DIRECTORY|O_NOFOLLOW during
validation); open_out_file uses target.dir_fd directly. Any parent
swap after validate can only redirect the pathname — the dir_fd
still points at the original inode, so the file lands where we
validated. lstat is also now dir_fd-relative (os.stat with dir_fd
and follow_symlinks=False), pinning the symlink check too.

Added tests: test_out_dir_fd_bind_rejects_regular_dir_parent_swap
(the exact bypass class), refactored test_out_dir_fd_bind_rejects
_symlink_parent_swap to assert the file lands in the RENAMED
original (not the replacement), and test_out_atomic_create_race for
the O_EXCL between-check-and-open race.

122 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- filter.py: error message no longer mentions 'or IS' (IS is
  intentionally rejected; the message would confuse operators).
- lifecycle.py: hoisted Fraction import to module scope (was inside
  linear_percentile call — trivial style).
- spec §3.1: grammar acknowledges NOT and parenthesised combinations
  (implementation already accepted them; spec caught up).
- spec §3.1: added 'Denylist-inside-literal' trade-off paragraph
  documenting that literals containing SQL keywords / comment tokens
  are rejected too, and operators should avoid the 20 keywords when
  naming workload / experiment / claim IDs.

122 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1: --runs-filter denylist now rejects user-supplied bind markers
(?, :name, @name, $name) pre-parse. Without this, 'run_id = ?'
compiled cleanly with 0 params then crashed at cursor.execute —
violating spec §3.1 'users cannot supply ?'.

P1: compile_runs_filter wraps RecursionError as ErrRunsFilterParseError.
Deep-nested fragments (e.g. 5000 parens) now exit 2 cleanly instead
of exit 1 with a Python stack trace leaking sqlglot module paths.

P2: _reject_non_predicate_leaves caps recursion at _MAX_TREE_DEPTH=32
(NOT-chain depth guard).

P2: IN () empty list rejected explicitly (was silent zero-cohort).

P2: cli.py compute loop distinguishes sqlite3.Error from generic
exceptions — schema drift surfaces distinctly instead of being
buried under upstream-missing.

P2: spec §3.1 grammar acknowledges !=, <, <=, >, >=, ILIKE, BETWEEN;
corrects misleading 'AND after its own filter' — no built-in filter
exists.

7 new regression tests. 129 pass, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- filter.py: drop exp.Boolean and exp.Null from _RHS_ATOMIC_TYPES.
  Spec §3.1 grammar never enumerated them; run_id = TRUE / NULL
  silently returned 0 rows (implementation drift from spec).
- filter.py: reject qualified column references (col.table or
  col.db non-empty) at compile-time. runs.run_id / route_reasons.
  run_id / main.runs.run_id previously slipped past the whitelist
  (leaf name matched) then failed at sqlite3 execute-time with
  'no such column'. Now cleanly rejected as ErrRunsFilterFieldNotAllowed.
- spec §3.1 denylist paragraph aligned with §7 (c) full 20-token list.
- test_field_whitelist_sqlite_master_reject pinned to
  ErrRunsFilterDenylist (was accepting either exception).

7 new regression tests: TRUE/FALSE/NULL RHS reject; NULL in IN reject;
runs./route_reasons./main.runs. qualified reject.

136 pass, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@yzs15 yzs15 merged commit 02d0819 into paper/v3-integration Jul 3, 2026
9 of 10 checks passed
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.

2 participants