Compare commits

..
Author SHA1 Message Date
profit ab3b857c7f Merge remote-tracking branch 'origin/main' into test/enrich-prd-pipeline
# Conflicts:
#	auditor/audit.ts
#	auditor/checks/inference.ts
#	auditor/checks/kb_query.ts
#	auditor/claim_parser.ts
#	tests/real-world/scrum_master_pipeline.ts
2026-04-23 00:29:22 -05:00
profit 56dbfb7d03 fact_extractor: project context + fixed verifier-verdict parser
lakehouse/auditor 8 warnings — see review
Two bundled changes. Both came out of J's observation that the
verifier was defaulting to UNVERIFIABLE on domain-specific facts
because it had no idea what Lakehouse was, which project's code it
was reading, or what framework the types belonged to.

1. Project context preamble. Added docs/AUDITOR_CONTEXT.md — a <400-
   word concise description of the project (crates, services,
   architecture phases, the auditor's role itself). fact_extractor
   reads it once, caches it, prepends it to the extract prompt as a
   "PROJECT CONTEXT (for grounding; do NOT extract from this)"
   section. Both extractor and verifier now see this context, so
   statements like "aggregate<T> returns Map<string, AggregateRow>"
   get grounded as "this is a TypeScript function in the Lakehouse
   auditor subsystem" and the verifier can reason about plausibility
   instead of guessing.

2. Verifier-verdict parser fix. Gemma2's output format varies between
   "**Verdict:** CORRECT" and just "* **CORRECT**" inline (observed
   variance across runs). The old regex required "Verdict:" as a
   label and missed the second format — causing all verdicts to
   stay UNCHECKED. Replaced with a two-pass approach: find each
   fact section start ("**N.**" or "N."), slice to the next section,
   scan the slice for the first CORRECT|INCORRECT|UNVERIFIABLE
   token. Handles both formats plus unfenced fallback.

Verified: 4-fact test extraction went from 0/4 verdicts scored
(pre-fix) to 2/4 CORRECT + 2/4 UNVERIFIABLE (post-fix). The 2
UNVERIFIABLE cases are domain-specific code behavior the verifier
legitimately can't confirm without reading source — correct stance,
not a parser miss.

No new consensus modes yet. J suggested adding codereview or
validator as a second pass; holding until we see whether context
injection alone gives sufficient signal lift.
2026-04-23 00:26:01 -05:00
profit 2a97fd7237 claim_parser: skip quoted patterns + tighten PR regex
lakehouse/auditor 7 warnings — see review
Two fixes observed in test sweep on b25e368:

1. The "Phase 45 shipped" quoted test example in a commit message
   body was triggering STRONG_PATTERNS despite being inside quotes —
   produced a block finding that flipped 1/0/1 across 3 back-to-back
   audits. Same bug class as auditor/checks/static.ts (fixed earlier):
   rubric files quote pattern examples, parser can't distinguish.

   Fix: firstUnquotedMatch() wraps firstMatch(); uses isInsideQuotedString()
   to check whether the regex's match position falls inside double /
   single / backtick quotes on the line. Mirrors static.ts exactly.

2. A regex misfire: `(?:PR|commit|prior|...)` in history/proof
   patterns was matching "verified ... in production" because `PR`
   (2 chars) matched the first 2 chars of "production" before the
   `\s*#?\w*` tail absorbed the rest. Tightened to require a digit
   after PR (`PR\s*#?\d+`) and commit to require a hex hash.

Verified: 3 back-to-back audit_one runs before this fix showed the
Phase 45 block flipping 1/0/1; after these fixes, unit tests confirm
quoted examples skip correctly AND real claims ("Phase 45 shipped",
"verified end-to-end against production", "Verified end-to-end on
PR #8") still classify correctly.
2026-04-23 00:18:58 -05:00
profit b25e36881c claim_parser: history/proof claims join empirical class
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "now classify as empirical; fresh claims like "Phase 45 shipped" stay"
PR #9's 4 block findings were all from commit message references to
prior work ("on PR #8", "the proven X", "flipping across N runs").
The cloud reviewer correctly said "the current diff does not prove
that", but the claim was never about the current diff — the proof
lives in the referenced prior PR or test run.

Extended EMPIRICAL_PATTERNS to cover two shared classes:

  1. Runtime metrics (existing) — "58 cloud calls", "306s elapsed"
  2. History/proof refs (new) — "verified on PR #8", "was flipping
     across 9 runs", "the proven escalation ladder", "previously
     observed in PR #6", "tested against commit abc1234"

Both skip diff-verification for the same reason: the proof is outside
the diff. Folded into the existing bucket rather than adding a new
strength tier — the skip discipline is identical so there's no value
in splitting them.

Unit-tested on PR #9's actual failing lines: all 5 historical claims
now classify as empirical; fresh claims like "Phase 45 shipped" stay
strong; pure implementation descriptions ("implements deterministic
classification") still don't match (expected — they're not
claims, they're restatements).
2026-04-22 23:53:07 -05:00
profit a264bcf3fc auditor/kb_stats.ts — on-demand observability without Grafana
lakehouse/auditor 4 blocking issues: cloud: claim not backed — "Primary reviewer (gpt-oss:120b) runs N=3 times in parallel, majority-vote per claim. Tie-break
Reads every KB scratchpad file and prints a dashboard of audit
health: verdict distribution, per-PR verdict instability rate,
consensus discrepancy counters, KB size + distinct-signature growth,
verifier verdict histogram, top recurring entities by cross-PR count.

Also supports --json for feeding CI gates or later piping into a
static dashboard page. --top N caps the entities section.

Current state from running it: 30 audits across 8 PRs, 25% verdict
instability rate (all pre-N=3-consensus), 0 discrepancies logged yet
(audits before commit A didn't persist them), 84 audit_lessons rows
with 28 distinct signatures, 4 audit_facts rows with 20 distinct
entities. No cross-PR recurrences yet — but the machinery prints
them as soon as audits on other PRs produce overlapping entities.

This is the full observability surface for PR #9 — the Grafana
alternative I proposed in the counter-plan. Zero infra, 280 LOC,
zero maintenance. If someone later wants a real dashboard, `--json`
output pipes directly into any visualization layer.
2026-04-22 23:41:50 -05:00
profit 181c35b829 scrum_master fact extraction + verifier gate + schema_version bump
Three bundled changes that round out the KB enrichment pipeline
(PR #9 commits B/C/D compressed into one — they all touch the same
persist surfaces so splitting them would just add noise):

B. scrum_master reviews now route accepted review bodies through
   fact_extractor (same llm_team extract pipeline as inference) and
   append to data/_kb/audit_facts.jsonl tagged source:"scrum_review".
   One KB, two producers — downstream consumers can filter by source
   when they care about provenance. Skips reviews <120 chars
   (one-liners / LGTM-type comments with no extractable knowledge).

C. Verifier-gated fact persistence. fact_extractor now parses the
   verifier's free-form prose into per-fact verdicts (CORRECT /
   INCORRECT / UNVERIFIABLE / UNCHECKED). Facts marked INCORRECT are
   dropped on write; CORRECT + UNVERIFIABLE + UNCHECKED are kept
   (dropping UNVERIFIABLE would lose ~90% of real signal — the
   verifier's prior-knowledge base doesn't know Lakehouse internals,
   so domain-specific facts read as UNVERIFIABLE by default).

   verifier_verdicts array is persisted alongside facts so downstream
   queries can surface high-confidence facts (CORRECT) separately
   from provisional ones (UNVERIFIABLE).

   schema_version:2 added to both scrum_reviews.jsonl and
   audit_facts.jsonl writes. Old (v1) rows remain readable; new rows
   get the field so the forward-compat reader in kb_query can
   differentiate.

D. scrum_master_reviewed:true flag added to scrum_reviews.jsonl
   rows on accept. Future kb_query surfacing can filter by this
   (e.g., "show me PRs where a scrum review exists vs only inference"
   as governance signal). Also carried into audit_facts.jsonl when
   the scrum_review source path writes there.
2026-04-22 23:40:21 -05:00
profit 2afad0f83f auditor/inference: N=3 consensus + qwen3-coder:480b tie-breaker
Closes the determinism gap observed in the 3-run baseline test: 1 of
8 findings (the "proven escalation ladder" block) was flipping across
identical-state audits. Root cause: cloud non-determinism at temp=0
is real in practice even though it shouldn't be in theory.

Fix: run the primary reviewer (gpt-oss:120b) N=3 times in PARALLEL
(Promise.all, wall-clock ≈ single call because they're independent
HTTP requests). Aggregate votes per claim_idx. Majority wins. On a
1-1-1 split, call a tie-breaker model with different architecture:
qwen3-coder:480b — newer coding specialist, 4x params of the primary,
distinct training lineage.

Every case where the 3 runs disagreed (even when majority resolved)
is logged to data/_kb/audit_discrepancies.jsonl with the vote counts
and resolution type. This is how we measure consensus drift over
time — a dashboard metric is literally `wc -l audit_discrepancies`
relative to audit count.

Verified: 2 back-to-back audits on unchanged PR #8 produced
identical 8 findings each (1 block + 7 warn). consensus=3/3 on every
claim, zero discrepancies logged. Cost: 3x primary tokens (7K per
audit vs 2K), wall-clock ~unchanged because calls are parallel.

New env vars:
  LH_AUDITOR_CONSENSUS_N        default 3
  LH_AUDITOR_TIEBREAKER_MODEL   default qwen3-coder:480b

Factored the cloud call into runCloudInference() helper so the
consensus loop is clean and the tie-breaker reuses the same prompt
shape as the primary.
2026-04-22 23:38:17 -05:00
profit 77650c4ba3 auditor: inference curation layer + llm_team fact extraction → KB
Closes the cycle J asked for: curated cloud output lands structured
knowledge in the KB so future audits have architectural context, not
just a log of per-finding signatures.

Three pieces:

1. Inference curation (tree-split) — when diff > 30KB, shard at 4.5KB,
   summarize each shard via cloud (temp=0, think=false on small
   shards; think=true on main call). Merge into scratchpad. The cloud
   verification then runs against the scratchpad, not truncated raw.
   Eliminates the 40KB MAX_DIFF_CHARS truncation path for large PRs
   (PR #8 is 102KB — was losing 62KB). Anti-false-positive guard in
   the prompt: cloud is told scratchpad absence is NOT diff absence,
   so it doesn't flag curated-out symbols as missing. unflagged_gaps
   section is dropped entirely when curated (scratchpad can't ground
   them).

2. fact_extractor — TS client for llm_team_ui's extract-facts mode at
   localhost:5000/api/run. Sends curated scratchpad through qwen2.5
   extractor + gemma2 verifier, parses SSE stream, returns structured
   {facts, entities, relationships, verification, llm_team_run_id}.
   Best-effort: if llm_team is down, extraction fails silently and
   the audit still completes. AWAITED so CLI tools (audit_one.ts)
   don't exit before extraction lands — the systemd poller has 90s
   headroom so the extra ~15s doesn't matter.

3. audit_facts.jsonl + checkAuditFacts() — one row per curated audit
   with the extraction result. kb_query tails the jsonl, explodes
   entity rows, aggregates by entity name with distinct-PR counting,
   surfaces entities recurring in 2+ PRs as info findings. Filters
   out short names (<3 chars, extractor truncation artifacts) and
   generic types (string/number/etc.) so signal isn't drowned.

Verified end-to-end on PR #8: 102KB diff → 23 shards → 1KB scratchpad
→ qwen2.5 extracted 4 facts + 6 entities + 6 relationships (real
code-level knowledge: AggregateOptions<T> type, aggregate<T> async
function with real signature, typed relationships). llm_team_run_id
cross-references to llm_team's own team_runs table.

Also: audit.ts passes (pr_number, head_sha) as InferenceContext so
extracted facts are scope-tagged for the KB index.
2026-04-22 23:09:14 -05:00
profit 47f1ca73e7 auditor: Level 1 correction — keep think=true, only temp=0 is needed
lakehouse/auditor 4 warnings — see review
The previous Level 1 commit set think=false which broke the cloud
inference check on real PR audits. gpt-oss:120b is a reasoning model;
at think=false on large prompts (40KB diff + 14 claims) it returned
empty content — verified by inspecting verdict 8-8e4ebbe4b38a which
showed "cloud returned unparseable output — skipped" with 13421
tokens used and head:<empty>.

Small-prompt tests passed because the model could respond without
needing to think. Real audits with the full diff + claims context
require the reasoning channel to produce any output at all.

The determinism we need comes from temp=0 (greedy sampling). The
reasoning trace at think=true varies in prose but greedy sampling
converges to the same FINAL classification from identical starting
state, so signatures remain stable.

max_tokens restored to 3000 for the think trace + response.
2026-04-22 22:24:25 -05:00
profit 8e4ebbe4b3 test: nine-consecutive audit run 5/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:17:11 -05:00
profit c6511427a4 test: nine-consecutive audit run 4/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:15:13 -05:00
profit b02554daec test: nine-consecutive audit run 3/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:13:26 -05:00
profit 2bb83d1bbb test: nine-consecutive audit run 2/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:11:34 -05:00
profit 0cdf9f7928 test: nine-consecutive audit run 1/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:10:17 -05:00
profit 1e00eb4472 auditor: inference temp=0, think=false — kill signature creep
9-run empirical test showed 20 of 27 audit_lessons signatures were
singletons (count=1) — the cloud producing slightly-different summary
phrasings for the SAME underlying claim on each audit, each hashing
to a fresh signature. That's the creep J flagged — not explosive,
but steady ~2 new sigs per run, unbounded over hundreds of runs.

Root cause: temperature=0.2 + think=true was letting variable prose
leak into the classification output. Fix: temp=0 (greedy sample →
identical input yields identical output on same model version),
think=false (no reasoning trace variance), max_tokens 3000→1500
(tighter bound prevents tail wander).

The compounding policy itself was validated by the 9 runs:
  - 7 recurring claims (the legitimate signals) all at conf 0.08-0.20
  - ratingSeverity() correctly held them at info (below 0.3 threshold)
  - cross-PR signal test separately confirmed conf=1.00 → sev=block

Also: LH_AUDIT_RUNS env so the test can validate with smaller N.
2026-04-22 22:09:35 -05:00
profit 81a2200344 test: nine-consecutive audit run 9/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 22:06:44 -05:00
profit c32289143c test: nine-consecutive audit run 8/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 22:04:47 -05:00
profit 6df0cdadb3 test: nine-consecutive audit run 7/9 (compounding probe)
lakehouse/auditor 3 warnings — see review
2026-04-22 22:02:50 -05:00
profit 6d507d5411 test: nine-consecutive audit run 6/9 (compounding probe)
lakehouse/auditor 7 warnings — see review
2026-04-22 22:01:03 -05:00
profit d95d7b193e test: nine-consecutive audit run 5/9 (compounding probe)
lakehouse/auditor 8 warnings — see review
2026-04-22 21:59:00 -05:00
profit 2e222c8eaa test: nine-consecutive audit run 4/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:57:18 -05:00
profit 0533aa78fb test: nine-consecutive audit run 3/9 (compounding probe)
lakehouse/auditor 4 warnings — see review
2026-04-22 21:55:26 -05:00
profit ac5577c4fa test: nine-consecutive audit run 2/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:53:33 -05:00
profit c5f0f35cdb test: nine-consecutive audit run 1/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:52:21 -05:00
profit 9d12a814e3 auditor: kb_index aggregator + nine-consecutive empirical test
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
Phase 1 — definition-layer over append-only JSONL scratchpads.

auditor/kb_index.ts is the single shared aggregator:

  aggregate<T>(jsonlPath, { keyFn, scopeFn, checkFn, tailLimit })
      → Map<signature, {count, distinct_scopes, confidence,
                        first_seen, last_seen, representative_summary, ...}>

  ratingSeverity(agg) — confidence × count severity policy shared
    across all KB readers. Kills the "same unfixed PR inflates its
    own recurrence score" failure mode by design: confidence =
    distinct_scopes/count, so same-scope noise stays below the 0.3
    escalation threshold no matter how many times it repeats.

checkAuditLessons now routes through aggregate + ratingSeverity.
Net effect: the recurrence detector's bespoke Map/Set bookkeeping is
gone; same behavior, shared discipline, reusable by scrum/observer.

Also: symbolsExistInRepo now skips files >500KB so the audit can't
get stuck slurping a fixture.

Phase 2 — nine-consecutive audit runner.

tests/real-world/nine_consecutive_audits.ts pushes 9 empty commits,
waits for each verdict, captures the audit_lessons aggregate state
after each run, reports:

  - sig_count trajectory (should stabilize, not grow linearly)
  - max_count trajectory (same-signature repeat rate)
  - max_confidence trajectory (must stay LOW on same-PR noise)
  - verdict_stable across runs (must NOT oscillate)

This is the empirical proof that the KB compounds favorably:
noise doesn't escalate itself, and signal stays distinguishable.

Unit-tested both failure modes: same-PR × 9 repeats = conf=0.11
(info); cross-PR × 5 distinct = conf=1.00 (block). The rating
function correctly discriminates.
2026-04-22 21:49:46 -05:00
profit f4be27a879 auditor: fix two false-positive classes from cloud inference
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
Observed on PR #8 audit (de11ac4): 7 warn findings, all from the
cloud inference check. Investigation showed two distinct bug classes
that weren't "ship bad code", they were "auditor misreads the diff":

1. Cloud flagged "X not defined in this diff / missing implementation"
   for symbols like `tailJsonl` and `stubFinding` that ARE defined —
   just not in the added lines of this diff. Fix: extract candidate
   symbols from the cloud's gap summary, grep the repo for their
   definitions (function/const/let/def/class/struct/enum/trait/fn).
   If every named symbol resolves, drop the finding; if some do,
   demote to info with the resolution in evidence.

2. Cloud flagged runtime metrics like "58 cloud calls, 306s
   end-to-end" as unbacked claims. These are empirical outputs
   from running the test, not things a static diff can prove.
   Fix: claim_parser now has an `empirical` strength class
   matching iteration counts, cloud-call counts, duration metrics,
   attempt counts, tier-count phrases. Inference drops empirical
   claims from its cloud prompt (verifiable[] subset only) and
   claim-index mapping uses verifiable[] so cloud responses still
   line up.

Added `claims_empirical` to audit metrics so the verdict is
introspectable: how many claims WERE runtime-only vs how many
are diff-verifiable?

Verified: unit tests confirm empirical classification on 5
sample commit messages; symbol resolver found both false-positive
symbols (tailJsonl + stubFinding) and correctly skipped a known-
fake symbol.
2026-04-22 21:40:03 -05:00
profit de11ac4018 auditor/README: document audit_lessons + scrum_reviews KB files
lakehouse/auditor 7 warnings — see review
Adds State section entries for the two KB files that close the
feedback loop: audit_lessons.jsonl (findings → recurrence detector)
and scrum_reviews.jsonl (scrum output → kb_query surfacing).

Touch-commit to trigger re-audit on fresh SHA with the restarted
auditor (which now has the fix-loaded code).
2026-04-22 21:33:27 -05:00
profit 0306dd88c1 auditor: close the verdict→playbook loop + fix rubric-string false positive
lakehouse/auditor 2 blocking issues: unimplemented!() macro call in tests/real-world/hard_task_escalation.ts
Two changes that fell out of running the auto-loop for real on PR #8:

1. The systemd auditor blocked PR #8 on 'unimplemented!()' / 'todo!()'
   in tests/real-world/hard_task_escalation.ts — but those strings are
   the rubric itself, not macro calls. Added isInsideQuotedString()
   detection in static.ts: BLOCK_PATTERNS now skip matches that fall
   inside double-quoted / single-quoted / backtick string literals on
   the added line. WARN/INFO patterns still run — a TODO comment in
   a string is still a valid signal.

2. Verdicts were being persisted to disk but never fed back as
   learning signal. Added appendAuditLessons() — every block/warn
   finding writes a JSONL row to data/_kb/audit_lessons.jsonl with a
   path-agnostic signature (strips file paths, line numbers, commit
   hashes) so the SAME class of finding on DIFFERENT files dedups to
   one signature.

   kb_query now tails audit_lessons.jsonl and emits recurrence
   findings: 2 distinct PRs hit a signature = info, 3-4 = warn, 5+ =
   block. Severity ramps on distinct-PR count, not total rows, so a
   single unfixed PR being re-audited doesn't inflate its own
   recurrence score.

Fires on post-verdict fire-and-forget (can't break the audit if
disk write fails). The learning loop is now closed: each audit
contributes to the KB that guides the next audit.

Tested: unit tests for normalizedSignature confirmed path-agnostic
dedup; static.ts regression tests confirmed rubric strings no longer
trip BLOCK while real unquoted unimplemented!() still does.
2026-04-22 21:31:35 -05:00
profit dc01ba0a3b auditor: kb_query surfaces scrum-master reviews for files in PR diff
lakehouse/auditor 2 blocking issues: unimplemented!() macro call in tests/real-world/hard_task_escalation.ts
Wires the cohesion-plan Phase C link: the scrum-master pipeline writes
per-file reviews to data/_kb/scrum_reviews.jsonl on accept; the
auditor now reads that same file and emits one kb_query finding per
scrum review whose `file` matches a path in the PR's diff.

Severity heuristic: attempt 1-3 → info, attempt 4+ → warn. Reaching
the cloud specialist (attempt 4+) means the ladder had to escalate,
which is meaningful signal reviewers should see. Tree-split fired is
also surfaced in the finding summary.

audit.ts now passes pr.files.map(f => f.path) into runKbCheck (the
old signature dropped it on the floor). Also adds auditor/audit_one.ts
— a dry-run CLI for auditing a single PR without posting to Gitea,
useful for verifying check behavior without spamming review comments.

Verified: after writing scrum_reviews for auditor/audit.ts and
mcp-server/observer.ts (both in PR #7), audit_one 7 surfaced both as
info findings with preview + accepted_model + tree_split flag. A
scrum review for playbook_memory.rs (NOT in PR #7) was correctly
filtered out.
2026-04-22 21:18:21 -05:00
root 89d188074b scrum_master: tree-split + scrum_reviews.jsonl writer + truncation warning
Extends the scrum-master pipeline to handle input overflow on large
source files (>6KB). Previously, the review prompt truncated the file
to first-chunk, which caused false-positive "field is missing"
findings whenever the actual field was past the cutoff.

Now each file >FILE_TREE_SPLIT_THRESHOLD (6000) is sharded at
FILE_SHARD_SIZE (3500), each shard summarized via gpt-oss:120b cloud,
and the distillations merged into a scratchpad. The review then runs
against the scratchpad with an explicit truncation-awareness clause
in the prompt: "DO NOT claim any field, function, or feature is
'missing' based on its absence from this distillation."

Also writes each accepted review as a JSONL row to
data/_kb/scrum_reviews.jsonl (file, reviewed_at, accepted_model,
accepted_on_attempt, attempts_made, tree_split_fired, preview).
This is the source the auditor's kb_query reads to surface
per-file scrum reviews on PRs that touch those files (cohesion
plan Phase C).

Verified: scrum review of 92KB playbook_memory.rs → 27 shards via
cloud → distilled scratchpad → qwen3.5 local 7B accepted on attempt 1
(5931 chars). Tree-split fires, jsonl row appended, output file
contains structured suggestions.
2026-04-22 21:17:53 -05:00
profitandClaude Opus 4.7 a7aba31935 tests/real-world: scrum-master pipeline — composes everything we built
The orchestrator J described: pulls git repo source + PRD +
suggested-changes doc, chunks them, hands each code piece through
the proven escalation ladder with learning context, collects
per-file suggestions in a consolidated handoff report.

Composes ONLY already-shipped primitives — no new core code:
  - chunker with 800-char / 120-overlap windows
  - sidecar /embed for real nomic-embed-text embeddings
  - in-memory cosine retrieval for top-5 PRD + top-5 proposal
    chunks per target file
  - escalation ladder (qwen3.5 → qwen3 → gpt-oss:20b → gpt-oss:120b
    → devstral-2:123b → mistral-large-3:675b)
  - per-attempt learning-context injection (prior failures as
    "do not repeat" block)
  - acceptance rubric (length ≥ 200 chars + structured form)

Live-run (tests/real-world/runs/scrum_moatqkee/):
  targets: 3 files
    - crates/vectord/src/playbook_memory.rs  (920 lines)
    - crates/vectord/src/doc_drift.rs        (163 lines)
    - auditor/audit.ts                        (170 lines)
  resolved: 3/3 on attempt 1 by qwen3.5:latest local 7B
  total duration: 111.7s
  output: scrum_report.md + per-file JSON

Sample from scrum_report.md (playbook_memory.rs review):
  - Alignment score: 9/10 vs PRD Phase 19
  - 4 concrete change suggestions naming specific lines + PLAN/PRD
    chunk offsets
  - 3 gap analyses with PRD-reference citations

Honest findings from this run:
1. Local 7B handled review-style tasks first-try. The escalation
   ladder infrastructure is live but didn't fire — review is an
   easier task shape than strict code-generation (see hard_task
   test which needed devstral-2 specialist).
2. 6KB file-truncation caused one false positive: model claimed
   playbook_memory.rs lacks a `doc_refs` field, but that field
   exists past the 6KB cutoff. Trade-off between context-size
   and review-depth needs tuning per file.
3. Chunk-offset citations are real: model output includes
   `[PRD @27880]` and `[PLAN @16320]` which map to the actual
   byte offsets of retrieved context chunks. Auditor pattern could
   adopt this for traceable claims.

This is the scrum-master-handoff shape J asked for:
  repo + PRD + proposal → chunk → retrieve → escalate → consolidate
  → human-reviewable markdown report

Not shipping: per-PR diff analysis, open-PR integration, Gitea
posting of suggestions. Those compose the same primitives
differently — this proves the core pattern.

Env override: LH_SCRUM_FILES=path1,path2,... to target a different
file set. Default 3 files keeps runtime ~2min.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:52:42 -05:00
profitandClaude Opus 4.7 540c493ff1 tests/real-world: hard-task escalation — prove the ladder solves tasks local can't
J asked (2026-04-22): construct a task the local model provably can't
complete, then watch the escalation + retry + cloud pipeline actually
solve it.

The task: generate a Rust async function with 15 specific
structural rules (exact signature, bounded concurrency, exponential
backoff 250/500/1000ms, NO .unwrap(), rustdoc comments, etc.).
Small enough to fit in one response but strict enough that one
rule violation = not accepted. Fits Rust + async + concurrency +
error-handling — across the hardest dimensions for 7B models.

Escalation ladder (corrected per J — kimi-k2.x requires Ollama
Cloud Pro subscription which J's key lacks; mistral-large-3:675b
is the biggest provisioned model):

  1. qwen3.5:latest        (local 7B)
  2. qwen3:latest          (local 7B)
  3. gpt-oss:20b           (local 20B)
  4. gpt-oss:120b          (cloud 120B)
  5. devstral-2:123b       (cloud 123B coding specialist)
  6. mistral-large-3:675b  (cloud 675B — biggest available)

Each attempt gets PRIOR failures' rubric violations injected as
learning context. Loop caps at MAX_ATTEMPTS=6.

Live run (runs/hard_task_moapd3g3/):
  attempt 1: qwen3.5:latest         11/15  — missed concurrency + some constraints
  attempt 2: qwen3:latest           11/15  — different misses after learning
  attempt 3: gpt-oss:20b             0/1  — empty response (local model dead-end)
  attempt 4: gpt-oss:120b            0/1  — empty (heavy learning context may confuse)
  attempt 5: devstral-2:123b        15/15  ✅ ACCEPTED after 10.4s
  attempt 6: (not reached)

Total: 5 attempts, 145.6s, coding-specialist succeeded.

Honest findings from the run:
- Pipeline works: escalated through 4 distinct model tiers, injected
  learning, bounded at 6, graceful failure surfaces.
- Learning injection doesn't always help general-purpose models —
  gpt-oss:120b returned empty when given heavy prior-failure context
  (attempt 4). The coding specialist (devstral) worked better because
  the task is domain-aligned.
- Local 7B came within 4 rules of success first-try (11/15) — not
  bad for the scale, but specific constraints like "EXACT signature"
  and "bounded concurrency at 4" are where small models slip.
- Kimi K2.5/K2.6 both require a paid subscription on our current
  Ollama Cloud key — verified via direct ollama.com curl. Swap
  to kimi once subscription lands.

Also includes a rubric bug-fix caught in the run: the regex for
"reaches 500/1000ms backoff" originally required literal constants,
but devstral-2:123b wrote idiomatic `retry_delay *= 2;` which
doubles 250 → 500 → 1000 correctly. Broadened rubric to recognize
`*= 2`, bit-shift, `.pow()`, and literal forms. Without this the
ladder would have false-failed on semantically-correct code.

Files:
  tests/real-world/hard_task_escalation.ts (270 LOC)
  tests/real-world/runs/hard_task_moapd3g3/
    attempt_{1..5}.txt     — raw model outputs (last successful)
    attempt_{1..5}.json    — per-attempt rubric verdict + error
    summary.json           — ladder summary

What this PROVES that no prior test did:
- Task-level retry ESCALATES across distinct model capabilities
  (not just same model retried)
- Bigger and more-specialized models ACTUALLY solve what smaller
  ones can't — the ladder works by design, not by luck
- The subscription boundary (Kimi K2.x) is a real operational
  constraint, not a code issue
- Rubric engineering is its own discipline — a strict-but-wrong
  validator can reject correct code; shipping the test harness
  required tuning against actual model outputs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:50:53 -05:00
profit 6d6a306d4e tests/real-world: add task-level 6-retry loop (per J 2026-04-22)
Two distinct retry loops now both cap at 6 and serve different
purposes:

1. Per-cloud-call continuation (Phase 21 primitive) — when a single
   cloud call returns empty or truncated, stitches up to 6
   continuation calls. Handles output-overflow.

2. Per-TASK retry (this commit) — when the whole task errors
   (500/404, thin answer, etc.), retries the full task up to 6
   times. Each retry gets PRIOR ATTEMPTS' failures injected into
   the prompt as learning context, so attempt N+1 is informed by
   what N failed at. Handles error-recovery with compounding
   context.

Both loops fired on iter 3 of the stress run, proving them
independent and composable:

  FORCING TASK-RETRY LOOP — iter 3 will cycle through 5 invalid
  models + 1 valid
    attempt 1/6: model=deliberately-invalid-model-attempt-1
        /v1/chat 502: ollama.com 404: model not found
    attempt 2/6: [with prior-failure context]
    ... (5 failures total, each with the full chain of prior errors)
    attempt 6/6: model=gpt-oss:20b [with prior-failure context]
        continuation retry 1..6 (empty responses)
        SUCCEEDED after 5 prior failures (441 chars)

What J was asking to prove:
  "I expect it to retry the process six times to build on the
   knowledge database... when an error is legitimately triggered
   that it will go through six times... without getting caught in
   a loop"

Proof:
  - 6/6 attempts fired on the FORCED iteration
  - Each retry embedded the preceding attempts' errors as "do not
    repeat" context
  - Hard cap at MAX_TASK_RETRIES (6) prevents infinite loops
  - Last-ditch local fallback exists if all 6 still fail
  - Other iterations succeed on attempt 1 — the loop ONLY fires
    when errors are legitimately triggered

Stress run totals (runs/moan4h71/):
  6/6 iterations complete, 58 cloud calls, 306s end-to-end
  tree-splits: 6/6   continuations: 10   rescues: 2
  iter 3: 8197+2800 tok, 6 task attempts, 6 continuation retries
  local stored summary + per-iter JSON for inspection

What this proves that prior stress runs did NOT:
  - Error-recovery at task granularity is live, not aspirational
  - Compounding failure context flows between retries as text
  - Loop bound is enforced; runaway cases aren't possible
  - Two retry mechanisms compose without deadlock (continuation
    inside task-retry inside tree-split)

Follow-ups worth doing (separate PRs):
  - Persist retry-history to observer :3800 so cross-run learning
    sees the failure patterns
  - Route retries through /vectors/hybrid to surface similar prior
    errors from the real KB (currently only in-memory across one
    iteration)
  - Fix citation regex in summary — iter 6 received 5 prior IDs
    but counter shows 0 (regex needs to tolerate hyphens in IDs)
2026-04-22 17:50:53 -05:00
profitandClaude Opus 4.7 4458c94f45 tests/real-world: enrich_prd_pipeline — architecture stress test
Real end-to-end test of the Lakehouse pipeline at scale. Runs the
PRD (63 KB, 901 lines → 93 chunks) through 6 iterations with cloud
inference, intentional failure injection, and tight context budget
to force every Phase 21 primitive to fire.

What the test exercises:
- Sidecar /embed for 93 chunks (nomic-embed-text)
- In-memory cosine retrieval for top-K per iteration
- Tree-split (shard → summarize → scratchpad → merge) when context
  chunks exceed the 4000-char budget
- Scratchpad truncation to keep compounding context bounded
- Cloud inference via /v1/chat provider=ollama_cloud (gpt-oss:120b)
- Injected primary-cloud failure on iter 3 (invalid model name) +
  rescue with gpt-oss:20b — proves catch-and-retry isn't dead code
- Playbook seeding per iteration (real HTTP against gateway)
- Prior-iteration answer injection for compounding (not just IDs —
  the first version passed IDs only and the model ignored them)

Live run results (tests/real-world/runs/moamj810/):
  6/6 iterations complete, 42 cloud calls total, 245s end-to-end
  tree-splits: 6/6 (every iter overflowed 4K budget)
  continuations: 0 (no responses hit max_tokens)
  rescues: 1 (iter 3 injected failure → gpt-oss:20b → valid answer)
  iter 6 answer explicitly cites [pb:pb-seed-82e1] — compounding real
  scratchpad truncation fired on iter 6 as designed

What this PROVES:
- Tree-split primitives work under real context pressure, not just
  in unit tests. The 4000-char budget forced every iteration to
  shard 12 chunks → 6 shards → scratchpad → final answer.
- Rescue on primary failure is wired and produces answers from a
  weaker model rather than erroring out.
- Compounding context injection works: iter 6's prompt had the 5
  prior answers in its citation block, and the cloud model
  acknowledged at least one via [pb:...] notation.
- The existence claims in Phase 21 (continuation + tree-split) are
  backed by executable evidence, not just unit tests.

What this DOESN'T prove (deliberate — scoped for follow-up):
- Continuation retries (no iter hit max_tokens in this run; would
  need a harder prompt or lower max_tokens to force)
- Real integration with /vectors/hybrid endpoint (test does in-memory
  cosine instead, bypassing gateway vector surface)
- Observer consumption of these runs (nothing posted to :3800 during
  the test — adding that is Phase A integration, handled separately)

Files:
  tests/real-world/enrich_prd_pipeline.ts (333 LOC)
  tests/real-world/runs/moamj810/{iter_1..6.json, summary.json}
    — artifacts from the stress run, committed for inspection

Follow-ups worth doing:
1. Lower max_tokens / harder prompt to force continuation path
2. Route retrieval through /vectors/hybrid for real Phase 19 boost
3. POST per-iteration summary to observer :3800 so runs accumulate
   like scenario runs do

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:33:24 -05:00
1075 changed files with 3018 additions and 167121 deletions
@@ -1,42 +0,0 @@
# Real Archon workflow on the Lakehouse repo, fully via our gateway.
# Three Pi nodes, each fires LLM → /v1/chat/completions → OpenRouter,
# every call lands a Langfuse trace + observer event.
#
# Read-only (allowed_tools: [read]). Don't pass --branch / leave
# --no-worktree at runtime so Archon doesn't try to create a worktree.
name: lakehouse-architect-review
description: 'Pi reviews Lakehouse architecture in 3 turns through our gateway.'
provider: pi
model: openrouter/x-ai/grok-4.1-fast
nodes:
- id: shape
prompt: |
Read these files and answer in 3 short bullets describing the
architectural shape of Lakehouse:
- /home/profit/lakehouse/Cargo.toml
- /home/profit/lakehouse/lakehouse.toml
- /home/profit/lakehouse/docs/MODE_RUNNER_TUNING_PLAN.md
Be terse. No preamble.
allowed_tools: ["read"]
effort: low
idle_timeout: 90000
- id: weakness
prompt: |
Read /home/profit/lakehouse/crates/gateway/src/v1/mod.rs and
identify ONE real weakness or risk. Cite file:line. One paragraph.
allowed_tools: ["read"]
effort: low
idle_timeout: 90000
depends_on: [shape]
- id: improvement
prompt: |
Based on the prior weakness ($weakness.output), propose ONE
surgical improvement (≤6 lines of Rust). Show the patch as
`old_string` and `new_string` in markdown code blocks.
allowed_tools: []
effort: low
idle_timeout: 90000
depends_on: [weakness]
-71
View File
@@ -4,74 +4,3 @@
.env .env
__pycache__/ __pycache__/
*.pyc *.pyc
# Headshot pool — binary face JPGs are fetched by scripts/staffing/fetch_face_pool.py
# (synthetic StyleGAN, ~580MB for 1000 faces). Manifest + fetch script are tracked.
data/headshots/face_*.jpg
data/headshots/_thumbs/
# ComfyUI on-demand generated portraits (per-worker unique). Cached on first
# request; fully regeneratable via /headshots/generate/:key.
data/headshots_gen/
# Runtime data — all regeneratable from inputs or accumulated by daemons.
# Anything under data/_<name>/ is internal state (auditor outputs, KB caches,
# pathway memory snapshots, HNSW trial results, etc.). Anything under
# data/datasets/ or data/vectors/ is generated by ingest/index pipelines.
data/_*/
data/lance/
data/datasets/
data/vectors/
data/demo/
data/evidence/
data/face_test/
data/headshots_role_pool/
data/icons_pool/
data/scored-runs/
data/workspaces/
data/catalog/
data/**/*.bak-*
data/**/*.pre-*-bak
# Logs
logs/
# Build artifacts
node_modules/
exports/
mcp-server/data/
# Per-run distillation reports (timestamp-named); keep the parent dir tracked
# via .gitkeep if needed but don't carry every batch's report set.
reports/distillation/[0-9]*/
reports/distillation/*-*-*-*-*/
# Test scratch — scratchpads, traces, sessions are regenerated each run.
# PRD/scenario fixtures stay tracked (they ARE the test).
tests/agent_test/_*
tests/agent_test/sessions/
tests/real-world/runs/
# BIPA-quarantined photo uploads — Phase 1.6 Gate 3a writes to
# data/biometric/uploads/<safe_id>/<ts>_<uuid>.<ext> with mode 0700/0600.
# This is regulated subject-of-record data and must NEVER enter git.
data/biometric/
# Local-only scrum review evidence. Per `feedback_audit_findings_log.md`
# scrum runs fold fixes into a batch commit; the verdict / disposition
# files stay local for forensics.
reports/scrum/
# Per-event biometric verification reports (timestamp-named, regenerated
# per `verify_biometric_erasure.sh` invocation). Source-of-truth is the
# audit chain itself; these reports are derived views.
reports/biometric/
# Counsel transmission tarballs + manifests are regenerated by
# `bundle_counsel_packet.sh` from the tracked `docs/counsel/` source.
# The bundle is transmittable, not source-of-truth.
reports/counsel/
# Local experiments scratchpad — per the "Test code in main is ACTIVELY
# being cleaned out" policy (commits 6aafd41 + f4ebd22), one-off
# experiments stay out of the tracked tree.
experiments/
-8
View File
@@ -7,14 +7,6 @@
"LAKEHOUSE_URL": "http://localhost:3100", "LAKEHOUSE_URL": "http://localhost:3100",
"MCP_TRANSPORT": "stdio" "MCP_TRANSPORT": "stdio"
} }
},
"gitea": {
"command": "bunx",
"args": ["gitea-mcp"],
"env": {
"GITEA_HOST": "https://git.agentview.dev",
"GITEA_ACCESS_TOKEN": "SET_ME_FROM_GITEA_UI_USER_SETTINGS_APPLICATIONS"
}
} }
} }
} }
Generated
-35
View File
@@ -46,9 +46,7 @@ dependencies = [
name = "aibridge" name = "aibridge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"axum", "axum",
"lru",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -1365,13 +1363,10 @@ dependencies = [
"axum", "axum",
"bytes", "bytes",
"chrono", "chrono",
"hmac",
"object_store", "object_store",
"parquet 55.2.0",
"proto", "proto",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"shared", "shared",
"storaged", "storaged",
"tokio", "tokio",
@@ -4090,14 +4085,11 @@ dependencies = [
"shared", "shared",
"storaged", "storaged",
"tokio", "tokio",
"toml",
"tonic", "tonic",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"truth",
"validator",
"vectord", "vectord",
] ]
@@ -4686,7 +4678,6 @@ dependencies = [
"chrono", "chrono",
"croner", "croner",
"csv", "csv",
"journald",
"lopdf", "lopdf",
"mysql_async", "mysql_async",
"object_store", "object_store",
@@ -6904,7 +6895,6 @@ dependencies = [
"storaged", "storaged",
"tokio", "tokio",
"tracing", "tracing",
"truth",
"url", "url",
] ]
@@ -8736,17 +8726,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "truth"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tokio",
"toml",
"tracing",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -8913,19 +8892,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "validator"
version = "0.1.0"
dependencies = [
"arrow 55.2.0",
"parquet 55.2.0",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -8953,7 +8919,6 @@ dependencies = [
"object_store", "object_store",
"parquet 55.2.0", "parquet 55.2.0",
"queryd", "queryd",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
-3
View File
@@ -14,8 +14,6 @@ members = [
"crates/ui", "crates/ui",
"crates/lance-bench", "crates/lance-bench",
"crates/vectord-lance", "crates/vectord-lance",
"crates/truth",
"crates/validator",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -36,7 +34,6 @@ datafusion = "47"
bytes = "1" bytes = "1"
futures = "0.3" futures = "0.3"
sha2 = "0.10" sha2 = "0.10"
hmac = "0.12"
url = "2" url = "2"
tonic = "0.13" tonic = "0.13"
prost = "0.13" prost = "0.13"
-492
View File
@@ -1,492 +0,0 @@
# STATE OF PLAY — Lakehouse
**Last verified:** 2026-06-30 (search index-alignment fix live on :3700) · biometric substrate 2026-05-05
**Verified by (2026-06-30):** live probe after mcp-server restart — `/match` + `/search` with `state='IL'` return only IL workers (new PID on :3700). Not memory.
**Verified by (2026-05-05):** live probe (`/audit/health` 200, `/biometric/subject/{id}/erase` 21-test substrate + `/audit/subject/{id}` legal-tier endpoint live verified against WORKER-100; new `verify_biometric_erasure.sh` + `biometric_destruction_report.sh` + `bundle_counsel_packet.sh` smoke-tested clean against live data) — not memory.
> **Read this FIRST.** When the user says "we're working on lakehouse," they mean the working code captured below — NOT what `git log` framed as "the cutover" or what memory snapshots from 2 days ago suggest. If memory contradicts this file, this file wins. Update it when something is verified working — not when a phase finishes.
---
## WHAT LANDED 2026-06-30 (search index-alignment sweep + P1-already-done finding)
**Fixed a systemic hybrid-search bug.** Every `/vectors/hybrid` call that paired the
`ethereal_workers` SQL dataset (10K rows) with the `workers_500k_v1` vector index (50K,
a *different* worker set) returned wrong-entity results: same `worker_id` = different
person in each index, so a `state='IL'` query surfaced a **KY** worker flagged
`sql_verified:true`. Root cause: the default index didn't share an id-space with the
default dataset.
Swept **8 call sites** in `mcp-server/index.ts` to `ethereal_workers_v1` (10K, same
id-space as the SQL table). Aligned sites (`filter_dataset: workers_500k`) and pure-RAG
sites (`/vectors/rag`) were correctly left alone.
| Commit | Sites |
|---|---|
| `423400e` | `/search` HTTP handler |
| `35a80a7` | `search_workers` tool · `match_contract` tool · `/match` HTTP handler |
| `1570306` | 4 intelligence routes (similar-workers, role-discovery, smart_search, scenario-find) |
**Verified live** after an mcp-server restart (new PID on :3700): `/match` and `/search`
with `state='IL'` return only IL workers (Champaign/Springfield/Urbana); `state='WI'`
returns WI. Grep confirms no `workers_500k_v1` index remains paired with `ethereal_workers`.
Server boots clean. Only `mcp-server/index.ts` was touched.
**Finding — demo-plan P1 was already done.** The "search box that actually filters" item
in this doc's CURRENT PLAN (P1, from the 2026-04-27 demo audit) is **stale**. The UI now
posts structured `state`/`role` to the evolved `/intelligence/chat`, which returns real
`sql_results` (IL Forklift → 167 matches, correct workers; WI → WI). The P1 section below
describes a bug that no longer exists — don't re-implement it.
**⚠ Uncommitted WIP left untouched (pre-existing, not from this session).**
`mcp-server/search.html` + `proof.html` (edited 2026-05-30; proof.html restructured into
chapters) and a `biometric_endpoint.rs` comment cleanup remain uncommitted in the working
tree. Also untracked and newer than this doc: `RECOVERY_PLAN_2026-05-05.md`,
`mcp-server/UX_FIX_PLAN.md`, `docs/scope-sentinel/`. Someone should reconcile these — they
imply planning context this file doesn't yet capture.
---
## WHAT LANDED 2026-05-05 (afternoon wave — full BIPA lifecycle endpoints + UIs + ops dashboard)
After the morning's doc-reconciliation wave (described below), the afternoon wave shipped the full **operational lifecycle** for biometric data. Phase 1.6 now has the complete end-to-end runtime: candidate consent flow → photo upload → withdrawal → retention sweep flag → erase. Five commits, all on `demo/post-pr11-polish-2026-04-28`.
| Commit | What | Verified |
|---|---|---|
| `76cb5ac` | `POST /biometric/subject/{id}/consent` — Gate 2 backend. Flips status NeverCollected→Given, computes retention_until = given_at + 18mo (per retention schedule v1 §4), captures consent_version_hash + collection_method (closed set: electronic_signature/paper/click_acceptance) + operator + evidence path to audit row. State machine: 409 already_given, 409 post-withdrawal-requires-erase, 403 subject_inactive. | 12 unit tests; live POST returns 401/400/404 on guards |
| `7f0f500` | Candidate intake UI at `/biometric/intake?candidate_id=XXX` (mcp-server :3700, exposed via nginx at devop.live/lakehouse/biometric/intake). 4-screen flow: operator auth → consent template render + click-accept → photo capture (file or webcam getUserMedia) → confirmation showing audit hmac. SHA-256 of rendered consent block becomes consent_version_hash. sessionStorage-backed token (clears on tab close), neo-brutalist style matching onboard.html. | 22631-byte HTML, mcp-server route returns 200 |
| `68d226c` | Three things in one wave: <br>(a) `POST /biometric/subject/{id}/withdraw` — BIPA right of withdrawal. Flips Given→Withdrawn, accelerates retention_until from 18mo→30d (per consent template v1 §2 SLA), audit kind=biometric_consent_withdrawal. State machine 409s on NeverCollected/Pending (nothing_to_withdraw), Withdrawn (already_withdrawn), Expired (already_expired). 12 unit tests. <br>(b) Withdraw UI at `/biometric/withdraw` — 3-screen operator flow (token+name auth → reason+evidence form → confirmation showing 30-day clock + verify curl recipe). <br>(c) `lakehouse-retention-sweep.{service,timer}` systemd units in `ops/systemd/`. Daily 03:00 UTC, Persistent=true, install.sh updated to handle paired timer+oneshot service. <br>Plus operator_of_record bug fix in intake UI (was hardcoded `'intake_ui_operator'`). | 46/46 biometric_endpoint + 71/71 catalogd lib tests; manual sweep run: 100 subjects, 0 overdue, exit 0 |
| **(current HEAD post-this-wave)** | Stats endpoint `GET /biometric/stats` (legal-tier auth, returns subject counts by status + photo count + oldest active retention + last 20 state-change events with anonymizable trace_ids) + ops dashboard at `/biometric/dashboard` (single-page, polls /stats every 15s, table + status tiles, XSS-safe DOM construction not innerHTML). Plus consent_versions allowlist: `BiometricEndpointState.allowed_consent_versions: Option<Arc<HashSet<String>>>`, loaded from `/etc/lakehouse/consent_versions.json` (`LH_CONSENT_VERSIONS_FILE` override), missing-file = permissive (v1 compat), present + populated = strict mode (refuses unknown hashes with 400 consent_version_unknown). Plus `scripts/staffing/subject_timeline.sh` — pretty-prints any subject's full BIPA lifecycle from /audit/subject/{id} (manifest + on-disk photo + chronological audit chain + verification status). | 5 new allowlist unit tests + 4 stats tests; live demo on WORKER-100 ran end-to-end (consent → photo → withdraw, chain verified=true, chain_root=a47563ff…) |
### Live demo evidence (WORKER-100, 2026-05-05 20:12 UTC)
The full lifecycle was exercised against the live gateway as a verification artifact. The audit chain on WORKER-100 now contains 3 rows:
```
20:12:33.054 BIOMETRIC_CONSENT_GRANT result=given hmac=9c6f4153341e97d2… trace=live-demo-2026-05-05
20:12:47.957 BIOMETRIC_COLLECTION result=success hmac=856be6173c88277c… trace=live-demo-2026-05-05
20:15:27.298 BIOMETRIC_CONSENT_WITHDRAWAL result=withdrawn hmac=a47563ff937d50de… trace=live-demo-2026-05-05
```
`chain_verified=true`, chain_root = a47563ff937d50de43b09a0c903cff954233836c219a928ee8ca2aa6792272dd. Photo file at `data/biometric/uploads/WORKER-100/1778011967957907731_027b6bb1.jpg` (180 bytes — a minimal real JFIF JPEG), retention_until=2026-06-04 (= 30 days from withdrawal). Retention sweep will flag this subject on or after that date; operator runs `/biometric/subject/WORKER-100/erase` to destroy.
To re-verify: `./scripts/staffing/subject_timeline.sh WORKER-100`.
### Endpoint matrix (v1 BIPA lifecycle complete)
| Event | Endpoint | Method | Auth | Status flip | retention_until |
|---|---|---|---|---|---|
| consent given | `/biometric/subject/{id}/consent` | POST | legal | NeverCollected/Pending → Given | now + 18mo |
| photo collected | `/biometric/subject/{id}/photo` | POST | legal + consent gate | (no change) | (no change) |
| consent withdrawn | `/biometric/subject/{id}/withdraw` | POST | legal | Given → Withdrawn | now + 30d |
| destruction | `/biometric/subject/{id}/erase` | POST | legal | (manifest cleared) | n/a |
| audit read | `/audit/subject/{id}` | GET | legal | (read-only) | (read-only) |
| ops aggregates | `/biometric/stats` | GET | legal | (read-only) | (read-only) |
UIs:
- `/biometric/intake?candidate_id=X` — operator-driven consent + photo
- `/biometric/withdraw` — operator-driven withdrawal recording
- `/biometric/dashboard` — read-only ops aggregate, auto-refresh
CLI tools:
- `scripts/staffing/verify_biometric_erasure.sh <id>` — post-erasure verification
- `scripts/staffing/biometric_destruction_report.sh --month YYYY-MM` — anonymized monthly report
- `scripts/staffing/subject_timeline.sh <id>` — full lifecycle pretty-print (NEW 2026-05-05)
- `scripts/staffing/bundle_counsel_packet.sh` — counsel review tarball
- `scripts/staffing/attest_pre_identityd_biometric_state.sh` — defense attestation generator
### What's blocking production cutover NOW
**Counsel calendar.** Engineering substrate is done end-to-end: every state transition has a defensible endpoint, every endpoint has tests + live verification, every UI is reachable, retention sweep is scheduled, allowlist hardening is wired. The remaining work is signature/review:
1. Counsel review of consent template v1 (revised for Option C — classifications deferred)
2. Counsel review of retention schedule v1 (revised for Option C)
3. Counsel review of destruction runbook
4. Counsel + J signatures on §2 attestation
5. Once counsel signs the consent template, populate `/etc/lakehouse/consent_versions.json` with the signed hash to flip the gateway from permissive to strict mode
Counsel-review packet at `reports/counsel/counsel_packet_2026-05-05.tar.gz` (regenerable via `bundle_counsel_packet.sh` to pick up the latest doc state).
---
## WHAT LANDED 2026-05-05 (morning wave — doc reconciliation + Gate 3b decision + counsel packet)
This was a **doc-only wave**, not code. Background: J asked for an audit of the BIPA/biometric documentation before production cutover. Audit found moderate fragmentation between docs and shipped code (post-`identityd` collapse, post-Gate-3a-ship, pre-Gate-3b-decision). Closed it in one pass.
| Item | What changed | Status |
|---|---|---|
| **Gate 3b — DECIDED: Option C (defer classifications)** | `BiometricCollection.classifications` stays `Option<JSON> = None` in v1. `docs/specs/GATE_3B_DEEPFACE_DESIGN.md` status flipped from "draft / awaits product" to "DECIDED 2026-05-05". | Locked |
| **Endpoint-path drift** | `PHASE_1_6_BIPA_GATES.md` (3 spots), `BIPA_DESTRUCTION_RUNBOOK.md` (2 spots), `biometric_retention_schedule_v1.md` (1 spot) updated from legacy `/v1/identity/subjects/*` (proposed under separate identityd daemon, never shipped) to actual `/biometric/subject/*` (catalogd-local, shipped `848a458`). Schema block in `PHASE_1_6_BIPA_GATES.md` rewritten to reflect JSON `SubjectManifest.biometric_collection` substrate (not the proposed Postgres `subjects` table). | Reconciled |
| **Consent template + retention schedule** | Both revised for Option C: removed all "automated facial-classification" / "deepface" language so disclosed scope matches implemented scope. Pending counsel review — they were already eng-staged with ⚖ markers. | Eng-staged for counsel |
| **`scripts/staffing/verify_biometric_erasure.sh`** (NEW) | Operator-side verification of an erasure event. Curls `/audit/subject/{id}` with legal-tier token, checks: manifest.biometric_collection null, uploads dir empty, last audit row is `biometric_erasure`/`full_erasure` with `erased`/`success`, chain_verified=true. Writes a hashed report to `reports/biometric/`. | Smoke-tested live |
| **`scripts/staffing/biometric_destruction_report.sh`** (NEW) | Monthly destruction-event aggregation. Anonymizes candidate IDs (sha256-12 prefix), counts by scope + trigger, flags anomalies. Smoke-test on May 2026 data found 1 historical `biometric_erasure`/`consent_withdrawal` event (test fixture). | Smoke-tested live |
| **`docs/runbooks/LEGAL_AUDIT_KEY_ROTATION.md`** (NEW) | Captures the rotation procedure operationalized after the 2026-05-05 `/tmp` wipe incident. Covers: when to rotate, pre-rotation snapshot, atomic-swap procedure, post-rotation verification (incl. expected pre-rotation chain tamper-detect under new key), recovery from lost key, ⚖ counsel notes. | Authored |
| **`docs/counsel/COUNSEL_REVIEW_PACKET_2026-05-05.md`** + `bundle_counsel_packet.sh` (NEW) | Cover note bundling all eng-staged BIPA docs for counsel review with per-doc questions, sign-off checklist, recommended review sequence. Bundler script tarballs the 8 referenced files + emits a SHA-256 manifest. Tarball ready for transmission: `reports/counsel/counsel_packet_2026-05-05.tar.gz`. | Bundled, ready to send |
### Eng follow-up that this wave surfaced
- **Double-upload file leak — DIAGNOSED + FIXED** (2026-05-05 same wave). `verify_biometric_erasure.sh` smoked WORKER-2 and surfaced a stranded photo file. Investigation showed:
- The file was 13 bytes of test fixture (`ff d8 ff d9 + ASCII "TESTBYTES"`), byte-identical to the unit-test fixture at `biometric_endpoint.rs:841`. NO PII, NO biometric content, NO synthetic-face content. Came from manual integration testing on 2026-05-03.
- Audit log timeline showed two consecutive uploads (09:54, 10:04) followed by one erasure (10:22). The erasure unlinked only the SECOND file (which the manifest pointed at by then); the first file was orphaned because the second upload had silently overwritten `manifest.data_path`.
- **Real bug found**: the upload handler did NOT refuse a second upload to a subject with `biometric_collection.is_some()`. Patched `process_upload` to return HTTP 409 + `error: "biometric_already_collected"` when a re-upload is attempted; operator must explicitly POST `/biometric/subject/{id}/erase` first.
- Stranded test file removed (`rm` of the 13-byte fixture).
- New unit test `second_upload_without_erase_returns_409` asserts the 409 + that the first photo's data_path remains unchanged + that the first file remains untouched on disk.
- Existing `repeated_uploads_grow_the_chain` replaced with `upload_erase_upload_grows_the_chain_cleanly` (covers the legitimate re-collection cycle: upload → erase → upload, chain grows to 3 rows).
- Existing `content_type_with_parameters_accepted` test updated to use two distinct subjects (it had used one subject for two uploads to test content-type parsing — now would 409).
- **22 biometric_endpoint tests + 59 catalogd lib tests all green** post-patch (was 21+58 pre-patch).
- Production posture: gateway binary needs rebuild (`cargo build --release`) + `systemctl restart lakehouse.service` to pick up the new 409 behavior in live traffic.
- **Pre-rotation chain tamper-detect (expected, not a bug).** WORKER-{1..5} had pre-2026-05-05 audit chains under the prior `LH_SUBJECT_AUDIT_KEY`. Under the new key (post-`/tmp` wipe rotation), those chains correctly tamper-detect. The rotation runbook §4.4 documents this as expected; a §2.2 pre-rotation snapshot is what would prove they were intact pre-rotation if defensibility ever needs it.
### What's blocking production cutover NOW (after this wave)
- **Counsel calendar:** the four sign-off items in `COUNSEL_REVIEW_PACKET_2026-05-05.md` (retention schedule, consent template, destruction runbook, pre-identityd attestation). The packet tarball is ready; ⚖ counsel is the bottleneck.
- **Nothing else.** Engineering is no longer the long pole.
### Phase 1.6 BIPA gates — status table (this is the final post-Option-C state)
| # | Gate | Status |
|---|---|---|
| 1 | Public retention schedule | **eng-staged**, revised for Option C, ready for counsel |
| 2 | Informed consent template | **eng-staged**, revised for Option C, ready for counsel |
| 3a | Photo upload endpoint | **DONE** (shipped `f1fa6e4`, 11 unit tests, live verified) |
| 3b | Deepface classification | **DECIDED 2026-05-05: Option C (defer)** |
| 4 | Name → ethnicity inference removal | **DONE** (shipped, 4/4 mcp-server tests pass) |
| 5 | Destruction runbook + erasure endpoint | **eng-DONE** (`848a458`, 21 tests). Runbook scripts (verify + report) shipped 2026-05-05. Counsel review pending. |
| §2 | Pre-identityd attestation | **eng-DONE** (3/3 evidence checks). Awaits J + counsel signature. |
| §3 | Employee training | **deferred** (consolidated into runbook §7 acknowledgment for current operator population) |
---
## WHAT LANDED 2026-05-03 (16 commits this wave — local-first audit substrate + Phase 1.6 BIPA gates)
The dominant work today: **`docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md` Steps 1-8 SHIPPED end-to-end** + **5 of 7 Phase 1.6 BIPA pre-launch gates** + **6th cross-runtime parity probe**. Wave was structured as eight ship-then-scrum cycles — every wave caught real bugs, every fix wave landed within the same session.
| Commit | What | Verified |
|---|---|---|
| `d259909` | catalogd: Step 1 — `SubjectManifest` type + Registry CRUD | 17 catalogd subject tests PASS |
| `d16131b` | catalogd: Step 2 — `SubjectAuditWriter` HMAC-SHA256 chain + per-subject Mutex + canonical-JSON via BTreeMap | tamper-detection + concurrent-append race tests PASS |
| `bce6dfd` | catalogd: Step 3 — `bin/backfill_subjects` (BIPA-defensible defaults: vertical=unknown, consent=pending_backfill_review, retention=4yr) | 100 subjects loaded into live catalog |
| `fef1efd` | gateway: Step 4 — wire SubjectAuditWriter into `/v1/chat` tool dispatch + `audit_subject_hits_in` (inline, not spawn) | tool calls log accessor.kind="gateway_lookup" |
| `cd8c59a` | gateway: Step 5 — `AuditingWorkerLookup` decorator wraps validator's WorkerLookup; spawns audit on every find() | live `/v1/validate` produces audit rows |
| `e38f357` | subjects Steps 1-4 fixes from cross-lineage scrum (concurrency race, schema-evolution HMAC drift, hardcoded "success" classifier) | 41 tests green |
| `15cfd76` | catalogd + gateway: Step 6 — `/audit/subject/{id}` legal-tier endpoint with constant-time-eq token check + tampering detection | live curl returns chain_verified=true |
| `2a4b316` | subjects 2nd scrum fix wave (token min 16→32, chain_root from full chain via `chain_tip()`, rebuild collision warn, tightened result-state heuristic) | 17 catalogd + 6 gateway tests PASS |
| `8fc6238` | catalogd: Step 7 — `bin/retention_sweep` (BIPA-aware on biometric clock, idempotent across daily runs, no auto-mutation) | 8 sweep tests PASS, live verified at `--as-of 2031-06-01` flagging 100/100 expired |
| `2413c96` | catalogd: Step 8 — `bin/parity_subject_audit` (Rust side of cross-runtime parity probe) | known-answer + verify modes match Go byte-for-byte |
| `2222227` | parity helper hardening (panic-noise → die() helper, abs path stripped from doc) from scrum | parity probe still 6/6 |
| `4708717` | **Phase 1.6 BIPA wave** — Gate 4 absence test (4/4 with bypass coverage), §2 attestation script (3/3 evidence checks), Gate 1/2/5 doc scaffolds with ⚖ COUNSEL markers | 4/4 mcp-server Bun tests, 3/3 evidence on live data |
| `c7aa607` | Phase 1.6 scrum fixes — schema fingerprint hashes name+type+nullable, Gate 4 catches object-literal + class-field bypasses, pyarrow dep gate, item 7 deferral rationale | 4/4 + 3/3 still pass |
| `f1fa6e4` | **Phase 1.6 Gate 3a** — `crates/catalogd/src/biometric_endpoint.rs`: `POST /biometric/subject/{id}/photo` with consent gate, quarantined storage (mode 0700/0600), audit chain link, `BiometricCollection` field on SubjectManifest | 11 unit tests PASS, live roundtrip 200 |
| `3708e6a` | Gate 3a scrum fixes — transactional rollback on audit failure (BIPA convergent BLOCK), Content-Type parameter handling, relative data_path, ts+uuid filename, dead code removed | 11 tests + cross-runtime parity 6/6 |
| `7e0112b` | retention_sweep: stray indent fix on biometric_collection field | sweep tests still 8/8 |
| `848a458` | **Phase 1.6 Gate 5** — `POST /biometric/subject/{id}/erase` per BIPA destruction runbook. Two scopes (biometric_only / full); audit row appended BEFORE photo unlink so the chain has legal proof of intent even if file delete fails; manifest rolled back on audit failure. Trigger taxonomy: retention_expiry / consent_withdrawal / rtbf / court_order. | 21 unit tests (10 erasure-specific) PASS |
| `8ec43e0` | **Phase 1.6 Gate 3b** — deepface integration design doc (Option A subprocess / Option B ONNX-in-Rust / **Option C defer**). Recommends C: BIPA-safest, classifications field stays None, all load-bearing surfaces (consent + audit + retention + erasure) ship without it. Forces "do we actually need classifications" to be answered by product, not spec inertia. | doc-only |
**Cross-runtime parity (post-this-wave):** 6 probes, 38/38 byte-identical assertions —
`validator(6/6) + extract_json(12/12) + session_log(4/4) + materializer(2/2) + embed(8/8) + subject_audit(6/6)`.
Run `cd /home/profit/golangLAKEHOUSE && for p in scripts/cutover/parity/*.sh; do bash "$p"; done` to re-verify.
**Three runtime-divergence classes caught + fixed by the parity probe authoring loop** (cataloged because they recur):
1. Go `omitempty` on string fields strips empty values that Rust serde always emits → broken HMAC
2. `time.RFC3339Nano` truncates trailing-zero nanoseconds where chrono `AutoSi` keeps 9 digits → broken HMAC
3. Go `json.Marshal` HTML-escapes `<>&` where serde keeps literal → broken HMAC on any field with those chars
All three have regression-locked tests; structural impossibility going forward.
**Phase 1.6 BIPA pre-launch gates — status table:**
| Item | Status | Evidence |
|---|---|---|
| Gate 1 — public retention schedule | eng-staged, ⚖ counsel pending | `docs/policies/consent/biometric_retention_schedule_v1.md` |
| Gate 2 — informed consent template | eng-staged, ⚖ counsel pending | `docs/policies/consent/biometric_consent_template_v1.md` |
| Gate 3a — photo-upload endpoint | **DONE** | 11 unit tests + live `POST /biometric/subject/{id}/photo` |
| Gate 3b — deepface classification | **design doc shipped** (`8ec43e0`) — recommends Option C (defer); awaits product confirmation | `docs/PHASE_1_6_BIPA_GATES.md` Gate 3b section |
| Gate 4 — name→ethnicity removal | **DONE** | `mcp-server/phase_1_6_gate_4.test.ts` 4/4 with bypass coverage |
| Gate 5 — destruction runbook + erasure endpoint | **eng-DONE** (`848a458`); ⚖ counsel review of runbook still pending | `docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md` + `POST /biometric/subject/{id}/erase` (21 tests) |
| §2 cryptographic attestation | eng-DONE, signature pending | `docs/attestations/BIPA_PRE_IDENTITYD_ATTESTATION_2026-05-03.md` (SHA-256 evidence hash, 3/3 checks pass on live data) |
| §3 employee training | deferred | conditional on operator population size |
**Calendar bottleneck:** counsel review of items 1/2/5-runbook/§2 attestation. Engineering long pole is Gate 3b (deepface) — design doc landed (`8ec43e0`); needs product confirmation that classifications are required before engineering starts. Recommendation in doc is Option C (defer) on BIPA-safety grounds.
**Operational state:**
- `LH_SUBJECT_AUDIT_KEY=/etc/lakehouse/subject_audit.key` (32-byte HMAC signing key, mode 0400) loaded into systemd unit. **Moved off /tmp 2026-05-05** — /tmp wipes on reboot, which on May 5 disabled `/audit` + `/biometric` endpoints (gateway fails-closed at `crates/gateway/src/main.rs:459` if signing key is absent). Persistent path is per spec line 112.
- `LH_LEGAL_AUDIT_TOKEN_FILE=/etc/lakehouse/legal_audit.token` (44-char legal-tier token, mode 0400) loaded into systemd unit
- **Key rotation 2026-05-05:** prior key was lost when /tmp wiped on reboot. New key generated at canonical path. The 5 pre-rotation audit chains for `WORKER-{1..5}` (backfill data with `consent=pending_backfill_review`) will tamper-detect under the new key — expected and correct behavior on key rotation, not a bug. New chain entries from 2026-05-05 forward verify cleanly.
- `data/_catalog/subjects/` holds 100 backfilled `WORKER-N.json` manifests + per-subject `WORKER-N.audit.jsonl` HMAC chains
- `data/biometric/uploads/<safe_id>/<ts>_<uuid>.<ext>` quarantined photo storage (mode 0700 dir / 0600 file). 2 photos uploaded for WORKER-2 during live verify.
- `/audit/subject/{id}` mounted on gateway with chain_verified=true on every probe
- `/biometric/subject/{id}/photo` mounted on gateway, refuses 403 without `consent.biometric.status="given"`
---
## WHAT LANDED 2026-05-01 → 2026-05-02 (10 commits — Lance gauntlet + cross-runtime parity wave)
| Commit | What | Verified |
|---|---|---|
| `5d30b3d` | lance: auto-build doc_id btree in `lance_migrate` handler | doc-fetch ~5ms (was ~100ms full scan) on scale_test_10m |
| `044650a` | lance-bench: same scalar build post-IVF (matches gateway) | cargo check clean |
| `7594725` | lance: 4-pack — `sanitize_lance_err` + 7 unit tests + 9-probe smoke + 10M re-bench | smoke 9/9 PASS, tests 7/7 PASS |
| `98b6647` | gateway: `IterateResponse.trace_id` echoed; session_log_path enabled | parity probes see one unified JSONL |
| `57bde63` | gateway: trace-id propagation + coordinator session JSONL (Rust parity with Go wave) | session_log_parity 4/4 |
| `ba928b1` | aibridge: drop Python sidecar from hot path; AiClient → direct Ollama | aibridge tests 32/32 PASS, /ai/embed live 768d |
| `654797a` | gateway: pub `extract_json` + `parity_extract_json` bin | extract_json_parity 12/12 |
| `c5654d4` | docs: pointer to `golangLAKEHOUSE/docs/ARCHITECTURE_COMPARISON.md` | — |
| `150cc3b` | aibridge: LRU embed cache, 236× RPS warm (78ms → 129us p50) | load test |
| `9eed982` | mcp-server: /_go/* pass-through for G5 cutover slice | — |
| `6e34ef7` | gitignore: stop tracking 100+ runtime ephemera (data/_*, lance, logs, node_modules) | untracked dropped 100+ → 0 |
| `41b0a99` | chore: add 33 real items that were sitting untracked (scripts, scenarios, kimi reports, dev UIs) | clean working tree |
**Cross-runtime parity (post-this-wave):** 32/32 across 5 probes — `validator(6/6) + extract_json(12/12) + session_log(4/4) + materializer(2/2) + embed(8/8)`. Run `cd /home/profit/golangLAKEHOUSE && for p in scripts/cutover/parity/*.sh; do bash "$p"; done` to re-verify.
**Lance backend (was untested 5 days ago, now gauntlet-ready):**
- `cargo test -p vectord-lance --release` → 7/7 PASS
- `./scripts/lance_smoke.sh` → 9/9 PASS against live gateway
- `reports/lance_10m_rebench_2026-05-02.md` — search warm ~20ms / cold ~46ms median, doc-fetch ~5ms post-btree
---
## VERIFIED WORKING RIGHT NOW
### The client demo (Staffing Co-Pilot)
**Public URL:** `https://devop.live/lakehouse/` — 200, "Staffing Co-Pilot" (159 KB SPA, leaflet maps, dark theme).
**Local URL:** `http://localhost:3700/` — same page, served by `mcp-server/index.ts` (PID 1271, started 09:48 CDT today).
**The staffers console** (the one the client was thoroughly impressed with):
- `https://devop.live/lakehouse/console` — 200, "Lakehouse — What Your Staffing System Would Do" (26 KB)
- Pulls project index via `/api/catalog/datasets` (36 datasets) + playbook memory via `/api/vectors/playbook_memory/stats` (4,701 entries with embeddings, real ops like *"fill: Maintenance Tech x2 in Milwaukee, WI"*)
Client-visible flow that works end-to-end on the public URL:
| Endpoint | Sample output |
|---|---|
| `GET /api/catalog/datasets` | 36 datasets indexed: timesheets 1M, call_log 800K, workers_500k 500K, email_log 500K, workers_100k 100K, candidates 100K, placements 50K, job_orders 15K, successful_playbooks_live 2,077 |
| `GET /api/vectors/playbook_memory/stats` | 4,701 fill operations with embeddings |
| `GET /system/summary` | 36 datasets, 2.98M rows, 60 indexes, 500K workers loaded, 1K candidates |
| `POST /intelligence/staffing_forecast` | 744 Production Workers needed in 30d, 11,281 bench (4,687 reliable), coverage 1,444%, risk=ok. Same for Electrician (need 32, bench 2,440) and Maintenance Tech (need 17, bench 5,004). |
| `POST /intelligence/permit_contracts` | permit `3442956` $500K → 3 Production Workers, 886-candidate pool, 95% fill, $36K gross. 5 more Chicago permits with 8 workers each, same pool, 95% fill, $96K each. |
| `POST /intelligence/market` | major Chicago permits ranked: $730M O'Hare, $615M 307 N Michigan, $580M casino, $445M Loop transit (real geo coords). |
| `POST /intelligence/permit_entities` | architects + contractors from permit contacts (e.g. "KACPRZYNSKI, ANDY", "SLS ELECTRICAL SERVICE"). |
| `POST /intelligence/activity` + `/intelligence/arch_signals` + `/intelligence/chat` | all 200 |
The demo tells the story: *"upcoming Chicago contracts → workers needed → coverage from the bench → architects/contractors involved → revenue and margin."* That's the "live data + anticipating contracts + complete workflow" pitch — working as of right now.
### Backend, verified live this session
| Surface | State |
|---|---|
| Gateway `:3100` | up, 4 providers configured, `/v1/health` 200 with 500K workers loaded |
| MCP server `:3700` (Co-Pilot demo) | up, all `/intelligence/*` endpoints respond |
| VCP UI `:3950` | started this session, `/data/*` 200, real numbers |
| Observer `:3800` | ring full (2,000/2,000) — older events evicted, query Langfuse for 24h-ago state |
| Sidecar `:3200` | up |
| Langfuse `:3001` | recording, `gw:/log` + `v1.chat:openrouter` traces visible |
| LLM Team UI `:5000` | up, only `extract` mode registered |
| OpenCode fleet | **40 models reachable through one `sk-*` key** (verified live `GET https://opencode.ai/zen/v1/models`) |
OpenCode catalog (live):
- Claude: opus-4-7, opus-4-6, opus-4-5, opus-4-1, sonnet-4-6, sonnet-4-5, sonnet-4, haiku-4-5
- GPT-5: 5.5-pro, 5.5, 5.4-pro, 5.4, 5.4-mini, 5.4-nano, 5.3-codex-spark, 5.3-codex, 5.2, 5.2-codex, 5.1-codex-max, 5.1-codex, 5.1-codex-mini, 5.1, 5-codex, 5-nano, 5
- Gemini: 3.1-pro, 3-flash
- GLM: 5.1, 5
- Minimax: m2.7, m2.5
- Kimi: k2.6, k2.5
- Qwen: 3.6-plus, 3.5-plus
- Other: BIG-PKL (was a typo-prone name in the catalog, model id starts with "big-pkl-something")
- Free tier: minimax-m2.5-free, hy3-preview-free, ling-2.6-flash-free, trinity-large-preview-free
### The substrate (frozen — do not re-architect)
- Distillation v1.0.0 at tag `e7636f2` — **145/145 bun tests pass, 22/22 acceptance, 16/16 audit-full**
- Output: `data/_kb/distilled_{facts,procedures,config_hints}.jsonl` + `data/vectors/distilled_{factual,procedural,config_hint}_v20260423102847.parquet`
- Auditor cross-lineage: Kimi K2.6 ↔ Haiku 4.5 alternation, Opus auto-promote on diffs >100k chars, **per-PR cap=3 with auto-reset on new head SHA**
- Pathway memory: 88 traces, 11/11 successful replays (probation gate crossed)
- Mode runner: 5 native modes; `codereview_isolation` is default; composed-corpus auto-downgrade verified Apr 26 (composed lost 5/5 vs isolation, p=0.031)
### Matrix indexer
30+ live corpora including:
- 5 versions of `workers_500k_v1..v9` (50K embedded chunks each)
- 11 batched 2K-row shards `w500k_b3..b17`
- `chicago_permits_v1` (3,420), `resumes_100k_v2` (100K candidates), `ethereal_workers_v1` (10K)
- `lakehouse_arch_v1` (2,119), `lakehouse_symbols_v1` (2,470), `lakehouse_answers_v1` (1,269), `scrum_findings_v1` (1,260)
- `kb_team_runs_v1` (12,693) + `kb_team_runs_agent` (4,407) — LLM-team play history embedded
- `distilled_factual_v20260423102507` (8) — distillation output
### Code health
- `cargo check --workspace` → **0 warnings, 0 errors**
- `bun test auditor + tests/distillation` → **145/145 pass**
- `ui/server.ts` + `auditor.ts` bundle clean
---
## DO NOT RELITIGATE
- **PR #11 is merged into `origin/main` as `ed57eda`** — do not "still need to merge PR #11."
- **Distillation tag `distillation-v1.0.0` at `e7636f2` is FROZEN** — do not re-architect schemas, scorer rules, audit fixtures.
- **Kimi forensic HOLD verdict (2026-04-27) was 2/8 false + 6/8 latent** — do not re-debate, see `reports/kimi/audit-last-week-full.md`.
- **`candidates_safe` `vertical` column bug** — fixed at catalog metadata layer in commit `c3c9c21`. Do not "discover" it again.
- **Decisions A/B/C/D from `synthetic-data-gap-report.md`** — all four scripts shipped today (`d56f08e`, `940737d`, `c3c9c21`). Do not "ask J for approval."
- **`workers_500k.phone` type fixup** — already string. The fixup script is idempotent; running it is a no-op.
- **`client_workerskjkk` typo dataset** — was breaking every SQL query (catalog had it registered, file didn't exist). Removed via `DELETE /catalog/datasets/by-name/client_workerskjkk` this session. Do not re-add. Adding a startup gate that errors on unrecognized parquet names is the long-term fix per now.md Step 2C.
- **Python sidecar dropped from hot path 2026-05-02 (`ba928b1`)** — AiClient calls Ollama directly. Do not "wire python embedding back in." `lab_ui.py` + `pipeline_lab.py` keep running as dev-only UIs (not on the runtime path).
- **Lance backend gauntlet (2026-05-02)** — sanitizer over all 5 routes, 7 unit tests, 9-probe smoke, 10M re-bench. The `doc_id` btree auto-builds inside `lance_migrate` AND `lance-bench`. Do not "discover" the missing scalar index again or the leaked filesystem paths in error bodies.
- **Cross-runtime parity = 32/32** across 5 probes in `golangLAKEHOUSE/scripts/cutover/parity/`. Do not "build a parity probe for X" without checking — validator, extract_json, session_log, materializer, and embed are all already covered.
- **Decisions tracker is `golangLAKEHOUSE/docs/ARCHITECTURE_COMPARISON.md`** — single living source of truth for cross-runtime decisions. As of 2026-05-02 it has 0 `_open_` code work items; only 2 strategic items left (Lance vs Parquet+HNSW-with-spilling, Go-vs-Rust primary cutover).
- **PRD line 70 is load-bearing — "Everything runs locally, no cloud APIs."** Yesterday's PR #13 violated this by routing customer hot-path inference to opencode/openrouter/ollama_cloud. **REVERTED 2026-05-03 (`d054c0b`).** The customer hot path (modes.toml staffing_inference, doc_drift_check; execution_loop overseer escalation) is now local Ollama (qwen3.5:latest). Cloud providers stay configured in providers.toml for **explicit dev-tool opt-in only** (scrum, auditor, bot/propose). Do NOT re-add cloud models to the hot path defaults — the customer demo runs on local + free.
- **`/v1/usage` shows by_provider=ollama only** for any customer-shape request. If you see by_provider including kimi/openrouter/opencode/ollama_cloud for normal /v1/iterate or /v1/respond traffic, something has regressed. Verify with: `curl -sf http://127.0.0.1:3100/v1/usage | jq .by_provider`.
- **`./scrum` is a TOOL, not architecture.** Lives at repo root. J runs it manually. Outputs findings to `data/_kb/scrum_findings.jsonl` (the KB). Findings inform development; they do NOT auto-fold into PRDs/design docs/code. If you find yourself proposing to "wire scrum into [some pipeline]" — stop. It's J's tool.
- **Test code in main is ACTIVELY being cleaned out.** 2026-05-03 commits `6aafd41` + `f4ebd22` removed 12 orphaned dev experiments (~2900 LOC) from `tests/real-world/` and `scripts/`. If you find more zero-reference experimental files, surface them — don't auto-delete unless the pattern is clearly a one-time-experiment-with-zero-consumers like the ones already removed.
---
## FIXES MADE THIS SESSION (2026-04-27 evening)
1. **`crates/gateway/src/v1/iterate.rs:93`** — `state` → `_state` (cleared the one cargo warning).
2. **`lakehouse-ui.service` (Dioxus)** — disabled. Was failing 7,242 times against a missing `target/dx/ui/debug/web/public` build dir. `systemctl stop && disable`.
3. **VCP UI on `:3950`** — started `bun run ui/server.ts` (PID 1162212, log `/tmp/lakehouse_ui.log`). `/data/*` endpoints now 200 with real data.
4. **`client_workerskjkk` catalog entry** — `DELETE /catalog/datasets/by-name/client_workerskjkk` removed the dead manifest. **This was the actual root cause** of `/system/summary` reporting `workers_500k_rows: 0` and the demo showing zero bench. Every SQL query was failing schema inference on the missing file before reaching its target table. Fixed → `workers_500k_rows: 500000`, `candidates_rows: 1000`, demo coverage flipped from "critical 0%" to actual percentages on devop.live/lakehouse.
## FIXES MADE THIS SESSION (2026-04-28 early — face pool)
5. **Synthetic StyleGAN face pool — 1000 faces, gender+race+age tagged.** `scripts/staffing/fetch_face_pool.py` fetches from thispersondoesnotexist.com; `scripts/staffing/tag_face_pool.py --min-age 22` runs deepface and excludes minors. `data/headshots/manifest.jsonl` now has gender (494 men / 458 women), race (caucasian 662 · east_asian 128 · hispanic 86 · middle_eastern 59 · black 14 · south_asian 3), age, and 48 minor exclusions. Server pool = 952 servable faces.
6. **`mcp-server/index.ts:1308` `/headshots/:key` route** — gender×race×age intersection bucketing with graceful fallback (gender-only → all). Same key always returns same face; different keys spread evenly.
7. **`/headshots/_thumbs/` pre-resized 384×384 webp** (60× smaller: 587KB → ~11KB). Without this, 40-card grids overran Chrome's parallel-connection budget and ~75% of tiles never finished decoding. Generated via parallel ffmpeg (`xargs -P 8`); `.gitignore`d.
8. **`mcp-server/search.html` + `console.html`** — dropped `img.loading='lazy'`. With 11KB thumbs, eager load is cheap (~500KB for 50 cards) and avoids the off-screen race that lazy decode produced.
9. **ComfyUI on-demand uniqueness — `serve_imagegen.py:32`** added `seed` to `_cache_key()` (was caching by prompt only — 3 different worker seeds collapsed to 1 cached image). Verified: seed=839185194/195/196 → 3 distinct md5s.
10. **`mcp-server/index.ts:1234` `/headshots/generate/:key`** — ComfyUI hot-path that derives a deterministic-per-worker seed via djb2-style hash; cold ~1.5s, cached ~1ms. Worker prompt format: `professional corporate headshot portrait of a {age}-year-old {race} {gender}, {role}, neutral expression, plain studio background, soft natural lighting, sharp focus, photorealistic, dslr`. Cache at `data/headshots_gen/` (gitignored, regeneratable).
11. **Confidence-default name resolution** in `search.html` — `genderFor()` and `guessEthnicityFromFirstName()` lookup tables (FEMALE_NAMES, MALE_NAMES, NAMES_HISPANIC, NAMES_BLACK, NAMES_SOUTH_ASIAN, NAMES_EAST_ASIAN, NAMES_MIDDLE_EASTERN). Xavier → man+hispanic, Aisha → woman+black, etc. Every worker resolves to a face-pool bucket.
End-to-end verified: playwright run on `https://devop.live/lakehouse/?q=forklift+operators+IL` → 21/21 cards loaded, 0 broken, all 384×384 webp thumbs.
---
## ⚠ PRODUCTION-READY BLOCKER (2026-05-03)
**Audit-trail capability is the gate to client signature.** Smoke + parity tests prove the surface compiles; they do NOT prove an audit response can be produced for a specific person. Staffing client won't sign without defensible discrimination-claim response capability.
**Authoritative document:** `docs/AUDIT_TRAIL_PRD.md` — drafted 2026-05-03. Defines worked example (John Martinez at Warehouse B), the per-decision audit row schema, the surface map of where decisions happen today, current-state-vs-target gap table, and 9-phase implementation sequence.
**Phase 1 (discovery walk) requires NO J approval — it's read-only.** Phases 2+ have explicit open questions in §10 of the PRD that need J's call before they can start.
Until phase 9 exit criterion is met, **do not claim "production-ready" on customer-facing surfaces.** Internal substrate (Lance, sidecar drop, parity probes) is solid; subject-of-record audit story is not.
---
## OPEN — but not blocking the demo
| Item | What | When to act |
|---|---|---|
| `modes.toml` `staffing_inference.matrix_corpus` | still says `workers_500k_v8`. v9 in vector index is from Apr 17 (raw-sourced, not safe-view). The new `build_workers_v9.sh` rebuilds from `workers_safe`. | Run when you have 30+ min for the rebuild. |
| Open PRs #6, #7, #10 | All closed 2026-05-02 — superseded / empty / stale. PR #12 merged 2026-05-03 (`a5d9070`). PR #13 merged 2026-05-03 (`feb638e`). | Done. |
| `test/enrich-prd-pipeline` branch | 35 unmerged commits, includes more-evolved auditor/inference.ts (666 vs main's 580 lines), curation+fact-extractor wiring | Reconcile or formally archive — see `memory/project_unmerged_architecture_work.md`. |
| `federation-hnsw-trials` stash | Lance + S3/MinIO prototype, `aws-config` crate added, 708 insertions | Phase B from EXECUTION_PLAN.md — revisit when Parquet vector ceiling actually hurts. |
| `candidates` manifest drift | manifest 100K vs SQL 1K. Cosmetic. | Run a metadata resync if it matters. |
---
## RUNTIME CHEATSHEET
```bash
# Verify the demo (public + local both work)
curl -sS https://devop.live/lakehouse/ # Co-Pilot HTML
curl -sS https://devop.live/lakehouse/console # staffers console
curl -sS -X POST https://devop.live/lakehouse/intelligence/staffing_forecast \
-d '{}' -H 'content-type: application/json' \
| jq '.forecast[] | {role, demand_workers, bench_total, coverage_pct, risk}'
# Restart sequence (after Rust changes)
sudo systemctl restart lakehouse.service # gateway :3100
sudo systemctl restart lakehouse-auditor # auditor daemon
sudo systemctl restart lakehouse-observer # observer :3800
# UI bun on :3950 is NOT systemd-managed (lakehouse-ui.service is disabled).
# Restart manually: kill <pid>; nohup bun run ui/server.ts > /tmp/lakehouse_ui.log 2>&1 &
# Health checks
curl -sS http://localhost:3100/v1/health | jq # workers_count, providers
curl -sS http://localhost:3100/vectors/pathway/stats | jq
curl -sS http://localhost:3100/v1/usage | jq # since-restart cost
curl -sS http://localhost:3700/system/summary | jq # dataset counts
```
---
## VISION — what we're actually building (not what's done)
J's framing for the legacy staffing company:
- Pull live data, anticipate contracts based on Chicago permits → real architect/contractor associations, headcount, time period, money, scope.
- Hybrid + memory index → search large corpora cheaply.
- Email comes in → verify against contract; SMS comes in → alert when index changes.
- Real-time.
- Invent metrics nobody else has using the hybrid index.
- Next stage: workers download an app → geolocation clock-in → automatic responsiveness measurement, no user effort, with incentives for using it.
- Find people getting certificates (passive cert tracking).
- Pull union data → bring contracts that work for **employees**, not just employers.
- All metrics visible, nothing hidden, value-aligned with what each side actually needs.
If a future session is shaving away from this vision toward "fix the cutover" or "land Phase X," the vision wins. Phases are scaffolding for the vision, not the goal.
---
## CURRENT PLAN — fix the demo for the legacy staffing client
Built from playwright audit of the live demo (2026-04-27 evening). Each item ends in something the client can SEE, not internal cleanups.
**Demo state is anchored by git tag `demo-2026-04-27`** (commit `ed57eda`, the merge of PR #11). To restore code state: `git checkout demo-2026-04-27`. To restore runtime state: `DELETE /catalog/datasets/by-name/client_workerskjkk` (catalog hot-fix is not in git).
### P1 — Search box that actually filters (highest visible impact)
**Problem:** typing in `#sq` and pressing Enter fires `POST /intelligence/chat` with body `{"message":"<query>"}`. The state (`#sst`) and role (`#srl`) selects are ignored — never sent in the body. So every search returns a generic chat completion, never a SQL+vector hybrid filter against `workers_500k`. That is the "cached/generic response" the client sees.
**Fix:** in `mcp-server/search.html`, change the search-submit handler to call the real worker search endpoint with `{query, state, role, top_k}`. The MCP `search_workers` tool surface already exists; route the form there. Render returned worker rows in the existing card grid.
**Done when:** typing "forklift" + state IL + role "Forklift Operator" returns ≤ top_k IL Forklift Operators, and changing state to WI returns different workers.
### P2 — Contractor-name click → `/contractor` profile page
**Problem:** clicking a contractor name in any rendered card stays on `/lakehouse/`. URL doesn't change.
**Fix:** wrap contractor names in `<a href="/contractor?name=<encoded>">`. The page `mcp-server/contractor.html` (14.8 KB, "Contractor Profile · Staffing Co-Pilot") already exists at `/contractor` and the data endpoint `/intelligence/contractor_profile` already returns rich data.
**Then check contractor.html actually shows:** full history of every record the database has on that contractor + heat map of locations underneath + relevant info (per J 2026-04-27). If the page is incomplete, finish it. Otherwise just wire the link.
**Done when:** clicking "KACPRZYNSKI, ANDY" opens a profile with: every Chicago permit they're contact_1 or contact_2 on, a leaflet map with markers for each address, and any matched workers from prior placements at their sites.
### P3 — Substrate signal at the bottom shows the right numbers
**Problem:** J reports the bottom panel says "playbook memory empty, 80 traces 0 replies." Reality from the live endpoints: `/api/vectors/playbook_memory/stats` = 4,701 entries with embeddings; `/vectors/pathway/stats` = 88 traces, 11/11 replays.
**Fix:** find the renderer in search.html that builds the substrate signal panel; verify it's hitting the right endpoints and reading the right keys; fix shape mismatches.
**Done when:** bottom panel shows real numbers (4,701 playbooks, 88 traces, 11/11 replays) and references at least one specific recent operation from the playbook stats sample.
### P4 — Top nav reflects today's architecture
**Problem:** Walkthrough/Architecture/Spec/Onboard/Alerts/Workspaces tabs all return 200 but content is from old architecture. Doesn't mention: gateway scratchpad, memory indexer, ranker, mode runner, OpenCode 40-model fleet, distillation substrate, auditor cross-lineage.
**Fix:** rewrite `mcp-server/proof.html` (or add a single new page "What's running" that replaces Architecture+Spec) to describe what's actually shipped as of `demo-2026-04-27`. Keep one architecture page, drop redundancy. Either complete or hide Onboard/Alerts/Workspaces — J's call which.
**Done when:** the architecture page tells a non-technical reader, in 2 minutes, what each piece does in coordinator-relatable terms ("intern that read every email", not "3-stage adversarial inference pipeline").
### P5 — Caching for the project-index build_signal (J flagged unfinished)
**Problem:** "we never finished our caching for project index build signal it's not pulling new information." Need to find what `build_signal` refers to. Likely a scrum/auditor signal that should rebuild the `lakehouse_arch_v1` corpus on commit but isn't wired to.
**Fix:** identify the build-signal pipeline (likely in `auditor/` or `crates/vectord/`), wire its emit to a corpus rebuild, verify by making a test commit and watching the new chunk appear in `/vectors/indexes` for `lakehouse_arch_v1`.
**Done when:** committing a new file to `crates/` causes `lakehouse_arch_v1` chunk_count to increase within N minutes.
### P0 — Anchor the demo state (DONE)
Tagged `ed57eda` as `demo-2026-04-27`. Future sessions: `git checkout demo-2026-04-27` to land in this exact code state.
---
## EXECUTION ORDER
1. **P1 first** — biggest visible bug, ~30-60 min
2. **P2 next** — contractor click is the second-biggest "doesn't work" the client sees, ~20 min if profile is mostly done
3. **P3** — small fix, big "looks alive" win
4. **P4** — biggest scope; might split across sessions
5. **P5** — feature work, only after the visible bugs are fixed
Each item commits independently with the format `demo: P<n> — <one-line>` so the commit log doubles as a progress journal. After each merge to main, re-tag `demo-latest` to point at the new HEAD.
Stop here and let J pick which item to start with. Do not silently extend scope.
+2 -26
View File
@@ -23,7 +23,6 @@ import { runStaticCheck } from "./checks/static.ts";
import { runDynamicCheck } from "./checks/dynamic.ts"; import { runDynamicCheck } from "./checks/dynamic.ts";
import { runInferenceCheck } from "./checks/inference.ts"; import { runInferenceCheck } from "./checks/inference.ts";
import { runKbCheck } from "./checks/kb_query.ts"; import { runKbCheck } from "./checks/kb_query.ts";
import { runKimiArchitectCheck } from "./checks/kimi_architect.ts";
const VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/verdicts"; const VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/verdicts";
// Playbook for audit findings — one row per block/warn finding from a // Playbook for audit findings — one row per block/warn finding from a
@@ -68,29 +67,6 @@ export async function auditPr(pr: PrSnapshot, opts: AuditOptions = {}): Promise<
...kbFindings, ...kbFindings,
]; ];
// Kimi-architect second-pass review. Off by default; enabled with
// LH_AUDITOR_KIMI=1. Sequential (not in the parallel block above)
// because it consumes the prior findings as context — Kimi sees what
// deepseek already flagged and is asked "what did everyone miss?"
// Failure-isolated by design: any error returns a single info-level
// skip finding so the existing audit pipeline never blocks on Kimi.
if (process.env.LH_AUDITOR_KIMI === "1") {
try {
const kimiFindings = await runKimiArchitectCheck(diff, allFindings, {
pr_number: pr.number,
head_sha: pr.head_sha,
});
allFindings.push(...kimiFindings);
} catch (e) {
allFindings.push({
check: "kimi_architect",
severity: "info",
summary: `kimi_architect outer error — ${(e as Error).message.slice(0, 160)}`,
evidence: [(e as Error).stack?.slice(0, 360) ?? ""],
});
}
}
const duration_ms = Date.now() - t0; const duration_ms = Date.now() - t0;
const metrics = { const metrics = {
audit_duration_ms: duration_ms, audit_duration_ms: duration_ms,
@@ -208,7 +184,7 @@ function formatReviewBody(v: Verdict): string {
lines.push(""); lines.push("");
// Per-check sections, only if the check produced findings. // Per-check sections, only if the check produced findings.
const checkOrder = ["static", "dynamic", "inference", "kb_query", "kimi_architect"] as const; const checkOrder = ["static", "dynamic", "inference", "kb_query"] as const;
for (const check of checkOrder) { for (const check of checkOrder) {
const fs = byCheck[check] ?? []; const fs = byCheck[check] ?? [];
if (fs.length === 0) continue; if (fs.length === 0) continue;
@@ -241,6 +217,6 @@ function formatReviewBody(v: Verdict): string {
return lines.join("\n"); return lines.join("\n");
} }
function stubFinding(check: "dynamic" | "inference" | "kimi_architect", why: string): Finding[] { function stubFinding(check: "dynamic" | "inference", why: string): Finding[] {
return [{ check, severity: "info", summary: `${check} check skipped — ${why}`, evidence: [why] }]; return [{ check, severity: "info", summary: `${check} check skipped — ${why}`, evidence: [why] }];
} }
+239 -158
View File
@@ -18,42 +18,36 @@ import { readFile, mkdir, appendFile } from "node:fs/promises";
import { extractFacts } from "../fact_extractor.ts"; import { extractFacts } from "../fact_extractor.ts";
const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100"; const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
// Rebuild 2026-04-26: route claim verification through /v1/mode/execute const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "gpt-oss:120b";
// (task_class=pr_audit) so we get pathway memory + lakehouse_answers_v1 // Tie-breaker for claims where the N=3 consensus produces a 1-1-1
// + JSON-shaped framing molded into ONE prompt. The hand-rolled // split (genuinely borderline). Different architecture from the
// systemMsg/userMsg path was reinventing the mode runner badly. // primary reviewer (gpt-oss) so the tie-break isn't correlated with
// // the original disagreement. qwen3-coder:480b is a newer coding
// 2026-04-27 update: original default kimi-k2:1t hit a sustained // specialist at 480B params, well-suited to PR-diff claim verification
// upstream outage on Ollama Cloud (consistent 500 ISE across hours of // and distinct in training lineage from gpt-oss.
// retries — verified with trivial 8-token probes). Swapped default to const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "qwen3-coder:480b";
// deepseek-v3.1:671b which is proven working end-to-end through the
// pr_audit mode runner during Phase 5 distillation acceptance testing.
//
// 2026-09-02 update: Ollama Cloud subscription lapsed — every
// ollama_cloud model 402s ("requires a subscription"). J's call: sweep
// the auditor to OpenRouter. Same deepseek lineage via OpenRouter;
// tie-breaker moves to grok-4.3 because OpenRouter 404s grok-4.1-fast
// as deprecated. Both probed live through the gateway before landing.
// Vendor lineages stay distinct so consensus + tie-break won't
// fail-correlate.
const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "openrouter/deepseek/deepseek-chat-v3.1";
const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "openrouter/x-ai/grok-4.3";
const N_CONSENSUS = Number(process.env.LH_AUDITOR_CONSENSUS_N ?? 3); const N_CONSENSUS = Number(process.env.LH_AUDITOR_CONSENSUS_N ?? 3);
const AUDIT_DISCREPANCIES_JSONL = "/home/profit/lakehouse/data/_kb/audit_discrepancies.jsonl"; const AUDIT_DISCREPANCIES_JSONL = "/home/profit/lakehouse/data/_kb/audit_discrepancies.jsonl";
// 40KB comfortably fits the consensus models' context windows // 40KB comfortably fits gpt-oss:120b's context. PR #1 (~39KB) was
// (deepseek-v3.1 64K, gpt-oss-120b 128K). When the raw PR diff // previously truncated at 15KB causing the reviewer to miss later
// exceeds this, we truncate and signal it via curationNote — the // files (gitea.ts, policy.ts) and flag "no Gitea client present" as a
// pr_audit mode runner's matrix retrieval (lakehouse_answers_v1 + // block finding when the file was simply outside the truncation window.
// arch + symbols) supplies the cross-PR context that tree-split //
// used to synthesize from scratch. Tree-split itself was retired // Above this threshold we curate via tree-split rather than truncate,
// 2026-04-27 (see commit deleting treeSplitDiff/callCloud/SHARD_*). // following the scrum_master pattern: shard the diff, summarize each
// shard against the claim-verification task, merge into a compact
// scratchpad, then ask the cloud to verify claims against the
// scratchpad. This gives the cloud full-PR fidelity without bursting
// its context window (observed failure mode: empty response or
// unparseable output when prompt exceeds model's comfortable range).
const MAX_DIFF_CHARS = 40000; const MAX_DIFF_CHARS = 40000;
// Tree-split kicks in above this. 30KB is below MAX_DIFF_CHARS so we
// curate BEFORE truncation would happen — never lose signal to a hard
// cut. Shard size is chosen so ~10 shards cover PR #8-size diffs in a
// reasonable round-trip budget.
const CURATION_THRESHOLD = 30000;
const DIFF_SHARD_SIZE = 4500;
const CALL_TIMEOUT_MS = 120_000; const CALL_TIMEOUT_MS = 120_000;
// Mode runner can take longer than a raw /v1/chat call because it does
// pathway-fingerprint lookup + matrix retrieval + relevance filter
// before the LLM call. Budget extra time so we don't trip on a slow
// answers-corpus search.
const MODE_RUNNER_TIMEOUT_MS = 240_000;
const REPO_ROOT = "/home/profit/lakehouse"; const REPO_ROOT = "/home/profit/lakehouse";
export interface InferenceContext { export interface InferenceContext {
@@ -92,23 +86,26 @@ export async function runInferenceCheck(
}]; }];
} }
// 2026-04-27 architecture simplification: dropped the tree-split // Diff source for the cloud prompt — either the raw diff (small
// scratchpad layer. Rationale: the mode runner's pr_audit pipeline // enough to fit), or a tree-split scratchpad (curation layer). We
// pulls from lakehouse_answers_v1 (gold-standard prior audits) + // prefer curation to truncation: truncation silently drops files
// lakehouse_arch_v1 + lakehouse_symbols_v1 via matrix retrieval. That // past the window; curation summarizes them so the cloud still sees
// corpus IS the cross-PR context the tree-split was synthesizing // what changed, just densified.
// from scratch on every audit run. With the distillation substrate let diffForPrompt: string;
// shipped (commits 27b1d27..1b433a9), per-shard fact extraction is let curationNote = "";
// redundant — and gpt-oss:120b at 168 calls/audit was the dominant if (diff.length > CURATION_THRESHOLD) {
// cost. Now: truncate diff to MAX_DIFF_CHARS, hand straight to the const ts = await treeSplitDiff(diff, verifiable);
// mode runner, let retrieval supply context. ONE strong-model call diffForPrompt = ts.scratchpad;
// per consensus rep × N=3 reps = 3 calls total per audit. curationNote = ` (curated: ${diff.length} chars → ${ts.shards} shards → scratchpad ${ts.scratchpad.length} chars)`;
const truncated = diff.length > MAX_DIFF_CHARS } else {
? diff.slice(0, MAX_DIFF_CHARS) + `\n...[${diff.length - MAX_DIFF_CHARS} more chars truncated — the pr_audit mode runner has matrix retrieval against lakehouse_answers_v1 + arch + symbols for cross-PR context]` diffForPrompt = diff;
: diff; }
const curationNote = diff.length > MAX_DIFF_CHARS // Belt-and-suspenders truncation — even a tree-split scratchpad
? ` (truncated ${diff.length}→${MAX_DIFF_CHARS} chars; matrix retrieval supplies cross-PR context)` // shouldn't exceed MAX_DIFF_CHARS in practice, but guard anyway so
: ""; // pathological inputs can't burst the prompt.
const truncated = diffForPrompt.length > MAX_DIFF_CHARS
? diffForPrompt.slice(0, MAX_DIFF_CHARS) + `\n...[${diffForPrompt.length - MAX_DIFF_CHARS} more chars truncated]`
: diffForPrompt;
// Build the reviewer prompt in the same shape as run_codereview's // Build the reviewer prompt in the same shape as run_codereview's
// review stage (llm_team_ui.py:10950), adapted for claim verification: // review stage (llm_team_ui.py:10950), adapted for claim verification:
@@ -117,20 +114,79 @@ export async function runInferenceCheck(
// "Review: bugs/security/perf/style/edge. Provide corrected code." // "Review: bugs/security/perf/style/edge. Provide corrected code."
// We add: claim list upfront + ask for structured JSON verdict. // We add: claim list upfront + ask for structured JSON verdict.
// //
// Curation flag is now just a truncation flag — when the diff was // When the diff was curated (tree-split scratchpad), we add an
// cut, tell the reviewer it didn't see the full picture so it doesn't // explicit anti-false-positive instruction: the scratchpad is a
// confidently mark a claim NOT BACKED based on absence in the // distillation, not the full source, so absence-from-scratchpad is
// (potentially incomplete) input. // NOT evidence of absence-from-diff. Mirrors the fix we made in
// scrum_master's review prompt for the same class of error.
const isCurated = curationNote.length > 0; const isCurated = curationNote.length > 0;
const prNumber = ctx?.pr_number ?? 0; const curationGuard = isCurated
? [
"",
"CRITICAL: the 'Diff' below is a curated multi-shard scratchpad,",
"NOT the full raw diff. The scratchpad distills each shard down",
"to facts useful for claim verification and drops the rest.",
"DO NOT flag a function/field/feature as 'missing' or 'not",
"implemented' based solely on its absence from the scratchpad —",
"absence in a distillation is NOT evidence of absence in the",
"actual diff. Only judge a claim NOT BACKED when the scratchpad",
"DIRECTLY contradicts it (e.g. scratchpad shows the function was",
"added empty, or shows the claimed code path is a stub).",
"Skip the unflagged_gaps section entirely when operating on a",
"curated scratchpad — you can't reliably detect gaps from a",
"distillation, and false positives there are worse than misses.",
].join("\n")
: "";
const systemMsg = [
"You review pull-request diffs against the author's own ship-claims.",
"For each claim, decide: is it backed by actual code in the diff, or is",
"it placeholder / aspirational / unwired?",
"",
"A claim is BACKED when the diff contains a real code path that delivers",
"the claimed behavior. A claim is NOT BACKED when:",
" - the claim asserts functionality but the diff only adds types/fields",
" with no consumer",
" - the claim mentions tests but no test function was added",
" - the claim claims integration but the integration point is a stub",
" - the diff contains unimplemented!() / todo!() / TODO comments",
" - the claim says 'works end-to-end' but the diff has no end-to-end test",
curationGuard,
"",
"Respond with strict JSON only. No prose before or after. Shape:",
"{",
' "claim_verdicts": [',
' {"claim_idx": 0, "backed": false, "evidence": "short reason"}',
" ],",
' "unflagged_gaps": [',
' {"location": "file:line", "summary": "short description"}',
" ]",
"}",
].join("\n");
// N=3 consensus — fire the mode runner three times in parallel. const userMsg = [
// Each /v1/mode/execute call composes pathway memory + answers corpus `Ship-claims the author made (numbered 0..N-1):`,
// + JSON-shaped pr_audit framing internally, so the auditor's only verifiable.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`).join("\n"),
// job here is to vote-aggregate. Wall-clock ~= single call. "",
`Diff:`,
"```",
truncated,
"```",
"",
`For each numbered claim above, emit a claim_verdicts entry. For gaps the`,
`author DIDN'T claim but that look like placeholder code, emit unflagged_gaps.`,
`Strict JSON only, matching the shape described. No prose outside JSON.`,
].join("\n");
// N=3 consensus — run the primary reviewer in parallel, collect
// all three parsed responses, majority-vote per claim. Parallel
// (Promise.all) because each call is ~20-30s and they're independent;
// wall-clock stays ~same as single call, cost 3x tokens. Empirical
// justification: in 3-run determinism tests, 7/8 findings were
// stable but 1 flipped across runs — majority vote stabilizes the
// flipping class without losing the stable signal.
const primaryRuns = await Promise.all( const primaryRuns = await Promise.all(
Array.from({ length: N_CONSENSUS }, () => Array.from({ length: N_CONSENSUS }, () =>
runModeRunnerInference(truncated, verifiable, prNumber, isCurated, MODEL)), runCloudInference(systemMsg, userMsg, MODEL)),
); );
const parsedRuns = primaryRuns.filter(r => r.parsed !== null); const parsedRuns = primaryRuns.filter(r => r.parsed !== null);
@@ -153,19 +209,9 @@ export async function runInferenceCheck(
interface Votes { trues: number; falses: number; evidences: string[] } interface Votes { trues: number; falses: number; evidences: string[] }
const votesByClaim = new Map<number, Votes>(); const votesByClaim = new Map<number, Votes>();
const unflaggedByRun: any[][] = []; const unflaggedByRun: any[][] = [];
// The N=3 consensus calls run via Promise.all — wall-clock is let totalTokens = 0;
// bounded by the SLOWEST call, not the sum. Pre-2026-04-27 we
// summed and reported "Xms total" which double/triple-counted
// (Opus self-audit caught it). Use max for accurate wall-clock.
let maxLatencyMs = 0;
let totalEnrichedChars = 0;
let bugFingerprintsSeen = 0;
let matrixKeptSeen = 0;
for (const run of parsedRuns) { for (const run of parsedRuns) {
maxLatencyMs = Math.max(maxLatencyMs, run.latency_ms ?? 0); totalTokens += run.tokens;
totalEnrichedChars += run.enriched_chars ?? 0;
bugFingerprintsSeen = Math.max(bugFingerprintsSeen, run.bug_fingerprints ?? 0);
matrixKeptSeen = Math.max(matrixKeptSeen, run.matrix_kept ?? 0);
unflaggedByRun.push(Array.isArray(run.parsed?.unflagged_gaps) ? run.parsed.unflagged_gaps : []); unflaggedByRun.push(Array.isArray(run.parsed?.unflagged_gaps) ? run.parsed.unflagged_gaps : []);
for (const v of run.parsed?.claim_verdicts ?? []) { for (const v of run.parsed?.claim_verdicts ?? []) {
const idx = Number(v?.claim_idx); const idx = Number(v?.claim_idx);
@@ -187,11 +233,10 @@ export async function runInferenceCheck(
findings.push({ findings.push({
check: "inference", check: "inference",
severity: "info", severity: "info",
summary: `pr_audit mode runner completed (model=${MODEL}, consensus=${parsedRuns.length}/${N_CONSENSUS}, ${maxLatencyMs}ms wall-clock)${curationNote}`, summary: `cloud review completed (model=${MODEL}, consensus=${parsedRuns.length}/${N_CONSENSUS}, tokens=${totalTokens})${curationNote}`,
evidence: [ evidence: [
`claims voted: ${votesByClaim.size}`, `claims voted: ${votesByClaim.size}`,
`parsed runs: ${parsedRuns.length} / ${N_CONSENSUS}`, `parsed runs: ${parsedRuns.length} / ${N_CONSENSUS}`,
`enrichment: ${bugFingerprintsSeen} bug fingerprints, ${matrixKeptSeen} answers-corpus chunks, prompt avg ${Math.round(totalEnrichedChars / Math.max(parsedRuns.length, 1))} chars`,
], ],
}); });
@@ -221,9 +266,8 @@ export async function runInferenceCheck(
notBacked = false; notBacked = false;
resolution = "majority_backed"; resolution = "majority_backed";
} else { } else {
// Tie. Run tie-breaker with a different-architecture model // Tie. Run tie-breaker with a different-architecture model.
// through the same mode runner so framing/enrichment match. const tb = await runCloudInference(systemMsg, userMsg, TIEBREAKER_MODEL);
const tb = await runModeRunnerInference(truncated, verifiable, prNumber, isCurated, TIEBREAKER_MODEL);
if (tb.parsed) { if (tb.parsed) {
const tv = (tb.parsed.claim_verdicts ?? []).find((v: any) => Number(v?.claim_idx) === idx); const tv = (tb.parsed.claim_verdicts ?? []).find((v: any) => Number(v?.claim_idx) === idx);
if (tv?.backed === false) { if (tv?.backed === false) {
@@ -291,13 +335,9 @@ export async function runInferenceCheck(
// don't exit before extraction lands; the systemd poller has plenty // don't exit before extraction lands; the systemd poller has plenty
// of headroom (90s cycle vs ~15s extraction). A failure inside // of headroom (90s cycle vs ~15s extraction). A failure inside
// extractAndPersistFacts is caught + logged but never throws. // extractAndPersistFacts is caught + logged but never throws.
// Post-2026-04-27: extraction now runs against the truncated diff
// (no scratchpad to extract from since tree-split was retired).
// Fact extraction is still useful for surfacing entities/symbols
// into audit_facts.jsonl even from truncated input.
if (isCurated && ctx && process.env.LH_AUDITOR_SKIP_EXTRACT !== "1") { if (isCurated && ctx && process.env.LH_AUDITOR_SKIP_EXTRACT !== "1") {
try { try {
await extractAndPersistFacts(truncated, ctx); await extractAndPersistFacts(diffForPrompt, ctx);
} catch (e) { } catch (e) {
console.error(`[inference] fact extraction failed: ${(e as Error).message}`); console.error(`[inference] fact extraction failed: ${(e as Error).message}`);
} }
@@ -354,106 +394,60 @@ export async function runInferenceCheck(
return findings; return findings;
} }
// Single mode-runner call — consensus + tie-breaker dispatch through // Single cloud call — the consensus loop calls this N times in
// here. Returns parsed JSON shape + telemetry from /v1/mode/execute // parallel. Returns the parsed JSON shape + token usage + any error
// (latency, enrichment metrics) + any error diagnostic. NEVER throws. // diagnostic. NEVER throws; the consensus aggregator handles partial
// The consensus aggregator handles partial failures by dropping // failures by dropping non-parsed runs from the vote.
// non-parsed runs from the vote.
interface CloudRunResult { interface CloudRunResult {
parsed: any | null; parsed: any | null;
latency_ms: number; tokens: number;
enriched_chars: number;
bug_fingerprints: number;
matrix_kept: number;
error?: string; // "unreachable" | "non_200" | "unparseable" error?: string; // "unreachable" | "non_200" | "unparseable"
diagnostic?: string; // first 200 chars for debugging diagnostic?: string; // first 200 chars for debugging
model: string; model: string;
} }
async function runModeRunnerInference( async function runCloudInference(systemMsg: string, userMsg: string, model: string): Promise<CloudRunResult> {
diffOrScratchpad: string,
claims: Claim[],
prNumber: number,
isCurated: boolean,
model: string,
): Promise<CloudRunResult> {
// user_question carries the claim list + the curation note (if any).
// pr_audit's framing (mode.rs FRAMING_PR_AUDIT) holds the JSON shape +
// strict-output rules so we don't repeat them here.
const claimDigest = claims
.map((c, i) => ` ${i}. [${c.strength}] "${c.text}" at ${c.location}`)
.join("\n");
const curationNote = isCurated
? "\n\nNOTE: the FILE below is a curated multi-shard scratchpad of the diff, not the raw diff itself. Absence in the scratchpad is NOT evidence of absence in the actual diff. Only mark backed=false on direct contradiction (e.g. scratchpad shows the function is empty / a stub). Skip unflagged_gaps entirely when scratchpad is curated."
: "";
const userQuestion = [
"Verify each ship-claim against the diff (or scratchpad).",
"",
"Ship-claims (numbered 0..N-1):",
claimDigest,
curationNote,
"",
"Every claim above must produce exactly one claim_verdicts entry. Output strict JSON only — no prose outside the JSON object.",
].join("\n");
let resp: Response; let resp: Response;
try { try {
resp = await fetch(`${GATEWAY}/v1/mode/execute`, { resp = await fetch(`${GATEWAY}/v1/chat`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
task_class: "pr_audit", provider: "ollama_cloud",
file_path: `pr-${prNumber}.diff`, model,
file_content: diffOrScratchpad, messages: [
user_question: userQuestion, { role: "system", content: systemMsg },
force_model: model, { role: "user", content: userMsg },
force_temperature: 0, ],
// temp=0 (greedy) + think=true. think=true is required for
// gpt-oss:120b — without it the model returns empty content
// on large prompts. Variance from the think trace is observed
// in practice, which is why we use N=3 consensus, not single-
// call determinism.
max_tokens: 3000,
temperature: 0,
think: true,
}), }),
signal: AbortSignal.timeout(MODE_RUNNER_TIMEOUT_MS), signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
}); });
} catch (e) { } catch (e) {
return { return { parsed: null, tokens: 0, error: "unreachable", diagnostic: (e as Error).message.slice(0, 200), model };
parsed: null, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 0,
error: "unreachable", diagnostic: (e as Error).message.slice(0, 200), model,
};
} }
if (!resp.ok) { if (!resp.ok) {
return { return { parsed: null, tokens: 0, error: "non_200", diagnostic: `${resp.status}: ${(await resp.text()).slice(0, 160)}`, model };
parsed: null, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 0,
error: "non_200", diagnostic: `${resp.status}: ${(await resp.text()).slice(0, 160)}`, model,
};
} }
let body: any; let body: any;
try { body = await resp.json(); } try { body = await resp.json(); }
catch (e) { catch (e) { return { parsed: null, tokens: 0, error: "unparseable", diagnostic: (e as Error).message, model }; }
return { const content: string = body?.choices?.[0]?.message?.content ?? "";
parsed: null, latency_ms: 0, enriched_chars: 0, bug_fingerprints: 0, matrix_kept: 0, const tokens: number = body?.usage?.total_tokens ?? 0;
error: "unparseable", diagnostic: (e as Error).message, model,
};
}
const content: string = typeof body?.response === "string" ? body.response : "";
const parsed = extractJson(content); const parsed = extractJson(content);
// Number-coerced extractors so a non-numeric upstream value (string, if (!parsed) {
// null, NaN) collapses to 0 instead of poisoning downstream return { parsed: null, tokens, error: "unparseable", diagnostic: content.slice(0, 200), model };
// arithmetic. Caught 2026-04-27 by kimi_architect self-audit — }
// optional-chaining + ?? only catches null/undefined, not type drift. return { parsed, tokens, model };
const num = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
};
return {
parsed,
latency_ms: num(body?.latency_ms),
enriched_chars: num(body?.enriched_prompt_chars),
bug_fingerprints: num(body?.sources?.bug_fingerprints_count),
matrix_kept: num(body?.sources?.matrix_chunks_kept),
error: parsed ? undefined : "unparseable",
diagnostic: parsed ? undefined : content.slice(0, 200),
model,
};
} }
async function persistDiscrepancies(ctx: InferenceContext, discrepancies: any[]): Promise<void> { async function persistDiscrepancies(ctx: InferenceContext, discrepancies: any[]): Promise<void> {
await mkdir("/home/profit/lakehouse/data/_kb", { recursive: true }); await mkdir("/home/profit/lakehouse/data/_kb", { recursive: true });
const rows = discrepancies.map(d => JSON.stringify({ const rows = discrepancies.map(d => JSON.stringify({
@@ -496,7 +490,94 @@ async function extractAndPersistFacts(scratchpad: string, ctx: InferenceContext)
await appendFile(AUDIT_FACTS_JSONL, JSON.stringify(row) + "\n"); await appendFile(AUDIT_FACTS_JSONL, JSON.stringify(row) + "\n");
} }
// Curation via tree-split — ports the scrum_master pattern into the
// inference check. Shards the raw diff into DIFF_SHARD_SIZE chunks,
// summarizes each shard *against the claim-verification task* so the
// summary preserves exactly what the cloud needs to judge claims
// (function signatures, struct fields, deletions, new files), drops
// everything else. Merges into a compact scratchpad.
//
// Cost: N cloud calls for the shard summaries + 1 cloud call for the
// final verification = N+1 calls instead of 1. Mitigation: shards run
// serially (not parallel) to keep gateway load bounded; summary calls
// use max_tokens=400 so they're fast (~2s each on gpt-oss:120b).
//
// Determinism: each shard summary call uses temp=0 + think=true (same
// as the top-level inference call), so identical input yields
// identical scratchpad. The final verification call then sees a
// stable scratchpad, giving stable verdicts.
async function treeSplitDiff(
fullDiff: string,
claims: Claim[],
): Promise<{ scratchpad: string; shards: number }> {
const shards: Array<{ from: number; to: number; text: string }> = [];
for (let i = 0; i < fullDiff.length; i += DIFF_SHARD_SIZE) {
const end = Math.min(i + DIFF_SHARD_SIZE, fullDiff.length);
shards.push({ from: i, to: end, text: fullDiff.slice(i, end) });
}
// Curate the claim list into a short form the summary prompt can
// use to bias extraction toward relevant facts.
const claimDigest = claims.map((c, i) =>
`${i}. [${c.strength}] "${c.text.slice(0, 100)}"`
).join("\n");
let scratchpad = "";
for (const [si, shard] of shards.entries()) {
const prompt = [
`You are summarizing shard ${si + 1}/${shards.length} (chars ${shard.from}..${shard.to}) of a PR diff.`,
`The downstream task will verify these ship-claims against the full-PR summary. Extract ONLY facts that could confirm or refute these claims:`,
"",
claimDigest,
"",
"Extract: new function/method signatures, struct fields, deletions, new files, wiring (function X calls Y), absence-of-implementation markers, TODO comments on added lines.",
"Skip: comment-only edits, whitespace, import reordering, unrelated cosmetic changes.",
"",
"─────── shard diff ───────",
shard.text,
"─────── end shard ───────",
"",
"Output: up to 180 words of facts in bullet form. No prose preamble, no claim verdicts (that's for the downstream step).",
].join("\n");
const r = await callCloud(prompt, 400);
if (r.content) {
scratchpad += `\n--- shard ${si + 1} (chars ${shard.from}..${shard.to}) ---\n${r.content.trim()}\n`;
}
}
return { scratchpad: scratchpad.trim(), shards: shards.length };
}
// Minimal cloud caller used only by treeSplitDiff — same gateway +
// model as the top-level call, but think=false. Shards are small
// (≤DIFF_SHARD_SIZE ~4500 chars) and the task is pure fact
// extraction, not reasoning. think=true on the shards introduced
// variance in reasoning traces that compounded across 23 calls into
// a non-deterministic scratchpad (observed during curation
// validation: same-SHA runs produced 5/7/8 final findings).
// think=false on small prompts is stable — only breaks at the main
// call's 10K+ prompt size, which keeps think=true.
async function callCloud(prompt: string, maxTokens: number): Promise<{ content: string }> {
try {
const r = await fetch(`${GATEWAY}/v1/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
provider: "ollama_cloud",
model: MODEL,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokens,
temperature: 0,
think: false,
}),
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
});
if (!r.ok) return { content: "" };
const j: any = await r.json();
return { content: j?.choices?.[0]?.message?.content ?? "" };
} catch {
return { content: "" };
}
}
// Pull out plausible code-symbol names from a summary string. // Pull out plausible code-symbol names from a summary string.
// Matches: // Matches:
-463
View File
@@ -1,463 +0,0 @@
// Kimi-architect check — second-pass senior architectural review using
// kimi-for-coding (Kimi K2.6) via /v1/chat provider=kimi.
//
// Runs AFTER the deepseek inference check (N=3 consensus) and the
// static/kb_query checks. Reads their findings as context and asks Kimi
// "what did everyone else miss?" — complementing the cheap-consensus
// voting with a sparse senior pass that catches load-bearing issues
// (compile errors, false telemetry, schema bypasses, etc.) which the
// voting structure can't see.
//
// Why Kimi here and not in the inner inference loop:
// - Cost: ~3min wall-clock per call vs ~30s for deepseek consensus.
// - TOS: api.kimi.com is User-Agent-gated (see crates/gateway/src/v1/
// kimi.rs); cost-bounded calls only.
// - Value: experiment 2026-04-27 showed 7/7 grounding rate with full
// files vs ~50% on truncated input. Best as a sparse complement, not
// a replacement.
//
// Failure-isolated: any Kimi error returns a single info-level Finding
// "kimi_architect skipped — <reason>" so the existing audit pipeline
// is never blocked by a Kimi outage / TOS revocation / 429.
//
// Cost cap: if a kimi_verdicts/<pr>-<sha>.json file exists less than 24h
// old, return cached findings without calling upstream. New commits
// produce new SHAs so this is per-head, not per-day.
//
// Off by default: caller checks LH_AUDITOR_KIMI=1 before invoking.
import { readFile, writeFile, mkdir, appendFile, stat, realpath } from "node:fs/promises";
import { existsSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { Finding, CheckKind } from "../types.ts";
const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
const KIMI_VERDICTS_DIR = "/home/profit/lakehouse/data/_auditor/kimi_verdicts";
const KIMI_AUDITS_JSONL = "/home/profit/lakehouse/data/_kb/kimi_audits.jsonl";
const REPO_ROOT = "/home/profit/lakehouse";
// Canonicalize at module load — REPO_ROOT itself may be a symlink in
// some environments (e.g. /home/profit is a bind-mount). Computing
// once at startup means the per-finding grounding loop can compare
// realpath(target) against this stable anchor.
const REPO_ROOT_REAL = (() => {
try { return realpathSync(REPO_ROOT); }
catch { return REPO_ROOT; }
})();
// 15 min budget. Bun's fetch has an intrinsic ~300s limit that our
// AbortController + setTimeout combo could not override; we use curl
// via Bun.spawn instead (callKimi below). Curl honors -m for max
// transfer time without a hard intrinsic ceiling.
const CALL_TIMEOUT_MS = 900_000;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_DIFF_CHARS = 180_000;
const MAX_PRIOR_FINDINGS = 50;
// Default provider/model = openrouter/moonshotai/kimi-k2.6.
// History: pre-2026-04-27 direct api.kimi.com (User-Agent gating),
// then ollama_cloud/kimi-k2.6 while the sub was active. 2026-09-02:
// Ollama Cloud subscription lapsed (402 on every cloud model), J's
// call was to sweep the auditor to OpenRouter — same Moonshot model,
// probed live through the gateway. The api.kimi.com path
// (provider=kimi) remains wired in the gateway as a fallback.
const KIMI_PROVIDER = process.env.LH_AUDITOR_KIMI_PROVIDER ?? "openrouter";
const KIMI_MODEL = process.env.LH_AUDITOR_KIMI_MODEL ?? "moonshotai/kimi-k2.6";
// Cross-lineage alternation. 2026-04-27 J's call: Opus is too
// expensive to auto-fire (~$0.30/audit). Kimi K2.6 via Go-sub is
// effectively free; Haiku 4.5 via Zen is ~$0.04. Alternate between
// them so we get cross-lineage signal (Moonshot vs Anthropic) on
// every PR's audit history without burning the budget.
//
// Default: Kimi K2.6 on even audits, Haiku 4.5 on odd. Each PR's
// audits flip between vendors as new SHAs come in.
//
// Frontier models (Opus 4.7, GPT-5.5, Gemini 3.1) are NOT in the
// auto path. Operator hands distilled findings to a frontier model
// manually when high-leverage decisions need it. Removing Opus from
// auto-promotion saves ~$1-3/day on the daemon at our cadence.
//
// Override the alternation entirely with LH_AUDITOR_KIMI_MODEL
// (forces one model regardless of audit count); set
// LH_AUDITOR_KIMI_ALT_MODEL to the alternate.
const ALT_MODEL = process.env.LH_AUDITOR_KIMI_ALT_MODEL ?? "claude-haiku-4-5";
const ALT_PROVIDER = process.env.LH_AUDITOR_KIMI_ALT_PROVIDER ?? "opencode";
const FORCE_DEFAULT = process.env.LH_AUDITOR_KIMI_MODEL !== undefined && process.env.LH_AUDITOR_KIMI_MODEL !== "";
function selectModel(diffLen: number, auditIndex: number = 0): { provider: string; model: string; promoted: boolean } {
// Operator override — env-pinned model wins.
if (FORCE_DEFAULT) {
return { provider: KIMI_PROVIDER, model: KIMI_MODEL, promoted: false };
}
// Alternate Kimi (default, even index) ↔ Haiku (alt, odd index).
// diffLen kept in the signature for future "big diff → Haiku
// anyway" logic; not used yet so we don't auto-burn on big PRs.
void diffLen;
if (auditIndex % 2 === 1) {
return { provider: ALT_PROVIDER, model: ALT_MODEL, promoted: true };
}
return { provider: KIMI_PROVIDER, model: KIMI_MODEL, promoted: false };
}
// Model-aware max_tokens. Different upstream APIs cap at different
// limits and reject requests that exceed them:
// - Anthropic Opus 4.x: 32K output (with extended-output header)
// - Anthropic Haiku 4.5: 8K output
// - Kimi K2.6 (reasoning): 128K — needs headroom because
// reasoning_content counts against the budget
// - Default: 16K, conservative middle ground
//
// 2026-04-27 BLOCK from Opus self-audit: the prior single-default of
// 128K worked silently (Anthropic clamps server-side) but was
// technically invalid. Per-model caps make it explicit. Override via
// LH_AUDITOR_KIMI_MAX_TOKENS to force a value (also fixes the empty-
// env Number("") -> 0 trap by using `||` not `??`).
const MAX_TOKENS_OVERRIDE = Number(process.env.LH_AUDITOR_KIMI_MAX_TOKENS) || 0;
function maxTokensFor(model: string): number {
if (MAX_TOKENS_OVERRIDE > 0) return MAX_TOKENS_OVERRIDE;
if (model.startsWith("claude-opus")) return 32_000;
if (model.startsWith("claude-haiku") || model.startsWith("claude-sonnet")) return 8_192;
if (model.startsWith("kimi-") || model.includes("/kimi-")) return 128_000;
if (model.startsWith("gpt-5") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) return 32_000;
return 16_000;
}
export interface KimiArchitectContext {
pr_number: number;
head_sha: string;
}
interface KimiVerdictFile {
pr_number: number;
head_sha: string;
cached_at: string;
model: string;
latency_ms: number;
finish_reason: string;
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
raw_content: string;
findings: Finding[];
grounding: { total: number; verified: number; rate: number };
}
export async function runKimiArchitectCheck(
diff: string,
priorFindings: Finding[],
ctx: KimiArchitectContext,
): Promise<Finding[]> {
const cachePath = join(KIMI_VERDICTS_DIR, `${ctx.pr_number}-${ctx.head_sha.slice(0, 12)}.json`);
const outageSentinel = `${cachePath}.outage`;
const OUTAGE_TTL_MS = 10 * 60 * 1000;
// Outage negative-cache — if upstream failed within OUTAGE_TTL_MS,
// skip this audit and return immediately. Prevents the daemon from
// hammering a downed Kimi/Anthropic upstream every 90s.
if (existsSync(outageSentinel)) {
try {
const s = await stat(outageSentinel);
if (Date.now() - s.mtimeMs < OUTAGE_TTL_MS) {
const note = JSON.parse(await readFile(outageSentinel, "utf8"));
return [skipFinding(`upstream still down (cached ${Math.round((Date.now() - s.mtimeMs) / 1000)}s ago): ${String(note.reason).slice(0, 160)}`)];
}
} catch { /* malformed sentinel — fall through to fresh call */ }
}
// Cost cap — return cached findings if a verdict for this exact head
// SHA was generated within the TTL.
const cached = await loadCachedVerdict(cachePath);
if (cached) {
return cached.findings.length > 0
? cached.findings
: [{ check: "kimi_architect" as CheckKind, severity: "info", summary: "kimi_architect cached — 0 findings", evidence: [`cache: ${cachePath}`] }];
}
// Alternate model based on how many audits this PR has had — gives
// cross-lineage signal (Kimi/Moonshot ↔ Haiku/Anthropic) on every
// PR's audit history. Count is derived from existing kimi_verdicts
// files for this PR; cheap O(N_PRs) directory read.
let auditIndex = 0;
try {
const dir = "/home/profit/lakehouse/data/_auditor/kimi_verdicts";
if (existsSync(dir)) {
const all = require("node:fs").readdirSync(dir) as string[];
auditIndex = all.filter((f) => f.startsWith(`${ctx.pr_number}-`)).length;
}
} catch { /* default 0 — Kimi */ }
const selected = selectModel(diff.length, auditIndex);
let response: { content: string; usage: any; finish_reason: string; latency_ms: number };
try {
response = await callKimi(buildPrompt(diff, priorFindings, ctx), selected.provider, selected.model);
} catch (e) {
// Negative-cache for 10 min on outage (caught 2026-04-27 by Opus
// self-audit): without this, every audit cycle within the 24h
// TTL re-calls upstream while it's still down. Use a sentinel
// file with mtime check rather than persisting a verdict so the
// happy-path cache reader doesn't have to special-case it.
const sentinel = `${cachePath}.outage`;
try { await writeFile(sentinel, JSON.stringify({ at: new Date().toISOString(), reason: (e as Error).message.slice(0, 200) })); } catch {}
return [skipFinding(`kimi call failed (${selected.model}): ${(e as Error).message.slice(0, 200)}`)];
}
const findings = parseFindings(response.content);
const grounding = await computeGrounding(findings);
const verdict: KimiVerdictFile = {
pr_number: ctx.pr_number,
head_sha: ctx.head_sha,
cached_at: new Date().toISOString(),
model: selected.model,
latency_ms: response.latency_ms,
finish_reason: response.finish_reason,
usage: {
prompt_tokens: response.usage?.prompt_tokens ?? 0,
completion_tokens: response.usage?.completion_tokens ?? 0,
total_tokens: response.usage?.total_tokens ?? 0,
},
raw_content: response.content,
findings,
grounding,
};
// Cache-poisoning guard (caught 2026-04-27 by Opus self-audit):
// when parseFindings returns 0 findings (Kimi rambled, prompt too
// big, or the markdown shape changed and our regex missed every
// block), persisting the empty verdict short-circuits all future
// audits in the 24h TTL window with a useless cached "0 findings"
// result. Better to leave no cache and re-call upstream next time.
// Always append metrics — observability shouldn't depend on whether
// findings parsed.
await appendMetrics(verdict);
if (findings.length > 0) {
await persistVerdict(cachePath, verdict);
return findings;
}
return [{
check: "kimi_architect" as CheckKind,
severity: "info",
summary: `kimi_architect produced 0 ranked findings (${response.finish_reason}, ${verdict.usage.completion_tokens} tokens) — not cached`,
evidence: [`raw saved (no cache): see kimi_audits.jsonl ${verdict.cached_at}`],
}];
}
async function loadCachedVerdict(path: string): Promise<KimiVerdictFile | null> {
if (!existsSync(path)) return null;
try {
const s = await stat(path);
if (Date.now() - s.mtimeMs > CACHE_TTL_MS) return null;
return JSON.parse(await readFile(path, "utf8")) as KimiVerdictFile;
} catch { return null; }
}
function buildPrompt(diff: string, priorFindings: Finding[], ctx: KimiArchitectContext): string {
const truncatedDiff = diff.length > MAX_DIFF_CHARS
? diff.slice(0, MAX_DIFF_CHARS) + `\n\n... [truncated; original diff was ${diff.length} chars]`
: diff;
const priorBlock = priorFindings
.filter(f => f.severity !== "info")
.slice(0, MAX_PRIOR_FINDINGS)
.map(f => `- [${f.check}/${f.severity}] ${f.summary}${f.evidence?.[0] ? ` — ${f.evidence[0].slice(0, 160)}` : ""}`)
.join("\n");
return `You are a senior software architect doing a second-pass review on PR #${ctx.pr_number} (head ${ctx.head_sha.slice(0, 12)}). The team's automated auditor (deepseek-v3.1:671b, N=3 consensus) already produced findings. Your job is NOT to repeat what they found — your job is to catch what their voting structure CAN'T see: compile errors, type-system bypasses, false telemetry, silent determinism leaks, schema-bypass anti-patterns, load-bearing assumptions that look fine line-by-line.
GROUNDING RULES (non-negotiable):
- Cite file:line for EVERY finding. Lines you cite must actually contain what you claim. Confabulating a finding wastes more time than missing one.
- If the diff is truncated and you can't verify a claim, say "diff-truncated, can't verify" — DO NOT guess.
- Distinguish architectural concerns (no specific line) from concrete bugs (specific line). Don't dress one as the other.
PRIOR FINDINGS FROM DEEPSEEK CONSENSUS (do not repeat these):
${priorBlock || "(none)"}
OUTPUT FORMAT (markdown):
- ## Verdict (one sentence)
- ## Findings (5-10 items, each formatted EXACTLY as below)
For each finding use this exact shape so a parser can lift them:
### F1: <one-line summary>
- **Severity:** block | warn | info
- **File:** path/to/file.ext:LINE
- **Rationale:** one or two sentences
THE DIFF:
${truncatedDiff}
`;
}
async function callKimi(prompt: string, provider: string, model: string): Promise<{ content: string; usage: any; finish_reason: string; latency_ms: number }> {
const t0 = Date.now();
const body = JSON.stringify({
provider,
model,
messages: [{ role: "user", content: prompt }],
max_tokens: maxTokensFor(model),
temperature: 0.2,
});
// curl via Bun.spawn — bypasses Bun fetch's ~300s intrinsic ceiling.
// -m sets the max transfer time honored end-to-end. Body is piped via
// stdin to avoid argv length limits on big audit prompts (~50K+ tokens).
const proc = Bun.spawn({
cmd: [
"curl", "-sS", "-X", "POST",
"-m", String(Math.ceil(CALL_TIMEOUT_MS / 1000)),
"-H", "content-type: application/json",
"--data-binary", "@-",
`${GATEWAY}/v1/chat`,
],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(body);
await proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(`curl exit ${exitCode}: ${stderr.slice(0, 300)}`);
}
let j: any;
try { j = JSON.parse(stdout); }
catch (e) {
throw new Error(`bad response (${stdout.length} bytes): ${stdout.slice(0, 300)}`);
}
if (j.error || !j.choices) {
throw new Error(`gateway error: ${JSON.stringify(j).slice(0, 300)}`);
}
return {
content: j.choices?.[0]?.message?.content ?? "",
usage: j.usage ?? {},
finish_reason: j.choices?.[0]?.finish_reason ?? "unknown",
latency_ms: Date.now() - t0,
};
}
// Parse Kimi's markdown into Finding[]. Format expected (per buildPrompt):
// ### F<N>: <summary>
// - **Severity:** block | warn | info
// - **File:** path:line
// - **Rationale:** ...
function parseFindings(content: string): Finding[] {
const findings: Finding[] = [];
const blocks = content.split(/^###\s+F\d+:\s*/m).slice(1);
for (const block of blocks) {
const summary = (block.split("\n")[0] ?? "").trim();
if (!summary) continue;
const sev = /\*\*Severity:\*\*\s*(block|warn|info)/i.exec(block)?.[1]?.toLowerCase();
const fileLine = /\*\*File:\*\*\s*(\S+)/i.exec(block)?.[1] ?? "unknown";
const rationale = /\*\*Rationale:\*\*\s*([\s\S]+?)(?=\n###|\n\*\*|$)/i.exec(block)?.[1]?.trim() ?? "";
const severity: Finding["severity"] = sev === "block" ? "block" : sev === "warn" ? "warn" : "info";
findings.push({
check: "kimi_architect" as CheckKind,
severity,
summary: summary.slice(0, 240),
evidence: [fileLine, rationale.slice(0, 360)].filter(Boolean),
});
}
return findings;
}
// For each finding's cited file:line, grep the actual file to verify
// the line exists. Returns total + verified counts; per-finding metadata
// is appended into the evidence array so the reader can see which
// citations were verified.
async function computeGrounding(findings: Finding[]): Promise<{ total: number; verified: number; rate: number }> {
// readFile (async) instead of readFileSync — caught 2026-04-27 by
// Kimi's self-audit. Sync I/O in an async fn blocks the event loop
// for every cited file; doesn't matter at 10 findings, would matter
// at 100+.
const checks = await Promise.all(findings.map(async (f) => {
const cite = f.evidence[0] ?? "";
const m = /^(\S+?):(\d+)/.exec(cite);
if (!m) return false;
const [, relpath, lineStr] = m;
const line = Number(lineStr);
if (!line || !relpath) return false;
// Path-traversal guard, two-layer (caught 2026-04-27 by Kimi
// self-audits on dd77632 then 2d9cb12).
//
// Layer 1 (lexical): resolve() normalizes `..` segments. Refuse
// any path that doesn't anchor under REPO_ROOT.
//
// Layer 2 (symlink): even if the lexical path is anchored, it
// could be a symlink whose target escapes. realpath() resolves
// symlinks; compare the real path against REPO_ROOT_REAL.
//
// Both layers exist because attackers might bypass either alone:
// raw `../etc/passwd` triggers layer 1; a planted symlink at
// ./safe-looking-name → /etc/passwd triggers layer 2.
const abs = resolve(REPO_ROOT, relpath);
if (!abs.startsWith(REPO_ROOT + "/") && abs !== REPO_ROOT) {
f.evidence.push(`[grounding: path escapes repo root, refusing]`);
return false;
}
if (!existsSync(abs)) {
f.evidence.push("[grounding: file not found]");
return false;
}
try {
// Symlink-resolution check before any read. realpath() throws
// if the file doesn't exist; existsSync above shields the
// common case but a TOCTOU race could still error here — the
// outer catch handles it.
const realPath = await realpath(abs);
if (!realPath.startsWith(REPO_ROOT_REAL + "/") && realPath !== REPO_ROOT_REAL) {
f.evidence.push(`[grounding: symlink target escapes repo root, refusing]`);
return false;
}
const lines = (await readFile(realPath, "utf8")).split("\n");
if (line < 1 || line > lines.length) {
f.evidence.push(`[grounding: line ${line} > EOF (${lines.length})]`);
return false;
}
f.evidence.push(`[grounding: verified at ${relpath}:${line}]`);
return true;
} catch (e) {
f.evidence.push(`[grounding: read failed: ${(e as Error).message.slice(0, 80)}]`);
return false;
}
}));
const verified = checks.filter(Boolean).length;
const total = findings.length;
return { total, verified, rate: total === 0 ? 0 : verified / total };
}
async function persistVerdict(path: string, v: KimiVerdictFile): Promise<void> {
await mkdir(KIMI_VERDICTS_DIR, { recursive: true });
await writeFile(path, JSON.stringify(v, null, 2));
}
async function appendMetrics(v: KimiVerdictFile): Promise<void> {
// dirname() instead of join(path, "..") — caught 2026-04-27 by both
// Haiku and Opus self-audits. The "/.." idiom resolves correctly
// via Node path normalization but is non-idiomatic + breaks if the
// path ever has trailing dots.
await mkdir(dirname(KIMI_AUDITS_JSONL), { recursive: true });
await appendFile(KIMI_AUDITS_JSONL, JSON.stringify({
pr_number: v.pr_number,
head_sha: v.head_sha,
audited_at: v.cached_at,
model: v.model,
latency_ms: v.latency_ms,
finish_reason: v.finish_reason,
prompt_tokens: v.usage.prompt_tokens,
completion_tokens: v.usage.completion_tokens,
findings_total: v.findings.length,
findings_block: v.findings.filter(f => f.severity === "block").length,
findings_warn: v.findings.filter(f => f.severity === "warn").length,
grounding_verified: v.grounding.verified,
grounding_rate: Number(v.grounding.rate.toFixed(3)),
}) + "\n");
}
function skipFinding(why: string): Finding {
return {
check: "kimi_architect" as CheckKind,
severity: "info",
summary: `kimi_architect skipped — ${why}`,
evidence: [why],
};
}
+23 -150
View File
@@ -54,54 +54,20 @@ export function runStaticCheck(diff: string): Finding[] {
const isAuditorCheckerFile = path.startsWith("auditor/checks/") || const isAuditorCheckerFile = path.startsWith("auditor/checks/") ||
path.startsWith("auditor/fixtures/"); path.startsWith("auditor/fixtures/");
// Track multi-line backtick-template state across the file. Walks
// all post-merge lines (context + added, skipping removed lines)
// in order and keeps `inMultilineBacktick` flipping on each
// unescaped backtick. Pre-2026-04-26 the per-line walk in
// isInsideQuotedString missed `todo!()` matches inside docstring
// template literals because the opening backtick lived on a
// line above the match. Now we OR the file-level state into the
// per-line check.
let inMultilineBacktick = false;
for (let idx = 0; idx < lines.length; idx++) { for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx]; const line = lines[idx];
if (!line.startsWith("+") || line.startsWith("+++")) continue;
// Diff bookkeeping lines and removed lines don't contribute to const added = line.slice(1);
// the post-merge file's string state.
if (line.startsWith("+++") || line.startsWith("---") ||
line.startsWith("@@") || line.startsWith("\\ No newline")) continue;
if (line.startsWith("-")) continue;
const isAdded = line.startsWith("+");
// Strip the diff prefix (' ' for context, '+' for added).
const body = (isAdded || line.startsWith(" ")) ? line.slice(1) : line;
// Compute the file-level backtick state ENTERING this line.
// The state machine sees pattern matches against the right
// context: a line that opens a backtick block has its own
// pattern checks evaluated under "inside-backtick" semantics
// for the portion AFTER the opening tick. Pre-2026-04-27 the
// state was updated AFTER the pattern checks, so the FIRST
// pattern on a backtick-opening line slipped through with
// stale "outside-backtick" semantics. Caught by Kimi self-audit.
const stateAtLineStart = inMultilineBacktick;
const stateAtLineEnd = updateBacktickState(body, stateAtLineStart);
if (isAdded) {
const added = body;
if (!isAuditorCheckerFile) { if (!isAuditorCheckerFile) {
for (const { re, why } of BLOCK_PATTERNS) { for (const { re, why } of BLOCK_PATTERNS) {
const m = added.match(re); const m = added.match(re);
if (m && typeof m.index === "number") { if (m && typeof m.index === "number") {
// Skip if EITHER (a) the file was already inside a // Skip if the match sits inside a quoted string literal —
// multi-line backtick block when this line started, OR // this is how rubric files (tests/real-world/*, prompt
// (b) the match sits inside a quoted string literal on // templates) legitimately reference the patterns they
// THIS line. The earlier code only checked stateAtLineStart; // guard against, without actually executing them.
// now we also check that the match isn't past the if (isInsideQuotedString(added, m.index)) continue;
// opening backtick of a block that opens on this line.
if (stateAtLineStart || isInsideQuotedString(added, m.index)) continue;
findings.push({ findings.push({
check: "static", check: "static",
severity: "block", severity: "block",
@@ -133,30 +99,16 @@ export function runStaticCheck(diff: string): Finding[] {
} }
} }
// Carry the end-of-line state forward to the next iteration.
inMultilineBacktick = stateAtLineEnd;
}
// "Field added but never read" heuristic — catches exactly the // "Field added but never read" heuristic — catches exactly the
// Phase 45 DocRef placeholder pattern. Limited to the diff itself: // Phase 45 DocRef placeholder pattern. Limited to the diff itself:
// we're not doing a full-codebase grep here (too noisy; callers // we're not doing a full-codebase grep here (too noisy; callers
// elsewhere might exist). The point is: if NEITHER this diff nor // elsewhere might exist). The point is: if NEITHER this diff nor
// any other line in the diff reads the field, the PR is shipping // any other line in the diff reads the field, the PR is shipping
// state without a consumer. // state without a consumer.
//
// Serde exemption: if the field's parent struct derives Serialize
// or Deserialize, the read-site is the macro itself — JSON
// round-trips consume every public field. Without this exemption
// the check produces false positives on every response/request
// struct shipped through `/v1/*`.
const addedLines = lines.filter(l => l.startsWith("+") && !l.startsWith("+++")) const addedLines = lines.filter(l => l.startsWith("+") && !l.startsWith("+++"))
.map(l => l.slice(1)); .map(l => l.slice(1));
const newFields = extractNewFieldsWithLine(lines); const newFields = extractNewFields(addedLines);
const seenNames = new Set<string>(); for (const field of newFields) {
for (const { name: field, lineIdx } of newFields) {
if (seenNames.has(field)) continue;
seenNames.add(field);
if (parentStructHasSerdeDerive(lines, lineIdx)) continue;
const readPattern = new RegExp(`[\\.:]\\s*${escape(field)}\\b|\\b${escape(field)}\\s*:`); const readPattern = new RegExp(`[\\.:]\\s*${escape(field)}\\b|\\b${escape(field)}\\s*:`);
// The definition line itself matches readPattern — filter it out // The definition line itself matches readPattern — filter it out
// by requiring at least TWO lines in the diff mention the field // by requiring at least TWO lines in the diff mention the field
@@ -194,105 +146,26 @@ function splitDiffByFile(diff: string): Map<string, string[]> {
return out; return out;
} }
// Extract new `pub name: Type,` fields from the per-file diff lines, // Extract new `pub name: Type,` fields from added lines. Rust syntax.
// keeping each occurrence's line index so the caller can resolve the // Narrowly-scoped: only matches at the start of a trimmed line,
// parent struct. Same narrow rules as before: starts with `pub `, // requires `pub ` prefix, ignores `pub fn` / `pub struct` / etc.
// excludes `pub fn` / `pub struct` / etc. function extractNewFields(addedLines: string[]): string[] {
function extractNewFieldsWithLine(lines: string[]): Array<{ name: string; lineIdx: number }> { const fields = new Set<string>();
const out: Array<{ name: string; lineIdx: number }> = []; for (const line of addedLines) {
for (let i = 0; i < lines.length; i++) { const t = line.trim();
const line = lines[i]; // pub NAME: Type,
if (!line.startsWith("+") || line.startsWith("+++")) continue;
const t = line.slice(1).trim();
const m = t.match(/^pub\s+(?!fn\b|struct\b|enum\b|mod\b|use\b|trait\b|impl\b|const\b|static\b|type\b)(\w+)\s*:/); const m = t.match(/^pub\s+(?!fn\b|struct\b|enum\b|mod\b|use\b|trait\b|impl\b|const\b|static\b|type\b)(\w+)\s*:/);
if (m) out.push({ name: m[1], lineIdx: i }); if (m) fields.add(m[1]);
} }
return out; return Array.from(fields);
}
// True if the field at `fieldLineIdx` lives inside a struct whose
// declaration carries `#[derive(... Serialize|Deserialize ...)]`. We
// walk backward through the diff (added + context lines both count —
// a struct declaration unchanged by the PR still appears as context)
// to find the nearest `pub struct` boundary, then scan a few lines
// above it for derive attributes. Conservative bounds:
// - 80 lines back to find `struct` (struct definitions can grow large)
// - 8 lines above the `struct` keyword for attribute lines
// Stops the struct-search early if we hit a `}` at zero indent
// (the previous scope) or another `pub struct` (we left ours).
function parentStructHasSerdeDerive(lines: string[], fieldLineIdx: number): boolean {
// Bounds-check fieldLineIdx (caught 2026-04-27 by Kimi self-audit).
// Pre-fix: if fieldLineIdx >= lines.length, the loop ran from a
// negative implicit upper bound (fieldLineIdx - 80 could be > 0
// even when fieldLineIdx is past EOF) and read undefined slots.
// Defensive: bail early on out-of-range input.
if (fieldLineIdx < 0 || fieldLineIdx >= lines.length) return false;
let structLineIdx = -1;
for (let i = fieldLineIdx - 1; i >= 0 && i >= fieldLineIdx - 80; i--) {
const raw = lines[i];
if (typeof raw !== "string" || raw.length === 0) continue;
const body = stripDiffPrefix(raw);
const trimmed = body.trim();
if (/^pub\s+struct\s+\w/.test(trimmed)) {
structLineIdx = i;
break;
}
// Closing brace at column 0 means the enclosing scope ended above
// the field — we're not actually inside a struct.
if (body.startsWith("}")) return false;
}
if (structLineIdx < 0) return false;
for (let j = structLineIdx - 1; j >= 0 && j >= structLineIdx - 8; j--) {
const raw = lines[j];
if (typeof raw !== "string") continue;
const trimmed = stripDiffPrefix(raw).trim();
if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("///")) continue;
if (!trimmed.startsWith("#[")) break;
if (/derive\s*\([^)]*\b(Serialize|Deserialize)\b/.test(trimmed)) return true;
}
return false;
}
// Strip leading +/-/space from a unified-diff line, leaving the raw
// source line. Handles the case where the line is shorter than 1 char
// (rare but real for empty-context lines).
function stripDiffPrefix(line: string): string {
if (line.length === 0) return line;
const c = line[0];
if (c === "+" || c === "-" || c === " ") return line.slice(1);
return line;
}
// Walk a single line and toggle the cross-line backtick state on each
// unescaped backtick. Single-quote and double-quote runs are line-
// bounded in JS/TS/Rust by language rules (string literals don't span
// newlines without explicit `\` continuation), so we only track
// backticks across lines. Returns the new state for the next line.
function updateBacktickState(line: string, inBacktick: boolean): boolean {
let state = inBacktick;
let inDouble = false;
let inSingle = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
const esc = i > 0 && line[i - 1] === "\\";
if (esc) continue;
// Inside a multi-line backtick template, single/double quotes
// don't open new strings — they're literal characters of the
// template. Same applies the other way around.
if (c === '"' && !inSingle && !state) inDouble = !inDouble;
else if (c === "'" && !inDouble && !state) inSingle = !inSingle;
else if (c === "`" && !inDouble && !inSingle) state = !state;
}
return state;
} }
// True if `pos` falls inside a double- or single-quoted string on this // True if `pos` falls inside a double- or single-quoted string on this
// line (backtick template literals too). Walks left→right toggling the // line (backtick template literals too). Walks left→right toggling the
// "in quote" state on each unescaped quote. Per-line only — the file- // "in quote" state on each unescaped quote. Good enough for single-
// level walk in runStaticCheck handles multi-line backtick templates // line matches; multi-line strings aren't parsed (they're extremely
// via updateBacktickState. // rare in the patterns we're blocking on, and would require a proper
// tokenizer to handle correctly).
function isInsideQuotedString(line: string, pos: number): boolean { function isInsideQuotedString(line: string, pos: number): boolean {
let inDouble = false, inSingle = false, inBacktick = false; let inDouble = false, inSingle = false, inBacktick = false;
for (let i = 0; i < pos; i++) { for (let i = 0; i < pos; i++) {
+10 -24
View File
@@ -9,42 +9,28 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import type { PrSnapshot } from "./types.ts"; import type { PrSnapshot } from "./types.ts";
// 2026-09-02: repo home moved to git.warren.help in the VPS const HOST = process.env.GITEA_HOST ?? "https://git.agentview.dev";
// consolidation; git.agentview.dev now dead-ends at a Traefik default
// cert + 404 (auditor had been failing listOpenPrs every cycle since
// Aug 25). Credentials come from ~/.git-credentials for HOST's domain,
// falling back to the user:pass embedded in this repo's origin remote.
// Basic auth works for both a PAT and a password on Gitea.
const HOST = process.env.GITEA_HOST ?? "https://git.warren.help";
const OWNER = "profit"; const OWNER = "profit";
const REPO = "lakehouse"; const REPO = "lakehouse";
const CRED_FILE = "/home/profit/.git-credentials"; const CRED_FILE = "/home/profit/.git-credentials";
const GIT_CONFIG = "/home/profit/lakehouse/.git/config";
let cachedAuth: string | null = null; let cachedPat: string | null = null;
async function getAuthHeader(): Promise<string> { async function getPat(): Promise<string> {
if (cachedAuth) return cachedAuth; if (cachedPat) return cachedPat;
const hostDomain = new URL(HOST).host.replace(/\./g, "\\."); const raw = await readFile(CRED_FILE, "utf8");
const credRe = new RegExp(`^https://([^:]+):([^@]+)@${hostDomain}`);
for (const file of [CRED_FILE, GIT_CONFIG]) {
let raw: string;
try { raw = await readFile(file, "utf8"); } catch { continue; }
for (const line of raw.split("\n")) { for (const line of raw.split("\n")) {
const m = line.trim().replace(/^url = /, "").match(credRe); const m = line.match(/^https:\/\/[^:]+:([^@]+)@git\.agentview\.dev/);
if (m) { if (m) { cachedPat = m[1]; return m[1]; }
cachedAuth = `Basic ${Buffer.from(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`).toString("base64")}`;
return cachedAuth;
} }
} throw new Error(`no Gitea PAT in ${CRED_FILE}`);
}
throw new Error(`no Gitea credentials for ${HOST} in ${CRED_FILE} or ${GIT_CONFIG}`);
} }
async function giteaFetch(path: string, init: RequestInit = {}): Promise<Response> { async function giteaFetch(path: string, init: RequestInit = {}): Promise<Response> {
const pat = await getPat();
const url = `${HOST}/api/v1${path}`; const url = `${HOST}/api/v1${path}`;
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set("Authorization", await getAuthHeader()); headers.set("Authorization", `token ${pat}`);
if (init.body && !headers.has("content-type")) { if (init.body && !headers.has("content-type")) {
headers.set("content-type", "application/json"); headers.set("content-type", "application/json");
} }
+3 -63
View File
@@ -24,30 +24,14 @@ const POLL_INTERVAL_MS = 90_000; // 90s — enough budget for audit runs to comp
const PAUSE_FILE = "/home/profit/lakehouse/auditor.paused"; const PAUSE_FILE = "/home/profit/lakehouse/auditor.paused";
const STATE_FILE = "/home/profit/lakehouse/data/_auditor/state.json"; const STATE_FILE = "/home/profit/lakehouse/data/_auditor/state.json";
// Per-PR audit cap. Prevents the daemon from running away on a PR
// when each push surfaces new findings — operator wants to review
// in batch, not have the daemon burn budget while they're away.
// Default 3 audits per PR. Override via LH_AUDITOR_MAX_AUDITS_PER_PR.
// Set to 0 to disable the cap.
//
// Reset (after manual review): edit data/_auditor/state.json and
// set audit_count_per_pr.<N> = 0 (or delete the key). Daemon picks
// up the change on the next cycle without restart.
const MAX_AUDITS_PER_PR = Number(process.env.LH_AUDITOR_MAX_AUDITS_PER_PR) || 3;
interface State { interface State {
// Map: PR number → last-audited head SHA. Lets us dedupe audits // Map: PR number → last-audited head SHA. Lets us dedupe audits
// across restarts (poller can crash/restart without re-auditing // across restarts (poller can crash/restart without re-auditing
// all open PRs from scratch). // all open PRs from scratch).
last_audited: Record<string, string>; last_audited: Record<string, string>;
// Map: PR number → number of audits run on that PR since last reset.
// Daemon halts auditing a PR once this hits MAX_AUDITS_PER_PR.
// Operator clears the entry to resume.
audit_count_per_pr: Record<string, number>;
started_at: string; started_at: string;
cycles_total: number; cycles_total: number;
cycles_skipped_paused: number; cycles_skipped_paused: number;
cycles_skipped_capped: number;
audits_run: number; audits_run: number;
last_cycle_at?: string; last_cycle_at?: string;
} }
@@ -63,21 +47,17 @@ async function loadState(): Promise<State> {
return { return {
last_audited: s.last_audited ?? {}, last_audited: s.last_audited ?? {},
started_at: s.started_at ?? new Date().toISOString(), started_at: s.started_at ?? new Date().toISOString(),
audit_count_per_pr: s.audit_count_per_pr ?? {},
cycles_total: s.cycles_total ?? 0, cycles_total: s.cycles_total ?? 0,
cycles_skipped_paused: s.cycles_skipped_paused ?? 0, cycles_skipped_paused: s.cycles_skipped_paused ?? 0,
cycles_skipped_capped: s.cycles_skipped_capped ?? 0,
audits_run: s.audits_run ?? 0, audits_run: s.audits_run ?? 0,
last_cycle_at: s.last_cycle_at, last_cycle_at: s.last_cycle_at,
}; };
} catch { } catch {
return { return {
last_audited: {}, last_audited: {},
audit_count_per_pr: {},
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
cycles_total: 0, cycles_total: 0,
cycles_skipped_paused: 0, cycles_skipped_paused: 0,
cycles_skipped_capped: 0,
audits_run: 0, audits_run: 0,
}; };
} }
@@ -109,38 +89,12 @@ async function runCycle(state: State): Promise<State> {
console.log(`[auditor] cycle ${state.cycles_total}: ${prs.length} open PR(s)`); console.log(`[auditor] cycle ${state.cycles_total}: ${prs.length} open PR(s)`);
for (const pr of prs) { for (const pr of prs) {
const prKey = String(pr.number); const last = state.last_audited[String(pr.number)];
const last = state.last_audited[prKey];
if (last === pr.head_sha) { if (last === pr.head_sha) {
console.log(`[auditor] skip PR #${pr.number} (SHA ${pr.head_sha.slice(0, 8)} already audited)`); console.log(`[auditor] skip PR #${pr.number} (SHA ${pr.head_sha.slice(0, 8)} already audited)`);
continue; continue;
} }
// Per-head-SHA audit cap. Each new push gets MAX_AUDITS_PER_PR console.log(`[auditor] audit PR #${pr.number} (${pr.head_sha.slice(0, 8)}) — ${pr.title.slice(0, 60)}`);
// fresh attempts; the counter auto-resets when the head SHA
// changes. Operator only intervenes manually if a single SHA
// somehow needs MORE than the cap (rare — usually transient
// upstream errors clear themselves inside 3 attempts).
//
// Reset rule: if `last` exists (we've seen this PR before) AND
// pr.head_sha != last, that's a new push. Drop the counter.
// The dedup branch above already handles same-SHA → skip, so
// we only land here when the SHA actually moved.
if (last !== undefined && (state.audit_count_per_pr[prKey] ?? 0) > 0) {
const prior_count = state.audit_count_per_pr[prKey];
console.log(`[auditor] PR #${pr.number} new head ${pr.head_sha.slice(0, 8)} (prior ${last.slice(0, 8)}, was ${prior_count}/${MAX_AUDITS_PER_PR}) — resetting cap counter`);
state.audit_count_per_pr[prKey] = 0;
}
const auditedSoFar = state.audit_count_per_pr[prKey] ?? 0;
if (MAX_AUDITS_PER_PR > 0 && auditedSoFar >= MAX_AUDITS_PER_PR) {
// This branch only fires now if the SAME head SHA somehow
// burned MAX audits (transient upstream errors retried that
// many times). Operator can clear state.audit_count_per_pr.<N>
// = 0 to force one more attempt; otherwise wait for next push.
console.log(`[auditor] skip PR #${pr.number} (same head ${pr.head_sha.slice(0, 8)} burned ${auditedSoFar}/${MAX_AUDITS_PER_PR} — push new code or clear state.json audit_count_per_pr.${prKey})`);
state.cycles_skipped_capped += 1;
continue;
}
console.log(`[auditor] audit PR #${pr.number} (${pr.head_sha.slice(0, 8)}) — ${pr.title.slice(0, 60)} [${auditedSoFar + 1}/${MAX_AUDITS_PER_PR}]`);
try { try {
// Skip dynamic by default: it mutates live playbook state and // Skip dynamic by default: it mutates live playbook state and
// re-runs on every PR update would pollute quickly. Operator // re-runs on every PR update would pollute quickly. Operator
@@ -152,22 +106,8 @@ async function runCycle(state: State): Promise<State> {
skip_inference: process.env.LH_AUDITOR_SKIP_INFERENCE === "1", skip_inference: process.env.LH_AUDITOR_SKIP_INFERENCE === "1",
}); });
console.log(`[auditor] verdict=${verdict.overall} findings=${verdict.metrics.findings_total} (block=${verdict.metrics.findings_block} warn=${verdict.metrics.findings_warn})`); console.log(`[auditor] verdict=${verdict.overall} findings=${verdict.metrics.findings_total} (block=${verdict.metrics.findings_block} warn=${verdict.metrics.findings_warn})`);
state.last_audited[prKey] = pr.head_sha; state.last_audited[String(pr.number)] = pr.head_sha;
state.audit_count_per_pr[prKey] = auditedSoFar + 1;
state.audits_run += 1; state.audits_run += 1;
if (state.audit_count_per_pr[prKey] >= MAX_AUDITS_PER_PR) {
console.log(`[auditor] PR #${pr.number} reached cap (${MAX_AUDITS_PER_PR} audits) — daemon will skip further audits until reset`);
}
// Persist state immediately after each successful audit so the
// increment survives a crash. Pre-2026-04-27 the cycle saved
// once at the end (main.ts:140), which lost the count if the
// daemon was killed mid-cycle. Fix lifted from kimi_architect's
// own audit on this very file. saveState is idempotent + cheap
// (one JSON write), so per-audit cost is negligible.
try { await saveState(state); }
catch (e) {
console.error(`[auditor] saveState mid-cycle failed: ${(e as Error).message} — count held in memory`);
}
} catch (e) { } catch (e) {
console.error(`[auditor] audit failed: ${(e as Error).message}`); console.error(`[auditor] audit failed: ${(e as Error).message}`);
} }
@@ -1,85 +0,0 @@
// drift_report.ts — comparison of a current run summary vs the
// previous run summary on disk. Spec calls this "drift detection";
// concretely it answers: did the pipeline behave the same way as
// last time, and if not, was the change explained by an input change
// or did it appear out of nowhere (silent drift)?
//
// Severity:
// ok — within 20% on every metric, no hash surprises
// warn — record-count or category swing > 20%, OR new error class
// alert — output_hash differs while input_hash is identical
// (deterministic violation — same input → different output)
import {
ValidationResult, requireString, requireIsoTimestamp,
} from "./types";
import type { StageName } from "./stage_receipt";
export const DRIFT_REPORT_SCHEMA_VERSION = 2;
export const DRIFT_THRESHOLD_PCT = 0.20;
export type DriftSeverity = "ok" | "warn" | "alert";
export interface StageDrift {
stage: StageName;
delta_records_in: number; // current - prior
delta_records_out: number;
delta_accepted: number;
delta_quarantined: number;
pct_change_out: number | null; // null when prior had 0 records
// null when input_hash isn't materialized into the stage summary —
// schema v1 lied and reported `true` here. v2 is honest: callers
// that want determinism enforcement must read the full StageReceipt
// off disk and compute input_hash equality there.
input_hash_match: boolean | null;
output_hash_match: boolean;
// alert if input_hash matches but output_hash diverges
deterministic_violation: boolean;
notes: string[];
}
export interface DriftReport {
schema_version: number;
run_id: string;
prior_run_id: string | null; // null when no prior run on disk
generated_at: string;
severity: DriftSeverity;
stages: StageDrift[];
// Top-level swings the human reader should see immediately.
flags: string[];
}
export function validateDriftReport(input: unknown): ValidationResult<DriftReport> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object"] };
}
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== DRIFT_REPORT_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${DRIFT_REPORT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.run_id, "run_id", errors) && ok;
if (r.prior_run_id !== null && typeof r.prior_run_id !== "string") {
errors.push("prior_run_id: must be string or null");
ok = false;
}
ok = requireIsoTimestamp(r.generated_at, "generated_at", errors) && ok;
if (!["ok", "warn", "alert"].includes(r.severity as string)) {
errors.push(`severity: must be ok|warn|alert, got ${JSON.stringify(r.severity)}`);
ok = false;
}
if (!Array.isArray(r.stages)) {
errors.push("stages: expected array");
ok = false;
}
if (!Array.isArray(r.flags)) {
errors.push("flags: expected array");
ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as DriftReport };
}
@@ -1,116 +0,0 @@
// EvidenceRecord schema tests.
//
// Two positive fixtures (one per real-source prototype: distilled_facts
// + contract_analyses) and three negative fixtures pinning the
// non-negotiable invariants the spec demands:
// - every record must trace to a source (provenance)
// - schema_version must match — silent v1/v2 drift is the worst kind
// - required identity fields (run_id) cannot be missing
//
// Run with: bun test auditor/schemas/distillation/evidence_record.test.ts
import { test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { validateEvidenceRecord, EVIDENCE_SCHEMA_VERSION } from "./evidence_record";
const FIXTURE_DIR = resolve(import.meta.dir, "fixtures");
function loadFixture(name: string): unknown {
return JSON.parse(readFileSync(resolve(FIXTURE_DIR, name), "utf8"));
}
test("EVIDENCE_SCHEMA_VERSION is 1 — bump deliberately, never silently", () => {
expect(EVIDENCE_SCHEMA_VERSION).toBe(1);
});
test("positive: distilled_fact materialized record validates", () => {
const r = validateEvidenceRecord(loadFixture("evidence_positive_distilled_fact.json"));
if (!r.valid) console.error("unexpected errors:", r.errors);
expect(r.valid).toBe(true);
if (r.valid) {
expect(r.value.run_id).toBe("cae21289");
expect(r.value.model_role).toBe("extractor");
expect(r.value.provenance.source_file).toBe("data/_kb/distilled_facts.jsonl");
}
});
test("positive: contract_analysis materialized record validates with retrieval + observer fields", () => {
const r = validateEvidenceRecord(loadFixture("evidence_positive_contract_analysis.json"));
if (!r.valid) console.error("unexpected errors:", r.errors);
expect(r.valid).toBe(true);
if (r.valid) {
expect(r.value.observer_verdict).toBe("reject");
expect(r.value.observer_confidence).toBe(95);
expect(r.value.retrieved_context?.matrix_corpora?.length).toBe(4);
expect(r.value.failure_markers).toContain("observer_rejected");
}
});
test("negative: missing run_id is rejected with a specific error", () => {
const r = validateEvidenceRecord(loadFixture("evidence_negative_no_run_id.json"));
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors.some(e => e.includes("run_id"))).toBe(true);
}
});
test("negative: schema_version mismatch is rejected (silent v1/v2 drift guard)", () => {
const r = validateEvidenceRecord(loadFixture("evidence_negative_bad_schema_version.json"));
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors.some(e => e.includes("schema_version"))).toBe(true);
}
});
test("negative: bad provenance (non-sha256 sig_hash, non-ISO timestamp) is rejected", () => {
const r = validateEvidenceRecord(loadFixture("evidence_negative_bad_provenance.json"));
expect(r.valid).toBe(false);
if (!r.valid) {
// Must catch BOTH the sig_hash AND the recorded_at — comprehensive
// error reporting is part of the contract.
expect(r.errors.some(e => e.includes("sig_hash"))).toBe(true);
expect(r.errors.some(e => e.includes("recorded_at"))).toBe(true);
}
});
test("negative: non-object input is rejected with clear error", () => {
const r = validateEvidenceRecord("not an object");
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors[0]).toContain("expected object");
}
});
test("negative: human_override with invalid decision is rejected", () => {
const fixture = loadFixture("evidence_positive_distilled_fact.json") as Record<string, unknown>;
fixture.human_override = {
overrider: "test-user",
decision: "maybe", // invalid — must be accept|reject|needs_review
reason: "test",
overridden_at: "2026-04-26T22:30:00.000Z",
};
const r = validateEvidenceRecord(fixture);
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors.some(e => e.includes("human_override.decision"))).toBe(true);
}
});
test("positive: human_override = null is allowed (explicitly no override)", () => {
const fixture = loadFixture("evidence_positive_distilled_fact.json") as Record<string, unknown>;
fixture.human_override = null;
const r = validateEvidenceRecord(fixture);
expect(r.valid).toBe(true);
});
test("negative: observer_confidence outside [0, 100] is rejected", () => {
const fixture = loadFixture("evidence_positive_contract_analysis.json") as Record<string, unknown>;
fixture.observer_confidence = 150;
const r = validateEvidenceRecord(fixture);
expect(r.valid).toBe(false);
if (!r.valid) {
expect(r.errors.some(e => e.includes("observer_confidence"))).toBe(true);
}
});
@@ -1,202 +0,0 @@
// EvidenceRecord — the unified per-execution-trace record that the
// Evidence View emits and the Success Scorer reads.
//
// Derived from now.md spec + reconciliation of two existing prototypes:
// - distilled_facts.jsonl / distilled_procedures.jsonl (LLM-extracted
// text with run_id + sig_hash + extractor + verifier + embedding)
// - contract_analyses.jsonl (observer integration + retrieval
// telemetry + cost + duration)
//
// Required fields are the ones every record MUST have for traceability:
// run_id, task_id, timestamp, schema_version, provenance. Everything
// else is typed-but-optional because no single source has all of them
// — the Evidence View materializes them by JOINing across streams when
// the source data is present.
//
// schema_version starts at 1 and gets bumped on breaking changes.
// Validators MUST check schema_version and refuse unknown values so a
// future v2 reader doesn't silently accept v1 records (or vice versa).
import {
ValidationResult, Provenance,
requireString, requireNumber, requireIsoTimestamp, requireProvenance, requireStringArray,
} from "./types";
export const EVIDENCE_SCHEMA_VERSION = 1;
export type ModelRole =
| "executor" // produced the answer (e.g. scrum reviewer, mode runner LLM call)
| "reviewer" // judged an executor output (e.g. observer, hand-review)
| "extractor" // pulled structured data from text (e.g. fact_extractor)
| "verifier" // confirmed/rejected an extracted claim (verifier in distilled_*)
| "categorizer" // assigned a category (categorizer in distilled_*)
| "tiebreaker" // resolved a consensus split
| "applier" // landed code (scrum_applier)
| "embedder" // produced embeddings
| "other";
export interface EvidenceRecord {
// ── Identity ──
// run_id ties this record to a specific execution. Sources use it
// inconsistently (some stream-level, some per-call). The Evidence
// View canonicalizes to per-call; if the source is stream-level,
// synthesize as `${stream_run_id}:${row_index}`.
run_id: string;
// task_id groups records by logical task (e.g. one PR = one task_id
// across multiple per-call runs). Defaults to run_id when no group
// exists — never null.
task_id: string;
// ISO 8601 of when the EXECUTION happened, not when this record was
// materialized. Use the source row's timestamp; provenance carries
// the materialization time separately.
timestamp: string;
schema_version: number;
// ── Provenance ── (required — no record without source linkage)
provenance: Provenance;
// ── Model attribution (optional) ──
model_name?: string; // e.g. "kimi-k2:1t", "gpt-oss:120b"
model_provider?: string; // e.g. "ollama_cloud", "openrouter", "ollama"
model_role?: ModelRole;
// ── Content hashes (optional) ──
// sha256 of the full input prompt and full output content. Pre-
// computed so the Evidence Index can dedup across re-runs of the
// same prompt without re-hashing.
input_hash?: string;
output_hash?: string;
// ── Repo + execution context ──
source_files?: string[]; // files the run touched/read
commands_run?: string[]; // shell commands or tool calls fired
retrieved_context?: { // what the model saw via retrieval
matrix_corpora?: string[];
matrix_hits?: number;
matrix_chunks_kept?: number;
matrix_chunks_dropped?: number;
pathway_fingerprints_seen?: number;
};
// ── Observer + scratchpad ──
observer_notes?: string[]; // observer.review() free-form notes
observer_verdict?: "accept" | "reject" | "cycle" | string;
observer_confidence?: number; // 0-100
scratchpad_summary?: string; // tree-split scratchpad text or hash ref
// ── Outcome markers ──
// Both arrays exist because a run can have multiple succeeded gates
// AND multiple failed gates simultaneously. Empty arrays are valid;
// missing arrays are also valid (means "no evidence either way").
success_markers?: string[]; // e.g. "cargo_green", "tests_passed", "anchor_grounded"
failure_markers?: string[]; // e.g. "warning_count_up", "rationale_mismatch", "consensus_split"
// ── Validation telemetry ──
validation_results?: {
grounded_fraction?: number; // mode_compare grounding %
schema_valid?: boolean;
pathway_replay_succeeded?: boolean;
[key: string]: unknown;
};
// ── Human-in-loop ──
human_override?: {
overrider: string; // user identifier
decision: "accept" | "reject" | "needs_review";
reason: string;
overridden_at: string; // ISO 8601
} | null;
// ── Performance ──
cost_usd?: number;
latency_ms?: number;
prompt_tokens?: number;
completion_tokens?: number;
// ── Free-form text content (the actual run output) ──
// Optional because some sources are pure metadata (auto_apply.jsonl)
// and have no text payload. Present for distilled_*, contract_analyses,
// mode_experiments, scrum_reviews etc.
text?: string;
// ── Domain-specific metadata bucket ──
// Source-specific fields that don't earn a top-level slot. e.g.
// contract_analyses rows carry `contractor` here; mode_experiments
// could carry `corpus_set`. Typed scalar values only — keep this
// small or it becomes a junk drawer. Added 2026-04-27 (Kimi audit
// flagged `(ev as any).contractor` schema bypass at export_sft.ts:126).
metadata?: Record<string, string | number | boolean>;
}
export function validateEvidenceRecord(input: unknown): ValidationResult<EvidenceRecord> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object, got " + (input === null ? "null" : typeof input)] };
}
const r = input as Record<string, unknown>;
// Required
let ok = true;
ok = requireString(r.run_id, "run_id", errors) && ok;
ok = requireString(r.task_id, "task_id", errors) && ok;
ok = requireIsoTimestamp(r.timestamp, "timestamp", errors) && ok;
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
if (r.schema_version !== EVIDENCE_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${EVIDENCE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
// Optional but typed-when-present
if (r.model_role !== undefined) {
const valid: ModelRole[] = ["executor", "reviewer", "extractor", "verifier", "categorizer", "tiebreaker", "applier", "embedder", "other"];
if (!valid.includes(r.model_role as ModelRole)) {
errors.push(`model_role: must be one of ${valid.join("|")}, got ${JSON.stringify(r.model_role)}`);
ok = false;
}
}
if (r.input_hash !== undefined && !/^[0-9a-f]{64}$/.test(String(r.input_hash))) {
errors.push("input_hash: must be hex sha256 when present");
ok = false;
}
if (r.output_hash !== undefined && !/^[0-9a-f]{64}$/.test(String(r.output_hash))) {
errors.push("output_hash: must be hex sha256 when present");
ok = false;
}
if (r.source_files !== undefined && !requireStringArray(r.source_files, "source_files", errors)) ok = false;
if (r.commands_run !== undefined && !requireStringArray(r.commands_run, "commands_run", errors)) ok = false;
if (r.success_markers !== undefined && !requireStringArray(r.success_markers, "success_markers", errors)) ok = false;
if (r.failure_markers !== undefined && !requireStringArray(r.failure_markers, "failure_markers", errors)) ok = false;
if (r.observer_notes !== undefined && !requireStringArray(r.observer_notes, "observer_notes", errors)) ok = false;
if (r.observer_confidence !== undefined) {
if (!requireNumber(r.observer_confidence, "observer_confidence", errors)) ok = false;
else if ((r.observer_confidence as number) < 0 || (r.observer_confidence as number) > 100) {
errors.push("observer_confidence: must be in [0, 100]");
ok = false;
}
}
if (r.human_override !== undefined && r.human_override !== null) {
const ho = r.human_override as Record<string, unknown>;
if (typeof ho !== "object") {
errors.push("human_override: expected object or null");
ok = false;
} else {
ok = requireString(ho.overrider, "human_override.overrider", errors) && ok;
ok = requireString(ho.reason, "human_override.reason", errors) && ok;
ok = requireIsoTimestamp(ho.overridden_at, "human_override.overridden_at", errors) && ok;
if (!["accept", "reject", "needs_review"].includes(ho.decision as string)) {
errors.push(`human_override.decision: must be accept|reject|needs_review`);
ok = false;
}
}
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as EvidenceRecord };
}
@@ -1,11 +0,0 @@
{
"run_id": "cae21289",
"task_id": "team_runs:637",
"timestamp": "2026-04-23T09:54:40.729599Z",
"schema_version": 1,
"provenance": {
"source_file": "data/_kb/distilled_facts.jsonl",
"sig_hash": "not-a-real-sha256",
"recorded_at": "yesterday"
}
}
@@ -1,11 +0,0 @@
{
"run_id": "cae21289",
"task_id": "team_runs:637",
"timestamp": "2026-04-23T09:54:40.729599Z",
"schema_version": 99,
"provenance": {
"source_file": "data/_kb/distilled_facts.jsonl",
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
"recorded_at": "2026-04-26T22:30:00.000Z"
}
}
@@ -1,11 +0,0 @@
{
"task_id": "team_runs:637",
"timestamp": "2026-04-23T09:54:40.729599Z",
"schema_version": 1,
"provenance": {
"source_file": "data/_kb/distilled_facts.jsonl",
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
"recorded_at": "2026-04-26T22:30:00.000Z"
},
"text": "missing run_id should fail validation"
}
@@ -1,27 +0,0 @@
{
"run_id": "contract_analysis:101078392:1777250758717",
"task_id": "permit:101078392",
"timestamp": "2026-04-25T23:45:58.717Z",
"schema_version": 1,
"provenance": {
"source_file": "data/_kb/contract_analyses.jsonl",
"line_offset": 0,
"sig_hash": "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1",
"recorded_at": "2026-04-26T22:30:00.000Z"
},
"model_name": "kimi-k2:1t",
"model_role": "executor",
"model_provider": "ollama_cloud",
"retrieved_context": {
"matrix_corpora": ["entity_brief_v1", "chicago_permits_v1", "distilled_procedural_v20260423102847", "sec_tickers_v1"],
"matrix_hits": 10
},
"observer_notes": ["contractor history shows 0 prior fills in Chicago downtown zone"],
"observer_verdict": "reject",
"observer_confidence": 95,
"success_markers": ["matrix_hits_above_threshold"],
"failure_markers": ["observer_rejected"],
"cost_usd": 0.0002,
"latency_ms": 25419,
"text": "Permit 101078392 contractor ANTHONY FIORE — analysis: insufficient prior performance signal; recommend escalation."
}
@@ -1,19 +0,0 @@
{
"run_id": "cae21289",
"task_id": "team_runs:637",
"timestamp": "2026-04-23T09:54:40.729599Z",
"schema_version": 1,
"provenance": {
"source_file": "data/_kb/distilled_facts.jsonl",
"line_offset": 0,
"sig_hash": "21a809e2dc43dfae0000000000000000000000000000000000000000deadbeef",
"recorded_at": "2026-04-26T22:30:00.000Z"
},
"model_name": "qwen2.5:latest",
"model_role": "extractor",
"model_provider": "ollama",
"text": "Convergence refers to the system stabilizing into a state of high performance with low variance across iterations.",
"validation_results": {
"schema_valid": true
}
}
@@ -1,56 +0,0 @@
// ModelLedgerEntry — aggregate per-task-type-per-model performance.
// Built by aggregating mode_experiments.jsonl + model_trust.jsonl.
// Updated rather than appended — one row per (model_name, task_type)
// representing latest aggregates.
import {
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireStringArray,
} from "./types";
export const MODEL_LEDGER_SCHEMA_VERSION = 1;
export interface ModelLedgerEntry {
schema_version: number;
model_name: string;
model_provider: string;
task_type: string;
success_rate: number; // [0, 1]
failure_modes: string[]; // top failure mode tags
best_partner_model?: string; // pairs well with X (consensus / tie-break)
escalation_role?: string; // when this model gets escalated TO (or FROM)
cost_usd_p50?: number;
latency_ms_p50?: number;
latency_ms_p95?: number;
context_window?: number;
sample_count: number;
last_updated: string; // ISO 8601
notes?: string;
}
export function validateModelLedgerEntry(input: unknown): ValidationResult<ModelLedgerEntry> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== MODEL_LEDGER_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${MODEL_LEDGER_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.model_name, "model_name", errors) && ok;
ok = requireString(r.model_provider, "model_provider", errors) && ok;
ok = requireString(r.task_type, "task_type", errors) && ok;
ok = requireIsoTimestamp(r.last_updated, "last_updated", errors) && ok;
ok = requireStringArray(r.failure_modes, "failure_modes", errors) && ok;
if (!requireNumber(r.success_rate, "success_rate", errors)) ok = false;
else if ((r.success_rate as number) < 0 || (r.success_rate as number) > 1) {
errors.push("success_rate: must be in [0, 1]"); ok = false;
}
if (!requireNumber(r.sample_count, "sample_count", errors)) ok = false;
else if ((r.sample_count as number) < 1 || !Number.isInteger(r.sample_count)) {
errors.push("sample_count: must be positive integer (no aggregate from zero samples)"); ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as ModelLedgerEntry };
}
-68
View File
@@ -1,68 +0,0 @@
// Playbook — procedural knowledge extracted from accepted/partially-
// accepted runs. Different from pathway_memory's bug_fingerprints (which
// are pattern-detectors) — playbooks describe HOW to handle a task type.
import {
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray,
} from "./types";
export const PLAYBOOK_SCHEMA_VERSION = 1;
export interface Playbook {
schema_version: number;
playbook_id: string;
task_type: string; // e.g. "scrum_review", "pr_audit", "staffing.fill"
problem_pattern: string; // when does this playbook apply?
useful_context: string[]; // what to retrieve before running
model_routing_path: string[]; // ordered model attempts that worked
commands_worked: string[];
commands_failed: string[];
validation_steps: string[];
repo_files_touched: string[];
recovery_strategy: string; // what to do when the path fails
known_failure_modes: string[];
escalation_threshold: string; // when to switch to a stronger model
acceptance_criteria: string[]; // how to know it succeeded
source_run_ids: string[]; // FK to EvidenceRecord.run_id (provenance — every playbook traces to source)
created_at: string;
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
}
export function validatePlaybook(input: unknown): ValidationResult<Playbook> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== PLAYBOOK_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${PLAYBOOK_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.playbook_id, "playbook_id", errors) && ok;
ok = requireString(r.task_type, "task_type", errors) && ok;
ok = requireString(r.problem_pattern, "problem_pattern", errors) && ok;
ok = requireString(r.recovery_strategy, "recovery_strategy", errors) && ok;
ok = requireString(r.escalation_threshold, "escalation_threshold", errors) && ok;
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
ok = requireStringArray(r.useful_context, "useful_context", errors) && ok;
ok = requireStringArray(r.model_routing_path, "model_routing_path", errors) && ok;
ok = requireStringArray(r.commands_worked, "commands_worked", errors) && ok;
ok = requireStringArray(r.commands_failed, "commands_failed", errors) && ok;
ok = requireStringArray(r.validation_steps, "validation_steps", errors) && ok;
ok = requireStringArray(r.repo_files_touched, "repo_files_touched", errors) && ok;
ok = requireStringArray(r.known_failure_modes, "known_failure_modes", errors) && ok;
ok = requireStringArray(r.acceptance_criteria, "acceptance_criteria", errors) && ok;
ok = requireStringArray(r.source_run_ids, "source_run_ids", errors) && ok;
if (Array.isArray(r.source_run_ids) && r.source_run_ids.length === 0) {
errors.push("source_run_ids: must be non-empty — every playbook traces to source evidence (spec non-negotiable)");
ok = false;
}
if (Array.isArray(r.acceptance_criteria) && r.acceptance_criteria.length === 0) {
errors.push("acceptance_criteria: must be non-empty — every playbook needs success criteria (spec non-negotiable)");
ok = false;
}
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as Playbook };
}
@@ -1,60 +0,0 @@
// PreferenceSample — entry in exports/preference/chosen_rejected.jsonl.
// Source: real disagreements (audit_discrepancies, scrum ladder retries).
// Validator pins: chosen != rejected, both source_run_ids present, reason
// is non-empty. No synthesized preferences.
import {
ValidationResult, requireString, requireIsoTimestamp, requireProvenance,
} from "./types";
export const PREFERENCE_SAMPLE_SCHEMA_VERSION = 1;
export interface PreferenceSample {
schema_version: number;
id: string;
prompt: string;
chosen: string;
rejected: string;
reason: string; // why chosen > rejected — must be non-empty
chosen_run_id: string;
rejected_run_id: string;
created_at: string;
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
}
export function validatePreferenceSample(input: unknown): ValidationResult<PreferenceSample> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== PREFERENCE_SAMPLE_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${PREFERENCE_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.id, "id", errors) && ok;
ok = requireString(r.prompt, "prompt", errors) && ok;
ok = requireString(r.chosen, "chosen", errors) && ok;
ok = requireString(r.rejected, "rejected", errors) && ok;
ok = requireString(r.reason, "reason", errors) && ok;
ok = requireString(r.chosen_run_id, "chosen_run_id", errors) && ok;
ok = requireString(r.rejected_run_id, "rejected_run_id", errors) && ok;
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
// Self-pairing guard.
if (r.chosen === r.rejected && typeof r.chosen === "string") {
errors.push("chosen and rejected must differ — preference data needs a real disagreement");
ok = false;
}
if (r.chosen_run_id === r.rejected_run_id && typeof r.chosen_run_id === "string") {
errors.push("chosen_run_id and rejected_run_id must differ — same run can't disagree with itself");
ok = false;
}
if (typeof r.reason === "string" && (r.reason as string).trim().length === 0) {
errors.push("reason: must be non-whitespace (every preference needs WHY chosen > rejected)");
ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as PreferenceSample };
}
@@ -1,72 +0,0 @@
// RagSample — entry in exports/rag/playbooks.jsonl. Spec shape exactly,
// plus provenance + success_score (so the index can re-rank by quality).
import {
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireProvenance, requireStringArray,
} from "./types";
export const RAG_SAMPLE_SCHEMA_VERSION = 1;
// Allowed source_category values. RAG accepts accepted/partial freely;
// needs_human_review is opt-in (must be tagged so consumers can filter
// it out for SFT).
export const RAG_ALLOWED_CATEGORIES = ["accepted", "partially_accepted", "needs_human_review"] as const;
export type RagSourceCategory = (typeof RAG_ALLOWED_CATEGORIES)[number];
export interface RagSample {
schema_version: number;
id: string;
title: string;
content: string;
tags: string[];
source_run_id: string;
// Snapshot of the score the source carried at export time. Lets a
// consumer see "this was partial" without re-reading scored-runs.
success_score: RagSourceCategory;
// Same value as success_score by spec (now.md asks for both fields).
// Kept distinct so future schemas can diverge them (e.g. an
// "is_review_material" flag) without breaking old consumers.
source_category: RagSourceCategory;
embedding_text: string; // the text to embed (often == content but can be shorter)
created_at: string;
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
}
export function validateRagSample(input: unknown): ValidationResult<RagSample> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== RAG_SAMPLE_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${RAG_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.id, "id", errors) && ok;
ok = requireString(r.title, "title", errors) && ok;
ok = requireString(r.content, "content", errors) && ok;
ok = requireString(r.embedding_text, "embedding_text", errors) && ok;
ok = requireString(r.source_run_id, "source_run_id", errors) && ok;
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
ok = requireStringArray(r.tags, "tags", errors) && ok;
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
if (!RAG_ALLOWED_CATEGORIES.includes(r.success_score as RagSourceCategory)) {
errors.push(`success_score: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")} (rejected never enters RAG)`);
ok = false;
}
if (!RAG_ALLOWED_CATEGORIES.includes(r.source_category as RagSourceCategory)) {
errors.push(`source_category: must be one of ${RAG_ALLOWED_CATEGORIES.join("|")}`);
ok = false;
}
if (r.success_score !== r.source_category) {
errors.push("success_score and source_category must match (mirrored fields per spec)");
ok = false;
}
if (typeof r.content === "string" && (r.content as string).trim().length === 0) {
errors.push("content: must be non-whitespace");
ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as RagSample };
}
@@ -1,286 +0,0 @@
// Real-data validation test — proves the EvidenceRecord schema fits
// what we ALREADY produce, with the minimum transformation each source
// stream requires. Doubles as the stale-extraction probe: if
// distilled_facts.jsonl rows can't materialize, we know that stream
// has rotted and Phase 2 sources from elsewhere.
//
// Strategy:
// 1. Read first N rows from each source jsonl (skip if missing)
// 2. Apply minimal transformer: add schema_version + provenance,
// synthesize run_id/task_id when source doesn't carry them
// 3. Validate each materialized record
// 4. Tally pass/fail per source + collect failure reasons
//
// This file is allowed to skip when source files don't exist (fresh
// clone), so it acts as both a CI guard and a real-environment probe.
import { test, expect } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
validateEvidenceRecord, EVIDENCE_SCHEMA_VERSION, EvidenceRecord, ModelRole,
} from "./evidence_record";
const ROOT = "/home/profit/lakehouse";
const SAMPLE_PER_SOURCE = 10;
interface SourceProbe {
source_file: string;
transform: (row: any, lineNo: number) => Partial<EvidenceRecord> | null;
}
// Canonical 64-char synthetic sha256 for tests where the source row
// lacks one. Pretends the materializer would compute it via
// canonicalSha256(orderedKeys(row)) at Phase 2 time. We use a fixed
// value here to keep the test deterministic; real materialization
// re-hashes per row.
const PLACEHOLDER_SHA = "0000000000000000000000000000000000000000000000000000000000000000";
const RECORDED = "2026-04-26T22:30:00.000Z";
function provFor(source_file: string, lineNo: number, sigHashRaw?: string): EvidenceRecord["provenance"] {
// Pad shorter hashes (distilled_* uses 16-char) to 64 — mimics
// canonical recompute.
const sig = sigHashRaw && /^[0-9a-f]+$/.test(sigHashRaw)
? sigHashRaw.padEnd(64, "0").slice(0, 64)
: PLACEHOLDER_SHA;
return {
source_file: source_file.replace(`${ROOT}/`, ""),
line_offset: lineNo,
sig_hash: sig,
recorded_at: RECORDED,
};
}
const PROBES: SourceProbe[] = [
{
source_file: `${ROOT}/data/_kb/distilled_facts.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: String(row.run_id ?? `distilled_facts:${lineNo}`),
task_id: String(row.source_label ?? `distilled_facts:${lineNo}`),
timestamp: row.created_at,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/distilled_facts.jsonl`, lineNo, row.sig_hash),
model_name: row.extractor,
model_role: "extractor" as ModelRole,
model_provider: "ollama",
text: row.text,
}),
},
{
source_file: `${ROOT}/data/_kb/distilled_procedures.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: String(row.run_id ?? `distilled_procedures:${lineNo}`),
task_id: String(row.source_label ?? `distilled_procedures:${lineNo}`),
timestamp: row.created_at,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/distilled_procedures.jsonl`, lineNo, row.sig_hash),
model_name: row.extractor,
model_role: "extractor" as ModelRole,
model_provider: "ollama",
text: row.text,
}),
},
{
source_file: `${ROOT}/data/_kb/contract_analyses.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: `contract_analysis:${row.permit_id}:${new Date(row.ts).getTime()}`,
task_id: `permit:${row.permit_id}`,
timestamp: row.ts,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/contract_analyses.jsonl`, lineNo),
model_role: "executor" as ModelRole,
retrieved_context: {
matrix_corpora: Object.keys(row.matrix_corpora ?? {}),
matrix_hits: row.matrix_hits,
},
observer_notes: row.observer_notes ? [row.observer_notes].flat() : undefined,
observer_verdict: row.observer_verdict,
observer_confidence: row.observer_conf,
success_markers: row.ok ? ["matrix_hits_above_threshold"] : undefined,
failure_markers: !row.ok || row.observer_verdict === "reject" ? ["observer_rejected"] : undefined,
cost_usd: typeof row.cost === "number" ? row.cost / 1_000_000 : undefined,
latency_ms: row.duration_ms,
text: row.analysis,
}),
},
{
source_file: `${ROOT}/data/_kb/mode_experiments.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: `mode_exec:${new Date(row.ts).getTime()}:${row.file_path ?? "?"}`,
task_id: row.task_class,
timestamp: row.ts,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/mode_experiments.jsonl`, lineNo),
model_name: row.model,
model_role: "executor" as ModelRole,
model_provider: row.model?.includes("/") ? "openrouter" : "ollama_cloud",
retrieved_context: {
matrix_corpora: row.sources?.matrix_corpus,
matrix_chunks_kept: row.sources?.matrix_chunks_kept,
matrix_chunks_dropped: row.sources?.matrix_chunks_dropped,
pathway_fingerprints_seen: row.sources?.bug_fingerprints_count,
},
latency_ms: row.latency_ms,
text: row.response,
source_files: row.file_path ? [row.file_path] : undefined,
}),
},
{
source_file: `${ROOT}/data/_kb/scrum_reviews.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: `scrum:${new Date(row.reviewed_at).getTime()}:${row.file}`,
task_id: `scrum_review:${row.file}`,
timestamp: row.reviewed_at,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/scrum_reviews.jsonl`, lineNo),
model_name: row.accepted_model,
model_role: "executor" as ModelRole,
source_files: [row.file],
success_markers: row.accepted_on_attempt ? [`accepted_on_attempt_${row.accepted_on_attempt}`] : undefined,
text: row.suggestions_preview,
}),
},
{
source_file: `${ROOT}/data/_kb/observer_escalations.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: `obs_esc:${new Date(row.ts).getTime()}:${row.sig_hash}`,
task_id: `observer_escalation:${row.cluster_endpoint ?? "?"}`,
timestamp: row.ts,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/observer_escalations.jsonl`, lineNo, row.sig_hash),
model_role: "reviewer" as ModelRole,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
text: row.analysis,
}),
},
{
source_file: `${ROOT}/data/_kb/audit_facts.jsonl`,
transform: (row: any, lineNo: number) => ({
run_id: `audit_facts:${row.head_sha}:${lineNo}`,
task_id: `pr:${row.pr_number}`,
timestamp: row.extracted_at,
schema_version: EVIDENCE_SCHEMA_VERSION,
provenance: provFor(`${ROOT}/data/_kb/audit_facts.jsonl`, lineNo),
model_name: row.extractor,
model_role: "extractor" as ModelRole,
// facts/entities/relationships go into text as a JSON dump for now;
// structured handling lives in Phase 2 where we map to specific
// EvidenceRecord substructures.
text: JSON.stringify({
facts: row.facts?.length ?? 0,
entities: row.entities?.length ?? 0,
relationships: row.relationships?.length ?? 0,
}),
}),
},
];
interface ProbeResult {
source_file: string;
rows_attempted: number;
rows_present: boolean;
passed: number;
failed: number;
failure_reasons: string[]; // unique error strings, top 5
}
const RESULTS: ProbeResult[] = [];
for (const probe of PROBES) {
const sourceLabel = probe.source_file.replace(`${ROOT}/`, "");
test(`real-data: ${sourceLabel}`, () => {
const result: ProbeResult = {
source_file: sourceLabel,
rows_attempted: 0,
rows_present: false,
passed: 0,
failed: 0,
failure_reasons: [],
};
if (!existsSync(probe.source_file)) {
RESULTS.push(result);
// Skip silently — fresh clones won't have these files
return;
}
result.rows_present = true;
const lines = readFileSync(probe.source_file, "utf8").split("\n").filter(Boolean).slice(0, SAMPLE_PER_SOURCE);
const reasons = new Set<string>();
for (let i = 0; i < lines.length; i++) {
result.rows_attempted++;
let row: unknown;
try { row = JSON.parse(lines[i]); }
catch { continue; }
const transformed = probe.transform(row, i);
if (!transformed) continue;
const v = validateEvidenceRecord(transformed);
if (v.valid) result.passed++;
else {
result.failed++;
for (const e of v.errors) reasons.add(e);
}
}
result.failure_reasons = Array.from(reasons).slice(0, 5);
RESULTS.push(result);
// Test passes as long as we attempted something and got a result.
// Per-source pass/fail counts are reported in the markdown writeup.
expect(result.rows_attempted).toBeGreaterThanOrEqual(0);
});
}
test("real-data: emit markdown report", () => {
const md: string[] = [];
md.push("# Real-data validation report");
md.push("");
md.push("Schema = EvidenceRecord v" + EVIDENCE_SCHEMA_VERSION + ". Sample = first " + SAMPLE_PER_SOURCE + " rows per source.");
md.push("");
md.push("| Source | Present | Rows | Pass | Fail | Pass% |");
md.push("|---|---|---|---|---|---|");
for (const r of RESULTS) {
const pct = r.rows_attempted > 0 ? Math.round(100 * r.passed / r.rows_attempted) + "%" : "—";
md.push(`| ${r.source_file} | ${r.rows_present ? "✓" : "—"} | ${r.rows_attempted} | ${r.passed} | ${r.failed} | ${pct} |`);
}
md.push("");
let hasFailures = false;
for (const r of RESULTS) {
if (r.failed > 0) {
hasFailures = true;
md.push(`## Failures in ${r.source_file}`);
for (const reason of r.failure_reasons) md.push(`- \`${reason}\``);
md.push("");
}
}
if (!hasFailures) {
md.push("**No failures across all probed sources.** Every materialized record validates against EvidenceRecord v1.");
md.push("");
}
// Stale extraction probe: explicit pass/fail
const distilledFacts = RESULTS.find(r => r.source_file.endsWith("distilled_facts.jsonl"));
const distilledProc = RESULTS.find(r => r.source_file.endsWith("distilled_procedures.jsonl"));
md.push("## Stale-extraction probe");
md.push("");
if (distilledFacts && distilledFacts.rows_present && distilledFacts.passed > 0) {
md.push(`- **distilled_facts.jsonl:** ${distilledFacts.passed}/${distilledFacts.rows_attempted} materialize cleanly. Stream is alive at the schema level.`);
} else if (distilledFacts && !distilledFacts.rows_present) {
md.push(`- **distilled_facts.jsonl:** missing — stale or never produced. Phase 2 sources from live streams instead.`);
} else {
md.push(`- **distilled_facts.jsonl:** present but materialization failures; treat as suspect, prefer mode_experiments + scrum_reviews.`);
}
if (distilledProc && distilledProc.rows_present && distilledProc.passed > 0) {
md.push(`- **distilled_procedures.jsonl:** ${distilledProc.passed}/${distilledProc.rows_attempted} materialize cleanly.`);
}
md.push("");
// Write the markdown to a stable path and stdout
const out = md.join("\n");
Bun.write(`${ROOT}/data/_kb/realdata_validation_report.md`, out);
console.log("\n" + out);
});
-111
View File
@@ -1,111 +0,0 @@
// Receipt — per-pipeline-stage record with everything needed to
// reproduce the run. Spec non-negotiable: substantive receipts, not
// "ran successfully". Every field below has a deterministic source so
// the receipt schema validator catches "I forgot to fill it in" the
// same way it catches type errors.
import {
ValidationResult, requireString, requireNumber, requireIsoTimestamp,
} from "./types";
export const RECEIPT_SCHEMA_VERSION = 1;
export interface FileReference {
path: string; // relative to repo root
sha256: string; // hex
bytes?: number; // optional but recommended
}
export interface Receipt {
schema_version: number;
command: string; // shell-line or script identifier
git_sha: string; // 40-char hex (full SHA1)
git_branch?: string;
git_dirty?: boolean; // true if working tree had uncommitted changes
started_at: string; // ISO 8601
ended_at: string; // ISO 8601
duration_ms: number;
input_files: FileReference[];
output_files: FileReference[];
record_counts: {
in: number;
out: number;
[key: string]: number; // per-stage extras (filtered, dropped, etc.)
};
validation_pass: boolean; // explicit — never inferred
errors: string[];
warnings: string[];
}
function validateFileRef(v: unknown, field: string, errors: string[]): boolean {
if (typeof v !== "object" || v === null) {
errors.push(`${field}: expected object`);
return false;
}
const f = v as Record<string, unknown>;
let ok = true;
ok = requireString(f.path, `${field}.path`, errors) && ok;
if (typeof f.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(f.sha256)) {
errors.push(`${field}.sha256: must be hex sha256`);
ok = false;
}
if (f.bytes !== undefined && typeof f.bytes !== "number") {
errors.push(`${field}.bytes: expected number when present`);
ok = false;
}
return ok;
}
export function validateReceipt(input: unknown): ValidationResult<Receipt> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object"] };
}
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== RECEIPT_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${RECEIPT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.command, "command", errors) && ok;
if (typeof r.git_sha !== "string" || !/^[0-9a-f]{40}$/.test(r.git_sha as string)) {
errors.push("git_sha: must be 40-char hex");
ok = false;
}
ok = requireIsoTimestamp(r.started_at, "started_at", errors) && ok;
ok = requireIsoTimestamp(r.ended_at, "ended_at", errors) && ok;
ok = requireNumber(r.duration_ms, "duration_ms", errors) && ok;
if (typeof r.validation_pass !== "boolean") {
errors.push("validation_pass: must be boolean (explicit, never inferred)");
ok = false;
}
if (!Array.isArray(r.input_files)) {
errors.push("input_files: expected array");
ok = false;
} else {
for (let i = 0; i < r.input_files.length; i++) {
if (!validateFileRef(r.input_files[i], `input_files[${i}]`, errors)) ok = false;
}
}
if (!Array.isArray(r.output_files)) {
errors.push("output_files: expected array");
ok = false;
} else {
for (let i = 0; i < r.output_files.length; i++) {
if (!validateFileRef(r.output_files[i], `output_files[${i}]`, errors)) ok = false;
}
}
if (typeof r.record_counts !== "object" || r.record_counts === null) {
errors.push("record_counts: expected object");
ok = false;
} else {
const rc = r.record_counts as Record<string, unknown>;
if (typeof rc.in !== "number") { errors.push("record_counts.in: expected number"); ok = false; }
if (typeof rc.out !== "number") { errors.push("record_counts.out: expected number"); ok = false; }
}
if (!Array.isArray(r.errors)) { errors.push("errors: expected array"); ok = false; }
if (!Array.isArray(r.warnings)) { errors.push("warnings: expected array"); ok = false; }
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as Receipt };
}
@@ -1,90 +0,0 @@
// run_summary.ts — aggregates StageReceipt rows for one run_id.
// Spec field set: total records processed, total accepted/rejected/
// quarantined, dataset sizes, validation status, overall hash of run.
import {
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
} from "./types";
import type { StageName } from "./stage_receipt";
export const RUN_SUMMARY_SCHEMA_VERSION = 1;
export interface RunStageSummary {
stage: StageName;
records_in: number;
records_out: number;
accepted: number;
rejected: number;
quarantined: number;
skipped: number;
passed: boolean;
duration_ms: number;
output_hash: string;
}
export interface RunSummary {
schema_version: number;
run_id: string;
started_at: string; // earliest stage timestamp
ended_at: string; // latest stage timestamp + duration
git_commit: string;
stages: RunStageSummary[];
// Aggregates across stages
total_records_in: number;
total_records_out: number;
total_accepted: number;
total_rejected: number;
total_quarantined: number;
total_skipped: number;
// Dataset sizes — final outputs of each export stage
rag_records: number;
sft_records: number;
preference_pairs: number;
// Pipeline-wide pass = AND of every stage validation.passed
overall_passed: boolean;
// Run-wide hash: sha256 over each stage's output hash, sorted by stage name.
// Detects ANY change in any stage output across runs.
run_hash: string;
total_duration_ms: number;
}
export function validateRunSummary(input: unknown): ValidationResult<RunSummary> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object"] };
}
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== RUN_SUMMARY_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${RUN_SUMMARY_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.run_id, "run_id", errors) && ok;
ok = requireIsoTimestamp(r.started_at, "started_at", errors) && ok;
ok = requireIsoTimestamp(r.ended_at, "ended_at", errors) && ok;
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
errors.push("git_commit: must be 40-char hex");
ok = false;
}
if (typeof r.overall_passed !== "boolean") {
errors.push("overall_passed: must be boolean");
ok = false;
}
ok = requireSha256(r.run_hash, "run_hash", errors) && ok;
for (const k of ["total_records_in", "total_records_out", "total_accepted", "total_rejected",
"total_quarantined", "total_skipped", "rag_records", "sft_records",
"preference_pairs", "total_duration_ms"]) {
if (typeof (r as any)[k] !== "number") {
errors.push(`${k}: expected number`);
ok = false;
}
}
if (!Array.isArray(r.stages)) {
errors.push("stages: expected array");
ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as RunSummary };
}
@@ -1,367 +0,0 @@
// Combined schema tests for ScoredRun, Receipt, Playbook,
// ScratchpadSummary, ModelLedgerEntry, RagSample, SftSample,
// PreferenceSample. EvidenceRecord lives in its own file because it's
// the foundational schema and warrants the JSON-fixture round-trip
// pattern; the rest use inline fixture makers since they're simpler.
//
// Each schema: 1 positive fixture + 4-5 negative cases pinning the
// non-negotiable invariants from now.md.
//
// Run: bun test auditor/schemas/distillation/schemas.test.ts
import { test, expect } from "bun:test";
import { validateScoredRun, SCORED_RUN_SCHEMA_VERSION } from "./scored_run";
import { validateReceipt, RECEIPT_SCHEMA_VERSION } from "./receipt";
import { validatePlaybook, PLAYBOOK_SCHEMA_VERSION } from "./playbook";
import { validateScratchpadSummary, SCRATCHPAD_SCHEMA_VERSION } from "./scratchpad_summary";
import { validateModelLedgerEntry, MODEL_LEDGER_SCHEMA_VERSION } from "./model_ledger";
import { validateRagSample, RAG_SAMPLE_SCHEMA_VERSION } from "./rag_sample";
import { validateSftSample, SFT_SAMPLE_SCHEMA_VERSION } from "./sft_sample";
import { validatePreferenceSample, PREFERENCE_SAMPLE_SCHEMA_VERSION } from "./preference_sample";
const NOW = "2026-04-26T22:30:00.000Z";
const SHA = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const GIT_SHA = "f753e11157eef753e11157eef753e11157eef753";
const PROVENANCE = {
source_file: "data/_kb/scored_runs.jsonl",
line_offset: 0,
sig_hash: SHA,
recorded_at: NOW,
};
// ─── ScoredRun ───────────────────────────────────────────────────────
const SCORED_RUN_OK = {
schema_version: SCORED_RUN_SCHEMA_VERSION,
evidence_run_id: "run-abc",
evidence_task_id: "task-abc",
category: "accepted",
reasons: ["cargo_green=true", "anchor_grounding=0.95"],
scored_at: NOW,
scorer_version: "v1.0.0",
sub_scores: { cargo_green: true, anchor_grounding: 0.95 },
provenance: PROVENANCE,
};
test("ScoredRun: positive validates", () => {
const r = validateScoredRun(SCORED_RUN_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("ScoredRun: empty reasons rejected (every score needs a reason)", () => {
const r = validateScoredRun({ ...SCORED_RUN_OK, reasons: [] });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("reasons"))).toBe(true);
});
test("ScoredRun: invalid category rejected", () => {
const r = validateScoredRun({ ...SCORED_RUN_OK, category: "maybe_ok" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("category"))).toBe(true);
});
test("ScoredRun: anchor_grounding > 1 rejected (must be in [0, 1])", () => {
const r = validateScoredRun({ ...SCORED_RUN_OK, sub_scores: { ...SCORED_RUN_OK.sub_scores, anchor_grounding: 1.5 } });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("anchor_grounding"))).toBe(true);
});
// ─── Receipt ─────────────────────────────────────────────────────────
const RECEIPT_OK = {
schema_version: RECEIPT_SCHEMA_VERSION,
command: "bun run scripts/build_evidence_index.ts",
git_sha: GIT_SHA,
git_branch: "scrum/auto-apply-19814",
git_dirty: false,
started_at: NOW,
ended_at: NOW,
duration_ms: 1234,
input_files: [{ path: "data/_kb/scrum_reviews.jsonl", sha256: SHA, bytes: 448000 }],
output_files: [{ path: "data/evidence/2026/04/26/run.jsonl", sha256: SHA }],
record_counts: { in: 100, out: 95, filtered: 5 },
validation_pass: true,
errors: [],
warnings: [],
};
test("Receipt: positive validates", () => {
const r = validateReceipt(RECEIPT_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("Receipt: bad git_sha rejected (must be 40-char hex)", () => {
const r = validateReceipt({ ...RECEIPT_OK, git_sha: "abc123" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("git_sha"))).toBe(true);
});
test("Receipt: validation_pass must be boolean (never inferred)", () => {
const r = validateReceipt({ ...RECEIPT_OK, validation_pass: "yes" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("validation_pass"))).toBe(true);
});
test("Receipt: file refs without proper sha256 rejected", () => {
const r = validateReceipt({ ...RECEIPT_OK, output_files: [{ path: "x", sha256: "short" }] });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("sha256"))).toBe(true);
});
// ─── Playbook ────────────────────────────────────────────────────────
const PLAYBOOK_OK = {
schema_version: PLAYBOOK_SCHEMA_VERSION,
playbook_id: "pb-scrum-review-001",
task_type: "scrum_review",
problem_pattern: "Cargo workspace warning escalation after applier patch",
useful_context: ["pathway memory bug fingerprints for the file area"],
model_routing_path: ["x-ai/grok-4.1-fast"],
commands_worked: ["cargo check --workspace"],
commands_failed: [],
validation_steps: ["warning count must not increase"],
repo_files_touched: ["crates/queryd/src/service.rs"],
recovery_strategy: "git checkout -- file when cargo red",
known_failure_modes: ["unused import noise"],
escalation_threshold: "use kimi-k2:1t when isolation mode rejects 2 attempts",
acceptance_criteria: ["cargo green", "warning count stable", "rationale-diff aligned"],
source_run_ids: ["run-xyz", "run-abc"],
created_at: NOW,
provenance: PROVENANCE,
};
test("Playbook: positive validates", () => {
const r = validatePlaybook(PLAYBOOK_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("Playbook: empty source_run_ids rejected (every playbook traces to source — spec)", () => {
const r = validatePlaybook({ ...PLAYBOOK_OK, source_run_ids: [] });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("source_run_ids"))).toBe(true);
});
test("Playbook: empty acceptance_criteria rejected (every playbook needs success criteria — spec)", () => {
const r = validatePlaybook({ ...PLAYBOOK_OK, acceptance_criteria: [] });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("acceptance_criteria"))).toBe(true);
});
// ─── ScratchpadSummary ───────────────────────────────────────────────
const SCRATCHPAD_OK = {
schema_version: SCRATCHPAD_SCHEMA_VERSION,
run_id: "run-abc",
current_objective: "verify pr_audit mode end-to-end",
completed_steps: ["restart gateway"],
failed_steps: ["cloud chat returned 500"],
pending_steps: ["swap default model"],
important_paths: ["auditor/checks/inference.ts"],
decisions: ["defer kimi-k2 swap until upstream returns"],
unresolved_questions: ["does deepseek match kimi quality?"],
validation_status: "partial",
next_command: "bun run auditor/audit_one.ts 11",
source_scratchpad_hash: SHA,
summarized_at: NOW,
provenance: PROVENANCE,
};
test("ScratchpadSummary: positive validates", () => {
const r = validateScratchpadSummary(SCRATCHPAD_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("ScratchpadSummary: invalid validation_status rejected", () => {
const r = validateScratchpadSummary({ ...SCRATCHPAD_OK, validation_status: "tbd" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("validation_status"))).toBe(true);
});
test("ScratchpadSummary: short scratchpad_hash rejected", () => {
const r = validateScratchpadSummary({ ...SCRATCHPAD_OK, source_scratchpad_hash: "short" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("source_scratchpad_hash"))).toBe(true);
});
// ─── ModelLedgerEntry ────────────────────────────────────────────────
const LEDGER_OK = {
schema_version: MODEL_LEDGER_SCHEMA_VERSION,
model_name: "kimi-k2:1t",
model_provider: "ollama_cloud",
task_type: "pr_audit",
success_rate: 0.85,
failure_modes: ["upstream_500", "context_truncation"],
best_partner_model: "x-ai/grok-4.1-fast",
escalation_role: "primary",
cost_usd_p50: 0.0002,
latency_ms_p50: 50000,
latency_ms_p95: 90000,
context_window: 200000,
sample_count: 47,
last_updated: NOW,
};
test("ModelLedgerEntry: positive validates", () => {
const r = validateModelLedgerEntry(LEDGER_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("ModelLedgerEntry: success_rate > 1 rejected", () => {
const r = validateModelLedgerEntry({ ...LEDGER_OK, success_rate: 1.5 });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("success_rate"))).toBe(true);
});
test("ModelLedgerEntry: zero sample_count rejected (no aggregate from zero)", () => {
const r = validateModelLedgerEntry({ ...LEDGER_OK, sample_count: 0 });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("sample_count"))).toBe(true);
});
// ─── RagSample ───────────────────────────────────────────────────────
const RAG_OK = {
schema_version: RAG_SAMPLE_SCHEMA_VERSION,
id: "rag-pb-001",
title: "Scrum applier rationale-diff alignment",
content: "When the applier emits a patch with rationale claiming X but the diff shows Y, the rationale-token alignment gate catches it...",
tags: ["scrum_review", "applier"],
source_run_id: "run-xyz",
success_score: "accepted",
source_category: "accepted",
embedding_text: "applier rationale-diff alignment guard scrum",
created_at: NOW,
provenance: PROVENANCE,
};
test("RagSample: positive validates", () => {
const r = validateRagSample(RAG_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("RagSample: success_score=rejected forbidden (RAG never takes rejected)", () => {
const r = validateRagSample({ ...RAG_OK, success_score: "rejected", source_category: "rejected" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("success_score"))).toBe(true);
});
test("RagSample: success_score and source_category must match", () => {
const r = validateRagSample({ ...RAG_OK, success_score: "accepted", source_category: "partially_accepted" });
expect(r.valid).toBe(false);
});
test("RagSample: whitespace-only content rejected", () => {
const r = validateRagSample({ ...RAG_OK, content: " \n " });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("content"))).toBe(true);
});
// ─── SftSample (the strict one) ──────────────────────────────────────
const SFT_OK = {
schema_version: SFT_SAMPLE_SCHEMA_VERSION,
id: "sft-pr11-001",
instruction: "Audit this PR diff against ship-claims.",
context: "claims: 3 strong, 2 moderate",
response: "{\"claim_verdicts\": [...]}",
source_run_id: "run-pr11",
quality_score: "accepted",
created_at: NOW,
provenance: PROVENANCE,
};
test("SftSample: positive validates", () => {
const r = validateSftSample(SFT_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("SftSample: quality_score=partially_accepted ACCEPTED (--include-partial path)", () => {
// Phase 4 update: partial allowed at schema layer; CLI gate decides.
const r = validateSftSample({ ...SFT_OK, quality_score: "partially_accepted" });
expect(r.valid).toBe(true);
});
test("SftSample: quality_score=rejected REJECTED (spec non-negotiable, no leak)", () => {
const r = validateSftSample({ ...SFT_OK, quality_score: "rejected" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("quality_score"))).toBe(true);
});
test("SftSample: quality_score=needs_human_review REJECTED (no leak)", () => {
const r = validateSftSample({ ...SFT_OK, quality_score: "needs_human_review" });
expect(r.valid).toBe(false);
});
test("SftSample: missing context rejected (must be string, even if empty)", () => {
const fixture: Record<string, unknown> = { ...SFT_OK };
delete fixture.context;
const r = validateSftSample(fixture);
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("context"))).toBe(true);
});
test("SftSample: empty-string context allowed", () => {
const r = validateSftSample({ ...SFT_OK, context: "" });
expect(r.valid).toBe(true);
});
test("SftSample: empty response rejected (no empty pairs)", () => {
const r = validateSftSample({ ...SFT_OK, response: "" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("response"))).toBe(true);
});
test("SftSample: whitespace-only instruction rejected", () => {
const r = validateSftSample({ ...SFT_OK, instruction: " \t\n " });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("instruction"))).toBe(true);
});
// ─── PreferenceSample ────────────────────────────────────────────────
const PREF_OK = {
schema_version: PREFERENCE_SAMPLE_SCHEMA_VERSION,
id: "pref-task-x-001",
prompt: "Verify claim: 'all 3 services running on matrix-test'",
chosen: "{\"backed\": true, \"evidence\": \"systemctl status confirms 3 active\"}",
rejected: "{\"backed\": true, \"evidence\": \"the README says so\"}",
reason: "chosen cites runtime evidence, rejected cites doc claim only",
chosen_run_id: "run-A",
rejected_run_id: "run-B",
created_at: NOW,
provenance: PROVENANCE,
};
test("PreferenceSample: positive validates", () => {
const r = validatePreferenceSample(PREF_OK);
if (!r.valid) console.error(r.errors);
expect(r.valid).toBe(true);
});
test("PreferenceSample: chosen == rejected rejected (no self-pairing)", () => {
const r = validatePreferenceSample({ ...PREF_OK, chosen: "x", rejected: "x" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("chosen and rejected"))).toBe(true);
});
test("PreferenceSample: chosen_run_id == rejected_run_id rejected (no self-disagreement)", () => {
const r = validatePreferenceSample({ ...PREF_OK, chosen_run_id: "run-A", rejected_run_id: "run-A" });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("chosen_run_id"))).toBe(true);
});
test("PreferenceSample: empty reason rejected (every preference needs WHY)", () => {
const r = validatePreferenceSample({ ...PREF_OK, reason: " " });
expect(r.valid).toBe(false);
if (!r.valid) expect(r.errors.some(e => e.includes("reason"))).toBe(true);
});
@@ -1,86 +0,0 @@
// ScoredRun — output of the deterministic Success Scorer (Phase 3).
// Spec mandates 4 categories with explicit reasons; we add scorer
// versioning so a future scorer change is detectable in historical data.
import {
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray, requireNumber,
} from "./types";
export const SCORED_RUN_SCHEMA_VERSION = 1;
export const SCORE_CATEGORIES = ["accepted", "partially_accepted", "rejected", "needs_human_review"] as const;
export type ScoreCategory = (typeof SCORE_CATEGORIES)[number];
export interface ScoredRun {
schema_version: number;
evidence_run_id: string; // FK to EvidenceRecord.run_id
evidence_task_id: string; // FK to EvidenceRecord.task_id
category: ScoreCategory;
reasons: string[]; // human-readable, e.g. ["cargo_green=true", "anchor_grounding<0.7"]
scored_at: string; // ISO 8601
scorer_version: string; // e.g. "v1.0.0" — bumped on scorer code change
// Sub-scores that the scorer collapsed into the category. Persisted
// so a downstream UI can show "why" without re-running the scorer.
sub_scores?: {
cargo_green?: boolean;
anchor_grounding?: number;
schema_valid?: boolean;
pathway_replay_succeeded?: boolean;
observer_verdict?: "accept" | "reject" | "cycle";
[key: string]: unknown;
};
provenance: {
source_file: string;
line_offset?: number;
sig_hash: string;
recorded_at: string;
};
}
export function validateScoredRun(input: unknown): ValidationResult<ScoredRun> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object"] };
}
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== SCORED_RUN_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${SCORED_RUN_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.evidence_run_id, "evidence_run_id", errors) && ok;
ok = requireString(r.evidence_task_id, "evidence_task_id", errors) && ok;
ok = requireIsoTimestamp(r.scored_at, "scored_at", errors) && ok;
ok = requireString(r.scorer_version, "scorer_version", errors) && ok;
ok = requireStringArray(r.reasons, "reasons", errors) && ok;
if (Array.isArray(r.reasons) && r.reasons.length === 0) {
errors.push("reasons: must be non-empty (every score must have at least one reason)");
ok = false;
}
if (!SCORE_CATEGORIES.includes(r.category as ScoreCategory)) {
errors.push(`category: must be one of ${SCORE_CATEGORIES.join("|")}, got ${JSON.stringify(r.category)}`);
ok = false;
}
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
if (r.sub_scores !== undefined) {
if (typeof r.sub_scores !== "object" || r.sub_scores === null) {
errors.push("sub_scores: expected object when present");
ok = false;
} else {
const ss = r.sub_scores as Record<string, unknown>;
if (ss.anchor_grounding !== undefined) {
if (!requireNumber(ss.anchor_grounding, "sub_scores.anchor_grounding", errors)) ok = false;
else if ((ss.anchor_grounding as number) < 0 || (ss.anchor_grounding as number) > 1) {
errors.push("sub_scores.anchor_grounding: must be in [0, 1]");
ok = false;
}
}
if (ss.observer_verdict !== undefined && !["accept", "reject", "cycle"].includes(ss.observer_verdict as string)) {
errors.push("sub_scores.observer_verdict: must be accept|reject|cycle");
ok = false;
}
}
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as ScoredRun };
}
@@ -1,65 +0,0 @@
// ScratchpadSummary — structured normalization of a tree-split or
// long-running scratchpad. Distinct from EvidenceRecord because a
// scratchpad accumulates across many calls; this schema captures the
// state at a checkpoint moment.
import {
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireStringArray,
} from "./types";
export const SCRATCHPAD_SCHEMA_VERSION = 1;
export interface ScratchpadSummary {
schema_version: number;
run_id: string;
current_objective: string;
completed_steps: string[];
failed_steps: string[];
pending_steps: string[];
important_paths: string[]; // file paths the scratchpad references
decisions: string[]; // architectural/scope decisions made
unresolved_questions: string[];
validation_status: "pass" | "fail" | "partial" | "pending";
next_command?: string; // recommendation for next action
source_scratchpad_hash: string; // sha256 of the full source scratchpad text — diff detection
summarized_at: string; // ISO 8601
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
}
const STATUS = ["pass", "fail", "partial", "pending"];
export function validateScratchpadSummary(input: unknown): ValidationResult<ScratchpadSummary> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== SCRATCHPAD_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${SCRATCHPAD_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.run_id, "run_id", errors) && ok;
ok = requireString(r.current_objective, "current_objective", errors) && ok;
ok = requireIsoTimestamp(r.summarized_at, "summarized_at", errors) && ok;
if (typeof r.source_scratchpad_hash !== "string" || !/^[0-9a-f]{64}$/.test(r.source_scratchpad_hash as string)) {
errors.push("source_scratchpad_hash: must be hex sha256");
ok = false;
}
ok = requireStringArray(r.completed_steps, "completed_steps", errors) && ok;
ok = requireStringArray(r.failed_steps, "failed_steps", errors) && ok;
ok = requireStringArray(r.pending_steps, "pending_steps", errors) && ok;
ok = requireStringArray(r.important_paths, "important_paths", errors) && ok;
ok = requireStringArray(r.decisions, "decisions", errors) && ok;
ok = requireStringArray(r.unresolved_questions, "unresolved_questions", errors) && ok;
if (!STATUS.includes(r.validation_status as string)) {
errors.push(`validation_status: must be one of ${STATUS.join("|")}`);
ok = false;
}
if (r.next_command !== undefined && typeof r.next_command !== "string") {
errors.push("next_command: expected string when present");
ok = false;
}
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as ScratchpadSummary };
}
@@ -1,69 +0,0 @@
// SftSample — entry in exports/sft/instruction_response.jsonl. Spec
// non-negotiable: ONLY accepted runs, never partial/rejected/needs_human.
// Validator enforces that invariant — exporters can't bypass.
import {
ValidationResult, requireString, requireIsoTimestamp, requireProvenance, requireNumber,
} from "./types";
export const SFT_SAMPLE_SCHEMA_VERSION = 1;
// SFT default: only `accepted` ships. With --include-partial CLI flag,
// `partially_accepted` becomes legal. `rejected` and `needs_human_review`
// NEVER ship to SFT — that's the contamination firewall.
export const SFT_QUALITY_SCORES = ["accepted", "partially_accepted"] as const;
export type SftQualityScore = (typeof SFT_QUALITY_SCORES)[number];
export interface SftSample {
schema_version: number;
id: string;
instruction: string; // the prompt / user message
context: string; // retrieved context that was visible (empty string allowed; null/undefined not)
response: string; // the model output that was accepted
source_run_id: string;
quality_score: SftQualityScore;
created_at: string;
provenance: { source_file: string; line_offset?: number; sig_hash: string; recorded_at: string };
}
export function validateSftSample(input: unknown): ValidationResult<SftSample> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) return { valid: false, errors: ["expected object"] };
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== SFT_SAMPLE_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${SFT_SAMPLE_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.id, "id", errors) && ok;
ok = requireString(r.instruction, "instruction", errors) && ok;
ok = requireString(r.response, "response", errors) && ok;
ok = requireString(r.source_run_id, "source_run_id", errors) && ok;
ok = requireIsoTimestamp(r.created_at, "created_at", errors) && ok;
ok = requireProvenance(r.provenance, "provenance", errors) && ok;
// Empty pair guard.
if (typeof r.instruction === "string" && (r.instruction as string).trim().length === 0) {
errors.push("instruction: must be non-whitespace (no empty pairs)");
ok = false;
}
if (typeof r.response === "string" && (r.response as string).trim().length === 0) {
errors.push("response: must be non-whitespace (no empty pairs)");
ok = false;
}
// Context is required-string but empty is allowed (some SFT samples
// are pure instruction→response with no retrieval context).
if (typeof r.context !== "string") {
errors.push("context: expected string (use empty string for no-context samples)");
ok = false;
}
// The non-negotiable: SFT samples MUST have quality_score in
// SFT_QUALITY_SCORES. Anything else is a leak.
if (!SFT_QUALITY_SCORES.includes(r.quality_score as SftQualityScore)) {
errors.push(`quality_score: must be one of ${SFT_QUALITY_SCORES.join("|")} (no rejected/needs_human leak into SFT — spec non-negotiable). Got ${JSON.stringify(r.quality_score)}`);
ok = false;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as SftSample };
}
@@ -1,190 +0,0 @@
// stage_receipt.ts — forensic-grade per-stage receipt.
//
// Distinct from auditor/schemas/distillation/receipt.ts (Phase 1):
// - Phase 1 Receipt is per-script invocation, format inherited from
// the early auditor wiring
// - StageReceipt (THIS file) matches the now.md Phase 5 spec exactly
// and is the canonical artifact for pipeline observability
//
// Every pipeline stage (collect, score, export-rag, export-sft,
// export-preference, future extract-playbooks/index) emits ONE
// StageReceipt per run. Receipts are joined by `run_id` (shared
// across all stages of a single `run-all` invocation) so a future
// query can aggregate across the whole pipeline.
import {
ValidationResult, requireString, requireNumber, requireIsoTimestamp, requireSha256,
requireStringArray,
} from "./types";
export const STAGE_RECEIPT_SCHEMA_VERSION = 1;
export const STAGE_NAMES = [
"collect", // build_evidence_index — materialize source jsonls → EvidenceRecord
"score", // score_runs — EvidenceRecord → ScoredRun
"export-rag", // exports/rag/playbooks.jsonl
"export-sft", // exports/sft/instruction_response.jsonl
"export-preference",// exports/preference/chosen_rejected.jsonl
// Reserved for future stages — accept them in the schema so a stage
// can be added without bumping schema_version.
"extract-playbooks",
"index",
] as const;
export type StageName = (typeof STAGE_NAMES)[number];
export interface StageFileRef {
path: string; // relative to repo root
sha256: string; // 64-char hex
bytes?: number;
record_count?: number; // line count for jsonl, when meaningful
}
export interface StageIO {
files: StageFileRef[];
record_count: number;
hash: string; // 64-char hex — aggregate over all file hashes (sorted)
}
export interface StageStats {
accepted: number; // rows that ended up in the stage's output
rejected: number; // explicit category=rejected (Score), invalid pairs (Preference), etc.
quarantined: number; // routed to exports/quarantine/* with structured reason
skipped: number; // parse failures, schema violations at write time
}
export interface StageValidation {
passed: boolean; // explicit boolean — never inferred (spec non-negotiable)
errors: string[];
warnings: string[];
}
export interface StageReceipt {
schema_version: number;
run_id: string; // shared across all stages of one pipeline run
stage: StageName;
timestamp: string; // ISO 8601 — stage start
git_commit: string; // 40-char hex
inputs: StageIO;
outputs: StageIO;
stats: StageStats;
validation: StageValidation;
duration_ms: number;
}
function validateStageIO(v: unknown, field: string, errors: string[]): boolean {
if (typeof v !== "object" || v === null) {
errors.push(`${field}: expected object`);
return false;
}
const io = v as Record<string, unknown>;
let ok = true;
if (!Array.isArray(io.files)) {
errors.push(`${field}.files: expected array`);
ok = false;
} else {
for (let i = 0; i < io.files.length; i++) {
const f = io.files[i] as Record<string, unknown>;
if (typeof f !== "object" || f === null) {
errors.push(`${field}.files[${i}]: expected object`);
ok = false;
continue;
}
ok = requireString(f.path, `${field}.files[${i}].path`, errors) && ok;
ok = requireSha256(f.sha256, `${field}.files[${i}].sha256`, errors) && ok;
if (f.bytes !== undefined && typeof f.bytes !== "number") {
errors.push(`${field}.files[${i}].bytes: expected number when present`);
ok = false;
}
if (f.record_count !== undefined && typeof f.record_count !== "number") {
errors.push(`${field}.files[${i}].record_count: expected number when present`);
ok = false;
}
}
}
ok = requireNumber(io.record_count, `${field}.record_count`, errors) && ok;
ok = requireSha256(io.hash, `${field}.hash`, errors) && ok;
return ok;
}
export function validateStageReceipt(input: unknown): ValidationResult<StageReceipt> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { valid: false, errors: ["expected object"] };
}
const r = input as Record<string, unknown>;
let ok = true;
if (r.schema_version !== STAGE_RECEIPT_SCHEMA_VERSION) {
errors.push(`schema_version: expected ${STAGE_RECEIPT_SCHEMA_VERSION}, got ${JSON.stringify(r.schema_version)}`);
ok = false;
}
ok = requireString(r.run_id, "run_id", errors) && ok;
if (typeof r.run_id === "string" && r.run_id.length < 8) {
errors.push("run_id: too short — expect uuid-like");
ok = false;
}
if (typeof r.stage !== "string" || !STAGE_NAMES.includes(r.stage as StageName)) {
errors.push(`stage: must be one of ${STAGE_NAMES.join("|")}`);
ok = false;
}
ok = requireIsoTimestamp(r.timestamp, "timestamp", errors) && ok;
if (typeof r.git_commit !== "string" || !/^[0-9a-f]{40}$/.test(r.git_commit as string)) {
errors.push("git_commit: must be 40-char hex");
ok = false;
}
if (typeof r.duration_ms !== "number") {
errors.push("duration_ms: expected number");
ok = false;
}
if (typeof r.inputs !== "object" || r.inputs === null) {
errors.push("inputs: expected object");
ok = false;
} else {
ok = validateStageIO(r.inputs, "inputs", errors) && ok;
}
if (typeof r.outputs !== "object" || r.outputs === null) {
errors.push("outputs: expected object");
ok = false;
} else {
ok = validateStageIO(r.outputs, "outputs", errors) && ok;
}
if (typeof r.stats !== "object" || r.stats === null) {
errors.push("stats: expected object");
ok = false;
} else {
const s = r.stats as Record<string, unknown>;
for (const k of ["accepted", "rejected", "quarantined", "skipped"]) {
if (typeof s[k] !== "number") { errors.push(`stats.${k}: expected number`); ok = false; }
}
}
if (typeof r.validation !== "object" || r.validation === null) {
errors.push("validation: expected object");
ok = false;
} else {
const v = r.validation as Record<string, unknown>;
if (typeof v.passed !== "boolean") {
errors.push("validation.passed: must be boolean (explicit, never inferred)");
ok = false;
}
if (!Array.isArray(v.errors)) { errors.push("validation.errors: expected array"); ok = false; }
if (!Array.isArray(v.warnings)) { errors.push("validation.warnings: expected array"); ok = false; }
if (Array.isArray(v.errors)) ok = requireStringArray(v.errors, "validation.errors", errors) && ok;
if (Array.isArray(v.warnings)) ok = requireStringArray(v.warnings, "validation.warnings", errors) && ok;
}
if (!ok) return { valid: false, errors };
return { valid: true, value: r as unknown as StageReceipt };
}
// Compute the canonical aggregate hash over a list of file refs.
// Sorted by path so order-of-iteration doesn't drift the hash.
// Each entry contributes "<path>|<sha256>|<record_count>" so two
// files with identical content but different paths produce distinct
// digests (real difference = real hash difference).
export async function aggregateIoHash(files: StageFileRef[]): Promise<string> {
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
const parts = sorted.map(f => `${f.path}|${f.sha256}|${f.record_count ?? 0}`);
const h = new Bun.CryptoHasher("sha256");
h.update(parts.join("\n"));
return h.digest("hex");
}
-141
View File
@@ -1,141 +0,0 @@
// Shared types for distillation schemas. Hand-rolled validators (no Zod
// dependency) — bun:test runs them; runtime cost is one tiny function
// per record. Pattern: each schema exports `validate(x): ValidationResult`
// returning `{valid: true, value}` or `{valid: false, errors}`.
//
// Why hand-rolled: the auditor + scrum + observer pipelines emit JSONL
// rows in shapes that already work; we want to ENFORCE those shapes
// without adding a 100KB dependency or rewriting producers. The
// validators codify what we already produce.
//
// Naming: schemas live as nouns (`EvidenceRecord`), validators as
// `validate<Noun>`. Each schema file exports both the type and the
// validator.
export interface Provenance {
// Path to the JSONL or other source where this row came from. Always
// relative to /home/profit/lakehouse so receipts are reproducible
// across deploys with the same repo layout.
source_file: string;
// Optional byte offset / line number into the source file. Lets a
// future "open the source row" UI jump directly to the line. Some
// sources (single-row JSON files like _playbook_lessons/*.json) don't
// need this.
line_offset?: number;
// SHA-256 of the canonical JSON of the source row (sorted keys, no
// whitespace). This is the dedup key — running distillation twice on
// the same source produces identical sig_hash, so duplicates are
// detectable without full row comparison.
sig_hash: string;
// ISO 8601 of when this provenance link was recorded — usually the
// moment the unified Evidence Index ran. Distinct from the source
// row's own timestamp, which lives on the EvidenceRecord itself.
recorded_at: string;
}
// Returned by every schema validator. The shape is `{valid: true, value}`
// for success (so callers can use `value` with the right type narrowed)
// or `{valid: false, errors}` for failure (so callers can surface
// every error at once, not just the first).
export type ValidationResult<T> =
| { valid: true; value: T }
| { valid: false; errors: string[] };
// Standard helpers used by every schema. Centralized so naming +
// error message format stay consistent across schemas.
export function requireString(v: unknown, field: string, errors: string[]): v is string {
if (typeof v !== "string") {
errors.push(`${field}: expected string, got ${typeof v}`);
return false;
}
if (v.length === 0) {
errors.push(`${field}: must be non-empty`);
return false;
}
return true;
}
export function requireNumber(v: unknown, field: string, errors: string[]): v is number {
if (typeof v !== "number" || !Number.isFinite(v)) {
errors.push(`${field}: expected finite number, got ${typeof v}`);
return false;
}
return true;
}
export function requireIsoTimestamp(v: unknown, field: string, errors: string[]): v is string {
if (!requireString(v, field, errors)) return false;
// Permissive ISO 8601: YYYY-MM-DDTHH:MM:SS(.fraction)?(Z|±HH:MM)?
const re = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
if (!re.test(v as string)) {
errors.push(`${field}: not a valid ISO 8601 timestamp: ${(v as string).slice(0, 60)}`);
return false;
}
return true;
}
export function requireSha256(v: unknown, field: string, errors: string[]): v is string {
if (!requireString(v, field, errors)) return false;
if (!/^[0-9a-f]{64}$/.test(v as string)) {
errors.push(`${field}: not a valid hex sha256: ${(v as string).slice(0, 80)}`);
return false;
}
return true;
}
export function requireProvenance(v: unknown, field: string, errors: string[]): v is Provenance {
if (typeof v !== "object" || v === null) {
errors.push(`${field}: expected object, got ${v === null ? "null" : typeof v}`);
return false;
}
const p = v as Record<string, unknown>;
let ok = true;
ok = requireString(p.source_file, `${field}.source_file`, errors) && ok;
ok = requireSha256(p.sig_hash, `${field}.sig_hash`, errors) && ok;
ok = requireIsoTimestamp(p.recorded_at, `${field}.recorded_at`, errors) && ok;
if (p.line_offset !== undefined && typeof p.line_offset !== "number") {
errors.push(`${field}.line_offset: expected number when present`);
ok = false;
}
return ok;
}
export function requireStringArray(v: unknown, field: string, errors: string[]): v is string[] {
if (!Array.isArray(v)) {
errors.push(`${field}: expected array, got ${typeof v}`);
return false;
}
for (let i = 0; i < v.length; i++) {
if (typeof v[i] !== "string") {
errors.push(`${field}[${i}]: expected string, got ${typeof v[i]}`);
return false;
}
}
return true;
}
// Compute the canonical sha256 used for sig_hash. Sorts keys so the
// hash is stable regardless of producer's serialization order. Uses
// Bun.CryptoHasher (sync, fast) rather than node:crypto — matches the
// rest of the auditor.
export async function canonicalSha256(obj: unknown): Promise<string> {
const ordered = orderKeys(obj);
const json = JSON.stringify(ordered);
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(json);
return hasher.digest("hex");
}
function orderKeys(v: unknown): unknown {
if (v === null || typeof v !== "object") return v;
if (Array.isArray(v)) return v.map(orderKeys);
const out: Record<string, unknown> = {};
for (const k of Object.keys(v as object).sort()) {
out[k] = orderKeys((v as Record<string, unknown>)[k]);
}
return out;
}
+1 -1
View File
@@ -2,7 +2,7 @@
// if something can't be verified from a check, it goes into `evidence` // if something can't be verified from a check, it goes into `evidence`
// so the verdict is inspectable, not a black box. // so the verdict is inspectable, not a black box.
export type CheckKind = "static" | "dynamic" | "inference" | "kb_query" | "kimi_architect"; export type CheckKind = "static" | "dynamic" | "inference" | "kb_query";
export type Severity = "info" | "warn" | "block"; export type Severity = "info" | "warn" | "block";
+7 -17
View File
@@ -13,17 +13,10 @@ import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Gap, Proposal } from "./types.ts"; import type { Gap, Proposal } from "./types.ts";
// Phase 44 migration (2026-04-27): bot/propose.ts now flows through const SIDECAR_URL = process.env.LH_SIDECAR_URL ?? "http://localhost:3200";
// the gateway's /v1/chat instead of hitting the sidecar's /generate
// directly. /v1/usage tracks the call, Langfuse traces it, observer
// sees it. Gateway owns the routing.
//
// 2026-04-28: gpt-oss:120b → deepseek-v3.2 via Ollama Pro. Newer
// DeepSeek revision, faster, still on the same OLLAMA_CLOUD_KEY.
const GATEWAY_URL = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
const REPO_ROOT = "/home/profit/lakehouse"; const REPO_ROOT = "/home/profit/lakehouse";
const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`; const PRD_PATH = `${REPO_ROOT}/docs/PRD.md`;
const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "deepseek-v3.2"; const CLOUD_MODEL = process.env.LH_BOT_MODEL ?? "gpt-oss:120b";
const MAX_TOKENS = 6000; const MAX_TOKENS = 6000;
export async function findGaps(): Promise<Gap[]> { export async function findGaps(): Promise<Gap[]> {
@@ -79,16 +72,13 @@ export async function generateProposal(gap: Gap, historySummary: string = ""): P
sections.push("Propose a small change that addresses this gap. Respond with the JSON object only."); sections.push("Propose a small change that addresses this gap. Respond with the JSON object only.");
const userPrompt = sections.join("\n"); const userPrompt = sections.join("\n");
const r = await fetch(`${GATEWAY_URL}/v1/chat`, { const r = await fetch(`${SIDECAR_URL}/generate`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
model: CLOUD_MODEL, model: CLOUD_MODEL,
provider: "ollama_cloud", system: SYSTEM_PROMPT,
messages: [ prompt: userPrompt,
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.2, temperature: 0.2,
max_tokens: MAX_TOKENS, max_tokens: MAX_TOKENS,
think: false, think: false,
@@ -96,10 +86,10 @@ export async function generateProposal(gap: Gap, historySummary: string = ""): P
signal: AbortSignal.timeout(180000), // cloud T3 can be slow — 3 min signal: AbortSignal.timeout(180000), // cloud T3 can be slow — 3 min
}); });
if (!r.ok) { if (!r.ok) {
throw new Error(`gateway /v1/chat ${r.status}: ${await r.text()}`); throw new Error(`sidecar ${r.status}: ${await r.text()}`);
} }
const j = await r.json() as any; const j = await r.json() as any;
const raw: string = j?.choices?.[0]?.message?.content ?? ""; const raw: string = j.text ?? j.response ?? "";
const usage = j.usage ?? {}; const usage = j.usage ?? {};
const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0); const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0);
-89
View File
@@ -1,89 +0,0 @@
# Mode router config — task_class → mode mapping
#
# `preferred_mode` is the first choice for a task class; `fallback_modes`
# get tried in order if the preferred one isn't available (LLM Team can
# return Unknown mode for some, OR the matrix has stronger signal for a
# fallback). `default_model` seeds the mode runner's model field if the
# caller doesn't override.
#
# Modes are dispatched against LLM Team UI (localhost:5000/api/run) for
# now; future Rust-native runners will short-circuit before the proxy.
# See crates/gateway/src/v1/mode.rs for the dispatch path.
[[task_class]]
name = "scrum_review"
# 2026-04-26 pass5 variance test (5 reps × 4 conditions, grok-4.1-fast,
# pathway_memory.rs): composed corpus LOST 5/5 vs isolation (Δ −1.8
# grounded findings, p=0.031). See docs/MODE_RUNNER_TUNING_PLAN.md.
# Default is now isolation — bug fingerprints + adversarial framing +
# file content carries strong models without matrix noise. The
# `codereview_lakehouse` matrix path remains available via force_mode
# (auto-downgrades to isolation on strong models — see the
# is_strong_model gate in crates/gateway/src/v1/mode.rs).
preferred_mode = "codereview_isolation"
fallback_modes = ["codereview_lakehouse", "codereview", "consensus", "ladder"]
default_model = "qwen3-coder:480b"
# Corpora kept defined so experimental modes (codereview_matrix_only,
# pass2/pass5 sweeps) and weak-model rescue rungs can still pull them.
# scrum_findings_v1 is built but EXCLUDED — bake-off showed 24% OOB
# line citations from cross-file drift, only safe with same-file gating.
matrix_corpus = ["lakehouse_arch_v1", "lakehouse_symbols_v1"]
[[task_class]]
name = "contract_analysis"
preferred_mode = "deep_analysis"
fallback_modes = ["research", "extract"]
default_model = "kimi-k2:1t"
matrix_corpus = "chicago_permits_v1"
[[task_class]]
name = "staffing_inference"
# Staffing-domain native enrichment runner — Pass 4 (2026-04-26).
# Same composer architecture as codereview_lakehouse but with staffing
# framing + workers corpus.
preferred_mode = "staffing_inference_lakehouse"
fallback_modes = ["ladder", "consensus", "pipeline"]
# 2026-05-03: REVERTED to local. PRD line 70 — everything runs locally,
# no cloud APIs on the customer hot path. Cloud models stay available
# in providers.toml for explicit dev-tool opt-in (scrum, auditor).
default_model = "qwen3.5:latest"
matrix_corpus = "workers_500k_v8"
[[task_class]]
name = "fact_extract"
preferred_mode = "extract"
fallback_modes = ["distill"]
default_model = "qwen2.5"
matrix_corpus = "kb_team_runs_v1"
[[task_class]]
name = "doc_drift_check"
preferred_mode = "drift"
fallback_modes = ["validator"]
# 2026-05-03: REVERTED to local per PRD line 70.
default_model = "qwen3.5:latest"
matrix_corpus = "distilled_factual_v20260423095819"
[[task_class]]
name = "pr_audit"
# Auditor's claim-vs-diff verification mode (2026-04-26 rebuild).
# Replaces the auditor's hand-rolled inference check with the mode-runner
# composer: pathway memory (PR-level patterns) + lakehouse_answers_v1
# corpus (prior accepted reviews + observer escalations) + adversarial
# JSON-shaped framing. Default model is paid Ollama Cloud kimi-k2:1t for
# strong claim-grounding; tie-breaker via auditor-side env override.
preferred_mode = "pr_audit"
fallback_modes = ["consensus", "ladder"]
# kimi-k2:1t broken upstream 2026-04-27 (Ollama Cloud 500 ISE, multi-hour
# sustained outage verified by repeated probes). deepseek-v3.1:671b is
# the drop-in substitute — proven working end-to-end through pr_audit
# during Phase 5 distillation acceptance testing.
default_model = "deepseek-v3.1:671b"
matrix_corpus = "lakehouse_answers_v1"
# Fallback when task_class isn't in the table — useful for ad-hoc calls
# during development that don't yet have a mapped mode.
[default]
preferred_mode = "pipeline"
fallback_modes = ["consensus", "ladder"]
default_model = "qwen3.5:latest"
-106
View File
@@ -1,106 +0,0 @@
# Phase 39: Provider Registry
#
# Per-provider base_url, auth scheme, and default model. The gateway's
# /v1/chat dispatcher reads this file at boot to populate its provider
# table. Secrets (API keys) come from /etc/lakehouse/secrets.toml or
# environment variables — NEVER inline a key here.
#
# Adding a new provider:
# 1. New [[provider]] block with name, base_url, auth, default_model
# 2. Matching adapter at crates/aibridge/src/providers/<name>.rs
# implementing the ProviderAdapter trait (chat + embed + unload)
# 3. Route arm in crates/gateway/src/v1/mod.rs matching on `name`
# 4. Model-prefix routing hint in resolve_provider() if the provider
# uses an "<name>/..." model prefix (e.g. "openrouter/...")
[[provider]]
name = "ollama"
base_url = "http://localhost:11434"
auth = "none"
default_model = "qwen3.5:latest"
# Hot-path local inference. No bearer needed — direct to Ollama as of
# 2026-05-02 (Python sidecar's pass-through wrapper retired). Model
# names are bare (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
[[provider]]
name = "ollama_cloud"
base_url = "https://ollama.com"
auth = "bearer"
auth_env = "OLLAMA_CLOUD_KEY"
default_model = "deepseek-v3.2"
# Cloud-tier Ollama (Pro plan as of 2026-04-28). Key resolved from
# OLLAMA_CLOUD_KEY at gateway boot; Pro tier upgraded the account so
# rate limits + model access widen without a key change. Model-prefix
# routing: "cloud/<model>" auto-routes here. 39-model fleet now
# includes deepseek-v3.2, deepseek-v4-{flash,pro}, gemini-3-flash-
# preview, glm-{5,5.1}, kimi-k2.6, qwen3-coder-next.
# 2026-04-28: default upgraded gpt-oss:120b → deepseek-v3.2 (newest
# DeepSeek revision). NOTE: kimi-k2:1t is upstream-broken (HTTP 500
# on Ollama Pro probe 2026-04-28) — do not route to it. Use kimi-k2.6
# instead, which is what staffing_inference points at.
[[provider]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
auth = "bearer"
auth_env = "OPENROUTER_API_KEY"
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
default_model = "x-ai/grok-4.1-fast"
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
# resolve_openrouter_key() — env first, then fallback files.
# Model-prefix routing: "openrouter/<vendor>/<model>" auto-routes here,
# prefix stripped before upstream call.
[[provider]]
name = "opencode"
base_url = "https://opencode.ai/zen/v1"
# Unified endpoint — covers BOTH Zen (pay-per-token Anthropic/OpenAI/
# Gemini frontier) AND Go (flat-sub Kimi/GLM/DeepSeek/Qwen/Minimax).
# Upstream bills per-model: Zen models hit Zen balance, Go models hit
# Go subscription cap. /zen/go/v1 is the Go-only sub-path (rejects
# Zen models), kept for reference but not used by this provider.
auth = "bearer"
auth_env = "OPENCODE_API_KEY"
default_model = "claude-opus-4-7"
# OpenCode (Zen + GO unified endpoint). One sk-* key reaches Claude
# Opus 4.7, GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
# Qwen, plus 4 free-tier models. OpenAI-compatible Chat Completions
# at /v1/chat/completions. Model-prefix routing: "opencode/<name>"
# auto-routes here, prefix stripped before upstream call.
# Key file: /etc/lakehouse/opencode.env (loaded via systemd EnvironmentFile).
# Model catalog: curl -H "Authorization: Bearer ..." https://opencode.ai/zen/v1/models
# Note: /zen/go/v1 is the GO-only sub-path (Kimi/GLM/DeepSeek tier);
# /zen/v1 covers everything including Anthropic (which /zen/go/v1 rejects).
[[provider]]
name = "kimi"
base_url = "https://api.kimi.com/coding/v1"
auth = "bearer"
auth_env = "KIMI_API_KEY"
default_model = "kimi-for-coding"
# Direct Kimi For Coding provider. `api.kimi.com` is a SEPARATE account
# system from `api.moonshot.ai` and `api.moonshot.cn` — keys are NOT
# interchangeable. Used as a fallback when Ollama Cloud's kimi-k2.6 is
# unavailable and OpenRouter's `moonshotai/kimi-k2.6` is rate-limited.
# (Was `kimi-k2:1t` here pre-2026-05-03 — that model is upstream-broken
# and removed from operator guidance.)
# Model id: `kimi-for-coding` (kimi-k2.6 underneath).
# Key file: /etc/lakehouse/kimi.env (loaded via systemd EnvironmentFile).
# Model-prefix routing: "kimi/<model>" auto-routes here, prefix stripped.
# Planned (Phase 40 long-horizon — adapters not yet shipped):
#
# [[provider]]
# name = "gemini"
# base_url = "https://generativelanguage.googleapis.com/v1beta"
# auth = "api_key_query"
# auth_env = "GEMINI_API_KEY"
# default_model = "gemini-2.0-flash"
#
# [[provider]]
# name = "claude"
# base_url = "https://api.anthropic.com/v1"
# auth = "x_api_key"
# auth_env = "ANTHROPIC_API_KEY"
# default_model = "claude-3-5-sonnet-latest"
-61
View File
@@ -1,61 +0,0 @@
# Phase 40: Routing Engine Configuration
#
# Human-editable rules for model → provider routing.
# Matching order: first match wins.
[[rule]]
model_pattern = "gpt-4*"
provider = "openrouter"
max_tokens = 4096
temperature = 0.7
[[rule]]
model_pattern = "claude*"
provider = "claude"
max_tokens = 4096
temperature = 0.7
[[rule]]
model_pattern = "gemini*"
provider = "gemini"
max_tokens = 8192
temperature = 0.9
[[rule]]
model_pattern = "qwen3.5*"
provider = "ollama"
max_tokens = 4096
temperature = 0.3
[[rule]]
model_pattern = "qwen3*"
provider = "ollama"
max_tokens = 2048
temperature = 0.3
[[rule]]
model_pattern = "gpt-oss*"
provider = "ollama"
temperature = 0.1
[[rule]]
model_pattern = "*"
provider = "ollama"
temperature = 0.5
# Fallback chain: if primary fails, try these in order
fallback = ["ollama", "openrouter"]
# Cost gating (tokens = cents per 1M)
[cost]
ollama = 0
openrouter = 15
claude = 15
gemini = 0
# Daily budget per provider (cents)
[daily_budget]
ollama = 0
openrouter = 1000
claude = 500
gemini = 0
-2
View File
@@ -11,5 +11,3 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
async-trait = "0.1"
lru = "0.12"
+32 -488
View File
@@ -1,74 +1,12 @@
use lru::LruCache;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
/// HTTP client for Ollama (post-2026-05-02 — sidecar dropped). /// HTTP client for the Python AI sidecar.
///
/// `base_url` was historically the Python sidecar at `:3200`, which
/// pass-through-proxied to Ollama at `:11434`. The sidecar added zero
/// logic on the hot path (embed.py + generate.py + rerank.py +
/// admin.py = ~120 LOC of pure Ollama wrappers), so this client now
/// talks to Ollama directly and the sidecar process can be retired.
///
/// What stayed Python: `lab_ui.py` + `pipeline_lab.py` (~888 LOC of
/// dev-mode Streamlit-shape UIs) — those aren't on the runtime hot
/// path and continue running for prompt experimentation.
///
/// `generate()` has two transport modes:
/// - When `gateway_url` is None (default), posts directly to Ollama's
/// `${base_url}/api/generate`.
/// - When `gateway_url` is `Some(url)`, posts to `${url}/v1/chat`
/// with `provider="ollama"` so the call appears in `/v1/usage` and
/// Langfuse traces.
///
/// `embed()`, `rerank()`, and admin methods always go direct to
/// Ollama — no `/v1` equivalent for those surfaces yet.
///
/// Phase 44 part 2 (2026-04-27): the gateway URL is wired in by
/// callers that want observability (vectord modules); it's left
/// unset by callers that ARE the gateway internals (avoids self-loops
/// + redundant hops).
/// Per-text embed cache key. We key on (model, text) so different
/// model selections produce distinct cache lines — a query embedded
/// under nomic-embed-text-v2-moe must NOT collide with the same
/// query under nomic-embed-text v1.
#[derive(Eq, PartialEq, Hash, Clone)]
struct EmbedCacheKey {
model: String,
text: String,
}
/// Default LRU cache size — 4096 entries × ~6KB per 768-d f64
/// vector ≈ 24MB. Sized for typical staffing-domain repetition
/// (coordinator workflows have query repetition rates around 70-90%
/// per session). Tunable via [aibridge].embed_cache_size in the
/// config; 0 disables the cache entirely.
const DEFAULT_EMBED_CACHE_SIZE: usize = 4096;
#[derive(Clone)] #[derive(Clone)]
pub struct AiClient { pub struct AiClient {
client: Client, client: Client,
base_url: String, base_url: String,
gateway_url: Option<String>,
/// Closes the 63× perf gap with Go side. Mirrors the shape of
/// Go's internal/embed/cached.go::CachedProvider — same
/// (model, text) → vector caching, same nil-disable semantics.
/// None = caching disabled (cache_size=0); Some = bounded LRU.
embed_cache: Option<Arc<Mutex<LruCache<EmbedCacheKey, Vec<f64>>>>>,
/// Hit / miss counters for /admin observability + load-test
/// validation. Atomic so Clone'd AiClients share the same counts.
embed_cache_hits: Arc<AtomicU64>,
embed_cache_misses: Arc<AtomicU64>,
/// Pinned at construction time so the EmbedResponse can carry
/// dimension consistently even when every text was a cache hit
/// (no fresh upstream call to learn the dim from). Set on first
/// successful Ollama embed; checked on every cache hit.
cached_dim: Arc<AtomicU64>,
} }
// -- Request/Response types -- // -- Request/Response types --
@@ -141,386 +79,68 @@ pub struct RerankResponse {
impl AiClient { impl AiClient {
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self::with_embed_cache(base_url, DEFAULT_EMBED_CACHE_SIZE)
}
/// Constructs an AiClient with an explicit embed-cache size.
/// Pass 0 to disable the cache entirely (matches Go-side
/// CachedProvider's nil-cache semantics).
pub fn with_embed_cache(base_url: &str, cache_size: usize) -> Self {
let client = Client::builder() let client = Client::builder()
.timeout(Duration::from_secs(120)) .timeout(Duration::from_secs(120))
.build() .build()
.expect("failed to build HTTP client"); .expect("failed to build HTTP client");
let embed_cache = if cache_size > 0 {
// SAFETY: cache_size > 0 just verified, NonZeroUsize::new
// returns Some.
let cap = NonZeroUsize::new(cache_size).expect("cache_size > 0");
Some(Arc::new(Mutex::new(LruCache::new(cap))))
} else {
None
};
Self { Self {
client, client,
base_url: base_url.trim_end_matches('/').to_string(), base_url: base_url.trim_end_matches('/').to_string(),
gateway_url: None,
embed_cache,
embed_cache_hits: Arc::new(AtomicU64::new(0)),
embed_cache_misses: Arc::new(AtomicU64::new(0)),
cached_dim: Arc::new(AtomicU64::new(0)),
} }
} }
/// Cache hit/miss/size snapshot. Useful for /admin endpoints +
/// load-test validation ("did the cache fire as expected?").
pub fn embed_cache_stats(&self) -> (u64, u64, usize) {
let hits = self.embed_cache_hits.load(Ordering::Relaxed);
let misses = self.embed_cache_misses.load(Ordering::Relaxed);
let len = self
.embed_cache
.as_ref()
.map(|c| c.lock().map(|g| g.len()).unwrap_or(0))
.unwrap_or(0);
(hits, misses, len)
}
/// Same as `new`, but every `generate()` is routed through
/// `${gateway_url}/v1/chat` (provider=ollama) for observability.
/// Use this for callers OUTSIDE the gateway. Inside the gateway
/// itself, prefer `new()` — calling /v1/chat from /v1/chat works
/// (no infinite loop, ollama_arm doesn't use AiClient) but adds
/// a wasted localhost hop.
pub fn new_with_gateway(base_url: &str, gateway_url: &str) -> Self {
let mut c = Self::new(base_url);
c.gateway_url = Some(gateway_url.trim_end_matches('/').to_string());
c
}
/// Reachability + version check. Hits Ollama's `/api/version`,
/// returns a sidecar-shaped envelope so callers reading
/// `.status` / `.ollama_url` don't break across the
/// pre-/post-2026-05-02 cutover.
pub async fn health(&self) -> Result<serde_json::Value, String> { pub async fn health(&self) -> Result<serde_json::Value, String> {
let resp = self.client let resp = self.client
.get(format!("{}/api/version", self.base_url)) .get(format!("{}/health", self.base_url))
.send() .send()
.await .await
.map_err(|e| format!("ollama unreachable: {e}"))?; .map_err(|e| format!("sidecar unreachable: {e}"))?;
let body: serde_json::Value = resp.json().await resp.json().await.map_err(|e| format!("invalid response: {e}"))
.map_err(|e| format!("invalid response: {e}"))?;
Ok(serde_json::json!({
"status": "ok",
"ollama_url": &self.base_url,
"ollama_version": body.get("version"),
}))
} }
/// Embed with per-text LRU caching. Mirrors Go-side
/// CachedProvider behavior: cache key is (model, text);
/// cache-hit texts skip the sidecar; cache-miss texts batch
/// into a single sidecar call; results are interleaved in the
/// caller's input order.
///
/// Closes ~95% of the load-test perf gap vs Go side (loadgen
/// 2026-05-01: Rust 128 RPS → with cache ≥ 7000 RPS expected
/// for warm-cache workloads). Cold-cache behavior unchanged
/// (every text is a miss → single sidecar call, identical to
/// pre-cache).
pub async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> { pub async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
let model_key = req.model.clone().unwrap_or_default();
// Fast path: cache disabled → original behavior.
let Some(cache) = self.embed_cache.as_ref() else {
return self.embed_uncached(&req).await;
};
if req.texts.is_empty() {
return self.embed_uncached(&req).await;
}
// First pass: check cache for each text. Track which positions
// need a sidecar fetch.
let mut embeddings: Vec<Option<Vec<f64>>> = vec![None; req.texts.len()];
let mut miss_indices: Vec<usize> = Vec::new();
let mut miss_texts: Vec<String> = Vec::new();
{
let mut guard = cache.lock().map_err(|e| format!("cache lock poisoned: {e}"))?;
for (i, text) in req.texts.iter().enumerate() {
let key = EmbedCacheKey { model: model_key.clone(), text: text.clone() };
if let Some(vec) = guard.get(&key) {
embeddings[i] = Some(vec.clone());
self.embed_cache_hits.fetch_add(1, Ordering::Relaxed);
} else {
miss_indices.push(i);
miss_texts.push(text.clone());
self.embed_cache_misses.fetch_add(1, Ordering::Relaxed);
}
}
}
// All hit? Return immediately. Use cached_dim to populate
// the response dimension (no sidecar to ask).
if miss_indices.is_empty() {
let dim = self.cached_dim.load(Ordering::Relaxed) as usize;
let dim = if dim == 0 { embeddings[0].as_ref().map(|v| v.len()).unwrap_or(0) } else { dim };
return Ok(EmbedResponse {
embeddings: embeddings.into_iter().map(|opt| opt.expect("filled")).collect(),
model: req.model.unwrap_or_else(|| "nomic-embed-text".to_string()),
dimensions: dim,
});
}
// Second pass: fetch the misses in one sidecar call.
let miss_req = EmbedRequest { texts: miss_texts.clone(), model: req.model.clone() };
let resp = self.embed_uncached(&miss_req).await?;
if resp.embeddings.len() != miss_texts.len() {
return Err(format!(
"embed cache: sidecar returned {} embeddings for {} texts",
resp.embeddings.len(),
miss_texts.len()
));
}
// Pin cached_dim on first successful response.
if resp.dimensions > 0 {
self.cached_dim.store(resp.dimensions as u64, Ordering::Relaxed);
}
// Insert misses into cache + fill response slots.
{
let mut guard = cache.lock().map_err(|e| format!("cache lock poisoned: {e}"))?;
for (j, idx) in miss_indices.iter().enumerate() {
let key = EmbedCacheKey {
model: model_key.clone(),
text: miss_texts[j].clone(),
};
let vec = resp.embeddings[j].clone();
guard.put(key, vec.clone());
embeddings[*idx] = Some(vec);
}
}
Ok(EmbedResponse {
embeddings: embeddings.into_iter().map(|opt| opt.expect("filled")).collect(),
model: resp.model,
dimensions: resp.dimensions,
})
}
/// Direct Ollama call — used internally by embed() for cache-miss
/// batches and as the transparent fallback when the cache is
/// disabled. Loops per-text against `${base_url}/api/embed`,
/// matching the sidecar's pre-2026-05-02 behavior. Ollama 0.4+
/// supports batch input but per-text keeps compatibility broader
/// + lets cache-miss-only batches share the loop with cold runs.
async fn embed_uncached(&self, req: &EmbedRequest) -> Result<EmbedResponse, String> {
let model = req.model.clone().unwrap_or_else(|| "nomic-embed-text".to_string());
let mut embeddings: Vec<Vec<f64>> = Vec::with_capacity(req.texts.len());
for text in &req.texts {
let resp = self.client let resp = self.client
.post(format!("{}/api/embed", self.base_url)) .post(format!("{}/embed", self.base_url))
.json(&serde_json::json!({ .json(&req)
"model": &model,
"input": text,
}))
.send() .send()
.await .await
.map_err(|e| format!("embed request failed: {e}"))?; .map_err(|e| format!("embed request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(format!("ollama embed error: {body}")); return Err(format!("embed error ({}): {text}", text.len()));
} }
// Ollama returns {"embeddings": [[...]], "model": "...", ...}. resp.json().await.map_err(|e| format!("embed parse error: {e}"))
// The outer `embeddings` is always a list; for a scalar input
// we get a single inner vector.
let parsed: serde_json::Value = resp.json().await
.map_err(|e| format!("embed parse error: {e}"))?;
let arr = parsed.get("embeddings")
.and_then(|v| v.as_array())
.ok_or_else(|| format!("ollama embed: missing 'embeddings' field in {parsed}"))?;
if arr.is_empty() {
return Err("ollama embed: empty embeddings array".to_string());
}
let first = arr[0].as_array()
.ok_or_else(|| "ollama embed: embeddings[0] not an array".to_string())?;
let vec: Vec<f64> = first.iter()
.filter_map(|n| n.as_f64())
.collect();
if vec.is_empty() {
return Err("ollama embed: numeric coercion produced empty vector".to_string());
}
embeddings.push(vec);
}
let dimensions = embeddings.first().map(|v| v.len()).unwrap_or(0);
Ok(EmbedResponse {
embeddings,
model,
dimensions,
})
} }
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> { pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
if let Some(gw) = self.gateway_url.as_deref() {
return self.generate_via_gateway(gw, req).await;
}
// Direct Ollama path. Used by gateway internals (so the ollama
// provider can call upstream without a self-loop through
// /v1/chat) and by any consumer that wants raw transport
// without /v1/usage accounting.
let model = req.model.clone().unwrap_or_else(|| "qwen3.5:latest".to_string());
let mut body = serde_json::json!({
"model": &model,
"prompt": &req.prompt,
"stream": false,
});
let mut options = serde_json::Map::new();
if let Some(t) = req.temperature {
options.insert("temperature".to_string(), serde_json::json!(t));
}
if let Some(mt) = req.max_tokens {
options.insert("num_predict".to_string(), serde_json::json!(mt));
}
if !options.is_empty() {
body["options"] = serde_json::Value::Object(options);
}
if let Some(sys) = &req.system {
body["system"] = serde_json::json!(sys);
}
if let Some(th) = req.think {
body["think"] = serde_json::json!(th);
}
let resp = self.client let resp = self.client
.post(format!("{}/api/generate", self.base_url)) .post(format!("{}/generate", self.base_url))
.json(&body) .json(&req)
.send() .send()
.await .await
.map_err(|e| format!("generate request failed: {e}"))?; .map_err(|e| format!("generate request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(format!("ollama generate error: {text}")); return Err(format!("generate error: {text}"));
} }
let parsed: serde_json::Value = resp.json().await resp.json().await.map_err(|e| format!("generate parse error: {e}"))
.map_err(|e| format!("generate parse error: {e}"))?;
Ok(GenerateResponse {
text: parsed.get("response").and_then(|v| v.as_str()).unwrap_or("").to_string(),
model,
tokens_evaluated: parsed.get("prompt_eval_count").and_then(|v| v.as_u64()),
tokens_generated: parsed.get("eval_count").and_then(|v| v.as_u64()),
})
} }
/// Phase 44 part 2: route generate() through the gateway's
/// /v1/chat with provider="ollama" so the call lands in
/// /v1/usage + Langfuse. Translates between the sidecar
/// GenerateRequest/Response shape and the OpenAI-compat
/// chat shape on the wire.
async fn generate_via_gateway(&self, gateway_url: &str, req: GenerateRequest) -> Result<GenerateResponse, String> {
let mut messages = Vec::with_capacity(2);
if let Some(sys) = &req.system {
messages.push(serde_json::json!({"role": "system", "content": sys}));
}
messages.push(serde_json::json!({"role": "user", "content": req.prompt}));
let mut body = serde_json::json!({
"messages": messages,
"provider": "ollama",
});
if let Some(m) = &req.model { body["model"] = serde_json::json!(m); }
if let Some(t) = req.temperature { body["temperature"] = serde_json::json!(t); }
if let Some(mt) = req.max_tokens { body["max_tokens"] = serde_json::json!(mt); }
if let Some(th) = req.think { body["think"] = serde_json::json!(th); }
let resp = self.client
.post(format!("{}/v1/chat", gateway_url))
.json(&body)
.send()
.await
.map_err(|e| format!("/v1/chat request failed: {e}"))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("/v1/chat error: {text}"));
}
let parsed: serde_json::Value = resp.json().await
.map_err(|e| format!("/v1/chat parse error: {e}"))?;
let text = parsed
.pointer("/choices/0/message/content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let model = parsed.get("model")
.and_then(|v| v.as_str())
.unwrap_or_else(|| req.model.as_deref().unwrap_or(""))
.to_string();
let prompt_tokens = parsed.pointer("/usage/prompt_tokens").and_then(|v| v.as_u64());
let completion_tokens = parsed.pointer("/usage/completion_tokens").and_then(|v| v.as_u64());
Ok(GenerateResponse {
text,
model,
tokens_evaluated: prompt_tokens,
tokens_generated: completion_tokens,
})
}
/// Cross-encoder reranking via Ollama generate. Asks the model to
/// rate each document's relevance to the query 0-10, then sorts
/// descending. Mirrors the sidecar's pre-2026-05-02 algorithm
/// exactly so callers see the same scores.
pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResponse, String> { pub async fn rerank(&self, req: RerankRequest) -> Result<RerankResponse, String> {
let model = req.model.clone().unwrap_or_else(|| "qwen3.5:latest".to_string());
let mut scored: Vec<ScoredDocument> = Vec::with_capacity(req.documents.len());
for (i, doc) in req.documents.iter().enumerate() {
let prompt = format!(
"Rate the relevance of the following document to the query on a scale of 0 to 10. \
Respond with ONLY a number.\n\n\
Query: {}\n\n\
Document: {}\n\n\
Score:",
req.query, doc,
);
let resp = self.client let resp = self.client
.post(format!("{}/api/generate", self.base_url)) .post(format!("{}/rerank", self.base_url))
.json(&serde_json::json!({ .json(&req)
"model": &model,
"prompt": prompt,
"stream": false,
"options": {"temperature": 0.0, "num_predict": 8},
}))
.send() .send()
.await .await
.map_err(|e| format!("rerank request failed: {e}"))?; .map_err(|e| format!("rerank request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(format!("ollama rerank error: {body}")); return Err(format!("rerank error: {text}"));
} }
let parsed: serde_json::Value = resp.json().await resp.json().await.map_err(|e| format!("rerank parse error: {e}"))
.map_err(|e| format!("rerank parse error: {e}"))?;
let text = parsed.get("response").and_then(|v| v.as_str()).unwrap_or("").trim();
// Parse the leading number; tolerate "7", "7.5", "7 — strong match".
let score = text.split_whitespace().next()
.and_then(|t| t.parse::<f64>().ok())
.unwrap_or(0.0)
.clamp(0.0, 10.0);
scored.push(ScoredDocument {
index: i,
text: doc.clone(),
score,
});
}
scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
if let Some(k) = req.top_k {
scored.truncate(k);
}
Ok(RerankResponse { results: scored, model })
} }
/// Force Ollama to unload the named model from VRAM (keep_alive=0). /// Force Ollama to unload the named model from VRAM (keep_alive=0).
@@ -529,116 +149,40 @@ impl AiClient {
/// profile's model can linger in VRAM next to the new one. /// profile's model can linger in VRAM next to the new one.
pub async fn unload_model(&self, model: &str) -> Result<serde_json::Value, String> { pub async fn unload_model(&self, model: &str) -> Result<serde_json::Value, String> {
let resp = self.client let resp = self.client
.post(format!("{}/api/generate", self.base_url)) .post(format!("{}/admin/unload", self.base_url))
.json(&serde_json::json!({ .json(&serde_json::json!({ "model": model }))
"model": model,
"prompt": "",
"keep_alive": 0,
"stream": false,
}))
.send().await .send().await
.map_err(|e| format!("unload request failed: {e}"))?; .map_err(|e| format!("unload request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(format!("ollama unload error: {text}")); return Err(format!("unload error: {text}"));
} }
// Ollama returns 200 with the empty-prompt response shape. resp.json().await.map_err(|e| format!("unload parse error: {e}"))
// Fold into the legacy {"unloaded": "<model>"} envelope so
// callers' parsing doesn't break.
Ok(serde_json::json!({ "unloaded": model }))
} }
/// Ask Ollama to load the named model into VRAM proactively. Makes /// Ask Ollama to load the named model into VRAM proactively. Makes
/// the first real request after profile activation fast (no cold-load /// the first real request after profile activation fast (no cold-load
/// latency). Empty prompts confuse some models, so we send a single /// latency).
/// space + cap num_predict=1 (matches the sidecar's prior behavior).
pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> { pub async fn preload_model(&self, model: &str) -> Result<serde_json::Value, String> {
let resp = self.client let resp = self.client
.post(format!("{}/api/generate", self.base_url)) .post(format!("{}/admin/preload", self.base_url))
.json(&serde_json::json!({ .json(&serde_json::json!({ "model": model }))
"model": model,
"prompt": " ",
"keep_alive": "5m",
"stream": false,
"options": {"num_predict": 1},
}))
.send().await .send().await
.map_err(|e| format!("preload request failed: {e}"))?; .map_err(|e| format!("preload request failed: {e}"))?;
if !resp.status().is_success() { if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default(); let text = resp.text().await.unwrap_or_default();
return Err(format!("ollama preload error: {text}")); return Err(format!("preload error: {text}"));
} }
let parsed: serde_json::Value = resp.json().await resp.json().await.map_err(|e| format!("preload parse error: {e}"))
.map_err(|e| format!("preload parse error: {e}"))?;
Ok(serde_json::json!({
"preloaded": model,
"load_duration_ns": parsed.get("load_duration"),
"total_duration_ns": parsed.get("total_duration"),
}))
} }
/// GPU + loaded-model snapshot. Combines nvidia-smi output (when /// GPU + loaded-model snapshot from the sidecar. Combines nvidia-smi
/// available) with Ollama's /api/ps. Same shape as the prior /// output (if available) with Ollama's /api/ps.
/// sidecar /admin/vram endpoint so callers don't need updating.
pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> { pub async fn vram_snapshot(&self) -> Result<serde_json::Value, String> {
let resp = self.client let resp = self.client
.get(format!("{}/api/ps", self.base_url)) .get(format!("{}/admin/vram", self.base_url))
.send().await .send().await
.map_err(|e| format!("ollama ps request failed: {e}"))?; .map_err(|e| format!("vram request failed: {e}"))?;
let loaded: Vec<serde_json::Value> = if resp.status().is_success() { resp.json().await.map_err(|e| format!("vram parse error: {e}"))
let parsed: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
parsed.get("models")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|m| serde_json::json!({
"name": m.get("name"),
"size_vram_mib": m.get("size_vram").and_then(|v| v.as_u64()).map(|n| n / (1024 * 1024)),
"expires_at": m.get("expires_at"),
})).collect())
.unwrap_or_default()
} else {
Vec::new()
};
let gpu = nvidia_smi_snapshot();
Ok(serde_json::json!({
"gpu": gpu,
"ollama_loaded": loaded,
}))
} }
} }
/// One-shot nvidia-smi poll. Returns Null if the tool isn't on PATH
/// or the call fails. Mirrors the sidecar's `_nvidia_smi_snapshot`
/// shape exactly so callers reading vram_snapshot don't break.
fn nvidia_smi_snapshot() -> serde_json::Value {
use std::process::Command;
let out = Command::new("nvidia-smi")
.args([
"--query-gpu=memory.used,memory.total,utilization.gpu,name",
"--format=csv,noheader,nounits",
])
.output();
let stdout = match out {
Ok(o) if o.status.success() => o.stdout,
_ => return serde_json::Value::Null,
};
let line = String::from_utf8_lossy(&stdout);
let line = line.trim();
if line.is_empty() {
return serde_json::Value::Null;
}
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 4 {
return serde_json::Value::Null;
}
let used = parts[0].parse::<u64>().unwrap_or(0);
let total = parts[1].parse::<u64>().unwrap_or(0);
let util = parts[2].parse::<u64>().unwrap_or(0);
serde_json::json!({
"name": parts[3],
"used_mib": used,
"total_mib": total,
"utilization_pct": util,
})
}
+16 -10
View File
@@ -13,9 +13,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::OnceLock;
// `estimate_tokens` moved to `shared::model_matrix::ModelMatrix::estimate_tokens` /// Rough token count. `chars / 4` ceiling. See module docs for why
// (cdc24d8). All callers migrated; the deprecated wrapper that stood in its /// this heuristic is sufficient.
// place has been removed since it had zero external consumers. pub fn estimate_tokens(text: &str) -> usize {
(text.chars().count() + 3) / 4
}
/// Phase 21 — per-model context windows, mirroring the TS table in /// Phase 21 — per-model context windows, mirroring the TS table in
/// `tests/multi-agent/agent.ts`. Anchored on each model's documented /// `tests/multi-agent/agent.ts`. Anchored on each model's documented
@@ -82,8 +84,8 @@ pub fn assert_context_budget(
let window = context_window_for(model); let window = context_window_for(model);
let safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN); let safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN);
let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS); let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
let sys_tokens = opts.system.map(shared::model_matrix::ModelMatrix::estimate_tokens).unwrap_or(0); let sys_tokens = opts.system.map(estimate_tokens).unwrap_or(0);
let estimated = shared::model_matrix::ModelMatrix::estimate_tokens(prompt) + sys_tokens + max_tokens; let estimated = estimate_tokens(prompt) + sys_tokens + max_tokens;
let remaining = window as i64 - estimated as i64 - safety as i64; let remaining = window as i64 - estimated as i64 - safety as i64;
let check = BudgetCheck { estimated, window, remaining }; let check = BudgetCheck { estimated, window, remaining };
if remaining < 0 && !opts.bypass { if remaining < 0 && !opts.bypass {
@@ -107,10 +109,14 @@ pub fn overflow_message(model: &str, check: &BudgetCheck, over_by: usize, safety
mod tests { mod tests {
use super::*; use super::*;
// Deprecated-function behavior is now canonically tested in #[test]
// crates/shared/src/model_matrix.rs. This test was the legacy fn estimate_tokens_ceiling_divides_by_four() {
// pin that preceded the migration; delete when the deprecated assert_eq!(estimate_tokens(""), 0);
// wrapper itself goes (see the #[deprecated] attribute). assert_eq!(estimate_tokens("abc"), 1); // 3 → ceil(3/4) = 1
assert_eq!(estimate_tokens("abcd"), 1); // 4 → ceil(4/4) = 1
assert_eq!(estimate_tokens("abcde"), 2); // 5 → ceil(5/4) = 2
assert_eq!(estimate_tokens(&"x".repeat(400)), 100);
}
#[test] #[test]
fn context_window_known_and_fallback() { fn context_window_known_and_fallback() {
@@ -173,7 +179,7 @@ mod tests {
).unwrap(); ).unwrap();
assert!(with_sys.estimated > without_sys.estimated, assert!(with_sys.estimated > without_sys.estimated,
"system prompt should raise estimate"); "system prompt should raise estimate");
assert_eq!(with_sys.estimated - without_sys.estimated, shared::model_matrix::ModelMatrix::estimate_tokens(&sys)); assert_eq!(with_sys.estimated - without_sys.estimated, estimate_tokens(&sys));
} }
#[test] #[test]
+5 -31
View File
@@ -138,17 +138,6 @@ pub struct ContinuableOutcome {
pub empty_retries: usize, pub empty_retries: usize,
pub continuations: usize, pub continuations: usize,
pub final_complete: bool, pub final_complete: bool,
/// Sum of `prompt_tokens` across every generator call made to
/// produce this outcome — including empty retries and continuations.
/// Lets callers (gateway execution loop, observability) stamp
/// accurate per-task usage without second-guessing the retry fan-out.
pub prompt_tokens: u32,
/// Sum of `completion_tokens` across every generator call.
pub completion_tokens: u32,
/// Total number of generator calls. `1 + empty_retries +
/// continuations` in the normal case; the field is explicit so
/// callers don't have to re-derive it.
pub calls: u32,
} }
fn make_request(opts: &ContinuableOpts, prompt: String, current_max: u32) -> GenerateRequest { fn make_request(opts: &ContinuableOpts, prompt: String, current_max: u32) -> GenerateRequest {
@@ -186,20 +175,11 @@ pub async fn generate_continuable<G: TextGenerator>(
let mut combined = String::new(); let mut combined = String::new();
let mut empty_retries = 0usize; let mut empty_retries = 0usize;
let mut continuations = 0usize; let mut continuations = 0usize;
let mut prompt_tokens: u32 = 0;
let mut completion_tokens: u32 = 0;
let mut calls: u32 = 0;
// Phase 21(a) — empty-response backoff loop. // Phase 21(a) — empty-response backoff loop.
for retry in 0..opts.max_empty_retries { for retry in 0..opts.max_empty_retries {
let req = make_request(opts, prompt.to_string(), current_max); let req = make_request(opts, prompt.to_string(), current_max);
let resp = generator.generate_text(req).await?; let resp = generator.generate_text(req).await?;
calls += 1;
// u32::try_from saturates at u32::MAX instead of silently
// truncating bits when tokens_evaluated/_generated comes back
// as a u64 > 4 billion. Caught 2026-04-27 by Opus self-audit.
prompt_tokens = prompt_tokens.saturating_add(u32::try_from(resp.tokens_evaluated.unwrap_or(0)).unwrap_or(u32::MAX));
completion_tokens = completion_tokens.saturating_add(u32::try_from(resp.tokens_generated.unwrap_or(0)).unwrap_or(u32::MAX));
if !resp.text.trim().is_empty() { if !resp.text.trim().is_empty() {
combined = resp.text; combined = resp.text;
break; break;
@@ -208,7 +188,9 @@ pub async fn generate_continuable<G: TextGenerator>(
current_max = (current_max.saturating_mul(2)).min(opts.budget_cap); current_max = (current_max.saturating_mul(2)).min(opts.budget_cap);
} }
// Phase 21(b) — structural-completion continuation loop. // Phase 21(b) — structural-completion continuation loop. Runs on
// the truncated-non-empty case; empty + exhausted retries falls
// through with empty combined and final_complete=false.
for _ in 0..opts.max_continuations { for _ in 0..opts.max_continuations {
if is_structurally_complete(&combined, opts.shape) { if is_structurally_complete(&combined, opts.shape) {
return Ok(ContinuableOutcome { return Ok(ContinuableOutcome {
@@ -216,22 +198,17 @@ pub async fn generate_continuable<G: TextGenerator>(
empty_retries, empty_retries,
continuations, continuations,
final_complete: true, final_complete: true,
prompt_tokens,
completion_tokens,
calls,
}); });
} }
if combined.trim().is_empty() { if combined.trim().is_empty() {
// Nothing to continue from — continuing "" is identical to // Nothing to continue from — continuing "" is identical to
// the initial call and would loop. // the initial call and would loop. Bail so the caller sees
// the failure rather than burning N extra calls.
break; break;
} }
let cont_prompt = continuation_prompt(prompt, &combined); let cont_prompt = continuation_prompt(prompt, &combined);
let req = make_request(opts, cont_prompt, current_max.min(opts.budget_cap)); let req = make_request(opts, cont_prompt, current_max.min(opts.budget_cap));
let resp = generator.generate_text(req).await?; let resp = generator.generate_text(req).await?;
calls += 1;
prompt_tokens = prompt_tokens.saturating_add(u32::try_from(resp.tokens_evaluated.unwrap_or(0)).unwrap_or(u32::MAX));
completion_tokens = completion_tokens.saturating_add(u32::try_from(resp.tokens_generated.unwrap_or(0)).unwrap_or(u32::MAX));
combined.push_str(&resp.text); combined.push_str(&resp.text);
continuations += 1; continuations += 1;
} }
@@ -242,9 +219,6 @@ pub async fn generate_continuable<G: TextGenerator>(
empty_retries, empty_retries,
continuations, continuations,
final_complete, final_complete,
prompt_tokens,
completion_tokens,
calls,
}) })
} }
-3
View File
@@ -1,8 +1,5 @@
pub mod client; pub mod client;
pub mod context; pub mod context;
pub mod continuation; pub mod continuation;
pub mod provider;
pub mod providers;
pub mod routing;
pub mod service; pub mod service;
pub mod tree_split; pub mod tree_split;
-39
View File
@@ -1,39 +0,0 @@
use async_trait::async_trait;
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
#[async_trait]
pub trait ProviderAdapter: Send + Sync {
/// Name for routing (ollama, openrouter, etc.)
fn name(&self) -> &str;
/// Chat completion — returns text, model, token counts
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String>;
/// Embeddings — returns vectors, model, dimensions
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String>;
/// Unload model from VRAM (optional, no-op if not supported)
async fn unload(&self, _model: &str) -> Result<(), String> {
Ok(())
}
/// Health check
async fn health(&self) -> Result<serde_json::Value, String>;
}
/// Routing key extracted from model name.
/// - "qwen3.5:latest" → "ollama"
/// - "openrouter/anthropic/claude-3.5-sonnet" → "openrouter"
/// - "gpt-4o" → "ollama" (default)
pub fn provider_key(model: &str) -> &'static str {
let lower = model.to_lowercase();
if lower.starts_with("openrouter/") {
"openrouter"
} else if lower.starts_with("gemini") {
"gemini"
} else if lower.starts_with("claude") {
"claude"
} else {
"ollama" // default: local Ollama
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod ollama;
pub mod openrouter;
-37
View File
@@ -1,37 +0,0 @@
use async_trait::async_trait;
use crate::client::{AiClient, GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
use crate::provider::ProviderAdapter;
pub struct OllamaAdapter {
client: AiClient,
}
impl OllamaAdapter {
pub fn new(client: AiClient) -> Self {
Self { client }
}
}
#[async_trait]
impl ProviderAdapter for OllamaAdapter {
fn name(&self) -> &str {
"ollama"
}
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
self.client.generate(req).await
}
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
self.client.embed(req).await
}
async fn unload(&self, model: &str) -> Result<(), String> {
let _: serde_json::Value = self.client.unload_model(model).await?;
Ok(())
}
async fn health(&self) -> Result<serde_json::Value, String> {
self.client.health().await
}
}
-152
View File
@@ -1,152 +0,0 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
use crate::provider::ProviderAdapter;
pub struct OpenRouterAdapter {
client: Client,
base_url: String,
api_key: String,
default_model: String,
}
#[derive(Serialize)]
struct OpenRouterChatRequest {
model: String,
messages: Vec<OpenRouterMessage>,
temperature: Option<f64>,
max_tokens: Option<u32>,
}
#[derive(Serialize)]
struct OpenRouterMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct OpenRouterChatResponse {
choices: Vec<OpenRouterChoice>,
usage: OpenRouterUsage,
model: String,
}
#[derive(Deserialize)]
struct OpenRouterChoice {
message: OpenRouterMessageOut,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct OpenRouterMessageOut {
role: String,
content: String,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct OpenRouterUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
total_tokens: Option<u32>,
}
impl OpenRouterAdapter {
pub fn new(base_url: &str, api_key: String, default_model: &str) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(180))
.build()
.expect("failed to build HTTP client");
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
default_model: default_model.to_string(),
}
}
fn chat_model(&self, model: &str) -> String {
// Strip "openrouter/" prefix if present
let m = model.trim_start_matches("openrouter/");
if m.is_empty() || m == model {
self.default_model.clone()
} else {
m.to_string()
}
}
fn to_openrouter_messages(req: &GenerateRequest) -> Vec<OpenRouterMessage> {
let mut out = vec![];
if let Some(sys) = &req.system {
out.push(OpenRouterMessage { role: "system".into(), content: sys.clone() });
}
out.push(OpenRouterMessage {
role: "user".into(),
content: req.prompt.clone(),
});
out
}
}
#[async_trait]
impl ProviderAdapter for OpenRouterAdapter {
fn name(&self) -> &str {
"openrouter"
}
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
let model = self.chat_model(req.model.as_deref().unwrap_or(""));
let or_req = OpenRouterChatRequest {
model: model.clone(),
messages: OpenRouterAdapter::to_openrouter_messages(&req),
temperature: req.temperature,
max_tokens: req.max_tokens,
};
let resp = self.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&or_req)
.send()
.await
.map_err(|e| format!("openrouter request failed: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("openrouter error ({}): {}", status, body));
}
let or_resp: OpenRouterChatResponse = serde_json::from_str(&body)
.map_err(|e| format!("openrouter parse error: {e}"))?;
let choice = or_resp.choices.into_iter().next()
.ok_or("no completion choice returned")?;
let usage = or_resp.usage;
Ok(GenerateResponse {
text: choice.message.content,
model: or_resp.model,
tokens_evaluated: usage.prompt_tokens.map(|n| n as u64),
tokens_generated: usage.completion_tokens.map(|n| n as u64),
})
}
async fn embed(&self, _req: EmbedRequest) -> Result<EmbedResponse, String> {
Err("openrouter: embed not implemented".into())
}
async fn health(&self) -> Result<serde_json::Value, String> {
// OpenRouter doesn't have a dedicated health endpoint,
// so we just return a healthy response if the client works.
Ok(serde_json::json!({
"status": "ok",
"provider": "openrouter",
}))
}
}
-119
View File
@@ -1,119 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RoutingRule {
pub model_pattern: String,
pub provider: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
}
#[derive(Clone, Default)]
pub struct RoutingEngine {
rules: Vec<RoutingRule>,
fallback_chain: Vec<String>,
}
impl RoutingEngine {
pub fn new() -> Self {
Self::default()
}
pub fn with_rules(mut self, rules: Vec<RoutingRule>) -> Self {
self.rules = rules;
self
}
pub fn with_fallback(mut self, chain: Vec<String>) -> Self {
self.fallback_chain = chain;
self
}
pub fn route(&self, model: &str) -> RouteDecision {
let lower = model.to_lowercase();
for rule in &self.rules {
if glob_match(&rule.model_pattern.to_lowercase(), &lower) {
return RouteDecision {
provider: rule.provider.clone(),
model: model.to_string(),
max_tokens: rule.max_tokens,
temperature: rule.temperature,
};
}
}
if let Some(first) = self.fallback_chain.first() {
RouteDecision {
provider: first.clone(),
model: model.to_string(),
max_tokens: None,
temperature: None,
}
} else {
RouteDecision {
provider: "ollama".to_string(),
model: model.to_string(),
max_tokens: None,
temperature: None,
}
}
}
}
#[derive(Clone, Debug)]
pub struct RouteDecision {
pub provider: String,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
}
fn glob_match(pattern: &str, name: &str) -> bool {
if !pattern.contains('*') { return pattern == name; }
let parts: Vec<&str> = pattern.split('*').collect();
// Multi-* support: first must be prefix, last must be suffix, each
// interior piece must appear in order. Fixes the iter-9 finding
// where gpt-*-large* silently fell through to an exact-match path.
// Also removes the dead `parts.len() == 1` branch that accessed
// parts[1] and would panic if ever reached (unreachable today
// since split('*') on a string containing '*' always yields ≥2).
if !name.starts_with(parts[0]) || !name.ends_with(parts.last().unwrap()) { return false; }
let mut cursor = parts[0].len();
parts[1..parts.len() - 1].iter().all(|mid| {
name[cursor..].find(mid).map(|pos| { cursor += pos + mid.len(); true }).unwrap_or(false)
})
}
impl Default for RoutingRule {
fn default() -> Self {
Self {
model_pattern: "*".to_string(),
provider: "ollama".to_string(),
max_tokens: None,
temperature: None,
}
}
}
#[cfg(test)]
mod glob_match_tests {
use super::glob_match;
#[test] fn exact_match() { assert!(glob_match("gpt-oss:120b", "gpt-oss:120b")); }
#[test] fn exact_mismatch() { assert!(!glob_match("a", "b")); }
#[test] fn leading_wildcard() { assert!(glob_match("*:120b", "gpt-oss:120b")); }
#[test] fn trailing_wildcard() { assert!(glob_match("gpt-oss:*", "gpt-oss:120b")); }
#[test] fn bare_wildcard() { assert!(glob_match("*", "anything")); }
#[test] fn multi_wildcard_in_order() { assert!(glob_match("gpt-*-oss-*", "gpt-4-oss-120b")); }
#[test] fn multi_wildcard_wrong_order() { assert!(!glob_match("b*a*", "abba")); }
#[test] fn multi_wildcard_panic_safety() {
// Regression: earlier impl had an unreachable `parts.len() == 1`
// branch that indexed parts[1] — would panic if ever hit. Now
// the split('*') invariant guarantees ≥2 parts when * present,
// and we handle all N-part cases explicitly.
assert!(glob_match("a*b*c", "abc"));
assert!(glob_match("a*b*c", "axxxbxxxc"));
assert!(!glob_match("a*b*c", "xxxbxxx"));
}
}
+4 -5
View File
@@ -19,9 +19,8 @@
//! we bubble the error up rather than silently truncating. That's the //! we bubble the error up rather than silently truncating. That's the
//! whole point of Phase 21. //! whole point of Phase 21.
use crate::context::{assert_context_budget, BudgetOpts, overflow_message, use crate::context::{assert_context_budget, BudgetOpts, estimate_tokens, overflow_message,
DEFAULT_MAX_TOKENS, DEFAULT_SAFETY_MARGIN}; DEFAULT_MAX_TOKENS, DEFAULT_SAFETY_MARGIN};
use shared::model_matrix::ModelMatrix;
use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator}; use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator};
/// Callback signatures — caller supplies closures that stitch the /// Callback signatures — caller supplies closures that stitch the
@@ -81,12 +80,12 @@ pub struct TreeSplitResult {
/// by `\n— shard N/M digest —\n` so we can find the first one and /// by `\n— shard N/M digest —\n` so we can find the first one and
/// chop everything before its successor. /// chop everything before its successor.
fn truncate_scratchpad(scratchpad: &mut String, budget_tokens: usize) -> bool { fn truncate_scratchpad(scratchpad: &mut String, budget_tokens: usize) -> bool {
if ModelMatrix::estimate_tokens(scratchpad) <= budget_tokens { return false; } if estimate_tokens(scratchpad) <= budget_tokens { return false; }
// Find the second delimiter — everything before it gets dropped. // Find the second delimiter — everything before it gets dropped.
const DELIM_PREFIX: &str = "\n— shard "; const DELIM_PREFIX: &str = "\n— shard ";
let mut cursor = 0; let mut cursor = 0;
let mut truncated = false; let mut truncated = false;
while ModelMatrix::estimate_tokens(&scratchpad[cursor..]) > budget_tokens { while estimate_tokens(&scratchpad[cursor..]) > budget_tokens {
// Skip past a leading delimiter (if we're sitting on one from // Skip past a leading delimiter (if we're sitting on one from
// a previous iteration), then find the next. // a previous iteration), then find the next.
let search_from = cursor + if scratchpad[cursor..].starts_with(DELIM_PREFIX) { let search_from = cursor + if scratchpad[cursor..].starts_with(DELIM_PREFIX) {
@@ -279,7 +278,7 @@ mod tests {
// Scratchpad should still fit roughly within the budget // Scratchpad should still fit roughly within the budget
// (post-truncation); the estimator uses chars/4 so the bound // (post-truncation); the estimator uses chars/4 so the bound
// is ~budget*4 chars. Give some slack for the delimiter. // is ~budget*4 chars. Give some slack for the delimiter.
let scratchpad_tokens = ModelMatrix::estimate_tokens(&result.scratchpad); let scratchpad_tokens = estimate_tokens(&result.scratchpad);
assert!(scratchpad_tokens <= opts.scratchpad_budget * 2, assert!(scratchpad_tokens <= opts.scratchpad_budget * 2,
"scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget); "scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget);
} }
-3
View File
@@ -16,8 +16,5 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
object_store = { workspace = true } object_store = { workspace = true }
arrow = { workspace = true } arrow = { workspace = true }
parquet = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
proto = { path = "../proto" } proto = { path = "../proto" }
tonic = { workspace = true } tonic = { workspace = true }
-505
View File
@@ -1,505 +0,0 @@
//! `/audit/subject/{id}` HTTP endpoint — legal-tier subject audit response.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §4 + §6.
//!
//! Responds to: `GET /subject/{candidate_id}?from=ISO&to=ISO`
//! Auth: requires `X-Lakehouse-Legal-Token` header matching the value in
//! the legal-token file (see LegalAuditAuth). Constant-time compare.
//!
//! Response shape (subject_audit_response.v1):
//! - manifest: full SubjectManifest from catalogd
//! - audit_log.rows: SubjectAuditRow entries within [from, to]
//! - audit_log.chain_verified: bool (HMAC chain verifies end-to-end)
//! - completeness_attestation: text the lawyer can quote
//!
//! V1 deliberately does NOT include:
//! - Ed25519 signature on the response — chain verification IS the
//! v1 attestation; signing is future hardening (spec §10 v2 reserve)
//! - Live row projection from the underlying datasets — caller can
//! fetch via /query/sql against the safe_views named in the manifest;
//! keeping projection out of this endpoint avoids depending on queryd
//! - External anchor / S3 Object Lock — local HMAC chain is the v1
//! integrity surface
//!
//! These are documented as future hardening, not silent gaps.
use crate::registry::Registry;
use crate::subject_audit::SubjectAuditWriter;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use shared::types::{SubjectAuditRow, SubjectManifest};
use std::sync::Arc;
const LEGAL_TOKEN_HEADER: &str = "x-lakehouse-legal-token";
const RESPONSE_SCHEMA: &str = "subject_audit_response.v1";
/// State for the audit endpoint router. Held outside Registry because
/// the audit writer + legal token aren't part of catalogd's data model
/// — they're sidecar concerns specific to this endpoint.
#[derive(Clone)]
pub struct AuditEndpointState {
pub registry: Registry,
pub writer: Arc<SubjectAuditWriter>,
/// Legal-tier token. Constant-time compared against the header on
/// every request. None = endpoint returns 503 (audit unavailable).
pub legal_token: Option<Arc<String>>,
}
impl AuditEndpointState {
/// Construct from existing pieces. `legal_token_path` is read at
/// construction and trimmed of trailing whitespace. Missing/empty
/// = endpoint will 503 every request with a clear reason.
pub async fn new(
registry: Registry,
writer: Arc<SubjectAuditWriter>,
legal_token_path: &std::path::Path,
) -> Self {
let legal_token = match tokio::fs::read_to_string(legal_token_path).await {
Ok(s) => {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
tracing::warn!(
"audit endpoint: legal token file at {} is empty — endpoint will 503",
legal_token_path.display()
);
None
} else if trimmed.len() < 32 {
// 2026-05-03 kimi scrum BLOCK at audit_endpoint.rs:59-60:
// 16-byte minimum was too permissive for a legal-tier
// secret. Aligned to the SubjectAuditWriter signing-key
// minimum (32 bytes — HMAC-SHA256 best practice).
tracing::warn!(
"audit endpoint: legal token at {} is {} chars (minimum 32) — endpoint will 503",
legal_token_path.display(), trimmed.len()
);
None
} else {
tracing::info!("audit endpoint: legal token loaded from {}", legal_token_path.display());
Some(Arc::new(trimmed))
}
}
Err(e) => {
tracing::warn!(
"audit endpoint: legal token unreadable from {} ({e}) — endpoint will 503",
legal_token_path.display()
);
None
}
};
Self { registry, writer, legal_token }
}
}
pub fn router(state: AuditEndpointState) -> Router {
Router::new()
.route("/subject/{candidate_id}", get(audit_subject))
.route("/health", get(audit_health))
.with_state(state)
}
async fn audit_health(State(state): State<AuditEndpointState>) -> impl IntoResponse {
if state.legal_token.is_some() {
(StatusCode::OK, "audit endpoint ready").into_response()
} else {
(StatusCode::SERVICE_UNAVAILABLE, "audit endpoint disabled (legal token missing)").into_response()
}
}
#[derive(Deserialize)]
struct AuditQuery {
/// ISO-8601 start of audit window. None = unbounded back.
from: Option<chrono::DateTime<chrono::Utc>>,
/// ISO-8601 end of audit window. None = unbounded forward.
to: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct AuditWindowEcho {
from: Option<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Serialize)]
struct AuditLogSection {
rows_in_window: usize,
rows: Vec<SubjectAuditRow>,
chain_root: String,
chain_verified: bool,
chain_rows_total: usize,
chain_verification_error: Option<String>,
}
#[derive(Serialize)]
struct AuditResponse {
schema: &'static str,
candidate_id: String,
generated_at: chrono::DateTime<chrono::Utc>,
generated_by: &'static str,
request_window: AuditWindowEcho,
manifest: SubjectManifest,
audit_log: AuditLogSection,
/// Datasets the manifest says contain rows for this subject — caller
/// can issue follow-up queries against the named safe_views to get
/// the actual row projection. Keeping projection out of this endpoint
/// avoids depending on queryd from catalogd.
datasets_referenced: Vec<DatasetRefEcho>,
safe_views_available: Vec<String>,
completeness_attestation: String,
}
#[derive(Serialize)]
struct DatasetRefEcho {
name: String,
key_column: String,
key_value: String,
}
/// Constant-time string comparison to avoid timing leaks on the legal
/// token check. `subtle` would be the standard but introducing a dep
/// for one comparison is overkill — manual byte-eq with an unconditional
/// fold over both byte slices.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// Extract + verify the legal-tier token from the request. Returns
/// Err((status, body)) on any failure (missing header, missing token
/// configured, mismatch). Does NOT log token values.
fn require_legal_auth(
state: &AuditEndpointState,
headers: &HeaderMap,
) -> Result<(), (StatusCode, &'static str)> {
let configured = state.legal_token.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"audit endpoint disabled — no legal token configured on this gateway",
))?;
let provided = headers.get(LEGAL_TOKEN_HEADER).ok_or((
StatusCode::UNAUTHORIZED,
"missing X-Lakehouse-Legal-Token header",
))?;
let provided = provided.to_str().map_err(|_| (
StatusCode::UNAUTHORIZED,
"X-Lakehouse-Legal-Token contains non-ASCII characters",
))?;
if !constant_time_eq(provided.as_bytes(), configured.as_bytes()) {
return Err((StatusCode::UNAUTHORIZED, "X-Lakehouse-Legal-Token mismatch"));
}
Ok(())
}
async fn audit_subject(
State(state): State<AuditEndpointState>,
Path(candidate_id): Path<String>,
Query(q): Query<AuditQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err((status, msg)) = require_legal_auth(&state, &headers) {
return (status, msg).into_response();
}
if candidate_id.is_empty() {
return (StatusCode::BAD_REQUEST, "candidate_id is empty").into_response();
}
// 1. Load the subject manifest.
let manifest = match state.registry.get_subject(&candidate_id).await {
Some(m) => m,
None => return (
StatusCode::NOT_FOUND,
format!("subject manifest not found for candidate_id={candidate_id}"),
).into_response(),
};
// 2. Read audit rows in window.
let rows = match state.writer.read_rows_in_range(&candidate_id, q.from, q.to).await {
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("audit log read failed: {e}"),
).into_response(),
};
// 3. Verify chain end-to-end (full log, not just the windowed slice;
// chain integrity is global, not per-window). chain_root MUST
// come from the actual log tip — taking it from the windowed
// `rows` would lie when `to` is set or the window catches no
// rows. (2026-05-03 opus scrum WARN audit_endpoint.rs:206.)
let (chain_verified, chain_rows_total, chain_root, chain_err) =
match state.writer.verify_chain(&candidate_id).await {
Ok(n) => {
let tip = state.writer.chain_tip(&candidate_id).await
.unwrap_or_else(|| "GENESIS".into());
(true, n, tip, None)
}
Err(e) => (false, 0, String::new(), Some(e)),
};
// 4. Tampering detection: if the manifest claims a non-trivial
// chain root but the log is empty (chain_rows_total == 0), the
// log was deleted between writes. Flag it. (2026-05-03 opus
// scrum WARN subject_audit.rs:347 + audit_endpoint coverage.)
let manifest_root = manifest.audit_log_chain_root.clone();
let log_was_tampered = chain_verified
&& chain_rows_total == 0
&& !manifest_root.is_empty()
&& manifest_root != "GENESIS";
let (chain_verified, chain_err) = if log_was_tampered {
(false, Some(format!(
"manifest expects chain root '{}' but the audit log has 0 rows; log was deleted or wiped",
manifest_root,
)))
} else {
(chain_verified, chain_err)
};
let datasets_referenced = manifest.datasets.iter()
.map(|d| DatasetRefEcho {
name: d.name.clone(),
key_column: d.key_column.clone(),
key_value: d.key_value.clone(),
})
.collect();
let safe_views_available = manifest.safe_views.clone();
let attestation = format!(
"All audit rows for candidate_id={} within [{}, {}] are included. \
HMAC chain verified end-to-end: {} rows total, integrity={}.",
candidate_id,
q.from.map(|d| d.to_rfc3339()).unwrap_or_else(|| "unbounded".into()),
q.to.map(|d| d.to_rfc3339()).unwrap_or_else(|| "unbounded".into()),
chain_rows_total,
if chain_verified { "verified" } else { "BROKEN" },
);
let resp = AuditResponse {
schema: RESPONSE_SCHEMA,
candidate_id: candidate_id.clone(),
generated_at: chrono::Utc::now(),
generated_by: "catalogd",
request_window: AuditWindowEcho { from: q.from, to: q.to },
manifest,
audit_log: AuditLogSection {
rows_in_window: rows.len(),
rows,
chain_root,
chain_verified,
chain_rows_total,
chain_verification_error: chain_err,
},
datasets_referenced,
safe_views_available,
completeness_attestation: attestation,
};
// Log the legal-tier read so it's discoverable in operations review.
// Token value is NEVER logged. Only the fact of the read.
tracing::info!(
candidate_id = %candidate_id,
rows_in_window = resp.audit_log.rows_in_window,
chain_verified = resp.audit_log.chain_verified,
"legal-tier audit response served"
);
(StatusCode::OK, Json(resp)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subject_audit::SubjectAuditWriter;
use object_store::memory::InMemory;
use shared::types::{
AuditAccessor, BiometricConsent, BiometricConsentStatus, ConsentStatus,
GeneralPiiConsent, SubjectAuditRow, SubjectConsent, SubjectRetention,
SubjectStatus, SubjectVertical,
};
fn fixture_manifest(candidate_id: &str) -> SubjectManifest {
SubjectManifest {
schema: "subject_manifest.v1".into(),
candidate_id: candidate_id.into(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
status: SubjectStatus::Active,
vertical: SubjectVertical::Unknown,
consent: SubjectConsent {
general_pii: GeneralPiiConsent {
status: ConsentStatus::PendingBackfillReview,
version: String::new(),
given_at: None,
withdrawn_at: None,
},
biometric: BiometricConsent {
status: BiometricConsentStatus::NeverCollected,
retention_until: None,
},
},
retention: SubjectRetention {
general_pii_until: chrono::Utc::now() + chrono::Duration::days(365 * 4),
policy: "test".into(),
},
datasets: vec![],
safe_views: vec![],
audit_log_path: String::new(),
audit_log_chain_root: String::new(),
biometric_collection: None,
}
}
fn fixture_audit_row(candidate_id: &str) -> SubjectAuditRow {
SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: chrono::Utc::now(),
candidate_id: candidate_id.into(),
accessor: AuditAccessor {
kind: "test".into(),
daemon: "test".into(),
purpose: "test".into(),
trace_id: String::new(),
},
fields_accessed: vec!["name".into()],
result: "success".into(),
prev_chain_hash: String::new(),
row_hmac: String::new(),
}
}
async fn fixture_state_with_token(token: &str) -> AuditEndpointState {
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let registry = Registry::new(store.clone());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(
store, (0u8..32).collect(),
));
AuditEndpointState {
registry,
writer,
legal_token: Some(Arc::new(token.into())),
}
}
#[test]
fn constant_time_eq_basic() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(constant_time_eq(b"", b""));
}
#[tokio::test]
async fn missing_legal_token_returns_503() {
let state = AuditEndpointState {
registry: Registry::new(Arc::new(InMemory::new())),
writer: Arc::new(SubjectAuditWriter::with_inline_key(
Arc::new(InMemory::new()), (0u8..32).collect(),
)),
legal_token: None,
};
let headers = HeaderMap::new();
let r = require_legal_auth(&state, &headers);
assert!(matches!(r, Err((StatusCode::SERVICE_UNAVAILABLE, _))));
}
#[tokio::test]
async fn missing_header_returns_401() {
let state = fixture_state_with_token("test-token-must-be-long-enough").await;
let headers = HeaderMap::new();
let r = require_legal_auth(&state, &headers);
assert!(matches!(r, Err((StatusCode::UNAUTHORIZED, _))));
}
#[tokio::test]
async fn wrong_token_returns_401() {
let state = fixture_state_with_token("correct-token-must-be-long-enough").await;
let mut headers = HeaderMap::new();
headers.insert(LEGAL_TOKEN_HEADER, "wrong".parse().unwrap());
let r = require_legal_auth(&state, &headers);
assert!(matches!(r, Err((StatusCode::UNAUTHORIZED, _))));
}
#[tokio::test]
async fn correct_token_passes_auth() {
let state = fixture_state_with_token("correct-token-must-be-long-enough").await;
let mut headers = HeaderMap::new();
headers.insert(LEGAL_TOKEN_HEADER, "correct-token-must-be-long-enough".parse().unwrap());
let r = require_legal_auth(&state, &headers);
assert!(r.is_ok());
}
#[tokio::test]
async fn audit_response_assembly_full_path() {
let state = fixture_state_with_token("test-token-must-be-long-enough").await;
// Plant a manifest + 3 audit rows.
state.registry.put_subject(fixture_manifest("CAND-AUDIT-1")).await.unwrap();
for _ in 0..3 {
state.writer.append(fixture_audit_row("CAND-AUDIT-1")).await.unwrap();
}
// Read rows in unbounded window.
let rows = state.writer
.read_rows_in_range("CAND-AUDIT-1", None, None)
.await
.unwrap();
assert_eq!(rows.len(), 3);
// Verify chain integrity.
let n = state.writer.verify_chain("CAND-AUDIT-1").await.unwrap();
assert_eq!(n, 3);
}
#[tokio::test]
async fn audit_response_window_filters_rows() {
let state = fixture_state_with_token("test-token-must-be-long-enough").await;
state.registry.put_subject(fixture_manifest("CAND-WIN-1")).await.unwrap();
// Append 5 rows.
let mut row_times = Vec::new();
for _ in 0..5 {
let row = fixture_audit_row("CAND-WIN-1");
row_times.push(row.ts);
state.writer.append(row).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
// Window covering only the middle 3 rows.
let from = row_times[1];
let to = row_times[3];
let rows = state.writer
.read_rows_in_range("CAND-WIN-1", Some(from), Some(to))
.await
.unwrap();
assert!(rows.len() >= 3, "expected ≥3 rows in window; got {}", rows.len());
assert!(rows.len() <= 3, "expected ≤3 rows in window; got {}", rows.len());
}
#[tokio::test]
async fn empty_token_file_results_in_disabled_endpoint() {
let tmp = std::env::temp_dir().join(format!("legal_empty_{}.tok", std::process::id()));
tokio::fs::write(&tmp, "").await.unwrap();
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let registry = Registry::new(store.clone());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(store, (0u8..32).collect()));
let state = AuditEndpointState::new(registry, writer, &tmp).await;
assert!(state.legal_token.is_none());
let _ = tokio::fs::remove_file(&tmp).await;
}
#[tokio::test]
async fn short_token_file_rejected_at_load() {
let tmp = std::env::temp_dir().join(format!("legal_short_{}.tok", std::process::id()));
tokio::fs::write(&tmp, "tooshort").await.unwrap();
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let registry = Registry::new(store.clone());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(store, (0u8..32).collect()));
let state = AuditEndpointState::new(registry, writer, &tmp).await;
assert!(state.legal_token.is_none());
let _ = tokio::fs::remove_file(&tmp).await;
}
}
@@ -1,341 +0,0 @@
//! Backfill subject manifests from a parquet source.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 3.
//!
//! For each row in the source parquet, creates a SubjectManifest with
//! the BIPA-defensible defaults from the spec:
//! - vertical = unknown (HIPAA fail-closed routing)
//! - consent.general_pii = pending_backfill_review (NOT inferred_existing)
//! - consent.biometric = never_collected (no biometric backfill)
//! - retention.general_pii_until = now + 4 years (default policy)
//!
//! Idempotent: if a manifest already exists for a candidate_id, the
//! backfill skips it. Re-runs are safe.
//!
//! Conservative defaults: --limit 1000 by default. Pass --all to do
//! the full source. Pass --concurrency to bound parallel writes.
//!
//! Usage examples:
//! cargo run --bin backfill_subjects -- \
//! --source data/datasets/workers_500k.parquet \
//! --dataset workers_500k \
//! --key-column worker_id \
//! --candidate-id-prefix WORKER- \
//! --limit 100 \
//! --dry-run
//!
//! cargo run --bin backfill_subjects -- \
//! --source data/datasets/workers_500k.parquet \
//! --dataset workers_500k \
//! --key-column worker_id \
//! --candidate-id-prefix WORKER- \
//! --safe-view workers_safe \
//! --all
//!
//! On the live host the binary reads from / writes to ./data so it
//! integrates with the running catalogd's persistence path.
use catalogd::registry::Registry;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use shared::types::{
BiometricConsent, BiometricConsentStatus, ConsentStatus, GeneralPiiConsent,
SubjectConsent, SubjectDatasetRef, SubjectManifest, SubjectRetention,
SubjectStatus, SubjectVertical,
};
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use storaged::backend;
const DEFAULT_LIMIT: usize = 1000;
const DEFAULT_CONCURRENCY: usize = 32;
const DEFAULT_RETENTION_YEARS: i64 = 4;
#[derive(Debug)]
struct Args {
source: PathBuf,
dataset_name: String,
key_column: String,
candidate_id_prefix: String,
safe_view: Option<String>,
storage_root: PathBuf,
limit: Option<usize>,
concurrency: usize,
dry_run: bool,
}
fn parse_args() -> Result<Args, String> {
let argv: Vec<String> = std::env::args().collect();
let mut source: Option<PathBuf> = None;
let mut dataset_name: Option<String> = None;
let mut key_column: Option<String> = None;
let mut candidate_id_prefix: String = String::new();
let mut safe_view: Option<String> = None;
let mut storage_root: PathBuf = PathBuf::from("./data");
let mut limit: Option<usize> = Some(DEFAULT_LIMIT);
let mut concurrency: usize = DEFAULT_CONCURRENCY;
let mut dry_run = false;
let mut i = 1;
while i < argv.len() {
let arg = &argv[i];
let next = || -> Result<String, String> {
argv.get(i + 1).cloned().ok_or_else(|| format!("{arg} needs a value"))
};
match arg.as_str() {
"--source" => { source = Some(PathBuf::from(next()?)); i += 2; }
"--dataset" => { dataset_name = Some(next()?); i += 2; }
"--key-column" => { key_column = Some(next()?); i += 2; }
"--candidate-id-prefix" => { candidate_id_prefix = next()?; i += 2; }
"--safe-view" => { safe_view = Some(next()?); i += 2; }
"--storage-root" => { storage_root = PathBuf::from(next()?); i += 2; }
"--limit" => { limit = Some(next()?.parse().map_err(|e| format!("--limit: {e}"))?); i += 2; }
"--all" => { limit = None; i += 1; }
"--concurrency" => { concurrency = next()?.parse().map_err(|e| format!("--concurrency: {e}"))?; i += 2; }
"--dry-run" => { dry_run = true; i += 1; }
"-h" | "--help" => { print_help(); std::process::exit(0); }
other => return Err(format!("unknown arg: {other}")),
}
}
Ok(Args {
source: source.ok_or("missing --source")?,
dataset_name: dataset_name.ok_or("missing --dataset")?,
key_column: key_column.ok_or("missing --key-column")?,
candidate_id_prefix,
safe_view,
storage_root,
limit,
concurrency,
dry_run,
})
}
fn print_help() {
eprintln!("backfill_subjects — populate subject manifests from a parquet source\n");
eprintln!("Usage: backfill_subjects [flags]\n");
eprintln!("Required:");
eprintln!(" --source PATH parquet file containing subject identifiers");
eprintln!(" --dataset NAME catalogd dataset name (must already exist)");
eprintln!(" --key-column COL column in the parquet that holds subject identifiers\n");
eprintln!("Optional:");
eprintln!(" --candidate-id-prefix STR prefix added to each row's value to form candidate_id");
eprintln!(" (e.g. 'WORKER-' on a worker_id int64 column)");
eprintln!(" --safe-view NAME name of an AiView that safely projects this subject's data");
eprintln!(" --storage-root PATH local-filesystem catalog root (default: ./data)");
eprintln!(" --limit N process at most N rows (default: {DEFAULT_LIMIT})");
eprintln!(" --all process every row in the source");
eprintln!(" --concurrency N max concurrent writes (default: {DEFAULT_CONCURRENCY})");
eprintln!(" --dry-run parse + count + sample, don't write anything");
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("error: {e}");
print_help();
std::process::exit(2);
}
};
if let Err(e) = run(args).await {
eprintln!("backfill failed: {e}");
std::process::exit(1);
}
}
async fn run(args: Args) -> Result<(), String> {
eprintln!("[backfill] source: {}", args.source.display());
eprintln!("[backfill] dataset: {}", args.dataset_name);
eprintln!("[backfill] key: {} (prefix: {:?})", args.key_column, args.candidate_id_prefix);
eprintln!("[backfill] limit: {}", args.limit.map(|n| n.to_string()).unwrap_or("all".into()));
eprintln!("[backfill] dry-run: {}", args.dry_run);
// Stand up catalogd over the same local FS the running daemon uses.
let store = backend::init_local(args.storage_root.to_str().ok_or("storage-root not utf-8")?);
let reg = Arc::new(Registry::new(store));
reg.rebuild().await?;
if reg.get_by_name(&args.dataset_name).await.is_none() {
return Err(format!(
"dataset '{}' not found in catalog at {}; backfill needs the dataset registered first",
args.dataset_name, args.storage_root.display(),
));
}
eprintln!("[backfill] dataset '{}' exists in catalog ✓", args.dataset_name);
// Open parquet reader.
let file = File::open(&args.source).map_err(|e| format!("open {}: {e}", args.source.display()))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file).map_err(|e| e.to_string())?;
let total_rows = builder.metadata().file_metadata().num_rows() as usize;
eprintln!("[backfill] parquet rows: {total_rows}");
// Project only the key column for IO efficiency.
let schema = builder.schema().clone();
let col_idx = schema.index_of(&args.key_column)
.map_err(|_| format!("column '{}' not found; parquet has: {:?}",
args.key_column,
schema.fields().iter().map(|f| f.name()).collect::<Vec<_>>(),
))?;
let projection = parquet::arrow::ProjectionMask::roots(builder.parquet_schema(), [col_idx]);
let mut reader = builder.with_projection(projection).build().map_err(|e| e.to_string())?;
// Collect candidate IDs (limited).
let cap = args.limit.unwrap_or(total_rows);
let mut candidate_ids: Vec<String> = Vec::with_capacity(cap.min(total_rows));
let mut rows_seen = 0usize;
'outer: while let Some(batch_result) = reader.next() {
let batch = batch_result.map_err(|e| format!("batch: {e}"))?;
let column = batch.column(0); // single projected column
for row in 0..batch.num_rows() {
if let Some(limit) = args.limit {
if candidate_ids.len() >= limit { break 'outer; }
}
rows_seen += 1;
let raw = format_cell_value(column, row)?;
candidate_ids.push(format!("{}{}", args.candidate_id_prefix, raw));
}
}
eprintln!("[backfill] collected {} candidate_ids ({} rows scanned)", candidate_ids.len(), rows_seen);
// Sample for the operator.
eprintln!("[backfill] sample (first 5):");
for cid in candidate_ids.iter().take(5) {
eprintln!(" - {cid}");
}
if args.dry_run {
eprintln!("[backfill] DRY-RUN — no writes. Re-run without --dry-run to commit.");
return Ok(());
}
// Write manifests with bounded concurrency.
let now = chrono::Utc::now();
let retention_until = now + chrono::Duration::days(365 * DEFAULT_RETENTION_YEARS);
let safe_views: Vec<String> = args.safe_view.into_iter().collect();
let dataset_ref = SubjectDatasetRef {
name: args.dataset_name.clone(),
key_column: args.key_column.clone(),
key_value: String::new(), // filled per-subject below
};
let attempted = Arc::new(AtomicUsize::new(0));
let inserted = Arc::new(AtomicUsize::new(0));
let skipped = Arc::new(AtomicUsize::new(0));
let failed = Arc::new(AtomicUsize::new(0));
let progress_every = ((candidate_ids.len() / 20).max(1)).min(5_000);
// Bounded concurrency via Semaphore.
let sem = Arc::new(tokio::sync::Semaphore::new(args.concurrency));
let mut handles = Vec::with_capacity(candidate_ids.len());
for (idx, cid) in candidate_ids.into_iter().enumerate() {
let permit = sem.clone().acquire_owned().await.map_err(|e| e.to_string())?;
let reg = reg.clone();
let mut ds_ref = dataset_ref.clone();
// strip_prefix gives one-shot semantics; trim_start_matches strips
// repeated occurrences (a "WORKER-WORKER-1" id would lose both
// prefixes). 2026-05-03 opus scrum WARN at backfill_subjects.rs:189.
ds_ref.key_value = cid.strip_prefix(&args.candidate_id_prefix)
.unwrap_or(&cid).to_string();
let safe_views = safe_views.clone();
let attempted = attempted.clone();
let inserted = inserted.clone();
let skipped = skipped.clone();
let failed = failed.clone();
let handle = tokio::spawn(async move {
let _permit = permit;
attempted.fetch_add(1, Ordering::Relaxed);
// Idempotency: skip if subject already exists.
if reg.get_subject(&cid).await.is_some() {
skipped.fetch_add(1, Ordering::Relaxed);
return;
}
let manifest = SubjectManifest {
schema: "subject_manifest.v1".into(),
candidate_id: cid.clone(),
created_at: now,
updated_at: now,
status: SubjectStatus::Active,
vertical: SubjectVertical::Unknown,
consent: SubjectConsent {
general_pii: GeneralPiiConsent {
status: ConsentStatus::PendingBackfillReview,
version: String::new(),
given_at: None,
withdrawn_at: None,
},
biometric: BiometricConsent {
status: BiometricConsentStatus::NeverCollected,
retention_until: None,
},
},
retention: SubjectRetention {
general_pii_until: retention_until,
policy: "4_year_default".into(),
},
datasets: vec![ds_ref],
safe_views,
audit_log_path: String::new(),
audit_log_chain_root: String::new(),
biometric_collection: None,
};
match reg.put_subject(manifest).await {
Ok(_) => { inserted.fetch_add(1, Ordering::Relaxed); }
Err(e) => {
failed.fetch_add(1, Ordering::Relaxed);
eprintln!(" ✗ {cid}: {e}");
}
}
let cur = attempted.load(Ordering::Relaxed);
if cur % progress_every == 0 {
eprintln!("[backfill] progress: {} attempted ({} inserted, {} skipped, {} failed)",
cur, inserted.load(Ordering::Relaxed),
skipped.load(Ordering::Relaxed),
failed.load(Ordering::Relaxed));
}
let _ = idx;
});
handles.push(handle);
}
for h in handles {
let _ = h.await;
}
eprintln!();
eprintln!("──────────────────────────────────────────────────────────────────");
eprintln!("[backfill] done");
eprintln!("[backfill] attempted: {}", attempted.load(Ordering::Relaxed));
eprintln!("[backfill] inserted: {}", inserted.load(Ordering::Relaxed));
eprintln!("[backfill] skipped: {} (already existed — idempotent re-run)", skipped.load(Ordering::Relaxed));
eprintln!("[backfill] failed: {}", failed.load(Ordering::Relaxed));
eprintln!("[backfill] subjects in catalog now: {}", reg.subjects_count().await);
Ok(())
}
/// Format a single cell value as a string for use in the candidate_id.
/// Supports the integer + string types most likely to be subject keys.
fn format_cell_value(array: &Arc<dyn arrow::array::Array>, row: usize) -> Result<String, String> {
use arrow::array::{Int32Array, Int64Array, StringArray, UInt32Array, UInt64Array};
use arrow::datatypes::DataType;
if array.is_null(row) {
return Err(format!("row {row}: key column is null — cannot form candidate_id"));
}
let dt = array.data_type();
let s = match dt {
DataType::Int64 => array.as_any().downcast_ref::<Int64Array>()
.ok_or("int64 downcast")?.value(row).to_string(),
DataType::Int32 => array.as_any().downcast_ref::<Int32Array>()
.ok_or("int32 downcast")?.value(row).to_string(),
DataType::UInt64 => array.as_any().downcast_ref::<UInt64Array>()
.ok_or("uint64 downcast")?.value(row).to_string(),
DataType::UInt32 => array.as_any().downcast_ref::<UInt32Array>()
.ok_or("uint32 downcast")?.value(row).to_string(),
DataType::Utf8 => array.as_any().downcast_ref::<StringArray>()
.ok_or("utf8 downcast")?.value(row).to_string(),
other => return Err(format!("unsupported key column type: {other:?}")),
};
Ok(s)
}
@@ -1,235 +0,0 @@
//! Cross-runtime parity helper for subject-audit chain.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 8.
//!
//! This binary is consumed by the Go-repo's
//! scripts/cutover/parity/subject_audit_parity.sh.
//! Go counterpart: scripts/cutover/parity/subject_audit_helper/main.go
//! (in the Go repo). Both helpers are kept in lockstep by the parity
//! script's byte-equality assertions — change one, change both.
//!
//! Both helpers MUST produce byte-identical output for the same inputs.
//! Divergence here is a parity break — a SubjectManifest written by Rust
//! that Go cannot verify, or vice versa.
//!
//! Two modes:
//!
//! --known-answer
//! Print the canonical bytes + HMAC of a hardcoded fixture row.
//! The Go helper must produce IDENTICAL bytes + IDENTICAL hash.
//! Hardcoded fixture matches Go test TestKnownAnswerVector.
//!
//! --verify <audit_log_path> --key <key_path>
//! Parse the JSONL audit log, replay the HMAC chain. Print
//! JSON: {"count": N, "tip": "<hash|GENESIS>", "verified": bool, "error": "<msg|null>"}.
//!
//! Output format: ONE JSON object per stdout, terminating newline. The
//! parity script diffs Rust stdout vs Go stdout via `diff -q`.
use catalogd::subject_audit::SubjectAuditWriter;
use hmac::{Hmac, Mac};
use object_store::ObjectStore;
use object_store::memory::InMemory;
use serde::Serialize;
use sha2::Sha256;
use shared::types::{AuditAccessor, SubjectAuditRow};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
type HmacSha256 = Hmac<Sha256>;
const GENESIS: &str = "GENESIS";
/// Same canonical-JSON algorithm as crates/catalogd/src/subject_audit.rs.
/// Reproduced here so the helper does not depend on internal-only items
/// in that file. If the algorithm there changes, change it here AND in
/// the Go helper in lockstep.
fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
fn rewrite(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (k.clone(), rewrite(v)))
.collect();
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(rewrite).collect())
}
other => other.clone(),
}
}
serde_json::to_vec(&rewrite(v)).expect("canonical-json serialize")
}
fn canonical_row_bytes(row: &SubjectAuditRow) -> Vec<u8> {
let mut v: serde_json::Value = serde_json::to_value(row).expect("row to value");
if let Some(obj) = v.as_object_mut() {
obj.remove("row_hmac");
}
canonical_json(&v)
}
fn compute_hmac(key: &[u8], prev: &str, canonical: &[u8]) -> String {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(prev.as_bytes());
mac.update(canonical);
let result = mac.finalize().into_bytes();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(64);
for byte in result {
s.push(HEX[(byte >> 4) as usize] as char);
s.push(HEX[(byte & 0x0f) as usize] as char);
}
s
}
fn deterministic_key() -> Vec<u8> {
(0u8..32).collect()
}
/// Print to stderr + exit(2). Never returns. Used in place of `.expect()`
/// on argv / file reads so failures produce a clean usage error rather
/// than a Rust panic backtrace that the parity script would mistake
/// for a parity break.
fn die(msg: &str) -> ! {
eprintln!("error: {msg}");
std::process::exit(2);
}
#[derive(Serialize)]
struct KnownAnswerOut {
mode: &'static str,
canonical: String,
hmac: String,
canonical_bytes_len: usize,
}
#[derive(Serialize)]
struct VerifyOut {
mode: &'static str,
count: usize,
tip: String,
verified: bool,
error: Option<String>,
}
fn known_answer() {
let row = SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: chrono::DateTime::parse_from_rfc3339("2026-05-03T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
candidate_id: "WORKER-FIXED".into(),
accessor: AuditAccessor {
kind: "gateway_lookup".into(),
daemon: "gateway".into(),
purpose: "parity_test".into(),
trace_id: "trace-fixed".into(),
},
fields_accessed: vec!["name".into()],
result: "success".into(),
prev_chain_hash: GENESIS.into(),
row_hmac: String::new(),
};
let canon = canonical_row_bytes(&row);
let hmac = compute_hmac(&deterministic_key(), GENESIS, &canon);
let out = KnownAnswerOut {
mode: "known_answer",
canonical: String::from_utf8(canon.clone()).expect("canonical is utf-8"),
hmac,
canonical_bytes_len: canon.len(),
};
println!("{}", serde_json::to_string(&out).unwrap());
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let argv: Vec<String> = std::env::args().collect();
let mut mode_known_answer = false;
let mut audit_path: Option<PathBuf> = None;
let mut key_path: Option<PathBuf> = None;
// Argv parsing fails via stderr+exit, never via panic. The parity
// script captures stdout for byte-comparison; a Rust panic backtrace
// would land in stdout (the script merges 2>&1) and read as a parity
// break instead of a usage error. (Caught 2026-05-03 opus scrum WARN.)
let mut i = 1;
while i < argv.len() {
match argv[i].as_str() {
"--known-answer" => {
mode_known_answer = true;
i += 1;
}
"--verify" => {
let p = argv.get(i + 1).unwrap_or_else(|| die("--verify needs a path"));
audit_path = Some(PathBuf::from(p));
i += 2;
}
"--key" => {
let p = argv.get(i + 1).unwrap_or_else(|| die("--key needs a path"));
key_path = Some(PathBuf::from(p));
i += 2;
}
"-h" | "--help" => {
eprintln!("parity_subject_audit --known-answer");
eprintln!("parity_subject_audit --verify <audit_log_path> --key <key_path>");
std::process::exit(0);
}
other => {
eprintln!("unknown arg: {other}");
std::process::exit(2);
}
}
}
if mode_known_answer {
known_answer();
return;
}
let audit_path = audit_path.unwrap_or_else(|| die("need --known-answer OR --verify <path> --key <path>"));
let key_path = key_path.unwrap_or_else(|| die("--verify also needs --key"));
let key = std::fs::read(&key_path).unwrap_or_else(|e| die(&format!("read key file: {e}")));
let candidate_id = audit_path
.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_suffix(".audit.jsonl"))
.unwrap_or_else(|| die("audit log path must end with <candidate_id>.audit.jsonl"))
.to_string();
// Stand up an in-memory object store, seed it with the audit log
// bytes at the canonical key, then ask SubjectAuditWriter to verify.
// This way we exercise the SAME verify_chain function the production
// gateway calls — not a re-implementation that might drift.
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let bytes = std::fs::read(&audit_path).expect("read audit log");
let log_key = format!("_catalog/subjects/{}.audit.jsonl", candidate_id);
storaged::ops::put(&store, &log_key, bytes::Bytes::from(bytes))
.await
.expect("seed object store");
let writer = SubjectAuditWriter::with_inline_key(store, key);
let result = writer.verify_chain(&candidate_id).await;
let tip = writer.chain_tip(&candidate_id).await.unwrap_or(GENESIS.into());
let out = match result {
Ok(count) => VerifyOut {
mode: "verify",
count,
tip,
verified: true,
error: None,
},
Err(e) => VerifyOut {
mode: "verify",
count: 0,
tip: GENESIS.into(),
verified: false,
error: Some(e),
},
};
println!("{}", serde_json::to_string(&out).unwrap());
}
-376
View File
@@ -1,376 +0,0 @@
//! Daily retention sweep for subject manifests.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 7:
//! "Subjects whose retention.general_pii_until < now AND status != erased
//! get marked for review (don't auto-delete; legal needs to approve)."
//!
//! Per shared::types::BiometricConsent doc-comment (BIPA requirement):
//! "max 3 years from last interaction. Implementation MUST enforce
//! daily expiration sweep against this field."
//!
//! Therefore this binary checks BOTH retention clocks and reports any
//! subject whose general-pii window OR biometric window has expired
//! while the subject is not already erased / already-flagged.
//!
//! It does NOT mutate subject manifests. It produces a JSONL review
//! queue at:
//! data/_catalog/subjects/_retention_sweep_<YYYY-MM-DD>.jsonl
//!
//! Operators / legal review the queue and act:
//! - extend retention (legal contract update),
//! - flip status to retention_expired,
//! - schedule erasure.
//!
//! Usage:
//! cargo run --bin retention_sweep # dry-run, stderr only
//! cargo run --bin retention_sweep -- --apply # also write report file
//! cargo run --bin retention_sweep -- --as-of 2030-01-01T00:00:00Z
//! cargo run --bin retention_sweep -- --storage-root /var/lib/lakehouse/data --apply
use catalogd::registry::Registry;
use chrono::{DateTime, Utc};
use serde::Serialize;
use shared::types::{BiometricConsentStatus, SubjectManifest, SubjectStatus};
use std::path::PathBuf;
use std::sync::Arc;
use storaged::backend;
#[derive(Debug)]
struct Args {
storage_root: PathBuf,
apply: bool,
as_of: Option<DateTime<Utc>>,
}
fn parse_args() -> Result<Args, String> {
let argv: Vec<String> = std::env::args().collect();
let mut storage_root = PathBuf::from("./data");
let mut apply = false;
let mut as_of: Option<DateTime<Utc>> = None;
let mut i = 1;
while i < argv.len() {
let arg = &argv[i];
let next = || -> Result<String, String> {
argv.get(i + 1).cloned().ok_or_else(|| format!("{arg} needs a value"))
};
match arg.as_str() {
"--storage-root" => { storage_root = PathBuf::from(next()?); i += 2; }
"--apply" => { apply = true; i += 1; }
"--as-of" => {
let raw = next()?;
as_of = Some(raw.parse().map_err(|e| format!("--as-of (need RFC3339): {e}"))?);
i += 2;
}
"-h" | "--help" => { print_help(); std::process::exit(0); }
other => return Err(format!("unknown arg: {other}")),
}
}
Ok(Args { storage_root, apply, as_of })
}
fn print_help() {
eprintln!("retention_sweep — daily retention check on subject manifests");
eprintln!();
eprintln!("Per docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 7.");
eprintln!("Reports overdue subjects to a JSONL review queue. Does NOT");
eprintln!("auto-delete. Legal must approve every action.");
eprintln!();
eprintln!("Flags:");
eprintln!(" --storage-root PATH local-filesystem catalog root (default: ./data)");
eprintln!(" --apply write report to data/_catalog/subjects/_retention_sweep_<date>.jsonl");
eprintln!(" --as-of RFC3339 use this timestamp as 'now' (test/forecast helper)");
}
#[derive(Debug, PartialEq, Clone, Copy)]
enum OverdueReason {
GeneralPii,
Biometric,
Both,
}
impl OverdueReason {
fn as_str(self) -> &'static str {
match self {
OverdueReason::GeneralPii => "general_pii_expired",
OverdueReason::Biometric => "biometric_expired",
OverdueReason::Both => "both_expired",
}
}
}
#[derive(Debug, Serialize)]
struct OverdueRow<'a> {
candidate_id: &'a str,
status: &'a str,
general_pii_until: DateTime<Utc>,
days_overdue_general: i64,
biometric_status: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
biometric_retention_until: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
days_overdue_biometric: Option<i64>,
reason: &'a str,
swept_at: DateTime<Utc>,
}
/// Skipped statuses (idempotent across daily runs):
/// - Erased: subject's data is already gone; nothing to review.
/// - RetentionExpired: subject is already flagged; do not re-fire.
///
/// Actionable statuses: PendingConsent, Active, Withdrawn — each can
/// still hold PII rows on disk past the retention window.
fn is_overdue(subject: &SubjectManifest, now: DateTime<Utc>) -> Option<OverdueReason> {
if matches!(subject.status, SubjectStatus::Erased | SubjectStatus::RetentionExpired) {
return None;
}
let general_overdue = subject.retention.general_pii_until < now;
let biometric_overdue = subject
.consent
.biometric
.retention_until
.map(|t| t < now)
.unwrap_or(false);
match (general_overdue, biometric_overdue) {
(true, true) => Some(OverdueReason::Both),
(true, false) => Some(OverdueReason::GeneralPii),
(false, true) => Some(OverdueReason::Biometric),
(false, false) => None,
}
}
fn status_str(s: &SubjectStatus) -> &'static str {
match s {
SubjectStatus::PendingConsent => "pending_consent",
SubjectStatus::Active => "active",
SubjectStatus::Withdrawn => "withdrawn",
SubjectStatus::RetentionExpired => "retention_expired",
SubjectStatus::Erased => "erased",
}
}
fn biometric_status_str(s: &BiometricConsentStatus) -> &'static str {
match s {
BiometricConsentStatus::NeverCollected => "never_collected",
BiometricConsentStatus::Pending => "pending",
BiometricConsentStatus::Given => "given",
BiometricConsentStatus::Withdrawn => "withdrawn",
BiometricConsentStatus::Expired => "expired",
}
}
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("error: {e}");
print_help();
std::process::exit(2);
}
};
if let Err(e) = run(args).await {
eprintln!("retention_sweep failed: {e}");
std::process::exit(1);
}
}
async fn run(args: Args) -> Result<(), String> {
let now = args.as_of.unwrap_or_else(Utc::now);
eprintln!("[retention_sweep] storage-root: {}", args.storage_root.display());
eprintln!("[retention_sweep] now (sweep clock): {}", now.to_rfc3339());
eprintln!("[retention_sweep] apply: {}", args.apply);
let store = backend::init_local(args.storage_root.to_str().ok_or("storage-root not utf-8")?);
let reg = Arc::new(Registry::new(store));
reg.rebuild().await?;
let subjects = reg.list_subjects().await;
eprintln!("[retention_sweep] subjects loaded: {}", subjects.len());
let mut overdue_rows: Vec<OverdueRow> = Vec::new();
let mut by_reason: std::collections::BTreeMap<&'static str, usize> = Default::default();
for s in &subjects {
if let Some(reason) = is_overdue(s, now) {
let reason_str: &'static str = reason.as_str();
*by_reason.entry(reason_str).or_insert(0) += 1;
overdue_rows.push(OverdueRow {
candidate_id: &s.candidate_id,
status: status_str(&s.status),
general_pii_until: s.retention.general_pii_until,
days_overdue_general: (now - s.retention.general_pii_until).num_days(),
biometric_status: biometric_status_str(&s.consent.biometric.status),
biometric_retention_until: s.consent.biometric.retention_until,
days_overdue_biometric: s
.consent
.biometric
.retention_until
.map(|t| (now - t).num_days()),
reason: reason_str,
swept_at: now,
});
}
}
eprintln!("[retention_sweep] overdue subjects: {}", overdue_rows.len());
for (reason, n) in &by_reason {
eprintln!("[retention_sweep] {reason}: {n}");
}
if overdue_rows.is_empty() {
eprintln!("[retention_sweep] nothing to do; all subjects within retention window.");
return Ok(());
}
eprintln!("[retention_sweep] sample (first 5):");
for row in overdue_rows.iter().take(5) {
eprintln!(
" - {} status={} reason={} general_overdue_days={}",
row.candidate_id, row.status, row.reason, row.days_overdue_general
);
}
if !args.apply {
eprintln!();
eprintln!("[retention_sweep] DRY-RUN: no file written. Re-run with --apply to persist.");
return Ok(());
}
let date_stamp = now.format("%Y-%m-%d");
let report_path = args
.storage_root
.join("_catalog")
.join("subjects")
.join(format!("_retention_sweep_{date_stamp}.jsonl"));
if let Some(parent) = report_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {parent:?}: {e}"))?;
}
let mut buf = String::new();
for row in &overdue_rows {
let line = serde_json::to_string(row).map_err(|e| format!("serialize row: {e}"))?;
buf.push_str(&line);
buf.push('\n');
}
std::fs::write(&report_path, &buf).map_err(|e| format!("write {report_path:?}: {e}"))?;
eprintln!("[retention_sweep] report written: {}", report_path.display());
eprintln!("[retention_sweep] action required by legal/operator. no manifests were mutated.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use shared::types::{
BiometricConsent, ConsentStatus, GeneralPiiConsent, SubjectConsent, SubjectRetention,
SubjectVertical,
};
fn fixture(
id: &str,
status: SubjectStatus,
gen_until: DateTime<Utc>,
bio_until: Option<DateTime<Utc>>,
) -> SubjectManifest {
SubjectManifest {
schema: "subject_manifest.v1".into(),
candidate_id: id.into(),
created_at: Utc::now(),
updated_at: Utc::now(),
status,
vertical: SubjectVertical::General,
consent: SubjectConsent {
general_pii: GeneralPiiConsent {
status: ConsentStatus::Given,
version: "v1".into(),
given_at: None,
withdrawn_at: None,
},
biometric: BiometricConsent {
status: if bio_until.is_some() {
BiometricConsentStatus::Given
} else {
BiometricConsentStatus::NeverCollected
},
retention_until: bio_until,
},
},
retention: SubjectRetention {
general_pii_until: gen_until,
policy: "test".into(),
},
datasets: vec![],
safe_views: vec![],
audit_log_path: String::new(),
audit_log_chain_root: String::new(),
biometric_collection: None,
}
}
#[test]
fn active_subject_inside_window_not_overdue() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::Active, now + Duration::days(30), None);
assert!(is_overdue(&s, now).is_none());
}
#[test]
fn active_subject_past_general_window_flags_general() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::Active, now - Duration::days(1), None);
assert_eq!(is_overdue(&s, now), Some(OverdueReason::GeneralPii));
}
#[test]
fn erased_subject_never_re_flags_even_if_far_past() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::Erased, now - Duration::days(1000), None);
assert!(is_overdue(&s, now).is_none(), "erased subjects must not re-fire");
}
#[test]
fn already_retention_expired_is_idempotent() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::RetentionExpired, now - Duration::days(1), None);
assert!(is_overdue(&s, now).is_none(), "already-flagged subjects must not re-fire");
}
#[test]
fn biometric_overdue_alone_triggers_bipa_path() {
let now = Utc::now();
let s = fixture(
"a",
SubjectStatus::Active,
now + Duration::days(30),
Some(now - Duration::days(1)),
);
assert_eq!(is_overdue(&s, now), Some(OverdueReason::Biometric));
}
#[test]
fn both_clocks_overdue_reports_both() {
let now = Utc::now();
let s = fixture(
"a",
SubjectStatus::Active,
now - Duration::days(2),
Some(now - Duration::days(1)),
);
assert_eq!(is_overdue(&s, now), Some(OverdueReason::Both));
}
#[test]
fn pending_consent_subject_can_still_expire() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::PendingConsent, now - Duration::days(1), None);
assert_eq!(is_overdue(&s, now), Some(OverdueReason::GeneralPii));
}
#[test]
fn withdrawn_subject_still_subject_to_retention() {
let now = Utc::now();
let s = fixture("a", SubjectStatus::Withdrawn, now - Duration::days(1), None);
assert_eq!(is_overdue(&s, now), Some(OverdueReason::GeneralPii));
}
}
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,6 +2,3 @@ pub mod registry;
pub mod service; pub mod service;
pub mod grpc; pub mod grpc;
pub mod tombstones; pub mod tombstones;
pub mod subject_audit;
pub mod audit_endpoint;
pub mod biometric_endpoint;
+1 -261
View File
@@ -1,6 +1,6 @@
use shared::types::{ use shared::types::{
AiView, ColumnMeta, DatasetId, DatasetManifest, FreshnessContract, Lineage, ModelProfile, AiView, ColumnMeta, DatasetId, DatasetManifest, FreshnessContract, Lineage, ModelProfile,
ObjectRef, RefreshPolicy, SchemaFingerprint, Sensitivity, SubjectManifest, Tombstone, ObjectRef, RefreshPolicy, SchemaFingerprint, Sensitivity, Tombstone,
}; };
use crate::tombstones::TombstoneStore; use crate::tombstones::TombstoneStore;
@@ -66,20 +66,16 @@ pub struct MetadataUpdate {
const MANIFEST_PREFIX: &str = "_catalog/manifests"; const MANIFEST_PREFIX: &str = "_catalog/manifests";
const VIEW_PREFIX: &str = "_catalog/views"; const VIEW_PREFIX: &str = "_catalog/views";
const SUBJECT_PREFIX: &str = "_catalog/subjects";
const PROFILE_PREFIX: &str = "_catalog/profiles"; const PROFILE_PREFIX: &str = "_catalog/profiles";
/// In-memory dataset registry backed by manifest persistence in object storage. /// In-memory dataset registry backed by manifest persistence in object storage.
/// Also tracks AiViews (Phase D) — safe projections over base datasets. /// Also tracks AiViews (Phase D) — safe projections over base datasets.
/// And tombstones (Phase E) — soft-delete markers applied at query time. /// And tombstones (Phase E) — soft-delete markers applied at query time.
/// And subject manifests (compliance/access metadata layer) — see
/// docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md.
#[derive(Clone)] #[derive(Clone)]
pub struct Registry { pub struct Registry {
datasets: Arc<RwLock<HashMap<DatasetId, DatasetManifest>>>, datasets: Arc<RwLock<HashMap<DatasetId, DatasetManifest>>>,
views: Arc<RwLock<HashMap<String, AiView>>>, views: Arc<RwLock<HashMap<String, AiView>>>,
profiles: Arc<RwLock<HashMap<String, ModelProfile>>>, profiles: Arc<RwLock<HashMap<String, ModelProfile>>>,
subjects: Arc<RwLock<HashMap<String, SubjectManifest>>>,
tombstones: TombstoneStore, tombstones: TombstoneStore,
store: Arc<dyn ObjectStore>, store: Arc<dyn ObjectStore>,
} }
@@ -90,7 +86,6 @@ impl Registry {
datasets: Arc::new(RwLock::new(HashMap::new())), datasets: Arc::new(RwLock::new(HashMap::new())),
views: Arc::new(RwLock::new(HashMap::new())), views: Arc::new(RwLock::new(HashMap::new())),
profiles: Arc::new(RwLock::new(HashMap::new())), profiles: Arc::new(RwLock::new(HashMap::new())),
subjects: Arc::new(RwLock::new(HashMap::new())),
tombstones: TombstoneStore::new(store.clone()), tombstones: TombstoneStore::new(store.clone()),
store, store,
} }
@@ -184,47 +179,6 @@ impl Registry {
tracing::info!("catalog: {} model profiles loaded", profiles.len()); tracing::info!("catalog: {} model profiles loaded", profiles.len());
} }
// Subject manifests (compliance/access metadata layer).
let subject_keys = ops::list(&self.store, Some(SUBJECT_PREFIX)).await.unwrap_or_default();
let mut subjects = self.subjects.write().await;
subjects.clear();
for key in &subject_keys {
if !key.ends_with(".json") { continue; }
let data = match ops::get(&self.store, key).await {
Ok(d) => d,
Err(e) => {
tracing::warn!("subject '{key}': read failed: {e}");
continue;
}
};
match serde_json::from_slice::<SubjectManifest>(&data) {
// Match put_subject: in-memory key is the SANITIZED form
// so get_subject lookups match the storage key shape.
Ok(subj) => {
let map_key = sanitize_view_name(&subj.candidate_id);
// Rebuild collision detection — symmetric with the
// put_subject collision guard. If pre-existing storage
// somehow contains two manifests whose raw ids
// sanitize to the same key, log loudly so operators
// know one was dropped instead of silently losing it.
// (2026-05-03 opus scrum WARN registry.rs:201.)
if let Some(existing) = subjects.get(&map_key) {
if existing.candidate_id != subj.candidate_id {
tracing::warn!(
"subject rebuild collision on sanitized key '{map_key}': raw '{}' was loaded earlier; raw '{}' is being silently overwritten in memory. Run dedupe + delete the duplicate manifest file at storage.",
existing.candidate_id, subj.candidate_id,
);
}
}
subjects.insert(map_key, subj);
}
Err(e) => tracing::warn!("subject '{key}': parse failed: {e}"),
}
}
if !subjects.is_empty() {
tracing::info!("catalog: {} subject manifests loaded", subjects.len());
}
Ok(count) Ok(count)
} }
@@ -732,94 +686,6 @@ impl Registry {
Ok(()) Ok(())
} }
// --- Subject manifests (compliance/access metadata layer) ---
// Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md
// Mirrors the put_view / get_view / list_views / delete_view shape.
// The audit log per subject is owned separately by SubjectAuditWriter
// (Step 2) — this layer just persists the manifest itself.
/// Create or replace a subject manifest. Validates that referenced
/// datasets exist in the catalog (fail-fast so dangling references
/// don't accumulate). Persists to `_catalog/subjects/<id>.json`.
///
/// **Storage-key collision guard** (2026-05-03 opus scrum WARN
/// at registry.rs:735): the storage key is `sanitize_view_name(id)`
/// — `/`, `:`, etc. become `_`. A naive in-memory keying by raw
/// candidate_id would let two distinct ids (`CAND/1` + `CAND_1`)
/// collide on disk while appearing distinct in memory; the second
/// put silently overwrites the first, and `rebuild` only loads one.
/// Both the in-memory map AND the storage key now use the sanitized
/// form. Caller's raw candidate_id is preserved on the manifest for
/// audit-trail integrity (the manifest body has the original).
/// We surface a clear error if two raw ids would collide on the
/// sanitized key.
pub async fn put_subject(&self, mut subj: SubjectManifest) -> Result<SubjectManifest, String> {
if subj.candidate_id.is_empty() {
return Err("subject candidate_id is empty".into());
}
for ds in &subj.datasets {
if self.get_by_name(&ds.name).await.is_none() {
return Err(format!(
"subject '{}' references dataset '{}' which is not in the catalog",
subj.candidate_id, ds.name,
));
}
}
let now = chrono::Utc::now();
if subj.created_at.timestamp() == 0 {
subj.created_at = now;
}
subj.updated_at = now;
let map_key = sanitize_view_name(&subj.candidate_id);
let key = format!("{SUBJECT_PREFIX}/{}.json", map_key);
// Collision guard: if the sanitized key already exists for a
// DIFFERENT raw candidate_id, refuse the put. Better to fail
// loudly than silently overwrite an existing subject.
{
let subjects = self.subjects.read().await;
if let Some(existing) = subjects.get(&map_key) {
if existing.candidate_id != subj.candidate_id {
return Err(format!(
"subject id '{}' collides with existing subject '{}' on sanitized storage key '{}'",
subj.candidate_id, existing.candidate_id, map_key,
));
}
}
}
let json = serde_json::to_vec_pretty(&subj).map_err(|e| e.to_string())?;
ops::put(&self.store, &key, json.into()).await?;
let mut subjects = self.subjects.write().await;
subjects.insert(map_key, subj.clone());
tracing::debug!("subject manifest persisted: {}", subj.candidate_id);
Ok(subj)
}
pub async fn get_subject(&self, candidate_id: &str) -> Option<SubjectManifest> {
self.subjects.read().await.get(&sanitize_view_name(candidate_id)).cloned()
}
pub async fn list_subjects(&self) -> Vec<SubjectManifest> {
self.subjects.read().await.values().cloned().collect()
}
pub async fn delete_subject(&self, candidate_id: &str) -> Result<(), String> {
let map_key = sanitize_view_name(candidate_id);
let key = format!("{SUBJECT_PREFIX}/{}.json", map_key);
ops::delete(&self.store, &key).await?;
self.subjects.write().await.remove(&map_key);
Ok(())
}
/// Subject count — convenience for the daily retention sweep + audit
/// dashboards that don't need the full list.
pub async fn subjects_count(&self) -> usize {
self.subjects.read().await.len()
}
/// Remove a dataset from the catalog by name. Deletes the manifest /// Remove a dataset from the catalog by name. Deletes the manifest
/// from both the in-memory registry and object storage. /// from both the in-memory registry and object storage.
/// ///
@@ -1161,130 +1027,4 @@ mod tests {
assert!(reg.get(&loser_a).await.is_none()); assert!(reg.get(&loser_a).await.is_none());
assert!(reg.get(&loser_b).await.is_none()); assert!(reg.get(&loser_b).await.is_none());
} }
// --- Subject manifests (Step 1 of subject_manifests_on_catalogd) ---
fn fixture_subject(id: &str) -> shared::types::SubjectManifest {
use shared::types::{
BiometricConsent, BiometricConsentStatus, ConsentStatus,
GeneralPiiConsent, SubjectConsent, SubjectManifest, SubjectRetention,
SubjectStatus, SubjectVertical,
};
SubjectManifest {
schema: "subject_manifest.v1".into(),
candidate_id: id.to_string(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
status: SubjectStatus::Active,
vertical: SubjectVertical::Unknown,
consent: SubjectConsent {
general_pii: GeneralPiiConsent {
status: ConsentStatus::PendingBackfillReview,
version: String::new(),
given_at: None,
withdrawn_at: None,
},
biometric: BiometricConsent {
status: BiometricConsentStatus::NeverCollected,
retention_until: None,
},
},
retention: SubjectRetention {
general_pii_until: chrono::Utc::now() + chrono::Duration::days(365 * 4),
policy: "4_year_default".into(),
},
datasets: vec![],
safe_views: vec![],
audit_log_path: String::new(),
audit_log_chain_root: String::new(),
biometric_collection: None,
}
}
#[tokio::test]
async fn put_subject_with_no_dataset_refs_succeeds() {
let reg = fixture();
let s = fixture_subject("CAND-000001");
let stored = reg.put_subject(s).await.unwrap();
assert_eq!(stored.candidate_id, "CAND-000001");
assert_eq!(reg.subjects_count().await, 1);
}
#[tokio::test]
async fn put_subject_rejects_dangling_dataset_ref() {
use shared::types::SubjectDatasetRef;
let reg = fixture();
let mut s = fixture_subject("CAND-000002");
s.datasets.push(SubjectDatasetRef {
name: "no_such_dataset".into(),
key_column: "candidate_id".into(),
key_value: "CAND-000002".into(),
});
let err = reg.put_subject(s).await.unwrap_err();
assert!(err.contains("not in the catalog"), "got: {err}");
}
#[tokio::test]
async fn put_subject_with_valid_dataset_ref_succeeds() {
use shared::types::SubjectDatasetRef;
let reg = fixture();
// Register a dataset first so the subject's reference resolves.
reg.register("workers".into(), fp("w1"), vec![obj("w.parquet")]).await.unwrap();
let mut s = fixture_subject("CAND-000003");
s.datasets.push(SubjectDatasetRef {
name: "workers".into(),
key_column: "candidate_id".into(),
key_value: "CAND-000003".into(),
});
let stored = reg.put_subject(s).await.unwrap();
assert_eq!(stored.datasets.len(), 1);
assert_eq!(stored.datasets[0].name, "workers");
}
#[tokio::test]
async fn subject_round_trips_through_object_store() {
let store = Arc::new(object_store::memory::InMemory::new());
let reg = Registry::new(store.clone());
reg.put_subject(fixture_subject("CAND-RT-001")).await.unwrap();
// Open a fresh registry over the same store and load → confirms
// persistence + load_all path actually round-trips the manifest.
let reg2 = Registry::new(store);
reg2.rebuild().await.unwrap();
let loaded = reg2.get_subject("CAND-RT-001").await;
assert!(loaded.is_some(), "subject not loaded from store");
assert_eq!(loaded.unwrap().status, shared::types::SubjectStatus::Active);
}
#[tokio::test]
async fn put_subject_rejects_sanitize_collision() {
// 2026-05-03 opus scrum WARN at registry.rs:735 regression:
// CAND/1 and CAND_1 sanitize to the same storage key. Without
// the collision guard, the second put silently overwrites the
// first on disk; with it, the second put fails loudly.
let reg = fixture();
let s1 = fixture_subject("CAND/1");
reg.put_subject(s1).await.unwrap();
let s2 = fixture_subject("CAND_1"); // sanitizes to same key
let err = reg.put_subject(s2).await.unwrap_err();
assert!(err.contains("collides"), "got: {err}");
assert_eq!(reg.subjects_count().await, 1, "first subject must survive");
}
#[tokio::test]
async fn delete_subject_removes_in_memory_and_persistence() {
let store = Arc::new(object_store::memory::InMemory::new());
let reg = Registry::new(store.clone());
reg.put_subject(fixture_subject("CAND-DEL-001")).await.unwrap();
assert_eq!(reg.subjects_count().await, 1);
reg.delete_subject("CAND-DEL-001").await.unwrap();
assert_eq!(reg.subjects_count().await, 0);
// Fresh registry over same store — confirm it's also gone from persistence.
let reg2 = Registry::new(store);
reg2.rebuild().await.unwrap();
assert_eq!(reg2.subjects_count().await, 0);
}
} }
-26
View File
@@ -31,10 +31,6 @@ pub fn router(registry: Registry) -> Router {
// Phase 17: model profiles // Phase 17: model profiles
.route("/profiles", post(create_profile).get(list_profiles)) .route("/profiles", post(create_profile).get(list_profiles))
.route("/profiles/{id}", get(get_profile).delete(delete_profile)) .route("/profiles/{id}", get(get_profile).delete(delete_profile))
// Phase 41: profile-type routes
.route("/profiles/retrieval", get(list_retrieval_profiles))
.route("/profiles/memory", get(list_memory_profiles))
.route("/profiles/observer", get(list_observer_profiles))
.with_state(registry) .with_state(registry)
} }
@@ -402,9 +398,6 @@ struct CreateProfileRequest {
/// indexes to. Defaults to Parquet+HNSW. /// indexes to. Defaults to Parquet+HNSW.
#[serde(default)] #[serde(default)]
vector_backend: shared::types::VectorBackend, vector_backend: shared::types::VectorBackend,
/// Phase 41: Profile type for routing + activation behavior.
#[serde(default)]
profile_type: shared::types::ProfileType,
} }
fn default_embed_model_req() -> String { "nomic-embed-text".to_string() } fn default_embed_model_req() -> String { "nomic-embed-text".to_string() }
@@ -424,7 +417,6 @@ async fn create_profile(
created_by: req.created_by, created_by: req.created_by,
bucket: req.bucket, bucket: req.bucket,
vector_backend: req.vector_backend, vector_backend: req.vector_backend,
profile_type: req.profile_type,
}; };
match registry.put_profile(profile).await { match registry.put_profile(profile).await {
Ok(p) => Ok((StatusCode::CREATED, Json(p))), Ok(p) => Ok((StatusCode::CREATED, Json(p))),
@@ -436,24 +428,6 @@ async fn list_profiles(State(registry): State<Registry>) -> impl IntoResponse {
Json(registry.list_profiles().await) Json(registry.list_profiles().await)
} }
async fn list_retrieval_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let retrieval: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Retrieval).collect();
Json(retrieval)
}
async fn list_memory_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let memory: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Memory).collect();
Json(memory)
}
async fn list_observer_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let observer: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Observer).collect();
Json(observer)
}
async fn get_profile( async fn get_profile(
State(registry): State<Registry>, State(registry): State<Registry>,
Path(id): Path<String>, Path(id): Path<String>,
-587
View File
@@ -1,587 +0,0 @@
//! Subject audit log writer with HMAC chain.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §3.
//!
//! Per-subject append-only JSONL at `_catalog/subjects/<id>.audit.jsonl`.
//! Each row's `row_hmac` is HMAC-SHA256(key, prev_chain_hash || canonical_json_of_row_minus_hmac).
//! The chain root is mirrored to the SubjectManifest.audit_log_chain_root
//! field after each append, so verifiers can find the latest valid root
//! from the manifest alone.
//!
//! Failures are non-blocking — audit-write errors are logged but do NOT
//! propagate to the caller (per spec §3.2: "better to leak a row than
//! block legitimate operations"). Operators MUST monitor the error count.
//!
//! Key loading: from a sealed file at the path the writer is constructed
//! with. Tests use `with_inline_key` to inject a deterministic key.
use bytes::Bytes;
use hmac::{Hmac, Mac};
use object_store::ObjectStore;
use sha2::Sha256;
use shared::types::SubjectAuditRow;
use std::collections::HashMap;
use std::sync::Arc;
use storaged::ops;
use tokio::sync::Mutex;
const SUBJECT_PREFIX: &str = "_catalog/subjects";
pub const GENESIS_HASH: &str = "GENESIS";
type HmacSha256 = Hmac<Sha256>;
/// Render a JSON value canonically: object keys sorted alphabetically,
/// no insignificant whitespace, no trailing newline. Recursive — nested
/// objects also sort their keys. Arrays preserve order (semantically
/// significant). Numbers, strings, booleans, nulls pass through.
///
/// This is intentionally narrower than RFC 8785 (no number normalization,
/// no UTF-8 NFC re-encoding). For our use — hashing structurally simple
/// audit-row JSON — alphabetical key order is the only canonicalization
/// that matters: it makes the byte sequence stable across struct field
/// reordering and additive schema evolution.
fn canonical_json(v: &serde_json::Value) -> Result<Vec<u8>, String> {
use std::collections::BTreeMap;
fn rewrite(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<String, serde_json::Value> = map
.iter()
.map(|(k, v)| (k.clone(), rewrite(v)))
.collect();
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(rewrite).collect())
}
other => other.clone(),
}
}
let canonical = rewrite(v);
serde_json::to_vec(&canonical).map_err(|e| e.to_string())
}
/// Per-subject audit log writer. Holds the signing key, a small
/// in-memory cache of latest chain hash per subject, and per-subject
/// mutexes that serialize concurrent appends to the same subject's log.
///
/// **No Debug impl deliberately** — auto-deriving Debug on this struct
/// would risk leaking the signing key into log lines. Tests that need
/// `Result::unwrap_err` on a function returning `Result<Self, _>` must
/// match on the result instead of using `.unwrap_err()`.
///
/// Concurrency: appends to DIFFERENT subjects run in parallel; appends
/// to the SAME subject serialize via a per-candidate Mutex. Without
/// per-subject locking the read-modify-write append_line race
/// (concurrent reads of the same `prev_hash` + concurrent writes
/// overwriting each other) silently corrupts the chain. Caught by
/// 2026-05-03 opus scrum BLOCK on subject_audit.rs:172.
#[derive(Clone)]
pub struct SubjectAuditWriter {
store: Arc<dyn ObjectStore>,
/// HMAC signing key. Loaded once at construction. Never logged.
/// `Vec<u8>` not `String` — keys are bytes, not text.
key: Arc<Vec<u8>>,
/// In-memory cache of the latest chain hash per candidate_id.
/// Loaded lazily from the audit JSONL on first append per subject.
latest_hash: Arc<Mutex<HashMap<String, String>>>,
/// Per-subject mutexes. Append acquires the subject's lock before
/// the read-modify-write to serialize concurrent appends to the
/// same subject. Different subjects don't contend.
subject_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
}
impl SubjectAuditWriter {
/// Construct a writer that loads its signing key from a file at
/// `key_path`. The file MUST be mode 0400 owner-only on production
/// hosts (operator's responsibility — not enforced here because the
/// permission check would be a footgun in tests/dev).
pub async fn from_key_file(
store: Arc<dyn ObjectStore>,
key_path: &std::path::Path,
) -> Result<Self, String> {
let key = tokio::fs::read(key_path)
.await
.map_err(|e| format!("read signing key from {}: {e}", key_path.display()))?;
if key.len() < 32 {
return Err(format!(
"signing key is {} bytes; recommend ≥32 bytes for HMAC-SHA256",
key.len()
));
}
Ok(Self {
store,
key: Arc::new(key),
latest_hash: Arc::new(Mutex::new(HashMap::new())),
subject_locks: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Construct a writer with an inline key. Intended for tests + bring-up.
/// Production should use `from_key_file`.
pub fn with_inline_key(store: Arc<dyn ObjectStore>, key: Vec<u8>) -> Self {
Self {
store,
key: Arc::new(key),
latest_hash: Arc::new(Mutex::new(HashMap::new())),
subject_locks: Arc::new(Mutex::new(HashMap::new())),
}
}
fn audit_key(candidate_id: &str) -> String {
let safe: String = candidate_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
c
} else {
'_'
}
})
.collect();
format!("{SUBJECT_PREFIX}/{}.audit.jsonl", safe)
}
/// Compute HMAC-SHA256(key, prev_hash_bytes || canonical_row_bytes).
/// Returns the HMAC as lowercase hex. Uses byte-level hex formatting
/// in a single pass (avoids per-byte format! allocations).
fn compute_hmac(&self, prev_hash: &str, canonical_row: &[u8]) -> String {
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.key)
.expect("HMAC accepts any key length");
mac.update(prev_hash.as_bytes());
mac.update(canonical_row);
let result = mac.finalize().into_bytes();
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(64);
for byte in result {
s.push(HEX[(byte >> 4) as usize] as char);
s.push(HEX[(byte & 0x0f) as usize] as char);
}
s
}
/// Render an audit row as canonical JSON: keys sorted alphabetically,
/// no insignificant whitespace, row_hmac field excluded. This is the
/// byte sequence the HMAC is computed over.
///
/// **Why a manual canonicalizer instead of `serde_json::to_vec`:**
/// serde_json serializes struct fields in DECLARATION order. If the
/// SubjectAuditRow struct ever gains/loses/reorders fields (schema
/// evolution), the bytes change even though the LOGICAL content is
/// the same. A verifier compiled against the new struct shape
/// re-serializes a stored row and gets DIFFERENT bytes than the
/// original write — chain breaks silently. Caught by 2026-05-03
/// opus scrum BLOCK on subject_audit.rs:108.
///
/// Canonical-JSON (RFC 8785-ish, simplified for our scalar types)
/// fixes this by using alphabetical key order + minimal whitespace,
/// stable across struct evolution.
fn canonical_row_bytes(row: &SubjectAuditRow) -> Result<Vec<u8>, String> {
// Project to a generic Value, drop row_hmac, then re-serialize
// through a BTreeMap so keys come out alphabetical.
let mut v: serde_json::Value = serde_json::to_value(row)
.map_err(|e| format!("audit row to value: {e}"))?;
if let Some(obj) = v.as_object_mut() {
obj.remove("row_hmac");
}
canonical_json(&v).map_err(|e| format!("canonicalize: {e}"))
}
/// Get-or-create per-subject lock. Held across the read-modify-write
/// in append() so concurrent appends to the same subject serialize.
async fn lock_for(&self, candidate_id: &str) -> Arc<Mutex<()>> {
let mut locks = self.subject_locks.lock().await;
locks
.entry(candidate_id.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
/// Append one audit row for a subject. Computes the HMAC chain link,
/// writes to the per-subject JSONL, returns the new chain root (which
/// the caller MAY mirror to SubjectManifest.audit_log_chain_root).
///
/// **Concurrency**: appends to the SAME subject serialize via a
/// per-subject Mutex. Appends to DIFFERENT subjects run in parallel.
/// Without the per-subject lock, the read-modify-write in
/// append_line races and silently corrupts the chain. (2026-05-03
/// opus scrum BLOCK on subject_audit.rs:172.)
///
/// On error: logs and returns Err. Caller decides whether to propagate
/// (per spec §3.2 the gateway tool registry SHOULD log + drop).
pub async fn append(&self, mut row: SubjectAuditRow) -> Result<String, String> {
let cid = row.candidate_id.clone();
if cid.is_empty() {
return Err("audit row candidate_id is empty".into());
}
// Acquire the per-subject lock for the duration of this append.
let subject_lock = self.lock_for(&cid).await;
let _guard = subject_lock.lock().await;
// 1. Find prev_chain_hash. Cache hot path; cold path scans
// the existing JSONL tail-style to find the last row.
let prev = {
let cache = self.latest_hash.lock().await;
cache.get(&cid).cloned()
};
let prev_hash = match prev {
Some(h) => h,
None => self.scan_latest_hash(&cid).await,
};
row.prev_chain_hash = prev_hash.clone();
row.row_hmac = String::new(); // Always cleared before HMAC computation.
// 2. Canonical-JSON the row (sorted keys, no whitespace, no
// row_hmac), then HMAC. Stable across struct evolution.
let canon = Self::canonical_row_bytes(&row)?;
let new_hmac = self.compute_hmac(&prev_hash, &canon);
row.row_hmac = new_hmac.clone();
// 3. Append the row to the JSONL. The per-subject lock above
// serializes the read-modify-write inside append_line.
let line = serde_json::to_vec(&row)
.map_err(|e| format!("serialize audit row: {e}"))?;
self.append_line(&cid, &line).await?;
// 4. Update cache.
self.latest_hash.lock().await.insert(cid, new_hmac.clone());
Ok(new_hmac)
}
/// Scan the audit JSONL for a subject and return the last row's
/// row_hmac. GENESIS if no log exists or it's empty.
/// Object-store-aware: works across LocalFileSystem + InMemory + S3.
async fn scan_latest_hash(&self, candidate_id: &str) -> String {
let key = Self::audit_key(candidate_id);
let bytes = match ops::get(&self.store, &key).await {
Ok(b) => b,
Err(_) => return GENESIS_HASH.to_string(), // file doesn't exist
};
// Find the last newline-delimited line that parses as a row.
let text = match std::str::from_utf8(&bytes) {
Ok(t) => t,
Err(_) => return GENESIS_HASH.to_string(),
};
for line in text.lines().rev() {
if line.trim().is_empty() {
continue;
}
if let Ok(parsed) = serde_json::from_str::<SubjectAuditRow>(line) {
return parsed.row_hmac;
}
}
GENESIS_HASH.to_string()
}
/// Append a single newline-terminated line to the per-subject log.
/// Reads the existing content (may be empty), appends, writes back.
/// Object stores don't have native append; this is the standard
/// "read-modify-write the tail file" pattern.
async fn append_line(&self, candidate_id: &str, line: &[u8]) -> Result<(), String> {
let key = Self::audit_key(candidate_id);
let existing = ops::get(&self.store, &key).await.unwrap_or_default();
let mut buf = Vec::with_capacity(existing.len() + line.len() + 1);
buf.extend_from_slice(&existing);
if !buf.is_empty() && !buf.ends_with(b"\n") {
buf.push(b'\n');
}
buf.extend_from_slice(line);
buf.push(b'\n');
ops::put(&self.store, &key, Bytes::from(buf)).await
}
/// Read all audit rows for a subject, optionally filtered by time
/// window (inclusive bounds). Returns rows in append order. Used by
/// the /audit/subject/{id} endpoint to assemble the legal-tier
/// response. Window bounds are optional — `from=None`+`to=None`
/// returns the full log.
pub async fn read_rows_in_range(
&self,
candidate_id: &str,
from: Option<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Vec<SubjectAuditRow>, String> {
let key = Self::audit_key(candidate_id);
let bytes = match ops::get(&self.store, &key).await {
Ok(b) => b,
Err(_) => return Ok(Vec::new()), // no audit log = empty result
};
let text = std::str::from_utf8(&bytes)
.map_err(|e| format!("audit log not utf-8: {e}"))?;
let mut out = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let row: SubjectAuditRow = match serde_json::from_str(line) {
Ok(r) => r,
Err(_) => continue, // skip unparseable lines (defensive)
};
if let Some(from) = from {
if row.ts < from { continue; }
}
if let Some(to) = to {
if row.ts > to { continue; }
}
out.push(row);
}
Ok(out)
}
/// Return the latest row_hmac in this subject's audit log, or None
/// if the log doesn't exist / has no rows. Cache-aware: hits the
/// in-memory cache when the writer has appended at least once for
/// this subject in the current process, otherwise scans the log
/// tail (single object_store read).
///
/// Used by /audit/subject/{id} to echo the actual chain tip in the
/// response. Taking the tip from a windowed slice of rows would
/// lie when the window doesn't cover the latest row.
pub async fn chain_tip(&self, candidate_id: &str) -> Option<String> {
// Cache hit?
if let Some(h) = self.latest_hash.lock().await.get(candidate_id).cloned() {
return Some(h);
}
// Cold path — scan the log tail. Returns GENESIS for empty/missing
// logs; we coerce that to None so callers can distinguish "no audit
// activity yet" from "real chain tip exists".
let tip = self.scan_latest_hash(candidate_id).await;
if tip == GENESIS_HASH { None } else { Some(tip) }
}
/// Verify the full HMAC chain for a subject. Returns Ok(rows_verified)
/// or Err with the first chain break encountered.
///
/// Special case: if no audit log exists (the subject has had no PII
/// access yet), returns Ok(0) — an empty log is trivially valid.
/// Callers that want to detect "log was deleted" should compare
/// against SubjectManifest.audit_log_chain_root: if the manifest's
/// chain root is non-empty/non-GENESIS but verify_chain returns 0,
/// the file was tampered.
pub async fn verify_chain(&self, candidate_id: &str) -> Result<usize, String> {
let key = Self::audit_key(candidate_id);
let bytes = match ops::get(&self.store, &key).await {
Ok(b) => b,
Err(_) => return Ok(0), // no log = 0 rows = trivially valid
};
let text = std::str::from_utf8(&bytes)
.map_err(|e| format!("audit log not utf-8: {e}"))?;
let mut prev = GENESIS_HASH.to_string();
let mut count = 0usize;
for (lineno, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let mut row: SubjectAuditRow = serde_json::from_str(line)
.map_err(|e| format!("line {} parse failed: {e}", lineno + 1))?;
if row.prev_chain_hash != prev {
return Err(format!(
"chain break at line {}: prev_chain_hash={} expected={}",
lineno + 1, row.prev_chain_hash, prev,
));
}
let claimed = std::mem::take(&mut row.row_hmac);
let canon = Self::canonical_row_bytes(&row)
.map_err(|e| format!("canonicalize line {}: {e}", lineno + 1))?;
let recomputed = self.compute_hmac(&prev, &canon);
if recomputed != claimed {
return Err(format!(
"hmac mismatch at line {}: stored={} recomputed={}",
lineno + 1, claimed, recomputed,
));
}
prev = claimed;
count += 1;
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
use shared::types::AuditAccessor;
fn fixture_writer() -> SubjectAuditWriter {
SubjectAuditWriter::with_inline_key(
Arc::new(InMemory::new()),
// Deterministic 32-byte key for test reproducibility.
(0u8..32).collect(),
)
}
fn fixture_row(candidate_id: &str, fields: &[&str]) -> SubjectAuditRow {
SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: chrono::Utc::now(),
candidate_id: candidate_id.into(),
accessor: AuditAccessor {
kind: "gateway_lookup".into(),
daemon: "gateway".into(),
purpose: "fill_validation".into(),
trace_id: String::new(),
},
fields_accessed: fields.iter().map(|s| s.to_string()).collect(),
result: "success".into(),
prev_chain_hash: String::new(), // filled in by writer
row_hmac: String::new(), // filled in by writer
}
}
#[tokio::test]
async fn first_append_uses_genesis_prev_hash() {
let w = fixture_writer();
let new_root = w.append(fixture_row("CAND-1", &["name"])).await.unwrap();
assert!(!new_root.is_empty());
assert_eq!(new_root.len(), 64); // SHA-256 hex
// After first append, the only row should reference GENESIS as prev.
let bytes = ops::get(&w.store, &SubjectAuditWriter::audit_key("CAND-1"))
.await.unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
let row: SubjectAuditRow = serde_json::from_str(text.trim()).unwrap();
assert_eq!(row.prev_chain_hash, "GENESIS");
assert_eq!(row.row_hmac, new_root);
}
#[tokio::test]
async fn chain_links_each_append() {
let w = fixture_writer();
let h1 = w.append(fixture_row("CAND-2", &["name"])).await.unwrap();
let h2 = w.append(fixture_row("CAND-2", &["phone"])).await.unwrap();
let h3 = w.append(fixture_row("CAND-2", &["email"])).await.unwrap();
assert_ne!(h1, h2);
assert_ne!(h2, h3);
let count = w.verify_chain("CAND-2").await.unwrap();
assert_eq!(count, 3);
}
#[tokio::test]
async fn separate_subjects_have_independent_chains() {
let w = fixture_writer();
let _ = w.append(fixture_row("CAND-A", &["name"])).await.unwrap();
let _ = w.append(fixture_row("CAND-B", &["name"])).await.unwrap();
let _ = w.append(fixture_row("CAND-A", &["phone"])).await.unwrap();
assert_eq!(w.verify_chain("CAND-A").await.unwrap(), 2);
assert_eq!(w.verify_chain("CAND-B").await.unwrap(), 1);
}
#[tokio::test]
async fn tamper_detected_on_verify() {
let w = fixture_writer();
let _ = w.append(fixture_row("CAND-T", &["name"])).await.unwrap();
let _ = w.append(fixture_row("CAND-T", &["phone"])).await.unwrap();
// Mutate the file to flip a `result` field on the first row.
let key = SubjectAuditWriter::audit_key("CAND-T");
let bytes = ops::get(&w.store, &key).await.unwrap();
let mut text = std::str::from_utf8(&bytes).unwrap().to_string();
text = text.replacen("\"success\"", "\"denied\"", 1);
ops::put(&w.store, &key, text.into_bytes().into()).await.unwrap();
let err = w.verify_chain("CAND-T").await.unwrap_err();
assert!(err.contains("hmac mismatch"), "expected hmac mismatch, got: {err}");
}
#[tokio::test]
async fn cold_writer_picks_up_existing_chain() {
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let key: Vec<u8> = (0u8..32).collect();
let w1 = SubjectAuditWriter::with_inline_key(store.clone(), key.clone());
let h1 = w1.append(fixture_row("CAND-COLD", &["name"])).await.unwrap();
// Drop w1's cache, simulate process restart.
drop(w1);
let w2 = SubjectAuditWriter::with_inline_key(store.clone(), key);
let h2 = w2.append(fixture_row("CAND-COLD", &["phone"])).await.unwrap();
assert_ne!(h1, h2);
assert_eq!(w2.verify_chain("CAND-COLD").await.unwrap(), 2);
}
#[tokio::test]
async fn empty_candidate_id_rejected() {
let w = fixture_writer();
let row = fixture_row("", &["name"]);
let err = w.append(row).await.unwrap_err();
assert!(err.contains("empty"), "got: {err}");
}
#[tokio::test]
async fn concurrent_appends_to_same_subject_serialize() {
// 2026-05-03 opus scrum BLOCK regression: without per-subject
// locking, concurrent appends raced and silently lost rows.
// This test spawns 50 parallel appends and asserts ALL 50
// land with an intact chain.
let w = Arc::new(fixture_writer());
let mut handles = Vec::new();
for i in 0..50 {
let w = w.clone();
handles.push(tokio::spawn(async move {
w.append(fixture_row("CAND-RACE", &[&format!("f{i}")])).await
}));
}
for h in handles {
h.await.unwrap().unwrap();
}
let count = w.verify_chain("CAND-RACE").await.unwrap();
assert_eq!(count, 50, "expected 50 rows; chain was corrupted");
}
#[tokio::test]
async fn concurrent_appends_to_different_subjects_run_parallel() {
let w = Arc::new(fixture_writer());
let mut handles = Vec::new();
for i in 0..20 {
let w = w.clone();
handles.push(tokio::spawn(async move {
w.append(fixture_row(&format!("CAND-PARA-{i}"), &["f"])).await
}));
}
for h in handles {
h.await.unwrap().unwrap();
}
// Each of the 20 subjects has exactly 1 row.
for i in 0..20 {
assert_eq!(w.verify_chain(&format!("CAND-PARA-{i}")).await.unwrap(), 1);
}
}
#[test]
fn canonical_json_sorts_keys_alphabetically() {
let v = serde_json::json!({"z": 1, "a": 2, "m": {"y": 1, "b": 2}});
let bytes = canonical_json(&v).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
// Keys should appear in alphabetical order at every nesting level.
let a_pos = s.find("\"a\"").unwrap();
let m_pos = s.find("\"m\"").unwrap();
let z_pos = s.find("\"z\"").unwrap();
assert!(a_pos < m_pos);
assert!(m_pos < z_pos);
// Nested object's keys also sorted.
let b_pos = s.find("\"b\"").unwrap();
let y_pos = s.find("\"y\"").unwrap();
assert!(b_pos < y_pos);
}
#[test]
fn canonical_json_arrays_preserve_order() {
let v = serde_json::json!({"k": ["c", "a", "b"]});
let bytes = canonical_json(&v).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("\"c\",\"a\",\"b\""), "got: {s}");
}
#[tokio::test]
async fn key_too_short_rejected_via_file() {
// Write a 16-byte key file (under the 32-byte minimum).
let tmp = std::env::temp_dir().join(format!("sa_key_short_{}.bin", std::process::id()));
tokio::fs::write(&tmp, vec![0u8; 16]).await.unwrap();
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
// `unwrap_err` would require Debug on SubjectAuditWriter, which
// we deliberately don't impl (key-leak risk). Match instead.
match SubjectAuditWriter::from_key_file(store, &tmp).await {
Err(e) => assert!(e.contains("≥32 bytes"), "got: {e}"),
Ok(_) => panic!("expected too-short-key rejection"),
}
let _ = tokio::fs::remove_file(&tmp).await;
}
}
-3
View File
@@ -12,8 +12,6 @@ aibridge = { path = "../aibridge" }
ingestd = { path = "../ingestd" } ingestd = { path = "../ingestd" }
vectord = { path = "../vectord" } vectord = { path = "../vectord" }
journald = { path = "../journald" } journald = { path = "../journald" }
truth = { path = "../truth" }
validator = { path = "../validator" }
tokio = { workspace = true } tokio = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -31,4 +29,3 @@ tracing-opentelemetry = { workspace = true }
arrow = { workspace = true } arrow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
toml = { workspace = true }
+1 -6
View File
@@ -93,7 +93,7 @@ impl AccessControl {
self.roles.write().await.insert(role.agent_name.clone(), role); self.roles.write().await.insert(role.agent_name.clone(), role);
} }
/// Get an agent's role. Called by `GET /access/roles/{agent}`. /// Get an agent's role.
pub async fn get_role(&self, agent: &str) -> Option<AgentRole> { pub async fn get_role(&self, agent: &str) -> Option<AgentRole> {
self.roles.read().await.get(agent).cloned() self.roles.read().await.get(agent).cloned()
} }
@@ -113,7 +113,6 @@ impl AccessControl {
} }
/// Determine which fields should be masked for an agent. /// Determine which fields should be masked for an agent.
#[allow(dead_code)]
pub async fn masked_fields( pub async fn masked_fields(
&self, &self,
agent: &str, agent: &str,
@@ -139,7 +138,6 @@ impl AccessControl {
} }
/// Log a query for audit. /// Log a query for audit.
#[allow(dead_code)]
pub async fn log_query(&self, audit: QueryAudit) { pub async fn log_query(&self, audit: QueryAudit) {
self.audit_log.write().await.push(audit); self.audit_log.write().await.push(audit);
} }
@@ -151,9 +149,6 @@ impl AccessControl {
log[start..].iter().rev().cloned().collect() log[start..].iter().rev().cloned().collect()
} }
/// Reports whether access-control enforcement is active.
/// Called by `GET /access/enabled` — ops tooling / dashboards poll
/// this to confirm the auth posture of the running gateway.
pub fn is_enabled(&self) -> bool { pub fn is_enabled(&self) -> bool {
self.enabled self.enabled
} }
+1 -21
View File
@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, Query, State}, extract::{Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{get, post},
@@ -13,12 +13,6 @@ pub fn router(ac: AccessControl) -> Router {
Router::new() Router::new()
.route("/roles", get(list_roles)) .route("/roles", get(list_roles))
.route("/roles", post(set_role)) .route("/roles", post(set_role))
// Scrum iter 11 / P13-001 finding: get_role was #[allow(dead_code)]
// because nothing called it — dead until exposed. Route activates it.
// Returns 404 when the agent isn't registered so clients can
// distinguish "missing role" from "access denied."
.route("/roles/{agent}", get(get_role))
.route("/enabled", get(enabled_status))
.route("/audit", get(query_audit)) .route("/audit", get(query_audit))
.route("/check", post(check_access)) .route("/check", post(check_access))
.with_state(ac) .with_state(ac)
@@ -66,17 +60,3 @@ async fn check_access(
"allowed": allowed, "allowed": allowed,
})) }))
} }
async fn get_role(
State(ac): State<AccessControl>,
Path(agent): Path<String>,
) -> impl IntoResponse {
match ac.get_role(&agent).await {
Some(role) => Ok(Json(role)),
None => Err((StatusCode::NOT_FOUND, format!("no role registered for agent '{agent}'"))),
}
}
async fn enabled_status(State(ac): State<AccessControl>) -> impl IntoResponse {
Json(serde_json::json!({ "enabled": ac.is_enabled() }))
}
+9 -30
View File
@@ -5,51 +5,30 @@ use axum::{
response::Response, response::Response,
}; };
// API key auth middleware. Checks X-API-Key header against configured key. /// API key auth middleware. Checks X-API-Key header against configured key.
// Fixed P5-001 (2026-04-23): previously #[allow(dead_code)] — the function
// existed but was never layered onto the router, so [auth] enabled=true
// silently enforced nothing. Now wired via from_fn_with_state in main.rs.
pub async fn api_key_auth( pub async fn api_key_auth(
axum::extract::State(expected): axum::extract::State<ApiKey>,
request: Request, request: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
// /health stays public (LB/systemd probes). Every other route is gated. // Get the expected key from the request extensions (set by the layer)
if request.uri().path() == "/health" { let expected_key = request.extensions().get::<ApiKey>().cloned();
return Ok(next.run(request).await);
}
if let Some(expected) = expected_key {
let provided = request let provided = request
.headers() .headers()
.get("x-api-key") .get("x-api-key")
.and_then(|v| v.to_str().ok()); .and_then(|v| v.to_str().ok());
// Constant-time-ish eq on the raw bytes; good enough for a shared-secret
// X-API-Key. Timing-attack resistance here matters less than the
// equivalent HMAC check would; adopt subtle crate if key-space grows.
match provided { match provided {
Some(key) if eq_ct(key.as_bytes(), expected.0.as_bytes()) => { Some(key) if key == expected.0 => {}
Ok(next.run(request).await)
}
_ => { _ => {
tracing::warn!( tracing::warn!("unauthorized request: missing or invalid API key");
path = %request.uri().path(), return Err(StatusCode::UNAUTHORIZED);
"unauthorized request: missing or invalid API key", }
);
Err(StatusCode::UNAUTHORIZED)
} }
} }
}
fn eq_ct(a: &[u8], b: &[u8]) -> bool { Ok(next.run(request).await)
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
} }
/// Wrapper type for the API key, stored in request extensions. /// Wrapper type for the API key, stored in request extensions.
@@ -1,37 +0,0 @@
//! Cross-runtime parity helper for `extract_json`.
//!
//! Reads a single model-output string from stdin, runs the Rust
//! extract_json, prints `{"matched": bool, "value": <object|null>}`
//! to stdout as JSON. Exit 0 on success, exit 1 on internal error.
//!
//! The Go counterpart lives at
//! `golangLAKEHOUSE/internal/validator/iterate.go::ExtractJSON`. The
//! parity probe at
//! `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`
//! feeds the same fixtures through both and diffs the outputs.
//!
//! Usage:
//! echo '<raw model output>' | parity_extract_json
//! parity_extract_json <<< '...'
use std::io::Read;
fn main() {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("read stdin: {e}");
std::process::exit(1);
}
let result = gateway::v1::iterate::extract_json(&buf);
let body = serde_json::json!({
"matched": result.is_some(),
"value": result.unwrap_or(serde_json::Value::Null),
});
match serde_json::to_string(&body) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("serialize result: {e}");
std::process::exit(1);
}
}
}
@@ -1,71 +0,0 @@
//! Cross-runtime parity helper for `SessionRecord` JSON shape.
//!
//! Reads a fixture JSON on stdin, builds a `SessionRecord`, emits
//! one JSONL row on stdout. Used by
//! `golangLAKEHOUSE/scripts/cutover/parity/session_log_parity.sh`
//! to verify the Rust gateway's session log shape stays byte-equal
//! to the Go-side validatord's `validator.SessionRecord` (commit
//! 1a3a82a in golangLAKEHOUSE).
use gateway::v1::session_log::{SessionAttemptRecord, SessionRecord, SESSION_RECORD_SCHEMA};
use serde::Deserialize;
use std::io::Read;
#[derive(Deserialize)]
struct FixtureInput {
session_id: String,
kind: String,
model: String,
provider: String,
prompt: String,
iterations: u32,
max_iterations: u32,
final_verdict: String,
attempts: Vec<SessionAttemptRecord>,
#[serde(default)]
artifact: Option<serde_json::Value>,
#[serde(default)]
grounded_in_roster: Option<bool>,
duration_ms: u64,
}
fn main() {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("read stdin: {e}");
std::process::exit(1);
}
let input: FixtureInput = match serde_json::from_str(&buf) {
Ok(v) => v,
Err(e) => {
eprintln!("parse stdin: {e}");
std::process::exit(1);
}
};
let rec = SessionRecord {
schema: SESSION_RECORD_SCHEMA.to_string(),
session_id: input.session_id,
// Pinned timestamp so both runtimes' rows compare byte-equal
// when the test wrapper normalizes on `daemon` only.
timestamp: "2026-01-01T00:00:00+00:00".to_string(),
daemon: "gateway".to_string(),
kind: input.kind,
model: input.model,
provider: input.provider,
prompt: input.prompt,
iterations: input.iterations,
max_iterations: input.max_iterations,
final_verdict: input.final_verdict,
attempts: input.attempts,
artifact: input.artifact,
grounded_in_roster: input.grounded_in_roster,
duration_ms: input.duration_ms,
};
match serde_json::to_string(&rec) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("marshal: {e}");
std::process::exit(1);
}
}
}
@@ -1,388 +0,0 @@
//! KB context loader — reads recent signal from `data/_kb/*.jsonl` for
//! a given sig_hash + task_class and returns a compact summary.
//!
//! This is the "pipe to the overviewer" from the 2026-04-23 session:
//! the overseer tier (T3, gpt-oss:120b) consumes this context before
//! generating a correction, so its suggestions are informed by
//! historical cost / latency / outcome / prior-correction patterns
//! across ALL profiles that have run this task class — not just the
//! single current loop.
//!
//! Hot-swap profiles read the SAME pool. When a profile activates and
//! starts iterating, its KB context is the shared surface — one
//! profile's learning becomes every profile's starting point.
//!
//! Best-effort throughout: missing files, corrupt rows, empty
//! directories all produce an empty KbContext. The overseer works
//! fine with no history; we just can't seed it then.
use serde::Serialize;
use std::path::Path;
use tokio::io::AsyncBufReadExt;
/// Compact summary returned to the overseer. Bounded size — recent
/// outcomes + corrections plus rolled-up rates. Goal is to fit in a
/// prompt without eating the overseer's context budget.
#[derive(Debug, Clone, Default, Serialize)]
pub struct KbContext {
pub sig_hash: String,
pub task_class: String,
pub recent_outcomes: Vec<OutcomeSummary>,
pub recent_corrections: Vec<CorrectionSummary>,
pub success_rate: Option<f64>,
pub avg_turns: Option<f64>,
pub avg_latency_ms: Option<u64>,
pub total_observed: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct OutcomeSummary {
pub created_at: String,
pub ok: bool,
pub polarity: String,
pub turns: u32,
pub latency_ms: u64,
pub total_tokens: u64,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CorrectionSummary {
pub created_at: String,
pub reason: String,
pub correction_preview: String, // first 300 chars
pub applied_at_turn: u32,
}
const OUTCOMES_PATH: &str = "data/_kb/outcomes.jsonl";
const CORRECTIONS_PATH: &str = "data/_kb/overseer_corrections.jsonl";
const RECENT_OUTCOME_LIMIT: usize = 5;
const RECENT_CORRECTION_LIMIT: usize = 3;
const AGGREGATE_WINDOW: usize = 50;
impl KbContext {
/// Build context from the default KB paths.
pub async fn load_for(sig_hash: &str, task_class: &str) -> Self {
Self::load_from(
sig_hash, task_class,
Path::new(OUTCOMES_PATH), Path::new(CORRECTIONS_PATH),
).await
}
/// Path-taking variant — tests inject tmp files without touching
/// the real KB directory (same pattern as append_outcomes_row_at).
pub async fn load_from(
sig_hash: &str,
task_class: &str,
outcomes_path: &Path,
corrections_path: &Path,
) -> Self {
let mut ctx = KbContext {
sig_hash: sig_hash.to_string(),
task_class: task_class.to_string(),
..Default::default()
};
// Scan outcomes — matches on sig_hash primary, task_class
// secondary (so different geos for the same task_class still
// contribute to aggregate rates even though they won't make
// the top-5 recent). The bounded window keeps scan cost
// linear in file size — we're reading tail only.
let outcome_rows = tail_matching(
outcomes_path, AGGREGATE_WINDOW * 4,
|row| {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
row_sig == sig_hash || row_tc == task_class
},
).await;
// Recent outcomes: exact sig_hash match first (strongest
// signal), then task_class fallback up to the limit.
let mut exact: Vec<OutcomeSummary> = Vec::new();
let mut loose: Vec<OutcomeSummary> = Vec::new();
for row in &outcome_rows {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let summary = summarize_outcome(row);
if row_sig == sig_hash { exact.push(summary); }
else { loose.push(summary); }
}
ctx.recent_outcomes = exact.into_iter().rev().take(RECENT_OUTCOME_LIMIT).collect();
if ctx.recent_outcomes.len() < RECENT_OUTCOME_LIMIT {
let need = RECENT_OUTCOME_LIMIT - ctx.recent_outcomes.len();
ctx.recent_outcomes.extend(loose.into_iter().rev().take(need));
}
// Aggregate rates across the full matched window (both
// sig_hash and task_class matches — gives a stable rate even
// on sparse sig_hash history).
let window = outcome_rows.iter().rev().take(AGGREGATE_WINDOW);
let mut ok_count = 0u32;
let mut total = 0u32;
let mut turn_sum = 0u32;
let mut latency_sum = 0u64;
for row in window {
total += 1;
if row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { ok_count += 1; }
turn_sum += row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
latency_sum += row.get("usage")
.and_then(|u| u.get("latency_ms"))
.and_then(|v| v.as_u64()).unwrap_or(0);
}
if total > 0 {
ctx.total_observed = total;
ctx.success_rate = Some(ok_count as f64 / total as f64);
ctx.avg_turns = Some(turn_sum as f64 / total as f64);
ctx.avg_latency_ms = Some(latency_sum / total as u64);
}
// Overseer corrections. Prefer sig_hash match; fall back to
// task_class. The overseer reading its OWN prior corrections
// is the main point — if the last 3 attempts produced
// corrections X, Y, Z, the new correction should acknowledge
// those patterns rather than suggest X for the fourth time.
let correction_rows = tail_matching(
corrections_path, RECENT_CORRECTION_LIMIT * 4,
|row| {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
row_sig == sig_hash || row_tc == task_class
},
).await;
let mut c_exact: Vec<CorrectionSummary> = Vec::new();
let mut c_loose: Vec<CorrectionSummary> = Vec::new();
for row in &correction_rows {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let summary = summarize_correction(row);
if row_sig == sig_hash { c_exact.push(summary); }
else { c_loose.push(summary); }
}
ctx.recent_corrections = c_exact.into_iter().rev().take(RECENT_CORRECTION_LIMIT).collect();
if ctx.recent_corrections.len() < RECENT_CORRECTION_LIMIT {
let need = RECENT_CORRECTION_LIMIT - ctx.recent_corrections.len();
ctx.recent_corrections.extend(c_loose.into_iter().rev().take(need));
}
ctx
}
/// Compact string form for the overseer prompt. Deterministic
/// ordering + bounded length so prompt caching stays stable
/// across iterations on the same task.
pub fn to_prompt_section(&self) -> String {
let mut s = String::new();
s.push_str("## Knowledge Base Context\n");
if let (Some(rate), Some(turns), Some(lat)) = (self.success_rate, self.avg_turns, self.avg_latency_ms) {
s.push_str(&format!(
"Across {} prior similar runs: success_rate={:.1}%, avg_turns={:.1}, avg_latency_ms={}\n",
self.total_observed, rate * 100.0, turns, lat,
));
} else {
s.push_str("No prior similar runs recorded.\n");
}
if !self.recent_outcomes.is_empty() {
s.push_str(&format!("\nRecent {} outcomes:\n", self.recent_outcomes.len()));
for o in &self.recent_outcomes {
let err = o.error.as_deref().map(|e| format!(" — {}", truncate(e, 80))).unwrap_or_default();
s.push_str(&format!(
" [{}] ok={} turns={} tokens={} lat={}ms{}\n",
&o.created_at[..19.min(o.created_at.len())],
o.ok, o.turns, o.total_tokens, o.latency_ms, err,
));
}
}
if !self.recent_corrections.is_empty() {
s.push_str(&format!("\nRecent {} overseer corrections (yours — don't repeat):\n", self.recent_corrections.len()));
for c in &self.recent_corrections {
s.push_str(&format!(
" [{}] turn={} reason={} correction={}\n",
&c.created_at[..19.min(c.created_at.len())],
c.applied_at_turn,
truncate(&c.reason, 40),
truncate(&c.correction_preview, 200),
));
}
}
s
}
}
fn summarize_outcome(row: &serde_json::Value) -> OutcomeSummary {
OutcomeSummary {
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
ok: row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false),
polarity: row.get("polarity").and_then(|v| v.as_str()).unwrap_or("").to_string(),
turns: row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
latency_ms: row.get("usage").and_then(|u| u.get("latency_ms"))
.and_then(|v| v.as_u64()).unwrap_or(0),
total_tokens: row.get("usage").and_then(|u| u.get("total_tokens"))
.and_then(|v| v.as_u64()).unwrap_or(0),
error: row.get("error").and_then(|v| v.as_str()).map(String::from),
}
}
fn summarize_correction(row: &serde_json::Value) -> CorrectionSummary {
let preview = row.get("correction").and_then(|v| v.as_str()).unwrap_or("");
CorrectionSummary {
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
reason: row.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
correction_preview: truncate(preview, 300),
applied_at_turn: row.get("applied_at_turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
}
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n { s.to_string() } else { format!("{}…", &s[..n]) }
}
/// Read a JSONL file from the tail, returning at most `limit` rows
/// that match `filter`. Missing file returns empty. Corrupt lines are
/// skipped. Limit is honored from the tail — a full-file scan with an
/// in-memory ring would be wasteful for large outcomes histories, but
/// we cap at reading the whole file and filtering post-hoc for now
/// (reverse-seek line iteration is a real engineering task and the
/// file is bounded by ingest rate; revisit when it bites).
async fn tail_matching<F>(
path: &Path,
limit: usize,
filter: F,
) -> Vec<serde_json::Value>
where
F: Fn(&serde_json::Value) -> bool,
{
let Ok(file) = tokio::fs::File::open(path).await else { return Vec::new(); };
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut matches: Vec<serde_json::Value> = Vec::new();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
if filter(&v) {
matches.push(v);
if matches.len() > limit {
// Keep the most-recent window only — drop from the
// front as we go rather than buffering everything.
matches.remove(0);
}
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncWriteExt;
async fn write_fixture(path: &Path, rows: Vec<serde_json::Value>) {
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await.unwrap();
}
let mut f = tokio::fs::OpenOptions::new()
.create(true).write(true).truncate(true).open(path).await.unwrap();
for r in rows {
let mut line = serde_json::to_string(&r).unwrap();
line.push('\n');
f.write_all(line.as_bytes()).await.unwrap();
}
}
fn tmp_path(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
std::env::temp_dir().join(format!("lh_kb_ctx_{}_{}_{}", std::process::id(), nanos, name))
}
#[tokio::test]
async fn empty_files_produce_empty_context() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
assert!(ctx.recent_outcomes.is_empty());
assert!(ctx.recent_corrections.is_empty());
assert!(ctx.success_rate.is_none());
assert_eq!(ctx.total_observed, 0);
}
#[tokio::test]
async fn exact_sig_hash_matches_take_priority() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
write_fixture(&op, vec![
// Other sig_hash, same task_class — loose match
serde_json::json!({
"sig_hash": "other", "task_class": "staffing.fill",
"ok": false, "polarity": "failure_pattern", "turns": 1,
"usage": {"latency_ms": 1000, "total_tokens": 100},
"created_at": "2026-04-22T10:00:00Z",
}),
// Exact sig_hash — should lead
serde_json::json!({
"sig_hash": "sig123", "task_class": "staffing.fill",
"ok": true, "polarity": "success_confirmation", "turns": 3,
"usage": {"latency_ms": 2000, "total_tokens": 500},
"created_at": "2026-04-23T10:00:00Z",
}),
]).await;
write_fixture(&cp, vec![]).await;
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
assert_eq!(ctx.recent_outcomes.len(), 2);
assert_eq!(ctx.recent_outcomes[0].created_at, "2026-04-23T10:00:00Z");
assert_eq!(ctx.recent_outcomes[0].ok, true);
assert_eq!(ctx.total_observed, 2);
assert!((ctx.success_rate.unwrap() - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn corrupt_rows_are_skipped() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
// Mix valid + invalid — invalid should be silently skipped.
if let Some(dir) = op.parent() { tokio::fs::create_dir_all(dir).await.unwrap(); }
tokio::fs::write(&op, "not json\n{\"sig_hash\":\"sig1\",\"task_class\":\"tc\",\"ok\":true,\"turns\":1,\"usage\":{}}\ngarbage\n").await.unwrap();
write_fixture(&cp, vec![]).await;
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
assert_eq!(ctx.recent_outcomes.len(), 1);
}
#[tokio::test]
async fn corrections_preview_is_truncated() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
let long = "x".repeat(500);
write_fixture(&op, vec![]).await;
write_fixture(&cp, vec![serde_json::json!({
"sig_hash": "sig1", "task_class": "tc",
"reason": "abort", "correction": long, "applied_at_turn": 3,
"created_at": "2026-04-23T10:00:00Z",
})]).await;
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
assert_eq!(ctx.recent_corrections.len(), 1);
// 300-char cap + 3-byte UTF-8 ellipsis character = 303-byte worst case.
assert!(ctx.recent_corrections[0].correction_preview.len() <= 303);
}
#[test]
fn prompt_section_is_stable_for_empty_context() {
let ctx = KbContext::default();
let s = ctx.to_prompt_section();
assert!(s.contains("No prior similar runs recorded"));
}
#[test]
fn prompt_section_reports_aggregate_rates() {
let ctx = KbContext {
total_observed: 10,
success_rate: Some(0.7),
avg_turns: Some(4.2),
avg_latency_ms: Some(45000),
..Default::default()
};
let s = ctx.to_prompt_section();
assert!(s.contains("success_rate=70.0%"));
assert!(s.contains("avg_turns=4.2"));
assert!(s.contains("avg_latency_ms=45000"));
}
}
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
//! Library facade for the gateway crate so sub-binaries (e.g.
//! `parity_extract_json`) can reuse the same modules the gateway
//! binary uses.
//!
//! Added 2026-05-02 to support the cross-runtime parity probe at
//! `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`.
//! `extract_json` is the load-bearing public surface for that probe.
//!
//! main.rs still uses local `mod foo;` declarations independently —
//! adding this file is purely additive (the binary's module tree is
//! unchanged).
pub mod access;
pub mod access_service;
pub mod auth;
pub mod execution_loop;
pub mod observability;
pub mod tools;
pub mod v1;
+10 -264
View File
@@ -1,7 +1,6 @@
mod access; mod access;
mod access_service; mod access_service;
mod auth; mod auth;
mod execution_loop;
mod observability; mod observability;
mod tools; mod tools;
mod v1; mod v1;
@@ -68,62 +67,14 @@ async fn main() {
let access = access::AccessControl::new(config.auth.enabled); let access = access::AccessControl::new(config.auth.enabled);
access.register_defaults().await; access.register_defaults().await;
// Phase 42 — file-backed truth rules. Probes the `truth/` directory
// at repo root (or $LAKEHOUSE_TRUTH_DIR override) and logs how many
// rules load. Current request paths still build their own stores
// via truth::default_truth_store() / truth::sql_query_guard_store();
// the composed-at-boot store gets plumbed through V1State in a
// follow-up. This boot probe catches parse errors + duplicate-ID
// collisions early rather than at first request.
{
let truth_dir = std::env::var("LAKEHOUSE_TRUTH_DIR")
.unwrap_or_else(|_| "/home/profit/lakehouse/truth".to_string());
if std::path::Path::new(&truth_dir).exists() {
let mut probe_store = truth::default_truth_store();
match truth::loader::load_from_dir(&mut probe_store, &truth_dir) {
Ok(n) => tracing::info!("truth: loaded {n} file-backed rule(s) from {truth_dir}"),
Err(e) => tracing::warn!("truth: failed to load rules from {truth_dir}: {e}"),
}
} else {
tracing::debug!("truth: no rule dir at {truth_dir}, skipping file-backed load");
}
}
// Workspace manager for agent-specific overlays // Workspace manager for agent-specific overlays
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone()); let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
if let Err(e) = workspace_mgr.rebuild().await { if let Err(e) = workspace_mgr.rebuild().await {
tracing::warn!("workspace rebuild: {e}"); tracing::warn!("workspace rebuild: {e}");
} }
// AI sidecar clients — Phase 44 part 3 (2026-04-27). // AI sidecar client
// let ai_client = aibridge::client::AiClient::new(&config.sidecar.url);
// Two flavors of the same client:
// - `ai_client_direct` posts directly to ${sidecar}/generate. Used
// inside the gateway by V1State + the legacy /ai proxy. These
// call sites are themselves the implementation of /v1/chat
// (or its sidecar shim), so routing them through /v1/chat
// would self-loop.
// - `ai_client_observable` posts via ${gateway}/v1/chat with
// provider="ollama". Used by vectord modules (autotune agent,
// /vectors service) so their LLM calls land in /v1/usage and
// Langfuse traces. Adds one localhost HTTP hop per call (~ms);
// accepted for the observability gain.
//
// The gateway can call its own /v1/chat over localhost during
// boot's transient period because we don't fire any LLM calls
// until the listener is up — the observable client is just
// configured here, not exercised.
let ai_client_direct = aibridge::client::AiClient::new(&config.sidecar.url);
let gateway_self_url = format!("http://{}:{}", config.gateway.host, config.gateway.port);
let ai_client_observable = aibridge::client::AiClient::new_with_gateway(
&config.sidecar.url,
&gateway_self_url,
);
// Backwards-compat alias for the (many) existing references in this file.
// Defaults to direct so the existing wiring (V1State, /ai proxy)
// keeps its non-self-loop transport. New vectord wiring below
// explicitly uses ai_client_observable.
let ai_client = ai_client_direct.clone();
// Vector service components — built before the router because both the // Vector service components — built before the router because both the
// /vectors service AND ingestd need the agent handle to enqueue triggers. // /vectors service AND ingestd need the agent handle to enqueue triggers.
@@ -141,12 +92,6 @@ async fn main() {
// operators call POST /vectors/playbook_memory/rebuild to populate. // operators call POST /vectors/playbook_memory/rebuild to populate.
let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone()); let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone());
let _ = pbm.load_from_storage().await; let _ = pbm.load_from_storage().await;
// Pathway memory — consensus-designed sidecar for full-context
// backtracking + hot-swap of successful review pathways. Same
// load-on-boot pattern as playbook_memory: empty state is fine,
// operators start populating via scrum_master_pipeline.ts.
let pwm = vectord::pathway_memory::PathwayMemory::new(store.clone());
let _ = pwm.load_from_storage().await;
// Phase 16.2: spawn the autotune agent. When config.agent.enabled=false // Phase 16.2: spawn the autotune agent. When config.agent.enabled=false
// this returns a handle that drops triggers silently — no surprise load. // this returns a handle that drops triggers silently — no surprise load.
@@ -161,9 +106,7 @@ async fn main() {
agent_cfg, agent_cfg,
vectord::agent::AgentDeps { vectord::agent::AgentDeps {
store: store.clone(), store: store.clone(),
// Observable: autotune agent's LLM calls go through ai_client: ai_client.clone(),
// /v1/chat for /v1/usage + Langfuse visibility.
ai_client: ai_client_observable.clone(),
catalog: registry.clone(), catalog: registry.clone(),
index_registry: index_reg.clone(), index_registry: index_reg.clone(),
hnsw_store: hnsw.clone(), hnsw_store: hnsw.clone(),
@@ -210,17 +153,10 @@ async fn main() {
agent_handle: agent_handle.clone(), agent_handle: agent_handle.clone(),
index_registry: index_reg.clone(), index_registry: index_reg.clone(),
schedules: sched_store, schedules: sched_store,
// P9-001 fix 2026-04-23: journal reference flows into ingest so
// successful uploads emit a record_ingest event. Journal is Clone
// (Arc<RwLock> inside) so the /journal nest below still sees the
// same buffer + persistence.
journal: Some(journal.clone()),
})) }))
.nest("/vectors", vectord::service::router(vectord::service::VectorState { .nest("/vectors", vectord::service::router(vectord::service::VectorState {
store: store.clone(), store: store.clone(),
// Observable: /vectors service's LLM calls (RAG, summary, ai_client: ai_client.clone(),
// playbook synthesis, etc.) flow through /v1/chat.
ai_client: ai_client_observable.clone(),
job_tracker: vectord::jobs::JobTracker::new(), job_tracker: vectord::jobs::JobTracker::new(),
index_registry: index_reg.clone(), index_registry: index_reg.clone(),
hnsw_store: hnsw, hnsw_store: hnsw,
@@ -236,52 +172,23 @@ async fn main() {
bucket_registry.clone(), index_reg.clone(), bucket_registry.clone(), index_reg.clone(),
), ),
playbook_memory: pbm, playbook_memory: pbm,
pathway_memory: pwm,
embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)), embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
})) }))
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr)) .nest("/workspaces", queryd::workspace_service::router(workspace_mgr))
.nest("/journal", journald::service::router(journal)) .nest("/journal", journald::service::router(journal))
.nest("/access", access_service::router(access)) .nest("/access", access_service::router(access))
.nest("/tools", tools::service::router({ .nest("/tools", tools::service::router({
let tool_reg = tools::registry::ToolRegistry::new(); let tool_reg = tools::registry::ToolRegistry::new_with_defaults();
tool_reg.register_defaults().await; tool_reg.register_defaults().await;
tools::ToolState { tools::ToolState {
registry: tool_reg, registry: tool_reg,
query_fn: tools::QueryExecutor::new(engine.clone()), query_fn: tools::QueryExecutor::new(engine.clone()),
truth: std::sync::Arc::new(truth::sql_query_guard_store()),
} }
})) }))
// Phase 38 — Universal API skeleton. Thin OpenAI-compatible // Phase 38 — Universal API skeleton. Thin OpenAI-compatible
// surface over the existing aibridge → Ollama path. Future // surface over the existing aibridge → Ollama path. Future
// phases add provider adapters (39), routing engine (40), // phases add provider adapters (39), routing engine (40),
// session state (41), etc. All without changing this mount. // session state (41), etc. All without changing this mount.
;
// Build subject-audit writer BEFORE the V1State literal so the
// validate_workers wrapper can reference it. None = audit disabled
// (key file missing or unreadable); the gateway still serves PII
// paths but produces no audit trail per spec §3.2.
let subject_audit_writer: Option<std::sync::Arc<catalogd::subject_audit::SubjectAuditWriter>> = {
let key_path = std::env::var("LH_SUBJECT_AUDIT_KEY")
.unwrap_or_else(|_| "/etc/lakehouse/subject_audit.key".into());
match catalogd::subject_audit::SubjectAuditWriter::from_key_file(
store.clone(), std::path::Path::new(&key_path)
).await {
Ok(w) => {
tracing::info!("v1: subject audit log enabled (key: {})", key_path);
Some(std::sync::Arc::new(w))
}
Err(e) => {
tracing::warn!(
"v1: subject audit log DISABLED — {} (set LH_SUBJECT_AUDIT_KEY or place ≥32-byte key at /etc/lakehouse/subject_audit.key)",
e
);
None
}
}
};
app = app
.nest("/v1", v1::router(v1::V1State { .nest("/v1", v1::router(v1::V1State {
ai_client: ai_client.clone(), ai_client: ai_client.clone(),
usage: std::sync::Arc::new(tokio::sync::RwLock::new(v1::Usage::default())), usage: std::sync::Arc::new(tokio::sync::RwLock::new(v1::Usage::default())),
@@ -297,95 +204,6 @@ async fn main() {
} }
k k
}, },
openrouter_key: {
// 2026-04-24 free-tier rescue rung for iter 5+. Shares
// the LLM Team UI's OPENROUTER_API_KEY so both systems
// draw from one quota.
let k = v1::openrouter::resolve_openrouter_key();
if k.is_some() {
tracing::info!("v1: OpenRouter key loaded — /v1/chat provider=openrouter enabled");
} else {
tracing::warn!("v1: no OpenRouter key — openrouter rescue rung will 503");
}
k
},
gemini_key: {
// Phase 40 provider. GEMINI_API_KEY in env or .env.
let k = v1::gemini::resolve_gemini_key();
if k.is_some() {
tracing::info!("v1: Gemini key loaded — /v1/chat provider=gemini enabled");
} else {
tracing::debug!("v1: no Gemini key — provider=gemini will 503");
}
k
},
claude_key: {
// Phase 40 provider. ANTHROPIC_API_KEY in env or .env.
let k = v1::claude::resolve_claude_key();
if k.is_some() {
tracing::info!("v1: Claude key loaded — /v1/chat provider=claude enabled");
} else {
tracing::debug!("v1: no Claude key — provider=claude will 503");
}
k
},
kimi_key: {
// Direct Kimi For Coding (api.kimi.com) — bypasses the
// broken-upstream kimi-k2:1t and OpenRouter rate caps.
// Key from /etc/lakehouse/kimi.env (KIMI_API_KEY=sk-kimi-…).
let k = v1::kimi::resolve_kimi_key();
if k.is_some() {
tracing::info!("v1: Kimi key loaded — /v1/chat provider=kimi enabled (model=kimi-for-coding)");
} else {
tracing::debug!("v1: no Kimi key — provider=kimi will 503");
}
k
},
opencode_key: {
// OpenCode GO multi-vendor gateway — Claude Opus 4.7,
// GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
// Qwen + free-tier. Key from /etc/lakehouse/opencode.env.
let k = v1::opencode::resolve_opencode_key();
if k.is_some() {
tracing::info!("v1: OpenCode key loaded — /v1/chat provider=opencode enabled (40 models)");
} else {
tracing::debug!("v1: no OpenCode key — provider=opencode will 503");
}
k
},
validate_workers: {
// Load workers_500k.parquet snapshot for /v1/validate.
// Path overridable via LH_WORKERS_PARQUET env. Missing
// file is non-fatal — validators run schema/PII checks
// unaffected; only worker-existence checks fail clean.
let path_str = std::env::var("LH_WORKERS_PARQUET")
.unwrap_or_else(|_| "/home/profit/lakehouse/data/datasets/workers_500k.parquet".into());
let path = std::path::Path::new(&path_str);
let raw: std::sync::Arc<dyn validator::WorkerLookup> = if path.exists() {
match validator::staffing::parquet_lookup::load_workers_parquet(path) {
Ok(lookup) => {
tracing::info!("v1: workers parquet loaded from {} — /v1/validate worker-existence checks enabled", path_str);
lookup
}
Err(e) => {
tracing::warn!("v1: workers parquet at {} unreadable ({e}) — /v1/validate worker-existence checks will fail Consistency", path_str);
std::sync::Arc::new(validator::InMemoryWorkerLookup::new())
}
}
} else {
tracing::warn!("v1: workers parquet at {} not found — /v1/validate worker-existence checks will fail Consistency", path_str);
std::sync::Arc::new(validator::InMemoryWorkerLookup::new())
};
// Wrap with subject-audit decorator. When subject_audit_writer
// is None, the wrapper is a transparent passthrough — no
// audit, no overhead. When Some, every find() call fires an
// audit row via the per-subject HMAC chain. (Step 5 of
// SUBJECT_MANIFESTS_ON_CATALOGD spec.)
std::sync::Arc::new(v1::auditing_worker_lookup::AuditingWorkerLookup::new(
raw,
subject_audit_writer.clone(),
))
},
// Phase 40 early deliverable — Langfuse trace emitter. // Phase 40 early deliverable — Langfuse trace emitter.
// Defaults match mcp-server/tracing.ts conventions so // Defaults match mcp-server/tracing.ts conventions so
// gateway traces land in the same staffing project. // gateway traces land in the same staffing project.
@@ -398,88 +216,16 @@ async fn main() {
} }
c c
}, },
// Coordinator session JSONL — one row per /v1/iterate
// session for offline DuckDB analysis. Cross-runtime
// parity with Go-side validatord (commit 1a3a82a).
session_log: {
let path = &config.gateway.session_log_path;
let s = v1::session_log::SessionLogger::from_path(path);
if s.is_some() {
tracing::info!(
"v1: session log enabled — coordinator sessions written to {}",
path
);
} else {
tracing::info!("v1: session log disabled (set [gateway].session_log_path to enable)");
}
s
},
// subject_audit was constructed BEFORE the V1State literal
// (see `subject_audit_writer` above) so validate_workers
// could wrap with it. Reuse the same Arc here.
subject_audit: subject_audit_writer.clone(),
})); }));
// Step 6 of SUBJECT_MANIFESTS_ON_CATALOGD spec — /audit/subject/{id} // Auth middleware (if enabled)
// legal-tier endpoint. Only mounted when BOTH the audit writer AND
// the legal token file are present. Disabled-by-default keeps the
// audit surface from accidentally serving without proper credentials
// in dev / bring-up environments.
if let Some(writer) = subject_audit_writer.clone() {
let legal_token_path = std::env::var("LH_LEGAL_AUDIT_TOKEN_FILE")
.unwrap_or_else(|_| "/etc/lakehouse/legal_audit.token".into());
let audit_state = catalogd::audit_endpoint::AuditEndpointState::new(
registry.clone(),
writer.clone(),
std::path::Path::new(&legal_token_path),
).await;
app = app.nest("/audit", catalogd::audit_endpoint::router(audit_state));
tracing::info!("audit endpoint mounted at /audit (legal token: {})", legal_token_path);
// Phase 1.6 Gate 3 — biometric photo upload endpoint. Same
// legal-token gate as /audit (writes to legal-tier storage are
// privileged). Storage root defaults to ./data/biometric/uploads
// unless overridden via LH_BIOMETRIC_STORAGE_ROOT (operators on
// multi-host deployments will want a dedicated mount point).
let biometric_storage_root: std::path::PathBuf = std::env::var("LH_BIOMETRIC_STORAGE_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("./data/biometric/uploads"));
// consent_versions allowlist — counsel-tier deployment safety.
// Defaults to /etc/lakehouse/consent_versions.json; override
// via LH_CONSENT_VERSIONS_FILE. Missing file → permissive
// mode (any non-empty hash accepted, the v1 behavior).
let consent_versions_path: std::path::PathBuf = std::env::var("LH_CONSENT_VERSIONS_FILE")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/etc/lakehouse/consent_versions.json"));
let biometric_state = catalogd::biometric_endpoint::BiometricEndpointState::new(
registry.clone(),
writer,
std::path::Path::new(&legal_token_path),
biometric_storage_root.clone(),
).await
.with_consent_versions(&consent_versions_path).await;
app = app.nest("/biometric", catalogd::biometric_endpoint::router(biometric_state));
tracing::info!(
"biometric endpoint mounted at /biometric (storage_root: {}, legal token: {}, consent_versions file: {})",
biometric_storage_root.display(), legal_token_path, consent_versions_path.display()
);
} else {
tracing::warn!("/audit + /biometric endpoints NOT mounted — subject_audit writer is None (no signing key)");
}
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
// previously only inserted the ApiKey as an extension and never layered
// the middleware, so auth.enabled=true enforced nothing. Now wraps the
// router with from_fn_with_state, which calls api_key_auth on every
// request. /health is exempted inside the middleware (LB probes).
if config.auth.enabled { if config.auth.enabled {
if let Some(ref key) = config.auth.api_key { if let Some(ref key) = config.auth.api_key {
tracing::info!("API key auth enabled — enforcing on all routes except /health"); tracing::info!("API key auth enabled");
let api_key = auth::ApiKey(key.clone()); let api_key = auth::ApiKey(key.clone());
app = app.layer(axum::middleware::from_fn_with_state( app = app.layer(axum::Extension(api_key));
api_key, // Note: auth middleware applied per-route in production
auth::api_key_auth, // For now, the ApiKey extension is available for handlers to check
));
} else { } else {
tracing::warn!("auth enabled but no api_key set — all requests allowed"); tracing::warn!("auth enabled but no api_key set — all requests allowed");
} }
-6
View File
@@ -3,18 +3,12 @@ pub mod service;
use queryd::context::QueryEngine; use queryd::context::QueryEngine;
use arrow::json::writer::{JsonArray, Writer as JsonWriter}; use arrow::json::writer::{JsonArray, Writer as JsonWriter};
use std::sync::Arc;
use truth::TruthStore;
/// State for the tool system. /// State for the tool system.
#[derive(Clone)] #[derive(Clone)]
pub struct ToolState { pub struct ToolState {
pub registry: registry::ToolRegistry, pub registry: registry::ToolRegistry,
pub query_fn: QueryExecutor, pub query_fn: QueryExecutor,
/// SQL guard (shared with queryd). Mirrors the queryd /sql truth
/// gate from P42-002 (9cc0ceb) — tools also execute model-
/// originated SQL, need the same destructive-verb block.
pub truth: Arc<TruthStore>,
} }
/// Wraps QueryEngine to provide a simple execute interface for tools. /// Wraps QueryEngine to provide a simple execute interface for tools.
+14 -4
View File
@@ -67,14 +67,24 @@ pub struct ToolRegistry {
} }
impl ToolRegistry { impl ToolRegistry {
/// Build an empty registry. Callers in an async context should follow
/// this with `.register_defaults().await` if they want the built-in
/// staffing tools pre-installed — main.rs does exactly that.
pub fn new() -> Self { pub fn new() -> Self {
Self { let registry = Self {
tools: Arc::new(RwLock::new(HashMap::new())), tools: Arc::new(RwLock::new(HashMap::new())),
audit_log: Arc::new(RwLock::new(Vec::new())), audit_log: Arc::new(RwLock::new(Vec::new())),
};
// Register built-in staffing tools
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(registry.register_defaults())
});
registry
} }
pub fn new_with_defaults() -> Self {
let registry = Self {
tools: Arc::new(RwLock::new(HashMap::new())),
audit_log: Arc::new(RwLock::new(Vec::new())),
};
registry
} }
/// Register default staffing tools. /// Register default staffing tools.
+1 -27
View File
@@ -7,7 +7,7 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
use super::registry::{ToolInvocation, ToolRegistry}; use super::registry::{Permission, ToolInvocation, ToolRegistry};
use crate::tools::ToolState; use crate::tools::ToolState;
pub fn router(state: ToolState) -> Router { pub fn router(state: ToolState) -> Router {
@@ -92,32 +92,6 @@ async fn call_tool(
} }
}; };
// Truth gate — same contract as queryd /sql (P42-002). Rejects
// destructive verbs + empty SQL. Scrum iter 11 CF-1 + CF-2 on this
// file: tools executed model-provided SQL parameters without any
// validation. Close the gap here so the parallel surface has the
// same safety floor as queryd.
let ctx = serde_json::json!({ "sql": sql });
for outcome in state.truth.evaluate("sql_query", &ctx) {
if outcome.passed {
if let truth::RuleAction::Reject { message } | truth::RuleAction::Block { message } = &outcome.action {
tracing::warn!("tool {name}: SQL blocked by truth gate ({}): {message}", outcome.rule_id);
state.registry.log_invocation(ToolInvocation {
id: format!("inv-{}", chrono::Utc::now().timestamp_millis()),
tool_name: name.clone(),
agent: req.agent.clone(),
params: req.params.clone(),
permission: tool.permission.clone(),
timestamp: chrono::Utc::now(),
success: false,
error: Some(format!("truth gate: {message}")),
rows_returned: None,
}).await;
return Err((StatusCode::FORBIDDEN, message.clone()));
}
}
}
// Execute via query engine // Execute via query engine
let result = state.query_fn.execute(&sql).await; let result = state.query_fn.execute(&sql).await;
@@ -1,194 +0,0 @@
//! Validator WorkerLookup decorator that fires per-find audit rows.
//!
//! Specification: docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 5.
//!
//! Wraps an inner `dyn WorkerLookup`. Every `find(candidate_id)` call
//! produces one `SubjectAuditRow` via the bundled `SubjectAuditWriter`,
//! tagged with `purpose="validator_worker_lookup"`. Audit-write failures
//! are logged only — never propagated to the caller (per spec §3.2:
//! audit-side gaps are preferable to blocking validation).
//!
//! **Why a decorator and not a trait extension**: `WorkerLookup` is a
//! sync trait by design (validator's contract is "in-memory snapshot,
//! no per-call I/O"). Audit writes are async. The decorator bridges
//! them by spawning the audit append onto the current Tokio runtime
//! handle. Per-subject sequencing inside the writer ensures the chain
//! stays intact even with spawned writes (regression-tested at
//! catalogd::subject_audit::tests::concurrent_appends_to_same_subject_serialize).
use catalogd::subject_audit::SubjectAuditWriter;
use shared::types::{AuditAccessor, SubjectAuditRow};
use std::sync::Arc;
use validator::{WorkerLookup, WorkerRecord};
pub struct AuditingWorkerLookup {
inner: Arc<dyn WorkerLookup>,
/// None = audit disabled. find() still runs; just no audit row.
audit: Option<Arc<SubjectAuditWriter>>,
/// Cached Tokio handle so the sync find() path can spawn audit
/// writes onto the right runtime. None at construction = audit
/// is no-op (no runtime context to dispatch into).
runtime_handle: Option<tokio::runtime::Handle>,
}
impl AuditingWorkerLookup {
/// Wrap an inner lookup with optional audit. If `audit` is None,
/// behavior is identical to the inner — no overhead, no audit.
/// MUST be constructed inside a Tokio runtime context (uses
/// `Handle::try_current` to capture the handle).
pub fn new(inner: Arc<dyn WorkerLookup>, audit: Option<Arc<SubjectAuditWriter>>) -> Self {
let runtime_handle = if audit.is_some() {
tokio::runtime::Handle::try_current().ok()
} else {
None
};
if audit.is_some() && runtime_handle.is_none() {
tracing::warn!(
"AuditingWorkerLookup: audit enabled but no Tokio runtime — audit writes disabled"
);
}
Self { inner, audit, runtime_handle }
}
}
impl WorkerLookup for AuditingWorkerLookup {
fn find(&self, candidate_id: &str) -> Option<WorkerRecord> {
let result = self.inner.find(candidate_id);
// Fire audit AFTER the find so the result-state field reflects
// the actual outcome ("success" if found, "not_found" if not).
if let (Some(audit), Some(handle)) = (self.audit.clone(), self.runtime_handle.clone()) {
let cid = candidate_id.to_string();
let result_state = if result.is_some() { "success" } else { "not_found" };
// Single field: "exists" — validator only consumes existence
// + role/city/state from the WorkerRecord. We don't enumerate
// every WorkerRecord field as fields_accessed because that
// would imply they were USED by the caller; the validator
// only proves existence + then reads policy fields downstream.
let row = SubjectAuditRow {
schema: "subject_audit.v1".into(),
ts: chrono::Utc::now(),
candidate_id: cid.clone(),
accessor: AuditAccessor {
kind: "validator_lookup".into(),
daemon: "gateway".into(),
purpose: "validator_worker_lookup".into(),
trace_id: String::new(),
},
fields_accessed: vec!["exists".into()],
result: result_state.into(),
prev_chain_hash: String::new(),
row_hmac: String::new(),
};
// Spawn fire-and-forget. Chain integrity is preserved by the
// writer's per-subject Mutex (regression-tested), so spawning
// is safe. Audit-write errors are logged only.
handle.spawn(async move {
if let Err(e) = audit.append(row).await {
tracing::warn!("validator audit write failed for {cid}: {e}");
}
});
}
result
}
fn len(&self) -> usize {
self.inner.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use catalogd::subject_audit::SubjectAuditWriter;
use object_store::memory::InMemory;
use validator::InMemoryWorkerLookup;
fn fixture_inner_with(records: Vec<WorkerRecord>) -> Arc<dyn WorkerLookup> {
Arc::new(InMemoryWorkerLookup::from_records(records))
}
fn fixture_record(candidate_id: &str) -> WorkerRecord {
WorkerRecord {
candidate_id: candidate_id.into(),
name: "Test".into(),
status: "active".into(),
city: Some("Chicago".into()),
state: Some("IL".into()),
role: Some("Welder".into()),
blacklisted_clients: vec![],
}
}
#[tokio::test]
async fn find_existing_subject_writes_success_audit_row() {
let inner = fixture_inner_with(vec![fixture_record("CAND-EXIST")]);
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(
store.clone(),
(0u8..32).collect(),
));
let lookup = AuditingWorkerLookup::new(inner, Some(writer.clone()));
let r = lookup.find("CAND-EXIST");
assert!(r.is_some());
// Audit is spawned — give it a tick to land.
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let count = writer.verify_chain("CAND-EXIST").await.unwrap();
assert_eq!(count, 1);
}
#[tokio::test]
async fn find_missing_subject_writes_not_found_audit_row() {
let inner = fixture_inner_with(vec![]);
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(
store.clone(),
(0u8..32).collect(),
));
let lookup = AuditingWorkerLookup::new(inner, Some(writer.clone()));
let r = lookup.find("CAND-PHANTOM");
assert!(r.is_none());
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// verify_chain returns the row count for that subject (1 row,
// result="not_found"). We don't assert on result text here —
// that's covered by the hand-rolled JSON inspection below.
assert_eq!(writer.verify_chain("CAND-PHANTOM").await.unwrap(), 1);
}
#[tokio::test]
async fn audit_disabled_means_no_writes_no_overhead() {
let inner = fixture_inner_with(vec![fixture_record("CAND-A")]);
let lookup = AuditingWorkerLookup::new(inner, None);
// Just confirm find() still works with no audit — no panic, no
// runtime-handle requirement.
assert!(lookup.find("CAND-A").is_some());
assert!(lookup.find("CAND-MISSING").is_none());
assert_eq!(lookup.len(), 1);
}
#[tokio::test]
async fn many_finds_to_same_subject_produce_intact_chain() {
// Same regression as catalogd's concurrent test, but exercised
// through the validator wrapper path. 30 sequential finds on
// the SAME subject should produce an intact 30-row chain.
let inner = fixture_inner_with(vec![fixture_record("CAND-CHAIN")]);
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let writer = Arc::new(SubjectAuditWriter::with_inline_key(
store.clone(),
(0u8..32).collect(),
));
let lookup = AuditingWorkerLookup::new(inner, Some(writer.clone()));
for _ in 0..30 {
let _ = lookup.find("CAND-CHAIN");
}
// Wait for all spawns to finish — the runtime is currently
// single-threaded test runtime, so a few yields drains.
for _ in 0..50 {
tokio::task::yield_now().await;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let count = writer.verify_chain("CAND-CHAIN").await.unwrap();
assert_eq!(count, 30, "chain corrupted under sequential spawn load");
}
}
-222
View File
@@ -1,222 +0,0 @@
//! Claude (Anthropic) adapter.
//!
//! POST `https://api.anthropic.com/v1/messages`. Auth via `x-api-key`
//! header (not bearer) + required `anthropic-version` header. Payload
//! is NOT OpenAI-compatible — response text lives at
//! `content[0].text`. Phase 40 deliverable. System prompts travel in
//! a top-level `system` field, separate from the `messages` array.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const CLAUDE_BASE_URL: &str = "https://api.anthropic.com/v1";
const CLAUDE_API_VERSION: &str = "2023-06-01";
const CLAUDE_TIMEOUT_SECS: u64 = 180;
pub fn resolve_claude_key() -> Option<String> {
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("ANTHROPIC_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "claude/" prefix if the caller used the namespaced form.
let model = req.model.strip_prefix("claude/").unwrap_or(&req.model).to_string();
// Anthropic carries system prompts outside the messages array.
// Concatenate any system-role messages into a single system string;
// keep user + assistant messages in `messages`.
let mut system_parts: Vec<String> = Vec::new();
let mut msgs: Vec<AnMessage> = Vec::new();
for m in &req.messages {
if m.role == "system" {
system_parts.push(m.text());
} else {
// Anthropic expects strictly "user" or "assistant"; anything
// else we normalize to "user".
let role = if m.role == "assistant" { "assistant" } else { "user" };
msgs.push(AnMessage { role: role.to_string(), content: m.text() });
}
}
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
let body = AnChatBody {
model: model.clone(),
messages: msgs,
max_tokens: req.max_tokens.unwrap_or(800),
temperature: req.temperature.unwrap_or(0.3),
system,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(CLAUDE_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let resp = client
.post(format!("{}/messages", CLAUDE_BASE_URL))
.header("x-api-key", key)
.header("anthropic-version", CLAUDE_API_VERSION)
.json(&body)
.send()
.await
.map_err(|e| format!("api.anthropic.com unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("claude {}: {}", status, body));
}
let parsed: AnChatResponse = resp.json().await
.map_err(|e| format!("invalid claude response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let text = parsed.content.into_iter()
.find(|b| b.block_type == "text")
.map(|b| b.text)
.unwrap_or_default();
let prompt_tokens = parsed.usage.as_ref().map(|u| u.input_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.output_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "claude",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"claude chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message::new_text("assistant", text),
finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- Anthropic Messages API wire shapes --
#[derive(Serialize)]
struct AnChatBody {
model: String,
messages: Vec<AnMessage>,
max_tokens: u32,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
#[derive(Serialize)]
struct AnMessage { role: String, content: String }
#[derive(Deserialize)]
struct AnChatResponse {
content: Vec<AnContentBlock>,
#[serde(default, rename = "stop_reason")]
stop_reason: Option<String>,
#[serde(default)]
usage: Option<AnUsage>,
}
#[derive(Deserialize)]
struct AnContentBlock {
#[serde(rename = "type")]
block_type: String,
#[serde(default)]
text: String,
}
#[derive(Deserialize)]
struct AnUsage { input_tokens: u32, output_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_claude_key_does_not_panic() {
let _ = resolve_claude_key();
}
#[test]
fn chat_body_serializes_with_separate_system() {
let body = AnChatBody {
model: "claude-3-5-sonnet-latest".into(),
messages: vec![
AnMessage { role: "user".into(), content: "hi".into() },
],
max_tokens: 800,
temperature: 0.3,
system: Some("You are helpful.".into()),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"system\":\"You are helpful.\""));
assert!(json.contains("\"messages\""));
assert!(json.contains("\"max_tokens\":800"));
}
#[test]
fn body_omits_system_when_none() {
let body = AnChatBody {
model: "claude-3-5-sonnet-latest".into(),
messages: vec![AnMessage { role: "user".into(), content: "hi".into() }],
max_tokens: 800,
temperature: 0.3,
system: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(!json.contains("\"system\""), "system field should be skipped when None: {json}");
}
#[test]
fn model_prefix_strip_preserves_bare_names() {
let cases = [
("claude/claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
("claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
];
for (input, expected) in cases {
let out = input.strip_prefix("claude/").unwrap_or(input);
assert_eq!(out, expected);
}
}
}
-230
View File
@@ -1,230 +0,0 @@
//! Gemini adapter — Google's Generative Language API.
//!
//! POST `https://generativelanguage.googleapis.com/v1beta/models/
//! {model}:generateContent?key=<API_KEY>`. Auth via query-string key
//! (not bearer). Payload shape is NOT OpenAI-compatible — we map
//! messages → contents + parts, extract response from `candidates[0]
//! .content.parts[0].text`. Phase 40 deliverable; gate: `/v1/chat`
//! with a prefixed or explicit gemini model returns normally.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_TIMEOUT_SECS: u64 = 180;
pub fn resolve_gemini_key() -> Option<String> {
if let Ok(k) = std::env::var("GEMINI_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("GEMINI_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "gemini/" prefix if the caller used the namespaced form.
let model = req.model.strip_prefix("gemini/").unwrap_or(&req.model).to_string();
// Gemini splits system prompt from conversation differently.
// Simplest working mapping: concatenate any system messages at the
// top of a single user turn, then append user/assistant turns as
// separate contents entries. Covers the common single-turn case
// the scrum pipeline uses.
let mut contents: Vec<GmContent> = Vec::new();
for m in &req.messages {
let role = match m.role.as_str() {
"system" | "user" => "user",
_ => "model",
};
contents.push(GmContent {
role: role.to_string(),
parts: vec![GmPart { text: m.text() }],
});
}
let body = GmChatBody {
contents,
generation_config: GmGenerationConfig {
temperature: req.temperature.unwrap_or(0.3),
max_output_tokens: req.max_tokens.unwrap_or(800),
},
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(GEMINI_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let url = format!("{}/models/{}:generateContent?key={}", GEMINI_BASE_URL, model, key);
let t0 = std::time::Instant::now();
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("generativelanguage.googleapis.com unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("gemini {}: {}", status, body));
}
let parsed: GmChatResponse = resp.json().await
.map_err(|e| format!("invalid gemini response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let candidate = parsed.candidates.into_iter().next()
.ok_or_else(|| "gemini returned no candidates".to_string())?;
let text = candidate.content.parts.into_iter()
.next()
.map(|p| p.text)
.unwrap_or_default();
let prompt_tokens = parsed.usage_metadata.as_ref()
.map(|u| u.prompt_token_count)
.unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage_metadata.as_ref()
.map(|u| u.candidates_token_count)
.unwrap_or_else(|| ((text.chars().count() + 3) / 4) as u32);
tracing::info!(
target: "v1.chat",
provider = "gemini",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"gemini chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message::new_text("assistant", text),
finish_reason: candidate.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- Gemini wire shapes --
#[derive(Serialize)]
struct GmChatBody {
contents: Vec<GmContent>,
#[serde(rename = "generationConfig")]
generation_config: GmGenerationConfig,
}
#[derive(Serialize)]
struct GmContent {
role: String,
parts: Vec<GmPart>,
}
#[derive(Serialize)]
struct GmPart { text: String }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GmGenerationConfig {
temperature: f64,
max_output_tokens: u32,
}
#[derive(Deserialize)]
struct GmChatResponse {
candidates: Vec<GmCandidate>,
#[serde(default, rename = "usageMetadata")]
usage_metadata: Option<GmUsage>,
}
#[derive(Deserialize)]
struct GmCandidate {
content: GmContentResp,
#[serde(default, rename = "finishReason")]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct GmContentResp { parts: Vec<GmPartResp> }
#[derive(Deserialize)]
struct GmPartResp { #[serde(default)] text: String }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GmUsage {
prompt_token_count: u32,
candidates_token_count: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_gemini_key_does_not_panic() {
let _ = resolve_gemini_key();
}
#[test]
fn chat_body_serializes_to_gemini_shape() {
let body = GmChatBody {
contents: vec![
GmContent {
role: "user".into(),
parts: vec![GmPart { text: "hello".into() }],
},
],
generation_config: GmGenerationConfig {
temperature: 0.3,
max_output_tokens: 800,
},
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"contents\""));
assert!(json.contains("\"parts\""));
// camelCase per Gemini API
assert!(json.contains("\"generationConfig\""));
assert!(json.contains("\"maxOutputTokens\":800"));
}
#[test]
fn model_prefix_strip_preserves_bare_names() {
let cases = [
("gemini/gemini-2.0-flash", "gemini-2.0-flash"),
("gemini-2.0-flash", "gemini-2.0-flash"),
];
for (input, expected) in cases {
let out = input.strip_prefix("gemini/").unwrap_or(input);
assert_eq!(out, expected);
}
}
}
-543
View File
@@ -1,543 +0,0 @@
//! /v1/iterate — the Phase 43 PRD's "generate → validate → correct → retry" loop.
//!
//! Closes the "0→85% with iteration" thesis structurally. A caller
//! posts a prompt + artifact kind + validation context; the gateway:
//! 1. Generates a JSON artifact via /v1/chat (any provider/model)
//! 2. Extracts the JSON object from the model output
//! 3. Validates via /v1/validate (FillValidator / EmailValidator /
//! PlaybookValidator with the shared WorkerLookup)
//! 4. On ValidationError, appends the error to the prompt and
//! retries up to `max_iterations` (default 3)
//! 5. Returns the accepted artifact + Report on success, OR the
//! attempt history + final error on max-iter exhaustion
//!
//! Internal calls go via HTTP loopback to localhost:gateway_port so
//! the same /v1/usage tracking and Langfuse traces apply. A small
//! latency cost (~1-3ms per loopback hop) for clean separation of
//! concerns and observability.
//!
//! 2026-04-27 Phase 43 v3 part 3: this endpoint makes the iteration
//! loop a first-class lakehouse capability rather than a per-caller
//! re-implementation. Staffing executors, agent loops, and future
//! validators all reach the same code path.
use axum::{extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
const DEFAULT_MAX_ITERATIONS: u32 = 3;
const LOOPBACK_TIMEOUT_SECS: u64 = 240;
/// Header name used to propagate a Langfuse parent trace id across
/// daemon boundaries. Matches Go's `shared.TraceIDHeader` constant
/// byte-for-byte (commit d6d2fdf in golangLAKEHOUSE) — same wire
/// format means a Go caller can hit Rust's /v1/iterate (or vice
/// versa) and the resulting Langfuse trees nest correctly.
pub const TRACE_ID_HEADER: &str = "x-lakehouse-trace-id";
#[derive(Deserialize)]
pub struct IterateRequest {
/// "fill" | "email" | "playbook" — picks which validator runs.
pub kind: String,
/// The prompt to seed generation. Validation errors from prior
/// attempts are appended on retry.
pub prompt: String,
/// Provider/model passed through to /v1/chat. e.g. "ollama_cloud"
/// + "kimi-k2.6", or "opencode" + "claude-haiku-4-5".
pub provider: String,
pub model: String,
/// Optional system prompt — sent to /v1/chat as the system message.
#[serde(default)]
pub system: Option<String>,
/// Validation context (target_count, city, state, role, client_id
/// for fills; candidate_id for emails). Forwarded to /v1/validate.
#[serde(default)]
pub context: Option<serde_json::Value>,
/// Cap on iteration count. Defaults to 3 per the Phase 43 PRD.
#[serde(default)]
pub max_iterations: Option<u32>,
/// Forwarded to /v1/chat. Defaults to 0.2 if unset.
#[serde(default)]
pub temperature: Option<f64>,
/// Forwarded to /v1/chat. Defaults to 4096 if unset.
#[serde(default)]
pub max_tokens: Option<u32>,
}
#[derive(Serialize)]
pub struct IterateAttempt {
pub iteration: u32,
pub raw: String,
pub status: AttemptStatus,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AttemptStatus {
/// Model output didn't contain extractable JSON.
NoJson,
/// JSON extracted but failed validation; carries the error.
ValidationFailed { error: serde_json::Value },
/// Validation passed (last attempt's terminal status).
Accepted,
}
#[derive(Serialize)]
pub struct IterateResponse {
pub artifact: serde_json::Value,
pub validation: serde_json::Value,
pub iterations: u32,
pub history: Vec<IterateAttempt>,
/// Echoes the resolved trace id (caller-forwarded header, body
/// field, langfuse-middleware mint, or local fallback). Operators
/// pivot from this id straight into Langfuse + the
/// coordinator_sessions.jsonl join key. Cross-runtime parity with
/// Go's `validator.IterateResponse` (commit 6847bbc in
/// golangLAKEHOUSE).
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
}
#[derive(Serialize)]
pub struct IterateFailure {
pub error: String,
pub iterations: u32,
pub history: Vec<IterateAttempt>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
}
pub async fn iterate(
State(state): State<super::V1State>,
headers: HeaderMap,
Json(req): Json<IterateRequest>,
) -> impl IntoResponse {
let max_iter = req.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS).max(1);
let temperature = req.temperature.unwrap_or(0.2);
let max_tokens = req.max_tokens.unwrap_or(4096);
let mut history: Vec<IterateAttempt> = Vec::with_capacity(max_iter as usize);
let mut attempt_records: Vec<super::session_log::SessionAttemptRecord> = Vec::with_capacity(max_iter as usize);
let mut current_prompt = req.prompt.clone();
// Resolve the parent Langfuse trace id. Caller-forwarded header
// wins (cross-daemon tree linkage); otherwise mint a fresh id so
// the iterate session is its own tree. Same shape as the Go-side
// validatord trace propagation.
let trace_id: String = headers
.get(TRACE_ID_HEADER)
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(new_trace_id);
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(LOOPBACK_TIMEOUT_SECS))
.build() {
Ok(c) => c,
Err(e) => {
// Even infrastructure failures get a session row so a
// missing /v1/iterate event never silently disappears
// from the longitudinal log.
write_infra_error(&state, &req, &trace_id, max_iter, 0, format!("client build: {e}")).await;
return (StatusCode::INTERNAL_SERVER_ERROR, format!("client build: {e}")).into_response();
}
};
// Self-loopback to the gateway port. Carries gateway internal
// calls through /v1/chat + /v1/validate so /v1/usage tracks them.
let gateway = "http://127.0.0.1:3100";
let t0 = std::time::Instant::now();
for iteration in 0..max_iter {
let attempt_started = chrono::Utc::now();
// ── Generate ──
let mut messages = Vec::with_capacity(2);
if let Some(sys) = &req.system {
messages.push(serde_json::json!({"role": "system", "content": sys}));
}
messages.push(serde_json::json!({"role": "user", "content": current_prompt}));
let chat_body = serde_json::json!({
"messages": messages,
"provider": req.provider,
"model": req.model,
"temperature": temperature,
"max_tokens": max_tokens,
});
let raw = match call_chat(&client, gateway, &chat_body, &trace_id).await {
Ok(r) => r,
Err(e) => {
write_infra_error(&state, &req, &trace_id, max_iter, t0.elapsed().as_millis() as u64, format!("/v1/chat hop failed at iter {iteration}: {e}")).await;
return (StatusCode::BAD_GATEWAY, format!("/v1/chat hop failed at iter {iteration}: {e}")).into_response();
}
};
// ── Extract JSON ──
let artifact = match extract_json(&raw) {
Some(a) => a,
None => {
let span_id = emit_attempt_span(
&state, &trace_id, iteration, &req, &current_prompt, &raw, "no_json", None,
attempt_started, chrono::Utc::now(),
);
history.push(IterateAttempt {
iteration,
raw: raw.chars().take(2000).collect(),
status: AttemptStatus::NoJson,
});
attempt_records.push(super::session_log::SessionAttemptRecord {
iteration,
verdict_kind: "no_json".to_string(),
error: None,
span_id,
});
current_prompt = format!(
"{}\n\nYour previous attempt did not contain a JSON object. Reply with ONLY a valid JSON object matching the requested artifact shape.",
req.prompt,
);
continue;
}
};
// ── Validate ──
let validate_body = serde_json::json!({
"kind": req.kind,
"artifact": artifact,
"context": req.context.clone().unwrap_or(serde_json::Value::Null),
});
match call_validate(&client, gateway, &validate_body, &trace_id).await {
Ok(report) => {
let span_id = emit_attempt_span(
&state, &trace_id, iteration, &req, &current_prompt, &raw, "accepted", None,
attempt_started, chrono::Utc::now(),
);
history.push(IterateAttempt {
iteration,
raw: raw.chars().take(2000).collect(),
status: AttemptStatus::Accepted,
});
attempt_records.push(super::session_log::SessionAttemptRecord {
iteration,
verdict_kind: "accepted".to_string(),
error: None,
span_id,
});
let duration_ms = t0.elapsed().as_millis() as u64;
let grounded = grounded_in_roster(&state, &req.kind, &artifact);
write_session_accepted(&state, &req, &trace_id, iteration + 1, max_iter, attempt_records, &artifact, grounded, duration_ms).await;
return (StatusCode::OK, Json(IterateResponse {
artifact,
validation: report,
iterations: iteration + 1,
history,
trace_id: Some(trace_id.clone()),
})).into_response();
}
Err(err) => {
let err_summary = err.to_string();
let span_id = emit_attempt_span(
&state, &trace_id, iteration, &req, &current_prompt, &raw, "validation_failed",
Some(err_summary.clone()),
attempt_started, chrono::Utc::now(),
);
history.push(IterateAttempt {
iteration,
raw: raw.chars().take(2000).collect(),
status: AttemptStatus::ValidationFailed {
error: serde_json::to_value(&err_summary).unwrap_or(serde_json::Value::Null),
},
});
attempt_records.push(super::session_log::SessionAttemptRecord {
iteration,
verdict_kind: "validation_failed".to_string(),
error: Some(err_summary.clone()),
span_id,
});
// Append validation feedback to prompt for next iter.
// The model sees concrete failure mode + retries with
// corrective context. This is the "observer correction"
// in Phase 43 PRD shape, simplified — the validator
// itself IS the observer for now.
current_prompt = format!(
"{}\n\nPrior attempt failed validation:\n{}\n\nFix the specific issue above and respond with a corrected JSON object.",
req.prompt, err_summary,
);
continue;
}
}
}
let duration_ms = t0.elapsed().as_millis() as u64;
write_session_failure(&state, &req, &trace_id, max_iter, max_iter, attempt_records, duration_ms).await;
(StatusCode::UNPROCESSABLE_ENTITY, Json(IterateFailure {
error: format!("max iterations reached ({max_iter}) without passing validation"),
iterations: max_iter,
history,
trace_id: Some(trace_id.clone()),
})).into_response()
}
// ─── Helpers — Langfuse spans + session log + roster check ─────────
fn emit_attempt_span(
state: &super::V1State,
trace_id: &str,
iteration: u32,
req: &IterateRequest,
prompt: &str,
raw: &str,
verdict: &str,
error: Option<String>,
started: chrono::DateTime<chrono::Utc>,
ended: chrono::DateTime<chrono::Utc>,
) -> Option<String> {
let lf = state.langfuse.as_ref()?;
Some(lf.emit_attempt_span(super::langfuse_trace::AttemptSpan {
trace_id: trace_id.to_string(),
iteration,
model: req.model.clone(),
provider: req.provider.clone(),
prompt: prompt.to_string(),
raw: raw.to_string(),
verdict: verdict.to_string(),
error,
start_time: started.to_rfc3339(),
end_time: ended.to_rfc3339(),
}))
}
/// Verify every fill artifact's candidate IDs exist in the roster.
/// Returns Some(true)/Some(false) on the fill kind, None otherwise
/// (other kinds don't have worker IDs to ground). Same semantics as
/// Go's `handlers.rosterCheckFor("fill")`.
fn grounded_in_roster(
state: &super::V1State,
kind: &str,
artifact: &serde_json::Value,
) -> Option<bool> {
if kind != "fill" {
return None;
}
let fills = artifact.get("fills").and_then(|v| v.as_array())?;
for f in fills {
let id = match f.get("candidate_id").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
_ => return Some(false),
};
if state.validate_workers.find(id).is_none() {
return Some(false);
}
}
Some(true)
}
async fn write_session_accepted(
state: &super::V1State,
req: &IterateRequest,
trace_id: &str,
iterations: u32,
max_iter: u32,
attempts: Vec<super::session_log::SessionAttemptRecord>,
artifact: &serde_json::Value,
grounded: Option<bool>,
duration_ms: u64,
) {
let Some(logger) = state.session_log.as_ref() else { return };
let rec = build_session_record(req, trace_id, "accepted", iterations, max_iter, attempts, Some(artifact.clone()), grounded, duration_ms);
logger.append(rec).await;
}
async fn write_session_failure(
state: &super::V1State,
req: &IterateRequest,
trace_id: &str,
iterations: u32,
max_iter: u32,
attempts: Vec<super::session_log::SessionAttemptRecord>,
duration_ms: u64,
) {
let Some(logger) = state.session_log.as_ref() else { return };
let rec = build_session_record(req, trace_id, "max_iter_exhausted", iterations, max_iter, attempts, None, None, duration_ms);
logger.append(rec).await;
}
async fn write_infra_error(
state: &super::V1State,
req: &IterateRequest,
trace_id: &str,
max_iter: u32,
duration_ms: u64,
error: String,
) {
let Some(logger) = state.session_log.as_ref() else { return };
let attempts = vec![super::session_log::SessionAttemptRecord {
iteration: 0,
verdict_kind: "infra_error".to_string(),
error: Some(error),
span_id: None,
}];
let rec = build_session_record(req, trace_id, "infra_error", 0, max_iter, attempts, None, None, duration_ms);
logger.append(rec).await;
}
fn build_session_record(
req: &IterateRequest,
trace_id: &str,
final_verdict: &str,
iterations: u32,
max_iter: u32,
attempts: Vec<super::session_log::SessionAttemptRecord>,
artifact: Option<serde_json::Value>,
grounded: Option<bool>,
duration_ms: u64,
) -> super::session_log::SessionRecord {
super::session_log::SessionRecord {
schema: super::session_log::SESSION_RECORD_SCHEMA.to_string(),
session_id: trace_id.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
daemon: "gateway".to_string(),
kind: req.kind.clone(),
model: req.model.clone(),
provider: req.provider.clone(),
prompt: super::session_log::truncate(&req.prompt, 4000),
iterations,
max_iterations: max_iter,
final_verdict: final_verdict.to_string(),
attempts,
artifact,
grounded_in_roster: grounded,
duration_ms,
}
}
/// Generate a fresh trace id when no parent was forwarded. Same
/// time-ordered hex shape Langfuse already accepts elsewhere in this
/// crate (see `langfuse_trace::uuid_v7_like`).
fn new_trace_id() -> String {
let ts = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
let rand = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("{:016x}-{:08x}", ts, rand)
}
async fn call_chat(client: &reqwest::Client, gateway: &str, body: &serde_json::Value, trace_id: &str) -> Result<String, String> {
let mut req = client.post(format!("{gateway}/v1/chat")).json(body);
if !trace_id.is_empty() {
req = req.header(TRACE_ID_HEADER, trace_id);
}
let resp = req.send().await.map_err(|e| format!("chat hop: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("chat {}: {}", status, body.chars().take(300).collect::<String>()));
}
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("chat parse: {e}"))?;
Ok(parsed.pointer("/choices/0/message/content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
}
async fn call_validate(client: &reqwest::Client, gateway: &str, body: &serde_json::Value, trace_id: &str) -> Result<serde_json::Value, String> {
let mut req = client.post(format!("{gateway}/v1/validate")).json(body);
if !trace_id.is_empty() {
req = req.header(TRACE_ID_HEADER, trace_id);
}
let resp = req.send().await.map_err(|e| format!("validate hop: {e}"))?;
let status = resp.status();
let parsed: serde_json::Value = resp.json().await.map_err(|e| format!("validate parse: {e}"))?;
if status.is_success() {
Ok(parsed)
} else {
// The /v1/validate endpoint returns a ValidationError JSON
// on 422; surface its structure verbatim so the prompt-
// appending step gets specific failure detail.
Err(serde_json::to_string(&parsed).unwrap_or_else(|_| format!("validation {} (unparseable body)", status)))
}
}
/// Extract the first JSON object from a model's output. Handles
/// fenced code blocks (```json ... ```), bare braces, and stray
/// prose around the JSON. Returns None on no extractable object.
///
/// Made `pub` 2026-05-02 to support the cross-runtime parity probe
/// at `golangLAKEHOUSE/scripts/cutover/parity/extract_json_parity.sh`.
/// The Go counterpart lives at `internal/validator/iterate.go::ExtractJSON`;
/// when either runtime's algorithm changes the parity probe surfaces
/// the divergence.
pub fn extract_json(raw: &str) -> Option<serde_json::Value> {
// Try fenced first.
let candidates: Vec<String> = {
let mut out = vec![];
let mut s = raw;
while let Some(start) = s.find("```") {
let after = &s[start + 3..];
// Skip optional language tag (json, etc.)
let body_start = after.find('\n').map(|n| n + 1).unwrap_or(0);
let body = &after[body_start..];
if let Some(end) = body.find("```") {
out.push(body[..end].trim().to_string());
s = &body[end + 3..];
} else { break; }
}
out
};
for c in &candidates {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(c) {
if v.is_object() { return Some(v); }
}
}
// Fall back to outermost {...} balance.
let bytes = raw.as_bytes();
let mut depth = 0i32;
let mut start: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'{' => { if start.is_none() { start = Some(i); } depth += 1; }
b'}' => {
depth -= 1;
if depth == 0 {
if let Some(s) = start {
let slice = &raw[s..=i];
if let Ok(v) = serde_json::from_str::<serde_json::Value>(slice) {
if v.is_object() { return Some(v); }
}
start = None;
}
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_json_from_fenced_block() {
let raw = "Here's my answer:\n```json\n{\"fills\": [{\"candidate_id\": \"W-1\"}]}\n```\nDone.";
let v = extract_json(raw).unwrap();
assert!(v.get("fills").is_some());
}
#[test]
fn extract_json_from_bare_braces() {
let raw = "Here you go: {\"fills\": [{\"candidate_id\": \"W-2\"}]}";
let v = extract_json(raw).unwrap();
assert!(v.get("fills").is_some());
}
#[test]
fn extract_json_returns_none_on_no_object() {
assert!(extract_json("just prose, no json").is_none());
}
#[test]
fn extract_json_picks_first_balanced() {
let raw = "{\"a\":1} then {\"b\":2}";
let v = extract_json(raw).unwrap();
assert_eq!(v.get("a").and_then(|v| v.as_i64()), Some(1));
}
}
-227
View File
@@ -1,227 +0,0 @@
//! Kimi For Coding adapter — direct provider for `kimi-for-coding`
//! (kimi-k2.6 underneath). Used when Ollama Cloud's `kimi-k2:1t` is
//! returning sustained 5xx (broken upstream) and OpenRouter's
//! `moonshotai/kimi-k2.6` is rate-limited.
//!
//! Endpoint per `kimi.com/code/docs` and `moonshotai.github.io/kimi-cli`:
//! base_url: https://api.kimi.com/coding/v1
//! model id: kimi-for-coding
//! auth: Bearer sk-kimi-…
//! protocol: OpenAI Chat Completions compatible
//!
//! IMPORTANT: `api.kimi.com` is a separate account system from
//! `api.moonshot.ai` and `api.moonshot.cn`. Keys are NOT interchangeable.
//! This adapter is for `sk-kimi-*` keys provisioned via the Kimi
//! membership console only.
//!
//! Key sourcing priority:
//! 1. Env var `KIMI_API_KEY` (loaded from /etc/lakehouse/kimi.env via
//! systemd EnvironmentFile=)
//! 2. /etc/lakehouse/kimi.env directly (rescue path if env not loaded)
//!
//! First hit wins. Resolved once at gateway startup, stored on
//! `V1State.kimi_key`.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const KIMI_BASE_URL: &str = "https://api.kimi.com/coding/v1";
// Default 600s — kimi-for-coding is a reasoning model; on large
// code-audit prompts (~50KB+ input + 8K output) it routinely needs
// 3-8 min to think + emit. Override with KIMI_TIMEOUT_SECS env var.
const KIMI_TIMEOUT_SECS_DEFAULT: u64 = 600;
fn kimi_timeout_secs() -> u64 {
std::env::var("KIMI_TIMEOUT_SECS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(KIMI_TIMEOUT_SECS_DEFAULT)
}
pub fn resolve_kimi_key() -> Option<String> {
if let Ok(k) = std::env::var("KIMI_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
if let Ok(raw) = std::fs::read_to_string("/etc/lakehouse/kimi.env") {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("KIMI_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "kimi/" namespace prefix if the caller used it so the
// upstream API sees the bare model id (e.g. "kimi-for-coding").
let model = req.model.strip_prefix("kimi/").unwrap_or(&req.model).to_string();
// Flatten content to a plain String. api.kimi.com is text-only on
// the coding endpoint; the OpenAI multimodal array shape
// ([{type:"text",text:"..."},{type:"image_url",...}]) returns 400.
// Message::text() concats text-parts and drops non-text. Caught
// 2026-04-27 by Kimi's self-audit (kimi.rs:137 — content as raw
// serde_json::Value risked upstream rejection).
let body = KimiChatBody {
model: model.clone(),
messages: req.messages.iter().map(|m| KimiMessage {
role: m.role.clone(),
content: serde_json::Value::String(m.text()),
}).collect(),
max_tokens: req.max_tokens.unwrap_or(800),
temperature: req.temperature.unwrap_or(0.3),
stream: false,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(kimi_timeout_secs()))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let resp = client
.post(format!("{}/chat/completions", KIMI_BASE_URL))
.bearer_auth(key)
// api.kimi.com gates this endpoint by User-Agent — only sanctioned
// coding agents (Claude Code, Kimi CLI, Roo Code, Kilo Code) get
// through. Generic clients receive 403 access_terminated_error.
// J accepted the TOS risk on 2026-04-27; revisit if Moonshot
// tightens enforcement.
.header("User-Agent", "claude-code/1.0.0")
.json(&body)
.send()
.await
.map_err(|e| format!("api.kimi.com unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("api.kimi.com {}: {}", status, body));
}
let parsed: KimiChatResponse = resp.json().await
.map_err(|e| format!("invalid kimi response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let choice = parsed.choices.into_iter().next()
.ok_or_else(|| "kimi returned no choices".to_string())?;
let text = choice.message.content;
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "kimi",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"kimi chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: serde_json::Value::String(text) },
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- Kimi wire shapes (OpenAI-compatible) --
#[derive(Serialize)]
struct KimiChatBody {
model: String,
messages: Vec<KimiMessage>,
max_tokens: u32,
temperature: f64,
stream: bool,
}
#[derive(Serialize)]
struct KimiMessage { role: String, content: serde_json::Value }
#[derive(Deserialize)]
struct KimiChatResponse {
choices: Vec<KimiChoice>,
#[serde(default)]
usage: Option<KimiUsage>,
}
#[derive(Deserialize)]
struct KimiChoice {
message: KimiMessageResp,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct KimiMessageResp { content: String }
#[derive(Deserialize)]
struct KimiUsage { prompt_tokens: u32, completion_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_kimi_key_does_not_panic() {
let _ = resolve_kimi_key();
}
#[test]
fn chat_body_serializes_to_openai_shape() {
let body = KimiChatBody {
model: "kimi-for-coding".into(),
messages: vec![
KimiMessage { role: "user".into(), content: "review this".into() },
],
max_tokens: 800,
temperature: 0.3,
stream: false,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"model\":\"kimi-for-coding\""));
assert!(json.contains("\"messages\""));
assert!(json.contains("\"max_tokens\":800"));
assert!(json.contains("\"stream\":false"));
}
#[test]
fn model_prefix_strip() {
let cases = [
("kimi/kimi-for-coding", "kimi-for-coding"),
("kimi-for-coding", "kimi-for-coding"),
("kimi/kimi-k2.6", "kimi-k2.6"),
];
for (input, expected) in cases {
let out = input.strip_prefix("kimi/").unwrap_or(input);
assert_eq!(out, expected, "{input} should become {expected}");
}
}
}
+21 -113
View File
@@ -76,85 +76,14 @@ impl LangfuseClient {
}); });
} }
/// Fire-and-forget per-iteration span emit. Returns the generated
/// span id synchronously so the caller can stamp it on
/// `IterateAttempt.span_id` before the network round-trip resolves.
/// Mirrors Go's `validator.Tracer` callback shape.
pub fn emit_attempt_span(&self, sp: AttemptSpan) -> String {
let span_id = uuid_v7_like();
let span_id_for_caller = span_id.clone();
let this = self.clone();
tokio::spawn(async move {
if let Err(e) = this.emit_attempt_span_inner(span_id, sp).await {
tracing::warn!(target: "v1.langfuse", "iterate span drop: {e}");
}
});
span_id_for_caller
}
async fn emit_attempt_span_inner(&self, span_id: String, sp: AttemptSpan) -> Result<(), String> {
let level = if sp.verdict == "accepted" { "DEFAULT" } else { "WARNING" };
let batch = IngestionBatch {
batch: vec![IngestionEvent {
id: uuid_v7_like(),
timestamp: sp.end_time.clone(),
kind: "span-create",
body: serde_json::json!({
"id": span_id,
"traceId": sp.trace_id,
"name": format!("iterate.attempt[{}]", sp.iteration),
"input": serde_json::json!({
"iteration": sp.iteration,
"model": sp.model,
"provider": sp.provider,
"prompt": truncate(&sp.prompt, 4000),
}),
"output": serde_json::json!({
"verdict": sp.verdict,
"error": sp.error,
"raw": truncate(&sp.raw, 4000),
}),
"level": level,
"startTime": sp.start_time,
"endTime": sp.end_time,
}),
}],
};
self.post_batch(batch).await
}
async fn post_batch(&self, batch: IngestionBatch) -> Result<(), String> {
let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
let resp = self.inner.http
.post(url)
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
.json(&batch)
.send()
.await
.map_err(|e| format!("POST failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
}
Ok(())
}
async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> { async fn emit_chat_inner(&self, ev: ChatTrace) -> Result<(), String> {
// When the caller forwarded a parent trace id (via the let trace_id = uuid_v7_like();
// X-Lakehouse-Trace-Id header → V1State plumbing), attach the
// generation as a child of that trace. Without a parent we
// mint a new top-level trace per call (Phase 40 default).
let trace_id = ev.parent_trace_id.clone().unwrap_or_else(uuid_v7_like);
let nested = ev.parent_trace_id.is_some();
let gen_id = uuid_v7_like(); let gen_id = uuid_v7_like();
let trace_ts = ev.start_time.clone(); let trace_ts = ev.start_time.clone();
let mut events = Vec::with_capacity(2); let batch = IngestionBatch {
if !nested { batch: vec![
// Only mint a fresh trace-create when we don't have a parent. IngestionEvent {
// Reusing a parent trace id without re-creating it is the
// contract that lets validatord's iterate-session show up
// as one tree in Langfuse.
events.push(IngestionEvent {
id: uuid_v7_like(), id: uuid_v7_like(),
timestamp: trace_ts.clone(), timestamp: trace_ts.clone(),
kind: "trace-create", kind: "trace-create",
@@ -170,9 +99,8 @@ impl LangfuseClient {
"think": ev.think, "think": ev.think,
}), }),
}), }),
}); },
} IngestionEvent {
events.push(IngestionEvent {
id: uuid_v7_like(), id: uuid_v7_like(),
timestamp: ev.end_time.clone(), timestamp: ev.end_time.clone(),
kind: "generation-create", kind: "generation-create",
@@ -201,17 +129,23 @@ impl LangfuseClient {
"latency_ms": ev.latency_ms, "latency_ms": ev.latency_ms,
}), }),
}), }),
}); },
],
};
self.post_batch(IngestionBatch { batch: events }).await let url = format!("{}{}", self.inner.base_url.trim_end_matches('/'), INGESTION_PATH);
let resp = self.inner.http
.post(url)
.basic_auth(&self.inner.public_key, Some(&self.inner.secret_key))
.json(&batch)
.send()
.await
.map_err(|e| format!("POST failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("{}: {}", resp.status(), resp.text().await.unwrap_or_default()));
}
Ok(())
} }
}
/// Truncate a string to at most `n` chars (NOT bytes). Matches the Go
/// `trim` helper used in session log + attempt-span emission so an
/// operator reading two cross-runtime traces sees the same boundary.
fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
} }
/// Everything the v1.chat handler collects for one completed call. /// Everything the v1.chat handler collects for one completed call.
@@ -228,32 +162,6 @@ pub struct ChatTrace {
pub start_time: String, pub start_time: String,
pub end_time: String, pub end_time: String,
pub latency_ms: u64, pub latency_ms: u64,
/// When set, attach this chat trace as a child of the named
/// Langfuse trace instead of starting a new top-level trace. Used
/// by `/v1/iterate` to nest its inner /v1/chat hops under the
/// iterate-session trace so a multi-call session shows in
/// Langfuse as ONE trace tree, not N+1 disconnected traces.
/// Matches the Go-side `X-Lakehouse-Trace-Id` propagation
/// (commit d6d2fdf in golangLAKEHOUSE).
pub parent_trace_id: Option<String>,
}
/// One iteration attempt inside `/v1/iterate`'s loop. Becomes one
/// span on the parent trace when emitted via `emit_attempt_span`.
/// Matches Go's `validator.AttemptSpan` shape so the cross-runtime
/// observability surface is consistent.
pub struct AttemptSpan {
pub trace_id: String,
pub iteration: u32,
pub model: String,
pub provider: String,
pub prompt: String,
pub raw: String,
/// Verdict kind: "no_json" | "validation_failed" | "accepted"
pub verdict: String,
pub error: Option<String>,
pub start_time: String,
pub end_time: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
+13 -448
View File
@@ -13,19 +13,7 @@
pub mod ollama; pub mod ollama;
pub mod ollama_cloud; pub mod ollama_cloud;
pub mod openrouter;
pub mod gemini;
pub mod claude;
pub mod kimi;
pub mod opencode;
pub mod validate;
pub mod iterate;
pub mod auditing_worker_lookup;
pub mod langfuse_trace; pub mod langfuse_trace;
pub mod session_log;
pub mod mode;
pub mod respond;
pub mod truth;
use axum::{ use axum::{
Router, Router,
@@ -46,58 +34,10 @@ pub struct V1State {
/// Ollama Cloud bearer token. Loaded at startup via /// Ollama Cloud bearer token. Loaded at startup via
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503. /// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
pub ollama_cloud_key: Option<String>, pub ollama_cloud_key: Option<String>,
/// OpenRouter bearer token — free-tier rescue rung. Loaded at
/// startup via `openrouter::resolve_openrouter_key()`. None means
/// provider="openrouter" calls 503 rather than attempt. Same key
/// sourcing as LLM Team UI so the two share one API quota.
pub openrouter_key: Option<String>,
/// Gemini API key (Google Generative Language). Loaded at startup
/// via `gemini::resolve_gemini_key()`. None = provider="gemini"
/// calls 503. Phase 40 deliverable.
pub gemini_key: Option<String>,
/// Anthropic Claude API key. Loaded at startup via
/// `claude::resolve_claude_key()`. None = provider="claude" calls
/// 503. Phase 40 deliverable.
pub claude_key: Option<String>,
/// Kimi For Coding (api.kimi.com) bearer token — direct provider
/// for `kimi-for-coding`. Used when Ollama Cloud's `kimi-k2:1t` is
/// upstream-broken. Loaded at startup via `kimi::resolve_kimi_key()`
/// from `KIMI_API_KEY` env or `/etc/lakehouse/kimi.env`. None =
/// provider="kimi" calls 503.
pub kimi_key: Option<String>,
/// OpenCode GO (opencode.ai) bearer token — multi-vendor curated
/// gateway. One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro,
/// Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM, Qwen + free-tier.
/// Loaded at startup via `opencode::resolve_opencode_key()` from
/// `OPENCODE_API_KEY` env or `/etc/lakehouse/opencode.env`. None =
/// provider="opencode" calls 503.
pub opencode_key: Option<String>,
/// Shared WorkerLookup loaded once at startup from
/// workers_500k.parquet (path: LH_WORKERS_PARQUET env, default
/// data/datasets/workers_500k.parquet). Used by /v1/validate to
/// run FillValidator/EmailValidator with worker-existence checks.
/// Falls back to an empty InMemoryWorkerLookup if the file is
/// missing — validators still run schema/PII checks but every
/// worker-existence check fails (Consistency error), which is
/// the correct behavior when the roster isn't configured.
pub validate_workers: std::sync::Arc<dyn validator::WorkerLookup>,
/// Phase 40 early deliverable — Langfuse client. None = tracing /// Phase 40 early deliverable — Langfuse client. None = tracing
/// disabled (keys missing or container unreachable). Traces are /// disabled (keys missing or container unreachable). Traces are
/// fire-and-forget: never block the response path. /// fire-and-forget: never block the response path.
pub langfuse: Option<langfuse_trace::LangfuseClient>, pub langfuse: Option<langfuse_trace::LangfuseClient>,
/// Coordinator session JSONL writer (path from
/// `[gateway].session_log_path`). One row per `/v1/iterate`
/// session for offline DuckDB analysis. None = disabled.
/// Cross-runtime parity with the Go-side `validatord`
/// `[validatord].session_log_path` (commit 1a3a82a in
/// golangLAKEHOUSE).
pub session_log: Option<session_log::SessionLogger>,
/// Per-subject audit log writer (HMAC-chained JSONL). Loaded at
/// startup from /etc/lakehouse/subject_audit.key if the file is
/// present and ≥32 bytes. None = audit disabled with a warning
/// at startup; PII access is still served but produces no audit
/// trail. See docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md.
pub subject_audit: Option<std::sync::Arc<catalogd::subject_audit::SubjectAuditWriter>>,
} }
#[derive(Default, Clone, Serialize)] #[derive(Default, Clone, Serialize)]
@@ -106,88 +46,25 @@ pub struct Usage {
pub prompt_tokens: u64, pub prompt_tokens: u64,
pub completion_tokens: u64, pub completion_tokens: u64,
pub total_tokens: u64, pub total_tokens: u64,
#[serde(default)]
pub by_provider: std::collections::HashMap<String, ProviderUsage>,
}
#[derive(Default, Clone, Serialize)]
pub struct ProviderUsage {
pub requests: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
} }
pub fn router(state: V1State) -> Router { pub fn router(state: V1State) -> Router {
Router::new() Router::new()
.route("/chat", post(chat)) .route("/chat", post(chat))
// Canonical OpenAI path alias — lets any client built on the
// openai SDK (pi-ai, langchain-js, etc.) treat the gateway as
// a drop-in middleware via OPENAI_BASE_URL=http://gw/v1 alone.
// Same handler as /chat; same OpenAI-compatible request shape.
.route("/chat/completions", post(chat))
.route("/respond", post(respond::respond))
.route("/usage", get(usage)) .route("/usage", get(usage))
.route("/sessions", get(sessions)) .route("/sessions", get(sessions))
.route("/context", get(truth::context))
.route("/mode", post(mode::route))
.route("/mode/list", get(mode::list))
.route("/mode/execute", post(mode::execute))
.route("/validate", post(validate::validate))
.route("/iterate", post(iterate::iterate))
.route("/health", get(health))
.with_state(state) .with_state(state)
} }
// -- Shared types (OpenAI-compatible) -- // -- Shared types (OpenAI-compatible) --
/// OpenAI-compatible message. `content` accepts either a plain string or
/// an array of content parts (the modern multimodal shape:
/// `[{type:"text", text:"..."}, {type:"image_url", ...}]`). We store as
/// `serde_json::Value` to preserve client shape on forward; downstream
/// providers can take it verbatim. `Message::text()` flattens for
/// places that need a plain string (Ollama prompt assembly, char
/// counts, the assistant's own response synthesis).
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Message { pub struct Message {
pub role: String, pub role: String,
pub content: serde_json::Value, pub content: String,
} }
impl Message { #[derive(Deserialize, Debug)]
/// Construct a plain text message — the common shape for callers
/// that don't need multimodal content. Wraps the body in
/// `serde_json::Value::String` so downstream serializers see the
/// canonical OpenAI shape.
pub fn new_text(role: impl Into<String>, body: impl Into<String>) -> Self {
Self {
role: role.into(),
content: serde_json::Value::String(body.into()),
}
}
/// Flatten content to a plain string. Strings pass through; content-
/// part arrays concatenate the `text` fields with newlines and skip
/// non-text parts (images etc.) — Phase 38/39 callers are text-only,
/// real multimodal forwarding is queued.
pub fn text(&self) -> String {
match &self.content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(parts) => {
let mut out = String::new();
for p in parts {
if let Some(t) = p.get("text").and_then(|v| v.as_str()) {
if !out.is_empty() { out.push('\n'); }
out.push_str(t);
}
}
out
}
other => other.to_string(),
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct ChatRequest { pub struct ChatRequest {
pub model: String, pub model: String,
pub messages: Vec<Message>, pub messages: Vec<Message>,
@@ -243,140 +120,8 @@ pub struct UsageBlock {
// -- Handlers -- // -- Handlers --
/// Phase 39: resolve (provider, effective_model) from a ChatRequest.
///
/// Explicit `req.provider` wins. If absent, infer from a model-name
/// prefix: "openrouter/..." → openrouter (strip prefix), "cloud/..." →
/// ollama_cloud (strip prefix). Bare names default to "ollama".
///
/// The stripped model is what the upstream adapter expects:
/// OpenRouter's API wants "openai/gpt-4o-mini", not
/// "openrouter/openai/gpt-4o-mini".
fn resolve_provider(req: &ChatRequest) -> (String, String) {
if let Some(p) = req.provider.as_deref() {
return (p.to_ascii_lowercase(), req.model.clone());
}
if let Some(rest) = req.model.strip_prefix("openrouter/") {
return ("openrouter".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("cloud/") {
return ("ollama_cloud".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("gemini/") {
return ("gemini".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("claude/") {
return ("claude".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("kimi/") {
return ("kimi".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("opencode/") {
return ("opencode".to_string(), rest.to_string());
}
// Bare `vendor/model` shape (e.g. `x-ai/grok-4.1-fast`,
// `moonshotai/kimi-k2`, `openai/gpt-oss-120b:free`) → OpenRouter.
// This makes the gateway a drop-in OpenAI-compatible middleware:
// clients using the official `openai` SDK only set OPENAI_BASE_URL
// + a model name and get correct upstream routing without needing
// our custom `provider` field. Ollama models in J's stack use
// `model:tag` form with NO slash (`qwen3.5:latest`, `kimi-k2:1t`),
// so a slash here unambiguously means "namespaced provider/model".
if req.model.contains('/') {
return ("openrouter".to_string(), req.model.clone());
}
// Vendor-bare model names (no slash, no colon) — `gpt-4o-mini`,
// `claude-3-5-sonnet-20241022`, etc. Tools like pi-ai validate
// models against an OpenAI-style catalog (no namespace prefix),
// so they send the bare name. Map to OpenRouter's namespaced form
// by inferring the vendor from the leading token. Falls through to
// ollama if no pattern matches — preserves existing behavior.
if !req.model.contains(':') && !req.model.contains('/') {
let m = req.model.as_str();
if m.starts_with("gpt-") || m.starts_with("o1-") || m.starts_with("o3-") || m.starts_with("o4-") || m == "o1" || m == "o3" || m == "o4-mini" {
return ("openrouter".to_string(), format!("openai/{}", m));
}
if m.starts_with("claude-") {
return ("openrouter".to_string(), format!("anthropic/{}", m));
}
if m.starts_with("grok-") {
return ("openrouter".to_string(), format!("x-ai/{}", m));
}
}
("ollama".to_string(), req.model.clone())
}
#[cfg(test)]
mod resolve_provider_tests {
use super::*;
fn mk_req(provider: Option<&str>, model: &str) -> ChatRequest {
ChatRequest {
model: model.to_string(),
messages: vec![],
temperature: None,
max_tokens: None,
stream: None,
think: None,
provider: provider.map(|s| s.to_string()),
}
}
#[test]
fn explicit_provider_wins() {
let r = mk_req(Some("openrouter"), "qwen3.5:latest");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "qwen3.5:latest".into()));
}
#[test]
fn bare_model_defaults_to_ollama() {
let r = mk_req(None, "qwen3.5:latest");
assert_eq!(resolve_provider(&r), ("ollama".into(), "qwen3.5:latest".into()));
}
#[test]
fn openrouter_prefix_infers_and_strips() {
let r = mk_req(None, "openrouter/openai/gpt-4o-mini");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openai/gpt-4o-mini".into()));
}
#[test]
fn cloud_prefix_infers_and_strips() {
let r = mk_req(None, "cloud/kimi-k2:1t");
assert_eq!(resolve_provider(&r), ("ollama_cloud".into(), "kimi-k2:1t".into()));
}
#[test]
fn explicit_provider_preserves_full_model_even_with_prefix() {
// If caller provides both provider and a model with a prefix,
// trust them — don't strip. The adapter will get the full model
// string as-is.
let r = mk_req(Some("openrouter"), "openrouter/openai/gpt-4o-mini");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openrouter/openai/gpt-4o-mini".into()));
}
#[test]
fn gemini_prefix_infers_and_strips() {
let r = mk_req(None, "gemini/gemini-2.0-flash");
assert_eq!(resolve_provider(&r), ("gemini".into(), "gemini-2.0-flash".into()));
}
#[test]
fn claude_prefix_infers_and_strips() {
let r = mk_req(None, "claude/claude-3-5-sonnet-latest");
assert_eq!(resolve_provider(&r), ("claude".into(), "claude-3-5-sonnet-latest".into()));
}
#[test]
fn kimi_prefix_infers_and_strips() {
let r = mk_req(None, "kimi/kimi-for-coding");
assert_eq!(resolve_provider(&r), ("kimi".into(), "kimi-for-coding".into()));
}
}
async fn chat( async fn chat(
State(state): State<V1State>, State(state): State<V1State>,
headers: axum::http::HeaderMap,
Json(req): Json<ChatRequest>, Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, (StatusCode, String)> { ) -> Result<Json<ChatResponse>, (StatusCode, String)> {
if req.messages.is_empty() { if req.messages.is_empty() {
@@ -386,111 +131,27 @@ async fn chat(
tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming"); tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming");
} }
// Provider resolution: explicit `req.provider` wins; otherwise let provider = req.provider.as_deref().unwrap_or("ollama").to_ascii_lowercase();
// infer from a model-name prefix. Phase 39 PRD gate example:
// `model: "openrouter/openai/gpt-4o-mini"` → provider "openrouter",
// adapter gets the stripped "openai/gpt-4o-mini".
let (provider, effective_model) = resolve_provider(&req);
let start_time = chrono::Utc::now(); let start_time = chrono::Utc::now();
let start_instant = std::time::Instant::now(); let start_instant = std::time::Instant::now();
// If we stripped a prefix, clone req with the effective model so let resp = match provider.as_str() {
// the adapter sees what the upstream provider expects (OpenRouter "ollama" | "local" | "" => ollama::chat(&state.ai_client, &req)
// wants "openai/gpt-4o-mini", not "openrouter/openai/gpt-4o-mini").
let req_for_adapter: std::borrow::Cow<'_, ChatRequest> =
if effective_model == req.model {
std::borrow::Cow::Borrowed(&req)
} else {
let mut cloned = req.clone();
cloned.model = effective_model.clone();
std::borrow::Cow::Owned(cloned)
};
let (resp, used_provider) = match provider.as_str() {
"ollama" | "local" | "" => {
let r = ollama::chat(&state.ai_client, &*req_for_adapter)
.await .await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?; .map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?,
(r, "ollama".to_string())
}
"ollama_cloud" | "cloud" => { "ollama_cloud" | "cloud" => {
let key = state.ollama_cloud_key.as_deref().ok_or(( let key = state.ollama_cloud_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
"OLLAMA_CLOUD_KEY not configured".to_string(), "OLLAMA_CLOUD_KEY not configured — set env or populate /root/llm_team_config.json".to_string(),
))?; ))?;
let r = ollama_cloud::chat(key, &*req_for_adapter) ollama_cloud::chat(key, &req)
.await .await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?; .map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?
(r, "ollama_cloud".to_string())
}
"openrouter" | "openrouter_free" => {
// Free-tier rescue rung. Added 2026-04-24 after iter 5
// repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
// gives a different provider backbone as fallback.
let key = state.openrouter_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"OPENROUTER_API_KEY not configured".to_string(),
))?;
let r = openrouter::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("openrouter: {e}")))?;
(r, "openrouter".to_string())
}
"gemini" => {
// Phase 40 provider adapter. Google Generative Language
// API via query-string key auth (not bearer).
let key = state.gemini_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"GEMINI_API_KEY not configured".to_string(),
))?;
let r = gemini::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("gemini: {e}")))?;
(r, "gemini".to_string())
}
"claude" | "anthropic" => {
// Phase 40 provider adapter. Anthropic Messages API via
// x-api-key header + anthropic-version:2023-06-01.
let key = state.claude_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"ANTHROPIC_API_KEY not configured".to_string(),
))?;
let r = claude::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("claude: {e}")))?;
(r, "claude".to_string())
}
"kimi" => {
// Direct Kimi For Coding provider — bypasses Ollama Cloud's
// upstream-broken kimi-k2:1t and OpenRouter's rate-limited
// moonshotai/kimi-k2.6. Uses sk-kimi-* keys from the Kimi
// membership console.
let key = state.kimi_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"KIMI_API_KEY not configured".to_string(),
))?;
let r = kimi::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("kimi: {e}")))?;
(r, "kimi".to_string())
}
"opencode" => {
// OpenCode GO multi-vendor gateway — Claude Opus 4.7,
// GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM,
// Qwen, free-tier. OpenAI-compat at opencode.ai/zen/go/v1.
let key = state.opencode_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"OPENCODE_API_KEY not configured".to_string(),
))?;
let r = opencode::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("opencode: {e}")))?;
(r, "opencode".to_string())
} }
other => { other => {
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("unknown provider '{other}' — supported: ollama, ollama_cloud, openrouter, gemini, claude, kimi, opencode"), format!("unknown provider '{other}' — supported: ollama, ollama_cloud"),
)); ));
} }
}; };
@@ -504,21 +165,10 @@ async fn chat(
// untouched. // untouched.
if let Some(lf) = &state.langfuse { if let Some(lf) = &state.langfuse {
let output = resp.choices.first() let output = resp.choices.first()
.map(|c| c.message.text()) .map(|c| c.message.content.clone())
.unwrap_or_default(); .unwrap_or_default();
// Cross-runtime trace linkage. When a caller (validatord on
// Go side, /v1/iterate on Rust side) forwards a parent trace
// id via X-Lakehouse-Trace-Id, attach this generation to that
// trace so the iterate session and its inner chat hops show
// up as ONE trace tree in Langfuse. Header name matches the
// Go-side `shared.TraceIDHeader` constant byte-for-byte.
let parent_trace_id = headers
.get(crate::v1::iterate::TRACE_ID_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
lf.emit_chat(langfuse_trace::ChatTrace { lf.emit_chat(langfuse_trace::ChatTrace {
provider: used_provider.clone(), provider: provider.clone(),
model: resp.model.clone(), model: resp.model.clone(),
input: req.messages.clone(), input: req.messages.clone(),
output, output,
@@ -530,63 +180,15 @@ async fn chat(
start_time: start_time.to_rfc3339(), start_time: start_time.to_rfc3339(),
end_time: end_time.to_rfc3339(), end_time: end_time.to_rfc3339(),
latency_ms, latency_ms,
parent_trace_id,
}); });
} }
// Phase 40 part 2 — fire-and-forget /event to observer at :3800.
// Same ring-buffer that scrum + scenario events land in, so any
// tool-routed-through-our-gateway (Pi, Archon, openai SDK clients)
// shows up alongside scrum_master events for KB consolidation +
// pathway-memory + bug-fingerprint compounding. Best-effort:
// observer being down doesn't block the chat response.
{
let provider = used_provider.clone();
let model = resp.model.clone();
let prompt_tokens = resp.usage.prompt_tokens;
let completion_tokens = resp.usage.completion_tokens;
let success = true;
tokio::spawn(async move {
let body = serde_json::json!({
"endpoint": "/v1/chat",
"source": "v1.chat",
"event_kind": "chat_completion",
"input_summary": format!(
"{} {} prompt={}t",
provider, model, prompt_tokens
),
"output_summary": format!(
"completion={}t {}ms",
completion_tokens, latency_ms
),
"success": success,
"duration_ms": latency_ms,
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let _ = client
.post("http://localhost:3800/event")
.json(&body)
.send()
.await;
});
}
// Phase 40: per-provider usage tracking
{ {
let mut u = state.usage.write().await; let mut u = state.usage.write().await;
u.requests += 1; u.requests += 1;
u.prompt_tokens += resp.usage.prompt_tokens as u64; u.prompt_tokens += resp.usage.prompt_tokens as u64;
u.completion_tokens += resp.usage.completion_tokens as u64; u.completion_tokens += resp.usage.completion_tokens as u64;
u.total_tokens += resp.usage.total_tokens as u64; u.total_tokens += resp.usage.total_tokens as u64;
let provider_usage = u.by_provider.entry(used_provider).or_default();
provider_usage.requests += 1;
provider_usage.prompt_tokens += resp.usage.prompt_tokens as u64;
provider_usage.completion_tokens += resp.usage.completion_tokens as u64;
provider_usage.total_tokens += resp.usage.total_tokens as u64;
} }
Ok(Json(resp)) Ok(Json(resp))
@@ -597,43 +199,6 @@ async fn usage(State(state): State<V1State>) -> impl IntoResponse {
Json(snapshot) Json(snapshot)
} }
/// Production operational health endpoint.
///
/// `/v1/health` reports per-subsystem status as a JSON object so an
/// operator (or the lakehouse-auditor service, or a load balancer)
/// can verify the gateway is fully booted, has its provider keys
/// loaded, the worker roster is hot, and Langfuse is reachable.
/// Returns 200 always — fields are observed-state, not pass/fail
/// gates. A monitoring tool should evaluate the booleans + counts
/// against its own thresholds.
async fn health(State(state): State<V1State>) -> impl IntoResponse {
// Honest worker count via WorkerLookup::len. Production switchover
// verification: after swapping workers_500k.parquet → real Chicago
// data and restarting, this number should match the row count of
// the new file. 0 means the file was missing / unreadable / had a
// schema mismatch and the gateway booted with the empty fallback.
let workers_count = state.validate_workers.len();
let providers_configured = serde_json::json!({
"ollama_cloud": state.ollama_cloud_key.is_some(),
"openrouter": state.openrouter_key.is_some(),
"kimi": state.kimi_key.is_some(),
"opencode": state.opencode_key.is_some(),
"gemini": state.gemini_key.is_some(),
"claude": state.claude_key.is_some(),
});
let langfuse_configured = state.langfuse.is_some();
let usage_snapshot = state.usage.read().await.clone();
Json(serde_json::json!({
"status": "ok",
"workers_count": workers_count,
"workers_loaded": workers_count > 0,
"providers_configured": providers_configured,
"langfuse_configured": langfuse_configured,
"usage_total_requests": usage_snapshot.requests,
"usage_by_provider": usage_snapshot.by_provider.keys().collect::<Vec<_>>(),
}))
}
// Phase 38 is stateless — no session persistence yet. Return an empty // Phase 38 is stateless — no session persistence yet. Return an empty
// list in OpenAI-ish shape so clients that probe this endpoint don't // list in OpenAI-ish shape so clients that probe this endpoint don't
// 404. Real session state lands in Phase 41 with the profile-system // 404. Real session state lands in Phase 41 with the profile-system
@@ -665,7 +230,7 @@ mod tests {
assert_eq!(r.model, "qwen3.5:latest"); assert_eq!(r.model, "qwen3.5:latest");
assert_eq!(r.messages.len(), 2); assert_eq!(r.messages.len(), 2);
assert_eq!(r.messages[0].role, "system"); assert_eq!(r.messages[0].role, "system");
assert_eq!(r.messages[1].text(), "Hi"); assert_eq!(r.messages[1].content, "Hi");
assert_eq!(r.temperature, Some(0.2)); assert_eq!(r.temperature, Some(0.2));
assert_eq!(r.max_tokens, Some(100)); assert_eq!(r.max_tokens, Some(100));
} }
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -60,7 +60,10 @@ pub async fn chat(client: &AiClient, req: &ChatRequest) -> Result<ChatResponse,
model: resp.model, model: resp.model,
choices: vec![Choice { choices: vec![Choice {
index: 0, index: 0,
message: Message::new_text("assistant", resp.text), message: Message {
role: "assistant".into(),
content: resp.text,
},
finish_reason: "stop".into(), finish_reason: "stop".into(),
}], }],
usage: UsageBlock { usage: UsageBlock {
@@ -86,14 +89,13 @@ fn flatten_messages(messages: &[Message]) -> (String, String) {
let mut system = String::new(); let mut system = String::new();
let mut prompt = String::new(); let mut prompt = String::new();
for m in messages { for m in messages {
let body = m.text();
if m.role == "system" { if m.role == "system" {
if !system.is_empty() { system.push('\n'); } if !system.is_empty() { system.push('\n'); }
system.push_str(&body); system.push_str(&m.content);
} else { } else {
prompt.push_str(&m.role); prompt.push_str(&m.role);
prompt.push_str(": "); prompt.push_str(": ");
prompt.push_str(&body); prompt.push_str(&m.content);
prompt.push_str("\n\n"); prompt.push_str("\n\n");
} }
} }
@@ -102,7 +104,7 @@ fn flatten_messages(messages: &[Message]) -> (String, String) {
} }
fn estimate_prompt_tokens(messages: &[Message]) -> u32 { fn estimate_prompt_tokens(messages: &[Message]) -> u32 {
let chars: usize = messages.iter().map(|m| m.text().chars().count()).sum(); let chars: usize = messages.iter().map(|m| m.content.chars().count()).sum();
((chars + 3) / 4) as u32 ((chars + 3) / 4) as u32
} }
+2 -2
View File
@@ -88,7 +88,7 @@ pub async fn chat(
let text = parsed.response.unwrap_or_default(); let text = parsed.response.unwrap_or_default();
let prompt_tokens = parsed.prompt_eval_count.unwrap_or_else(|| { let prompt_tokens = parsed.prompt_eval_count.unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum(); let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
((chars + 3) / 4) as u32 ((chars + 3) / 4) as u32
}); });
let completion_tokens = parsed.eval_count.unwrap_or_else(|| { let completion_tokens = parsed.eval_count.unwrap_or_else(|| {
@@ -112,7 +112,7 @@ pub async fn chat(
model: parsed.model.unwrap_or_else(|| req.model.clone()), model: parsed.model.unwrap_or_else(|| req.model.clone()),
choices: vec![Choice { choices: vec![Choice {
index: 0, index: 0,
message: Message::new_text("assistant", text), message: Message { role: "assistant".into(), content: text },
finish_reason: "stop".into(), finish_reason: "stop".into(),
}], }],
usage: UsageBlock { usage: UsageBlock {
-228
View File
@@ -1,228 +0,0 @@
//! OpenCode GO adapter — multi-vendor curated gateway via opencode.ai/zen/go.
//!
//! One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro, Gemini 3.1-pro,
//! Kimi K2.6, DeepSeek, GLM, Qwen, plus 4 free-tier models.
//! OpenAI-compatible Chat Completions; auth via Bearer.
//!
//! Why a separate adapter (vs reusing openrouter.rs):
//! - Different account, different key, different base_url
//! - No HTTP-Referer / X-Title headers (those are OpenRouter-specific)
//! - Future-proof for any opencode-only request shaping
//!
//! Key sourcing priority:
//! 1. Env var `OPENCODE_API_KEY` (loaded from /etc/lakehouse/opencode.env
//! via systemd EnvironmentFile=)
//! 2. /etc/lakehouse/opencode.env directly (rescue path if env missing)
//!
//! Resolved once at gateway startup, stored on `V1State.opencode_key`.
//! Model-prefix routing: "opencode/<model>" auto-routes here, prefix
//! stripped before upstream call.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
// /zen/v1 is the unified OpenCode endpoint that covers BOTH the
// Zen pay-per-token tier (Claude/GPT/Gemini frontier) AND the Go
// subscription tier (Kimi/GLM/DeepSeek/Qwen/Minimax/mimo). When the
// caller has both, opencode bills per-model: Zen models charge Zen
// balance, Go models charge against the Go subscription cap.
//
// /zen/go/v1 exists as a Go-only sub-path (rejects Zen models with
// "Model not supported"); we use the unified /zen/v1 since the same
// key works for both with correct billing routing upstream.
const OPENCODE_BASE_URL: &str = "https://opencode.ai/zen/v1";
// 600s default — opencode upstream models include reasoning-heavy
// variants (Claude Opus, Kimi K2.6, GLM-5.1) that legitimately take
// 3-5 min on big audit prompts. Override via OPENCODE_TIMEOUT_SECS.
const OPENCODE_TIMEOUT_SECS_DEFAULT: u64 = 600;
fn opencode_timeout_secs() -> u64 {
std::env::var("OPENCODE_TIMEOUT_SECS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(OPENCODE_TIMEOUT_SECS_DEFAULT)
}
pub fn resolve_opencode_key() -> Option<String> {
if let Ok(k) = std::env::var("OPENCODE_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
if let Ok(raw) = std::fs::read_to_string("/etc/lakehouse/opencode.env") {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("OPENCODE_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "opencode/" namespace prefix so the upstream sees the
// bare model id (e.g. "claude-opus-4-7", "kimi-k2.6").
let model = req.model.strip_prefix("opencode/").unwrap_or(&req.model).to_string();
// Anthropic models on opencode reject `temperature` with a 400
// "temperature is deprecated for this model" error. Strip the
// field for claude-* and the new gpt-5.x reasoning lineages
// (Anthropic/OpenAI's reasoning models all moved away from temp).
// Other models keep the caller's value or default to 0.3.
let drop_temp = model.starts_with("claude-")
|| model.starts_with("gpt-5")
|| model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4");
let body = OCChatBody {
model: model.clone(),
messages: req.messages.iter().map(|m| OCMessage {
role: m.role.clone(),
content: m.content.clone(),
}).collect(),
// filter(|&n| n > 0) catches Some(0) — same trap that bit the
// Kimi adapter when callers passed empty-env-parsed-to-0.
max_tokens: req.max_tokens.filter(|&n| n > 0).unwrap_or(800),
temperature: if drop_temp { None } else { Some(req.temperature.unwrap_or(0.3)) },
stream: false,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(opencode_timeout_secs()))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let resp = client
.post(format!("{}/chat/completions", OPENCODE_BASE_URL))
.bearer_auth(key)
.json(&body)
.send()
.await
.map_err(|e| format!("opencode.ai unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("opencode.ai {}: {}", status, body));
}
let parsed: OCChatResponse = resp.json().await
.map_err(|e| format!("invalid opencode response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let choice = parsed.choices.into_iter().next()
.ok_or_else(|| "opencode returned no choices".to_string())?;
let text = choice.message.content;
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "opencode",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"opencode chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: serde_json::Value::String(text) },
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- OpenCode wire shapes (OpenAI-compatible) --
#[derive(Serialize)]
struct OCChatBody {
model: String,
messages: Vec<OCMessage>,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
stream: bool,
}
#[derive(Serialize)]
struct OCMessage { role: String, content: serde_json::Value }
#[derive(Deserialize)]
struct OCChatResponse {
choices: Vec<OCChoice>,
#[serde(default)]
usage: Option<OCUsage>,
}
#[derive(Deserialize)]
struct OCChoice {
message: OCMessageResp,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct OCMessageResp { content: String }
#[derive(Deserialize)]
struct OCUsage { prompt_tokens: u32, completion_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_opencode_key_does_not_panic() {
let _ = resolve_opencode_key();
}
#[test]
fn model_prefix_strip() {
let cases = [
("opencode/claude-opus-4-7", "claude-opus-4-7"),
("opencode/kimi-k2.6", "kimi-k2.6"),
("claude-opus-4-7", "claude-opus-4-7"),
];
for (input, expected) in cases {
let out = input.strip_prefix("opencode/").unwrap_or(input);
assert_eq!(out, expected);
}
}
#[test]
fn max_tokens_filters_zero() {
// The trap: empty env -> Number("") -> 0 -> Some(0). Adapter
// must not pass 0 upstream; should fall to 800.
let some_zero: Option<u32> = Some(0);
let result = some_zero.filter(|&n| n > 0).unwrap_or(800);
assert_eq!(result, 800);
let some_real: Option<u32> = Some(4096);
assert_eq!(some_real.filter(|&n| n > 0).unwrap_or(800), 4096);
let none_val: Option<u32> = None;
assert_eq!(none_val.filter(|&n| n > 0).unwrap_or(800), 800);
}
}
-304
View File
@@ -1,304 +0,0 @@
//! OpenRouter adapter — free-tier rescue rung for /v1/chat.
//!
//! Direct HTTPS call to `https://openrouter.ai/api/v1/chat/completions`
//! with Bearer auth. Mirrors the OpenAI-compatible shape so the model
//! list can be expanded without code changes. Added 2026-04-24 after
//! iter 5 hit repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
//! free-tier models give us a different provider backbone as fallback.
//!
//! Key sourcing priority:
//! 1. Env var `OPENROUTER_API_KEY`
//! 2. `/home/profit/.env` (LLM Team convention)
//! 3. `/root/llm_team_config.json` → providers.openrouter.api_key
//!
//! First hit wins. Key is resolved once at gateway startup and stored
//! on `V1State.openrouter_key`.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const OR_BASE_URL: &str = "https://openrouter.ai/api/v1";
const OR_TIMEOUT_SECS: u64 = 180;
pub fn resolve_openrouter_key() -> Option<String> {
if let Ok(k) = std::env::var("OPENROUTER_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
// LLM Team UI writes its key to ~/.env on the host user — pick it up
// from the same source so the free-tier rescue path works without
// an explicit systemd Environment= line.
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("OPENROUTER_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(k) = v.pointer("/providers/openrouter/api_key").and_then(|x| x.as_str()) {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "openrouter/" prefix if the caller used the namespaced
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
let mut body = ORChatBody {
model: model.clone(),
// Pass content through verbatim — preserves OpenAI's multimodal
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
// upstream provider sees exactly what the client sent.
messages: req.messages.iter().map(|m| ORMessage {
role: m.role.clone(),
content: m.content.clone(),
}).collect(),
max_tokens: Some(req.max_tokens.unwrap_or(800)),
temperature: req.temperature.unwrap_or(0.3),
stream: false,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(OR_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let send = |client: reqwest::Client, key: String, body_json: serde_json::Value| async move {
client
.post(format!("{}/chat/completions", OR_BASE_URL))
.bearer_auth(key)
// OpenRouter recommends Referer + Title for attribution; absent
// headers do not fail the call but help us see our traffic in
// their dashboard.
.header("HTTP-Referer", "https://vcp.devop.live")
.header("X-Title", "Lakehouse Scrum")
.json(&body_json)
.send()
.await
.map_err(|e| format!("openrouter.ai unreachable: {e}"))
};
let mut resp = send(
client.clone(),
key.to_string(),
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
)
.await?;
// OpenRouter's affordability precheck reserves max_tokens × price against
// the account balance. Measured 2026-08-28: stripping the cap makes it
// WORSE (the precheck then prices the model's own maximum, e.g. 65536) —
// so a low-balance 402 surfaces verbatim and the fix is funding the
// account the key belongs to, not request-shape games. One clamp retry:
// when the error names an affordable budget ≥256 tokens, retry once at
// that budget so small answers still get through on a thin balance.
if resp.status() == reqwest::StatusCode::PAYMENT_REQUIRED && body.max_tokens.is_some() {
let err_body = resp.text().await.unwrap_or_else(|_| "?".into());
let afford = err_body
.split("can only afford ")
.nth(1)
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|n| n.parse::<u32>().ok());
match afford {
Some(n) if n >= 256 => {
tracing::warn!(
target: "v1.chat",
provider = "openrouter",
model = %model,
afford = n,
"402 affordability precheck — clamping max_tokens to the stated budget",
);
body.max_tokens = Some(n);
resp = send(
client.clone(),
key.to_string(),
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
)
.await?;
}
_ => return Err(format!("openrouter.ai 402 Payment Required: {}", err_body)),
}
}
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("openrouter.ai {}: {}", status, body));
}
let parsed: ORChatResponse = resp.json().await
.map_err(|e| format!("invalid openrouter response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let choice = parsed.choices.into_iter().next()
.ok_or_else(|| "openrouter returned no choices".to_string())?;
let reasoning_only = choice.message.content.is_none() && choice.message.reasoning.is_some();
let text = choice.message.content.unwrap_or_default();
if reasoning_only {
// The model spent its whole budget thinking. Return well-formed empty
// content (callers' schema validation + retry budgets handle it
// deterministically) rather than a decode error that reads as a
// gateway fault — and never pass thinking-text off as an answer.
tracing::warn!(
target: "v1.chat",
provider = "openrouter",
model = %model,
finish = %choice.finish_reason.as_deref().unwrap_or("?"),
"reasoning-only response (content null) — raise max_tokens or use a non-reasoning model",
);
}
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "openrouter",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"openrouter chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: serde_json::Value::String(text) },
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- OpenRouter wire shapes (OpenAI-compatible) --
#[derive(Serialize)]
struct ORChatBody {
model: String,
messages: Vec<ORMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
temperature: f64,
stream: bool,
}
#[derive(Serialize)]
struct ORMessage { role: String, content: serde_json::Value }
#[derive(Deserialize)]
struct ORChatResponse {
choices: Vec<ORChoice>,
#[serde(default)]
usage: Option<ORUsage>,
}
#[derive(Deserialize)]
struct ORChoice {
message: ORMessageResp,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct ORMessageResp {
// Reasoning models (GLM-5.x, deepseek-v4-pro) return `content: null` plus a
// `reasoning` field when they exhaust max_tokens mid-thought. A bare String
// here made serde reject the whole body ("invalid openrouter response",
// 2026-08-28) — which took down every reasoning model on the lane,
// including phase2's configured glm-5.2. Content stays the only text we
// ever return; reasoning is observability, never an answer.
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
}
#[derive(Deserialize)]
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reasoning_model_null_content_parses() {
// GLM-5.x / deepseek-v4-pro shape: content null + reasoning present
// (the 2026-08-28 decode failure). Must parse, not error.
let body = r#"{"choices":[{"message":{"role":"assistant","content":null,"reasoning":"thinking…"},"finish_reason":"length"}],"usage":{"prompt_tokens":5,"completion_tokens":10}}"#;
let parsed: ORChatResponse = serde_json::from_str(body).expect("null content must parse");
let choice = &parsed.choices[0];
assert!(choice.message.content.is_none());
assert_eq!(choice.message.reasoning.as_deref(), Some("thinking…"));
// And the normal shape still parses with reasoning absent.
let normal = r#"{"choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}"#;
let parsed: ORChatResponse = serde_json::from_str(normal).expect("normal shape");
assert_eq!(parsed.choices[0].message.content.as_deref(), Some("OK"));
}
#[test]
fn resolve_openrouter_key_does_not_panic() {
// Smoke test — all three sources may or may not be set depending
// on environment; just confirm the call returns cleanly.
let _ = resolve_openrouter_key();
}
#[test]
fn chat_body_serializes_to_openai_shape() {
let body = ORChatBody {
model: "openai/gpt-oss-120b:free".into(),
messages: vec![
ORMessage { role: "user".into(), content: "review this".into() },
],
max_tokens: Some(800),
temperature: 0.3,
stream: false,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"model\":\"openai/gpt-oss-120b:free\""));
assert!(json.contains("\"messages\""));
assert!(json.contains("\"max_tokens\":800"));
assert!(json.contains("\"stream\":false"));
}
#[test]
fn model_prefix_strip_preserves_unprefixed() {
// If caller passes "openrouter/openai/gpt-oss-120b:free" we strip.
// If caller passes "openai/gpt-oss-120b:free" unchanged, we keep.
let cases = [
("openrouter/openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
("openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
("google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free"),
];
for (input, expected) in cases {
let out = input.strip_prefix("openrouter/").unwrap_or(input);
assert_eq!(out, expected, "{input} should become {expected}");
}
}
}
-150
View File
@@ -1,150 +0,0 @@
//! `/v1/respond` — the **execution** API (distinct from `/v1/chat`, the
//! completion API).
//!
//! This is the consolidation move called out in the 2026-04-23 session:
//! lift the proven pipeline from `tests/multi-agent/orchestrator.ts`
//! (executor → reviewer → escalate → validate → seal playbook →
//! write-through to KB) into the gateway, so the production path has
//! the intelligence the tests already proved.
//!
//! `/v1/chat` stays a naive completion proxy for callers that want one.
//! `/v1/respond` is where the loop lives. Every orchestrator-style
//! caller migrates here and the TS harnesses become thin clients.
//!
//! This file holds the HTTP surface + request/response shapes. The loop
//! itself lives in `execution_loop::ExecutionLoop`.
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use super::V1State;
use crate::execution_loop::{ExecutionLoop, LogEntry, RespondOutcome};
/// A structured task — mirrors `TaskSpec` in `tests/multi-agent/agent.ts`.
/// Kept deliberately open so non-staffing task classes (code-gen,
/// DevOps-long-horizon) can land without a schema fight.
#[derive(Deserialize, Debug, Clone)]
pub struct RespondRequest {
/// Task class — routes to the right truth rules + validator. For the
/// staffing substrate: `staffing.fill`, `staffing.rescue`,
/// `staffing.sms_draft`. Truth-layer lookup is a no-op until a rule
/// set is registered for the class.
pub task_class: String,
/// Human-readable operation description — becomes the playbook
/// `operation` field on seal, and the primary signal for
/// playbook_memory embedding.
pub operation: String,
/// Free-form structured context. Passed to the executor prompt and
/// to the playbook seeder. Staffing tasks expect
/// `{target_role, target_count, target_city, target_state, approach_hint}`
/// but nothing here validates that — the validator crate will (Phase 43).
#[serde(default)]
pub spec: serde_json::Value,
/// Executor model. Defaults to the hot-path local model if omitted.
/// See orchestrator.ts:28 (`EXECUTOR_MODEL = "qwen3.5:latest"`).
#[serde(default)]
pub executor_model: Option<String>,
/// Reviewer model. Defaults to the hot-path local reviewer.
/// See orchestrator.ts:29 (`REVIEWER_MODEL = "qwen3:latest"`).
#[serde(default)]
pub reviewer_model: Option<String>,
/// Hard cap on executor turns. Default matches orchestrator.ts:30
/// (`MAX_TURNS = 12`). Cloud escalation counts as a turn.
#[serde(default)]
pub max_turns: Option<u32>,
}
#[derive(Serialize)]
pub struct RespondResponse {
/// `ok` = consensus reached, playbook sealed. `failed` = loop ran
/// out of turns or hit the drift cap. `blocked` = truth-layer
/// veto (Phase 42 rule citation in `error`).
pub status: &'static str,
/// The final artifact — for staffing fills, `{fills: [{candidate_id, name}]}`.
/// Empty on failure / block.
pub artifact: serde_json::Value,
/// Structured cross-turn log. Same shape as orchestrator.ts LogEntry
/// so existing tooling (kb extractors, fact_extractor.ts) reads it
/// without change.
pub log: Vec<LogEntry>,
/// Iteration count actually used. ≤ max_turns. Stamped on
/// outcomes.jsonl per the indicator audit (2026-04-23).
pub iterations: u32,
/// Error message on non-ok status. Truth-rule citations land here
/// when `status == "blocked"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn respond(
State(state): State<V1State>,
Json(req): Json<RespondRequest>,
) -> Result<Json<RespondResponse>, (StatusCode, String)> {
if req.operation.is_empty() {
return Err((StatusCode::BAD_REQUEST, "operation must be non-empty".into()));
}
if req.task_class.is_empty() {
return Err((StatusCode::BAD_REQUEST, "task_class must be non-empty".into()));
}
let mut loop_runner = ExecutionLoop::new(state, req);
let outcome = loop_runner.run().await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("execution loop: {e}"))
})?;
let (status, error) = match &outcome {
RespondOutcome::Ok { .. } => ("ok", None),
RespondOutcome::Failed { reason, .. } => ("failed", Some(reason.clone())),
RespondOutcome::Blocked { reason, .. } => ("blocked", Some(reason.clone())),
};
Ok(Json(RespondResponse {
status,
artifact: outcome.artifact(),
log: outcome.into_log(),
iterations: loop_runner.turns_used(),
error,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn respond_request_parses_minimal() {
let raw = r#"{
"task_class": "staffing.fill",
"operation": "fill: Welder x2 in Toledo, OH"
}"#;
let r: RespondRequest = serde_json::from_str(raw).unwrap();
assert_eq!(r.task_class, "staffing.fill");
assert_eq!(r.executor_model, None);
assert_eq!(r.max_turns, None);
}
#[test]
fn respond_request_parses_full() {
let raw = r#"{
"task_class": "staffing.fill",
"operation": "fill: Welder x2 in Toledo, OH",
"spec": {"target_role": "Welder", "target_count": 2, "target_city": "Toledo", "target_state": "OH"},
"executor_model": "qwen3.5:latest",
"reviewer_model": "qwen3:latest",
"max_turns": 12
}"#;
let r: RespondRequest = serde_json::from_str(raw).unwrap();
assert_eq!(r.executor_model.as_deref(), Some("qwen3.5:latest"));
assert_eq!(r.max_turns, Some(12));
assert_eq!(r.spec["target_count"], 2);
}
}
-235
View File
@@ -1,235 +0,0 @@
//! Coordinator session JSONL writer — Rust parity with the Go-side
//! `internal/validator/session_log.go` (commit 1a3a82a in
//! golangLAKEHOUSE). Same schema, same field names, same producer
//! semantics, so a unified longitudinal log can pull from either
//! runtime via DuckDB.
//!
//! Schema: `session.iterate.v1`. One row per `/v1/iterate` session.
//! Append-only. Best-effort posture: errors warn and the iterate
//! response always ships.
//!
//! See `golangLAKEHOUSE/docs/SESSION_LOG.md` for the full schema
//! reference + DuckDB query examples. This module produces rows
//! with `daemon: "gateway"`; the Go side produces `daemon:
//! "validatord"`. Operators who want a unified stream can point both
//! to the same path (the OS write-append is atomic for the row sizes
//! we produce) or query both files together via duckdb's `read_json`
//! glob support.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
pub const SESSION_RECORD_SCHEMA: &str = "session.iterate.v1";
/// One row in coordinator_sessions.jsonl. Field names are the on-wire
/// names — must stay byte-equal to the Go side's
/// `validator.SessionRecord` (proven by the cross-runtime parity
/// probe at golangLAKEHOUSE/scripts/cutover/parity/).
// Deserialize is supported so the parity helper binary can round-trip
// fixture inputs through serde without hand-rolling a parser. Production
// emit path uses Serialize only; SessionRecord rows are written by the
// gateway and consumed by DuckDB / external tooling, never re-read by us.
#[derive(Serialize, Deserialize)]
pub struct SessionRecord {
pub schema: String,
pub session_id: String,
pub timestamp: String,
pub daemon: String,
pub kind: String,
pub model: String,
pub provider: String,
pub prompt: String,
pub iterations: u32,
pub max_iterations: u32,
pub final_verdict: String, // "accepted" | "max_iter_exhausted" | "infra_error"
pub attempts: Vec<SessionAttemptRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grounded_in_roster: Option<bool>,
pub duration_ms: u64,
}
#[derive(Serialize, Deserialize)]
pub struct SessionAttemptRecord {
pub iteration: u32,
pub verdict_kind: String, // "no_json" | "validation_failed" | "accepted" | "infra_error"
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span_id: Option<String>,
}
/// Append-only writer. Cloneable handle — internal state is Arc'd so
/// V1State can keep its own clone and per-request clones are cheap.
#[derive(Clone)]
pub struct SessionLogger {
inner: Arc<Inner>,
}
struct Inner {
path: String,
/// tokio::Mutex (not std) because we hold it across the async
/// fs write. Contention is low (one row per /v1/iterate session).
mu: Mutex<()>,
}
impl SessionLogger {
/// Construct a logger writing to `path`. Empty path → None
/// (skip the wiring in the iterate handler entirely).
pub fn from_path(path: &str) -> Option<Self> {
if path.is_empty() {
return None;
}
Some(Self {
inner: Arc::new(Inner {
path: path.to_string(),
mu: Mutex::new(()),
}),
})
}
/// Append one record. Best-effort: failures land in `tracing::warn!`
/// and the caller sees Ok(()) — observability is a witness, never
/// a gate. Returns Err only on impossible cases the type system
/// can't rule out (here: serde_json::to_string failing on a
/// well-formed struct, which shouldn't happen).
pub async fn append(&self, rec: SessionRecord) {
let body = match serde_json::to_string(&rec) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "v1.session_log", "marshal: {e}");
return;
}
};
let _guard = self.inner.mu.lock().await;
if let Err(e) = self.write(&body).await {
tracing::warn!(target: "v1.session_log", "write {}: {e}", self.inner.path);
}
}
async fn write(&self, body: &str) -> std::io::Result<()> {
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
// Lazy mkdir on first write so a not-yet-mounted volume at
// startup doesn't kill the daemon.
if let Some(parent) = std::path::Path::new(&self.inner.path).parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await?;
}
}
let mut f = OpenOptions::new()
.append(true)
.create(true)
.open(&self.inner.path)
.await?;
f.write_all(body.as_bytes()).await?;
f.write_all(b"\n").await?;
Ok(())
}
}
/// Best-effort UTF-8 char truncation. Matches Go's `trim` helper so
/// rows produced by either runtime cap fields at the same boundaries.
pub fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tokio::fs;
fn fixture_record(session_id: &str) -> SessionRecord {
SessionRecord {
schema: SESSION_RECORD_SCHEMA.to_string(),
session_id: session_id.to_string(),
timestamp: "2026-05-02T08:00:00Z".to_string(),
daemon: "gateway".to_string(),
kind: "fill".to_string(),
model: "qwen3.5:latest".to_string(),
provider: "ollama".to_string(),
prompt: "produce a fill artifact".to_string(),
iterations: 1,
max_iterations: 3,
final_verdict: "accepted".to_string(),
attempts: vec![SessionAttemptRecord {
iteration: 0,
verdict_kind: "accepted".to_string(),
error: None,
span_id: Some("span-0".to_string()),
}],
artifact: Some(serde_json::json!({"fills":[{"candidate_id":"W-1"}]})),
grounded_in_roster: Some(true),
duration_ms: 50,
}
}
#[tokio::test]
async fn from_path_empty_returns_none() {
assert!(SessionLogger::from_path("").is_none());
}
#[tokio::test]
async fn append_writes_jsonl_row_with_schema_field() {
let dir = tempdir();
let path = dir.join("sessions.jsonl");
let path_str = path.to_string_lossy().to_string();
let logger = SessionLogger::from_path(&path_str).unwrap();
logger.append(fixture_record("trace-a")).await;
let body = fs::read_to_string(&path).await.unwrap();
assert!(body.contains("\"schema\":\"session.iterate.v1\""));
assert!(body.contains("\"session_id\":\"trace-a\""));
assert!(body.contains("\"grounded_in_roster\":true"));
assert!(body.ends_with('\n'));
}
#[tokio::test]
async fn append_concurrent_safe() {
let dir = tempdir();
let path = dir.join("sessions.jsonl");
let path_str = path.to_string_lossy().to_string();
let logger = SessionLogger::from_path(&path_str).unwrap();
let n = 32;
let mut handles = Vec::with_capacity(n);
for i in 0..n {
let l = logger.clone();
handles.push(tokio::spawn(async move {
l.append(fixture_record(&format!("trace-{i}"))).await;
}));
}
for h in handles {
h.await.unwrap();
}
let body = fs::read_to_string(&path).await.unwrap();
let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), n, "expected {n} rows, got {}", lines.len());
// Every row must round-trip through serde — a torn write
// would surface as a parse error.
for line in lines {
let _: serde_json::Value = serde_json::from_str(line).expect("valid json per row");
}
}
fn tempdir() -> PathBuf {
// Per-test unique path so prior runs don't pollute the next.
// The static counter increments across the whole test binary,
// so back-to-back tests in the same module get distinct dirs.
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let p = std::env::temp_dir().join(format!(
"session_log_test_{}_{}_{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
n,
));
std::fs::create_dir_all(&p).unwrap();
p
}
}
-47
View File
@@ -1,47 +0,0 @@
use serde::Serialize;
use truth::default_truth_store;
// Note: truth_router() was a stub wrapper around a single /context route
// that nothing called — v1/mod.rs wires get(truth::context) directly
// onto its own router. Removed 2026-04-24 along with its #[allow(dead_code)]
// attribute; the handler below is the real surface.
#[derive(Serialize)]
pub struct ContextResponse {
pub task_classes: Vec<String>,
pub rules: Vec<RuleInfo>,
}
#[derive(Serialize)]
pub struct RuleInfo {
pub id: String,
pub task_class: String,
pub description: String,
}
pub async fn context() -> axum::Json<ContextResponse> {
let store = default_truth_store();
let task_classes: Vec<String> = vec![
"staffing.fill".to_string(),
"staffing.rescue".to_string(),
"staffing.sms_draft".to_string(),
"staffing.any".to_string(),
];
let mut rules = Vec::new();
for tc in &task_classes {
for rule in store.get_rules(tc) {
rules.push(RuleInfo {
id: rule.id.clone(),
task_class: rule.task_class.clone(),
description: rule.description.clone(),
});
}
}
axum::Json(ContextResponse {
task_classes,
rules,
})
}
-82
View File
@@ -1,82 +0,0 @@
//! /v1/validate — gateway-side artifact validation endpoint.
//!
//! Phase 43 v3 part 2: makes the validator crate network-callable.
//! Any caller (scrum loop, test harness, future agent) can POST a
//! generated artifact and get back a Report (success) or
//! ValidationError (failure with structured field/reason).
//!
//! Request shape:
//! POST /v1/validate
//! {
//! "kind": "fill" | "email" | "playbook",
//! "artifact": { ... },
//! "context": { ... } // optional — folded into artifact._context
//! }
//!
//! Response on success: 200 + Report JSON
//! Response on failure: 422 + ValidationError JSON
//! Response on bad request: 400 + plain-text error
//!
//! The shared WorkerLookup is loaded once at gateway startup from
//! workers_500k.parquet (path configurable via LH_WORKERS_PARQUET
//! env, defaults to data/datasets/workers_500k.parquet). Falls back
//! to an empty InMemoryWorkerLookup if the file is missing — the
//! validators will still run schema/length/PII checks but worker-
//! existence checks will all fail (Consistency error), which is the
//! correct behavior when the roster isn't configured.
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use validator::{
Artifact, Validator, ValidationError,
staffing::{
fill::FillValidator,
email::EmailValidator,
playbook::PlaybookValidator,
},
};
#[derive(Deserialize)]
pub struct ValidateRequest {
/// `"fill" | "email" | "playbook"` — picks which validator runs.
pub kind: String,
/// The artifact JSON (free-form; shape depends on `kind`).
pub artifact: serde_json::Value,
/// Optional context bag — merged into `artifact._context` so the
/// validator can read fields like `target_count`, `city`,
/// `client_id`, `candidate_id` without callers having to embed
/// `_context` in the artifact themselves.
#[serde(default)]
pub context: Option<serde_json::Value>,
}
pub async fn validate(
State(state): State<super::V1State>,
Json(req): Json<ValidateRequest>,
) -> impl IntoResponse {
// Merge context into artifact under `_context` so validators can
// pull contract metadata uniformly.
let mut artifact_value = req.artifact;
if let Some(ctx) = req.context {
if let Some(obj) = artifact_value.as_object_mut() {
obj.insert("_context".to_string(), ctx);
}
}
// Dispatch.
let workers = state.validate_workers.clone();
let result: Result<validator::Report, ValidationError> = match req.kind.as_str() {
"fill" => FillValidator::new(workers).validate(&Artifact::FillProposal(artifact_value)),
"email" => EmailValidator::new(workers).validate(&Artifact::EmailDraft(artifact_value)),
"playbook" => PlaybookValidator.validate(&Artifact::Playbook(artifact_value)),
other => return (
StatusCode::BAD_REQUEST,
format!("unknown kind '{other}' — expected fill | email | playbook"),
).into_response(),
};
match result {
Ok(report) => (StatusCode::OK, Json(report)).into_response(),
Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, Json(e)).into_response(),
}
}
-1
View File
@@ -8,7 +8,6 @@ shared = { path = "../shared" }
storaged = { path = "../storaged" } storaged = { path = "../storaged" }
catalogd = { path = "../catalogd" } catalogd = { path = "../catalogd" }
vectord = { path = "../vectord" } vectord = { path = "../vectord" }
journald = { path = "../journald" }
tokio = { workspace = true } tokio = { workspace = true }
axum = { workspace = true, features = ["multipart"] } axum = { workspace = true, features = ["multipart"] }
lopdf = { workspace = true } lopdf = { workspace = true }
+3 -3
View File
@@ -2,9 +2,10 @@
/// When a source changes format (columns renamed, added, removed, type changed), /// When a source changes format (columns renamed, added, removed, type changed),
/// the system detects the diff and can auto-map using AI or heuristic matching. /// the system detects the diff and can auto-map using AI or heuristic matching.
use arrow::datatypes::{DataType, SchemaRef}; use arrow::datatypes::{DataType, Schema, SchemaRef};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
/// A detected change between two schema versions. /// A detected change between two schema versions.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -222,8 +223,7 @@ fn find_similar_column<'a>(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use arrow::datatypes::{Field, Schema}; use arrow::datatypes::Field;
use std::sync::Arc;
fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef { fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef {
Arc::new(Schema::new( Arc::new(Schema::new(
+1 -127
View File
@@ -3,7 +3,7 @@ use axum::{
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::IntoResponse, response::IntoResponse,
routing::{get, post}, routing::{delete, get, patch, post},
}; };
use bytes::Bytes; use bytes::Bytes;
use object_store::ObjectStore; use object_store::ObjectStore;
@@ -33,11 +33,6 @@ pub struct IngestState {
/// Scheduled-ingest registry. The scheduler task runs against this /// Scheduled-ingest registry. The scheduler task runs against this
/// store; HTTP CRUD endpoints write through it. /// store; HTTP CRUD endpoints write through it.
pub schedules: schedule::ScheduleStore, pub schedules: schedule::ScheduleStore,
/// Event journal for ADR-012 mutation history. Optional for back-compat
/// with callers (like scheduled ingest tests) that don't wire it yet.
/// When present, successful ingests emit a record_ingest event — closes
/// P9-001 on the file-upload path. (2026-04-23)
pub journal: Option<journald::journal::Journal>,
} }
/// Push `DatasetAppended` triggers for every HNSW index bound to this /// Push `DatasetAppended` triggers for every HNSW index bound to this
@@ -141,22 +136,6 @@ async fn ingest_file(
Ok(result) => { Ok(result) => {
if !result.deduplicated { if !result.deduplicated {
notify_agent_on_append(&state, &result.dataset_name).await; notify_agent_on_append(&state, &result.dataset_name).await;
// P9-001 fix (2026-04-23): emit a mutation event on every
// non-deduplicated ingest. Dedup no-ops don't need events
// (ADR-020 register() is already idempotent on same fingerprint).
if let Some(ref journal) = state.journal {
if let Err(e) = journal.record_ingest(
&result.dataset_name,
result.rows as usize,
"ingest_api",
&filename,
).await {
tracing::warn!(
"journal record_ingest failed for '{}': {}",
result.dataset_name, e,
);
}
}
} }
if result.deduplicated { if result.deduplicated {
Ok((StatusCode::OK, Json(result))) Ok((StatusCode::OK, Json(result)))
@@ -651,108 +630,3 @@ async fn run_schedule_now(
} }
Ok(Json(outcome)) Ok(Json(outcome))
} }
// ─── Tests ───
#[cfg(test)]
mod journal_integration_tests {
//! P9-001 integration test: prove that a successful ingest produces a
//! journal.record_ingest event. Block 2 on PR #10 was "journal event
//! verified live" being unbacked by the diff. This test makes the
//! verification committed and reproducible.
use journald::journal::{Event, Journal};
use object_store::memory::InMemory;
use std::sync::Arc;
// Helper: build a bare Journal against an in-memory object store.
// Flush threshold 1 so every recorded event is persisted immediately.
fn test_journal() -> Journal {
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
Journal::new(store, 1)
}
#[tokio::test]
async fn journal_record_ingest_increments_counter() {
// Arrange — fresh journal, counter starts at zero.
let journal = test_journal();
let stats0 = journal.stats().await;
assert_eq!(stats0.total_events_created, 0);
assert_eq!(stats0.buffer_events, 0);
// Act — simulate what the /ingest/file success path does.
journal
.record_ingest("test_dataset", 42, "ingest_api", "probe.csv")
.await
.expect("record_ingest should succeed");
// Assert — counter advanced, event exists. With threshold=1 the
// event flushed to store; with threshold>N it would be in-buffer.
let stats1 = journal.stats().await;
assert_eq!(stats1.total_events_created, 1, "counter should reflect one recorded event");
// Assert — the event is retrievable by entity.
let history = journal
.get_entity_history("batch:42")
.await
.expect("history lookup");
assert_eq!(history.len(), 1, "one event should be visible in history");
let ev = &history[0];
assert_eq!(ev.action, "ingest");
assert_eq!(ev.entity_type, "test_dataset");
assert_eq!(ev.actor, "ingest_api");
assert!(
ev.new_value.contains("probe.csv"),
"new_value should carry source filename, got: {}",
ev.new_value
);
}
#[tokio::test]
async fn optional_journal_field_none_is_valid_back_compat() {
// IngestState.journal is Option<Journal>. Back-compat path: when
// the field is None, the ingest handler MUST still succeed — the
// journal call is fire-and-forget, never load-bearing.
//
// This test asserts the type shape: Option<Journal> is what we
// expect. If a refactor makes it mandatory, this test forces an
// explicit re-consideration.
let none_journal: Option<Journal> = None;
assert!(none_journal.is_none());
let some_journal: Option<Journal> = Some(test_journal());
assert!(some_journal.is_some());
}
#[tokio::test]
async fn journal_record_event_fields_match_adr_012_schema() {
// ADR-012 locks the event schema: entity_type, entity_id, field,
// action, old_value, new_value, actor, source, workspace_id plus
// the auto-assigned event_id + timestamp. This test pins the
// field names so a future refactor can't silently drop one.
let journal = test_journal();
let base = Event {
event_id: String::new(),
timestamp: chrono::Utc::now(),
entity_type: "candidate".into(),
entity_id: "CAND-0001".into(),
field: "phone".into(),
action: "update".into(),
old_value: "555-0000".into(),
new_value: "555-9999".into(),
actor: "recruiter".into(),
source: "api".into(),
workspace_id: "ws-x".into(),
};
journal.record(base).await.expect("record should accept full-schema event");
let h = journal
.get_entity_history("CAND-0001")
.await
.expect("lookup");
assert_eq!(h.len(), 1);
assert_eq!(h[0].field, "phone");
assert_eq!(h[0].old_value, "555-0000");
assert_eq!(h[0].new_value, "555-9999");
assert_eq!(h[0].workspace_id, "ws-x");
}
}
+6 -8
View File
@@ -72,14 +72,12 @@ async fn process_inbox(
let path = entry.path(); let path = entry.path();
// Skip directories and hidden files. Bind filename once via // Skip directories and hidden files
// let-else so the subsequent use is unwrap-free — previous if path.is_dir() || path.file_name().map_or(true, |n| n.to_string_lossy().starts_with('.')) {
// version relied on a map_or guard above + an .unwrap() here continue;
// being consistent, which is a fragile invariant. }
if path.is_dir() { continue; }
let Some(fn_os) = path.file_name() else { continue; }; let filename = path.file_name().unwrap().to_string_lossy().to_string();
let filename = fn_os.to_string_lossy().to_string();
if filename.starts_with('.') { continue; }
tracing::info!("watcher: found new file '{}'", filename); tracing::info!("watcher: found new file '{}'", filename);
// Read file // Read file
+1 -1
View File
@@ -5,7 +5,7 @@
/// Storage: events buffer in memory, flush to Parquet periodically. /// Storage: events buffer in memory, flush to Parquet periodically.
/// Query: load Parquet files, filter by entity/field/actor/time. /// Query: load Parquet files, filter by entity/field/actor/time.
use arrow::array::{ArrayRef, RecordBatch, StringArray}; use arrow::array::{ArrayRef, RecordBatch, StringArray, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema}; use arrow::datatypes::{DataType, Field, Schema};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use object_store::ObjectStore; use object_store::ObjectStore;
-20
View File
@@ -456,26 +456,6 @@ async fn build_lance_vector_index(path: &str, _dims: usize) -> Result<()> {
.await .await
.context("create_index")?; .context("create_index")?;
// Also build the scalar btree on doc_id. This bench's
// measure_random_access_lance uses take(row_position) which doesn't
// need the btree, but the dataset this bench writes is also queried
// downstream by /vectors/lance/doc/<name>/<doc_id> (the production
// lookup path) — without this index that path falls back to a full
// table scan. Cheap to build (~1.2s on 10M rows) and matches the
// gateway's lance_migrate handler behavior so bench-produced datasets
// are immediately production-shape.
use lance_index::scalar::ScalarIndexParams;
dataset
.create_index(
&["doc_id"],
IndexType::Scalar,
Some("doc_id_btree".into()),
&ScalarIndexParams::default(),
true,
)
.await
.context("create_index doc_id btree")?;
Ok(()) Ok(())
} }
-1
View File
@@ -7,7 +7,6 @@ edition = "2024"
shared = { path = "../shared" } shared = { path = "../shared" }
catalogd = { path = "../catalogd" } catalogd = { path = "../catalogd" }
storaged = { path = "../storaged" } storaged = { path = "../storaged" }
truth = { path = "../truth" }
tokio = { workspace = true } tokio = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
+5 -8
View File
@@ -84,17 +84,14 @@ pub async fn compact(
// Load deltas // Load deltas
let delta_batches = load_deltas(store, dataset_name).await?; let delta_batches = load_deltas(store, dataset_name).await?;
let delta_count = delta_batches.len(); let delta_count = delta_batches.len();
// Row counts captured before extend; previously base_rows subtracted delta_count (files) from rows — unit mismatch.
let base_row_count: usize = base_batches.iter().map(|b| b.num_rows()).sum();
let delta_row_count: usize = delta_batches.iter().map(|b| b.num_rows()).sum();
let has_tombstones = !tombstones.is_empty(); let has_tombstones = !tombstones.is_empty();
let nothing_to_do = delta_batches.is_empty() && !has_tombstones; let nothing_to_do = delta_batches.is_empty() && !has_tombstones;
if nothing_to_do { if nothing_to_do {
return Ok(CompactResult { return Ok(CompactResult {
base_rows: base_row_count, base_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
delta_rows: 0, delta_rows: 0,
final_rows: base_row_count, final_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
deltas_merged: 0, deltas_merged: 0,
tombstones_applied: 0, tombstones_applied: 0,
rows_dropped_by_tombstones: 0, rows_dropped_by_tombstones: 0,
@@ -102,7 +99,7 @@ pub async fn compact(
} }
base_batches.extend(delta_batches); base_batches.extend(delta_batches);
let pre_filter_rows: usize = base_row_count + delta_row_count; let pre_filter_rows: usize = base_batches.iter().map(|b| b.num_rows()).sum();
// If primary key specified, deduplicate (keep last occurrence) // If primary key specified, deduplicate (keep last occurrence)
let merged_batches = if let Some(_pk) = primary_key_col { let merged_batches = if let Some(_pk) = primary_key_col {
@@ -186,8 +183,8 @@ pub async fn compact(
); );
Ok(CompactResult { Ok(CompactResult {
base_rows: base_row_count, base_rows: pre_filter_rows - delta_count, // rough base-before-deltas
delta_rows: delta_row_count, delta_rows: delta_count,
final_rows, final_rows,
deltas_merged: delta_count, deltas_merged: delta_count,
tombstones_applied: tombstones.len(), tombstones_applied: tombstones.len(),
+1 -119
View File
@@ -9,9 +9,7 @@ use axum::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use crate::cache::CacheStats;
use truth::{RuleAction, TruthStore};
use crate::context::QueryEngine; use crate::context::QueryEngine;
use crate::delta; use crate::delta;
use crate::paged::ResultStore; use crate::paged::ResultStore;
@@ -20,26 +18,12 @@ use crate::paged::ResultStore;
pub struct QueryState { pub struct QueryState {
pub engine: QueryEngine, pub engine: QueryEngine,
pub result_store: ResultStore, pub result_store: ResultStore,
// Policy gate for incoming SQL. Every /sql and /paged request is
// evaluated against this store before hitting DataFusion. Added for
// P42-002 ("raw SQL forwarded without schema or policy gate") after
// the scrum master's queryd/service.rs finding looped across iters
// 3-5 without ever being reachable by the 6-line auto-applier.
pub truth: Arc<TruthStore>,
} }
pub fn router(engine: QueryEngine) -> Router { pub fn router(engine: QueryEngine) -> Router {
router_with_truth(engine, Arc::new(truth::sql_query_guard_store()))
}
/// Test/integration hook: construct the router with a caller-supplied
/// TruthStore so tests can assert reject/pass behavior deterministically
/// without depending on the default needle list.
pub fn router_with_truth(engine: QueryEngine, truth: Arc<TruthStore>) -> Router {
let state = QueryState { let state = QueryState {
engine: engine.clone(), engine: engine.clone(),
result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results
truth,
}; };
Router::new() Router::new()
.route("/health", get(health)) .route("/health", get(health))
@@ -69,11 +53,6 @@ struct QueryResponse {
columns: Vec<ColumnInfo>, columns: Vec<ColumnInfo>,
rows: serde_json::Value, rows: serde_json::Value,
row_count: usize, row_count: usize,
// Elapsed wall time from handler entry to response. Required for
// audit-log parity — gateway's audit row previously stored null here.
// Scrum iter 9 finding, populated from std::time::Instant captured
// at the top of execute_query / paged_query.
latency_ms: u64,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -93,41 +72,12 @@ fn batches_to_json(batches: &[RecordBatch]) -> Result<serde_json::Value, String>
serde_json::from_slice(&buf).map_err(|e| format!("JSON parse error: {e}")) serde_json::from_slice(&buf).map_err(|e| format!("JSON parse error: {e}"))
} }
/// Evaluate the request SQL against the configured TruthStore. Returns
/// the Reject/Block message on the first failing mandatory rule so the
/// handler can short-circuit. Returns None when all rules pass (or when
/// the failures' declared action is non-mandatory like Redact/Pass).
fn sql_policy_check(truth: &TruthStore, sql: &str) -> Option<String> {
let ctx = serde_json::json!({ "sql": sql });
for outcome in truth.evaluate("sql_query", &ctx) {
if !outcome.passed {
// FieldEmpty / FieldContainsAny etc. are enforced only when
// condition HOLDS (i.e. passed=true). Below means "passed=false",
// so the rule condition did not hold — no enforcement.
continue;
}
match &outcome.action {
RuleAction::Reject { message } | RuleAction::Block { message } => {
return Some(message.clone());
}
_ => {}
}
}
None
}
async fn execute_query( async fn execute_query(
State(state): State<QueryState>, State(state): State<QueryState>,
Json(req): Json<QueryRequest>, Json(req): Json<QueryRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let started = std::time::Instant::now();
tracing::info!("executing query: {}", req.sql); tracing::info!("executing query: {}", req.sql);
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
tracing::warn!("sql rejected by truth gate: {reason}");
return Err((StatusCode::FORBIDDEN, reason));
}
match state.engine.query(&req.sql).await { match state.engine.query(&req.sql).await {
Ok(batches) => { Ok(batches) => {
if batches.is_empty() { if batches.is_empty() {
@@ -135,7 +85,6 @@ async fn execute_query(
columns: vec![], columns: vec![],
rows: serde_json::Value::Array(vec![]), rows: serde_json::Value::Array(vec![]),
row_count: 0, row_count: 0,
latency_ms: started.elapsed().as_millis() as u64,
})); }));
} }
@@ -154,7 +103,6 @@ async fn execute_query(
columns, columns,
rows, rows,
row_count, row_count,
latency_ms: started.elapsed().as_millis() as u64,
})) }))
} }
Err(e) => Err((StatusCode::BAD_REQUEST, e)), Err(e) => Err((StatusCode::BAD_REQUEST, e)),
@@ -168,10 +116,6 @@ async fn paged_query(
Json(req): Json<QueryRequest>, Json(req): Json<QueryRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
tracing::info!("paged query: {}", req.sql); tracing::info!("paged query: {}", req.sql);
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
tracing::warn!("paged sql rejected by truth gate: {reason}");
return Err((StatusCode::FORBIDDEN, reason));
}
match state.result_store.execute_and_store(&state.engine, &req.sql).await { match state.result_store.execute_and_store(&state.engine, &req.sql).await {
Ok(handle) => Ok(Json(handle)), Ok(handle) => Ok(Json(handle)),
Err(e) => Err((StatusCode::BAD_REQUEST, e)), Err(e) => Err((StatusCode::BAD_REQUEST, e)),
@@ -268,65 +212,3 @@ async fn compact_dataset(
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
} }
} }
#[cfg(test)]
mod sql_policy_tests {
use super::*;
use truth::sql_query_guard_store;
// These tests exercise the policy gate without spinning up a DataFusion
// engine — they only need `TruthStore`. Purpose: prove the P42-002
// enforcement point actually rejects destructive SQL. This is the
// regression guard for the queryd/service.rs finding that looped
// across scrum iters 3-5.
#[test]
fn blocks_drop_table() {
let store = sql_query_guard_store();
let reason = sql_policy_check(&store, "DROP TABLE users").expect("must reject");
assert!(reason.contains("destructive"), "reason: {reason}");
}
#[test]
fn blocks_delete_from() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "delete from t where 1=1").is_some());
}
#[test]
fn blocks_truncate() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "TRUNCATE workers").is_some());
}
#[test]
fn blocks_empty_sql() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "").is_some());
}
#[test]
fn allows_benign_select() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "SELECT count(*) FROM workers").is_none());
}
#[test]
fn allows_select_with_deleted_word_in_column() {
// Substring match is narrow ("delete from", not "delete"), so a
// column named `deleted_at` doesn't trip the guard. Important
// check — false positives on benign queries would make the gate
// unusable in practice.
let store = sql_query_guard_store();
assert!(
sql_policy_check(&store, "SELECT deleted_at FROM t").is_none(),
"column names containing 'delete' must not be rejected"
);
}
#[test]
fn case_insensitive_match_catches_mixed_case() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "Drop Table X").is_some());
}
}
+3
View File
@@ -2,12 +2,15 @@
/// Each workspace tracks an agent's activity on a specific contract or search, /// Each workspace tracks an agent's activity on a specific contract or search,
/// with daily/weekly/monthly tiers and instant handoff capability. /// with daily/weekly/monthly tiers and instant handoff capability.
use arrow::array::{ArrayRef, RecordBatch, StringArray, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::delta;
use object_store::ObjectStore; use object_store::ObjectStore;
/// Retention tier for workspace data. /// Retention tier for workspace data.
+2 -21
View File
@@ -62,15 +62,6 @@ pub struct GatewayConfig {
pub host: String, pub host: String,
#[serde(default = "default_gateway_port")] #[serde(default = "default_gateway_port")]
pub port: u16, pub port: u16,
/// Coordinator session JSONL output path. One row per
/// `/v1/iterate` session, schema=`session.iterate.v1`. Empty =
/// disabled. Cross-runtime parity with the Go side's
/// `[validatord].session_log_path` (added 2026-05-02). Default
/// empty so existing deployments aren't perturbed; production
/// sets `/var/lib/lakehouse/gateway/sessions.jsonl`. See
/// `golangLAKEHOUSE/docs/SESSION_LOG.md` for query examples.
#[serde(default)]
pub session_log_path: String,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -158,13 +149,7 @@ fn default_gateway_port() -> u16 { 3100 }
fn default_storage_root() -> String { "./data".to_string() } fn default_storage_root() -> String { "./data".to_string() }
fn default_profile_root() -> String { "./data/_profiles".to_string() } fn default_profile_root() -> String { "./data/_profiles".to_string() }
fn default_manifest_prefix() -> String { "_catalog/manifests".to_string() } fn default_manifest_prefix() -> String { "_catalog/manifests".to_string() }
// Post-2026-05-02: AiClient talks directly to Ollama; the Python fn default_sidecar_url() -> String { "http://localhost:3200".to_string() }
// sidecar's hot-path role was retired. The config field name
// `[sidecar].url` is preserved for migration compatibility (operators
// with existing TOMLs don't need to rename anything), but the value
// now points at Ollama. Lab UI / pipeline_lab Python remains as a
// dev-only tool; not on this URL.
fn default_sidecar_url() -> String { "http://localhost:11434".to_string() }
fn default_embed_model() -> String { "nomic-embed-text".to_string() } fn default_embed_model() -> String { "nomic-embed-text".to_string() }
fn default_gen_model() -> String { "qwen2.5".to_string() } fn default_gen_model() -> String { "qwen2.5".to_string() }
fn default_rerank_model() -> String { "qwen2.5".to_string() } fn default_rerank_model() -> String { "qwen2.5".to_string() }
@@ -199,11 +184,7 @@ impl Config {
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
gateway: GatewayConfig { gateway: GatewayConfig { host: default_host(), port: default_gateway_port() },
host: default_host(),
port: default_gateway_port(),
session_log_path: String::new(),
},
storage: StorageConfig { storage: StorageConfig {
root: default_storage_root(), root: default_storage_root(),
profile_root: default_profile_root(), profile_root: default_profile_root(),
-2
View File
@@ -4,5 +4,3 @@ pub mod arrow_helpers;
pub mod config; pub mod config;
pub mod pii; pub mod pii;
pub mod secrets; pub mod secrets;
pub mod model_matrix;
pub mod profiles;

Some files were not shown because too many files have changed in this diff Show More