Skip to content

release: v0.2.0 - deterministic retrieval, tracing, live GitHub intake - #4

Merged
cheneeheng merged 30 commits into
mainfrom
feat/v2-deterministic-retrieval-tracing-live-intake
Aug 12, 2026
Merged

release: v0.2.0 - deterministic retrieval, tracing, live GitHub intake#4
cheneeheng merged 30 commits into
mainfrom
feat/v2-deterministic-retrieval-tracing-live-intake

Conversation

@cheneeheng

Copy link
Copy Markdown
Owner

What

Releases v0.2.0 — the v2 plan family: live GitHub pull-request intake by URL, OpenTelemetry
tracing over OTLP to a self-hosted Langfuse, deterministic policy retrieval in place of the
vector store, and semgrep as the investigator's fourth tool. Merging this PR releases v0.2.0,
which will then be tagged on main.

Why

Four subsystems, one release:

  • Live GitHub intakePOST /api/reviews now accepts pr_url as an alternative to
    pr_id. A PRSource adapter splits work at the request/background seam: resolve runs in
    the handler and maps a bad URL, missing repo, or under-scoped token to 422/404/403 before a
    review thread exists; load runs in the background and fails by escalation instead. Metadata
    and diff come from the GitHub MCP server; the repo tree is a shallow clone at head_sha that
    becomes the investigator's sandbox.
  • Tracing — OTel spans exported over OTLP/HTTP to a self-hosted Langfuse. This turns the
    README's cost-gate claims into evidence: filter traces on gate.risk.level=low and there are
    no Sonnet spans.
  • Deterministic policy retrieval — the Chroma vector store, the embedding model, and the
    ingestion step are gone, replaced by a change_type -> file lookup validated at startup.
    Embedding search did not earn its place at a three-document corpus keyed by a closed enum.
  • run_semgrep — a fourth read-only investigator tool over a committed pinned ruleset whose
    rules map to policy ids.

Bump level

MINOR (0.1.2 -> 0.2.0). Adds a request field, a graph edge, three subsystems, and five state
fields. Not MAJOR only because the project is pre-1.0; the one narrowing change (removing
PolicyChunk.distance) is what GATE_STATE_VERSION=2 exists to refuse an old checkpoint over.

Security

pip-audit over the locked runtime dependencies found 5 vulnerabilities:

  • Two fixed inside existing constraints: cryptography 49.0.0 -> 50.0.0 (PYSEC-2026-3552),
    langgraph-checkpoint-sqlite 3.1.0 -> 3.1.1 (PYSEC-2026-3636).
  • The other three are all mcp 1.23.3 (PYSEC-2026-3481/-3482/-3483) and cannot be upgraded:
    semgrep==1.172.0, the pinned static-analysis extra and the current latest semgrep release,
    pins mcp==1.23.3 exactly. All three advisories are in the package's server half; this
    project imports ClientSession and stdio_client only and never runs an MCP server, so none
    of the vulnerable code paths are reachable. Documented as exemptions in
    .github/workflows/publish.yml.disabled, replacing a chromadb exemption that went dead when
    v2 removed Chroma. Full reasoning in .agents_workspace/DECISION_LOG.md entry 32.

PyPI publication

Coming within the next few patch releases. v0.2.0 lifted the hold — the stated condition was
live GitHub intake, which this PR ships — but did not act on it. What remains is the one-time
Trusted Publishing setup on pypi.org and renaming .github/workflows/publish.yml.disabled back
to .yml. The same note is recorded under [Unreleased] in CHANGELOG.md.

How

  • PRSource protocol has two methods on purpose (resolve cheap/handler, load slow/
    background) — collapsing them would force either a POST that blocks on a clone or a typo'd URL
    creating a review row.
  • validate_policy_index() runs in the lifespan and fails the process on an unmapped enum
    member, a missing file, or an unparseable policy_id — a runtime fallback would be
    unreachable in a correct install.
  • run_semgrep uses the committed rules/ ruleset resolved to an absolute path, never
    --config auto (would fetch rules over the network and break the no-network sandbox / result
    determinism). A failed run still increments semgrep_runs and spends a tool call.

Testing

All run locally on Windows, all green:

  • uv run ruff check . and uv run ruff format --check . — clean
  • uv run mypy src (strict) — no issues in 40 source files
  • uv run pytest --cov — 228 passed, 14 deselected, 100% statement and branch coverage of
    src/gate (fail_under = 100 enforced)
  • uv build — produces a 0.2.0 wheel; verified it bundles the policy corpus, sample PR
    fixtures, UI, and semgrep rules under gate/_bundled/

Not run: pytest -m llm (real Anthropic API, costs money) and pytest -m network (live GitHub,
needs a token). Stating this plainly rather than implying full coverage.

Known open items (not blocking this release)

  • B-03rules/ ships no vendored semgrep registry rules, held on an LGPL-2.1 licensing
    question since the repo is Apache-2.0.
  • B-04 — the README demo beats link no trace screenshots; needs a live Langfuse container
    plus a paid run.

Both documented in .agents_workspace/planning/v2/BACKLOG.md.

Checklist

  • All CI checks pass
  • Tests added or updated for new behavior
  • No any / @ts-ignore / # type: ignore introduced
  • No secrets or credentials in code
  • Migrations (if any) are backward-compatible (GATE_STATE_VERSION=2 refuses old checkpoints)
  • ARCHITECTURE.md Key Decisions updated (if a durable decision was made)
  • Attribution included if AI tooling assisted

Generated with Claude Code by CEH

The plan document for the v1 patch iteration that was already implemented and
released as v0.1.2 — the checkpoint allowlist, the SDK credential fix, and the
first-review race. It was written and applied but never committed alongside the
code it describes, which left the planning family missing its last artifact and
the v2 files pointing at a document that was not in the repo.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
…y lookup

Implements ITER_01_v2, and adds the four v2 plan documents.

`data/policies/index.yaml` maps each change_type to the policy files that govern
it, plus whole-token case-folded keyword overrides against the classifier's
touched_areas. Retrieval reads those files and splits them on their `##`
headings. No embedding model, no vector store, no ingest step.

RAG earns its place when, after metadata narrowing, the candidate set still
exceeds what fits in context, or when disambiguation inside a category is
genuinely semantic rather than categorical. Neither holds at a three-document
corpus keyed by a closed enum, so this was unpaid-for machinery. The threshold
for revisiting it is documented in the plan rather than left implicit, and it is
about policy text per category — not policy count.

Two things improve as a side effect. Retrieval is now deterministic, so
eval_routes is a real regression test rather than a sample. And specialists get
the *complete* set of headings for the relevant files instead of a top-6 slice,
so a policy can no longer be missed for ranking seventh — which matters because
index.yaml routes config and data_handling changes to data-handling.md, whose
DATA-* chunks the security specialist owns.

Validation runs in the FastAPI lifespan, not on first request: an unmapped
change_type, a missing file, or a heading without a parseable policy id fails the
*process*. change_type is a closed enum the classifier's structured output
already validates, so a missing key is a config error, not a condition to survive
mid-review. A runtime "unknown change_type -> empty list + WARN" branch was the
obvious alternative and is worse: unreachable in a correct install, and
unreachable branches are what this codebase deletes rather than pragmas.

Removing PolicyChunk.distance is the one narrowing in the v2 family, and it makes
pre-v2 checkpoints undeserializable on resume. There is deliberately no migration
— this is local single-user demo data — so GATE_STATE_VERSION is stamped into
checkpoints.db and checked at startup, and the fix is deleting the file. That is
a decision a reviewer should see stated, not discover as a crash.

`intake/store.py` goes with `intake/ingest.py`: the plan describes it as the
thread registry, but that is `app.state.reviews` — store.py was the Chroma
accessor and has no purpose left. See DECISION_LOG entry 17.

Testing: 132 pass at 100% statement and branch coverage; ruff and mypy --strict
clean. tests/unit/test_retrieval.py covers each change_type, docs resolving to an
empty set, the keyword union, whole-token matching (`auth` hits "auth module",
misses "author metadata"), dedup/order stability, and every startup-validation
failure.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_02_v2.

Spans are written in application code with real semantic conventions and
exported over OTLP/HTTP; Langfuse is addressed purely as an endpoint, so pointing
the same exporter at Jaeger or Tempo changes nothing. Standing up a collector plus
trace store plus query UI was considered and rejected: three moving parts for a
project whose constraint is a single-command local run, and raw OTel has no LLM
semantics — tokens, cost, prompt capture — which is exactly the schema work
Langfuse already did.

This is the point of the iteration: the README *claims* low-risk PRs never touch
Sonnet. A trace filtered on gate.risk.level=low with zero Sonnet spans *proves*
it. The run span closes with gate.route.taken, gate.loop_count.final,
gate.cost.investigator_usd and gate.specialists.timed_out for the same reason.

Three traps are designed for here rather than debugged later:

- The graph run outlives its HTTP request. POST returns as soon as the background
  task is spawned, so parenting node spans under the request span would attach a
  multi-minute run to a span that ended in milliseconds — and under a
  BatchSpanProcessor that parent may already be exported. gate.review.run is a
  *root* span opened inside the background task, carrying a link to the request
  span whose context was captured at handler time.
- OTel context lives in contextvars that do not follow into LangGraph's parallel
  branches, so a naive @traced_node makes both specialist spans trace roots. The
  run span's context is captured where the run span opens and passed to each node
  explicitly. Capturing it inside `intake` is the tempting shortcut and is wrong:
  intake is itself a child, so every later node would parent to it.
- BatchSpanProcessor buffers, so an unflushed exit drops the tail of every trace —
  which reads in the UI as runs that end mid-graph. The lifespan drains its
  tracked background runs *before* provider.shutdown(); reversed, an in-flight run
  writes to a dead provider and loses its tail anyway.

The kill switch holds the provider in this module rather than installing it
globally. `trace.set_tracer_provider` is guarded by a process-wide Once, so the
second call — GATE_OTEL_ENABLED=false after any enabled run, or a second app
instance — is refused with a log warning and no effect. A test caught that; see
DECISION_LOG entry 19. The app must run with the Langfuse container down, because
a reviewer cloning the repo should not need Docker to see the gate work.

LLM spans carry gate.llm.effort and gate.llm.structured_output_method because
that pairing is what silently broke the specialists in v1 — recorded, the next
occurrence is a filter query rather than a debugging session. Thinking tokens get
their own attribute instead of being folded into gen_ai.usage.output_tokens,
which stays exactly as the API reports it; burying them would make the two
unreconcilable against a bill.

Testing: 152 pass at 100% statement and branch coverage; ruff and mypy --strict
clean. tests/unit/test_tracing.py asserts against an in-memory exporter that the
two parallel specialist spans share the run span as parent, that the run span is
a root carrying a link rather than a child, and that a disabled-tracing run emits
zero spans and still reaches a verdict. conftest now disables tracing by default —
a live exporter spent its full retry budget on every lifespan teardown, which cost
a minute of wall clock per suite run. Not verified against a real Langfuse.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_03_v2.

`POST /api/reviews` now takes `pr_url` as an alternative to `pr_id`, with exactly
one required. A `PRSource` protocol sits behind it, and the protocol having *two*
methods is the load-bearing decision: they split exactly where the
request/background boundary already falls.

`resolve` is cheap, runs in the handler, and raises the HTTP-mapped errors — a
typo'd URL is a 422, a missing repo a 404, an under-scoped token a 403, all
*before* a review thread exists. `load` is slow, runs in the background task, and
fails by returning an EscalationRecord, because by then a thread exists to carry
the failure and nobody is waiting on the response. A single `load()` cannot
express that: it forces either the whole live path into the handler (a POST that
blocks on a clone, which the polling UI exists to avoid) or every failure into an
escalation (a typo'd URL creating a review row). The protocol is also the seam
that absorbs the one genuine unknown here — the GitHub MCP server's exact tool
names and response shapes. If they differ, one class changes.

MCP supplies the structured surface: PR metadata, the paginated changed-file
list, the diff. The repo *tree* is a shallow clone at head_sha, because pulling
hundreds of files one at a time through tool calls to satisfy read_file/grep_repo
would be absurd. The files endpoint pages at 30 by default, and a single
unpaginated call silently reviews a subset of a large PR — a compliance gate that
misses files is worse than one that refuses, so pagination runs to a short page
and GATE_GH_MAX_FILES escalates rather than truncating.

The clone directory is named by head_sha and nothing else. Branch names
legitimately contain `/` and can contain `..`; using one as a path component is a
traversal bug waiting to happen, and a 40-char hex SHA validated by regex cannot
escape. That directory becomes the investigator's sandbox root — injected by the
adapter, never derived from pr_id. This severs the coupling the v1 pr_id check was
defending against, so that check moves onto the fixture adapter where it belongs;
a live pr_id legitimately contains `/` and `#`.

One graph edge is added, and it is worth being explicit that it *is* a topology
change. Both new escalation reasons are raised before the graph has a diff, and
reconcile's rule 1 only fires after classify, risk and the specialists have run.
Routing a diff-less review that far means asking Haiku to classify nothing and
Sonnet to review nothing — spend and noise on a review whose outcome is already
decided. So intake short-circuits to the human gate. The graph still *runs*: a
failed load is seeded into the initial state and invoked normally, which keeps the
failed review checkpointed, resumable, traced under the same run span, and
reachable through the same /pending and /decision endpoints. A second, non-graph
failure path would have cost a parallel implementation of the review lifecycle.

The event-loop preflight moves to gate.runtime and is now shared: the Agent SDK,
the clone, and (next iteration) semgrep all fail identically on a
SelectorEventLoop with a bare NotImplementedError and an empty message.

Token scope is stated and deliberate: read-only, contents:read +
pull_requests:read. No write scopes — the gate never comments, labels or merges.

Testing: 195 pass at 100% statement and branch coverage; ruff and mypy --strict
clean. Explicitly a stub suite, not record/replay: tests/fixtures/github holds
payloads captured once by hand and replayed through the real adapter. Covered are
URL parsing against six malformed forms, three-page pagination, the pr_too_large
and source_unavailable escalations, 429 backoff, SHA-regex rejection, LRU
eviction, the preflight's 403/404 before thread creation, and the short-circuit
reaching the human gate with a model factory that raises — "no spend" is the point
of that edge, so it is asserted rather than inferred from a route string. The
clone runs against a local bare repo, so the default suite still makes no network
call. The one live smoke test is network-marked and excluded. Not verified against
a real GitHub MCP server; its tool names are assumed.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_04_v2, the v2 MVP terminator, and carries the cross-cutting
documentation for all four iterations.

The investigator's ITER_03 toolset was read-only exploration — read_file,
grep_repo, list_dir — and its findings were only as good as what grep surfaced,
which for a security and licensing gate is thin. Semgrep gives the harness an
actual analysis capability, added as a fourth *tool inside the existing loop*
rather than a new graph node: the architectural claim that LangGraph sees one
node and the Agent SDK owns the inner loop is reinforced, not diluted.

`rules/` is committed and pinned, and its rules map to policy ids — a rule
matching new outbound egress for SEC-01, PII reaching a logger for DATA-03 — which
is what makes the output *citable* rather than generic lint noise. `--config auto`
is never used: it fetches rules over the network at run time, which breaks the
harness's no-network sandbox and makes results non-deterministic between runs. The
config path is the absolute directory `paths.py` resolved, never the literal
`rules/`, since a relative config path re-introduces the CWD assumption the asset
resolver exists to kill.

A failed run still increments semgrep_runs and still spends a tool call.
Otherwise a broken scanner is free to retry, and the agent — told the tool is
available — burns its turn budget on it instead of falling back to read_file.
Semgrep is also not exempt from the tool-call budget just because it is
deterministic.

Findings reach the re-run specialists as rule/path/line evidence, so a specialist
can raise severity to `block` with something behind it. The merge_findings reducer
is what makes that raise visible: the re-run's batch replaces that specialist's
prior findings, so the pre-semgrep needs_more_context note does not linger beside
the evidence-backed block.

The UI keys its empty state off semgrep_runs, not off the list. static_analysis
defaults to [], so an empty list alone cannot distinguish "ran and found nothing"
from "never ran" — and that is exactly what a reviewer needs, because one means
the change is clean and the other means the evidence is missing.

Finding is deliberately *not* given a matching `source` field. Semgrep output is
evidence the investigator reports; a Finding stays a specialist's policy judgment
citing a policy_id. Blurring the two would make "which policy does this violate"
unanswerable for machine-generated hits.

Semgrep ships as an optional extra rather than a core dependency. The plan's
fallback condition (no wheel for a supported interpreter) does not fire — 1.172.0
has one — but it is a large install and the harness already degrades cleanly
without it, through the same path that covers a missing binary. `uv sync` stays
light and every semgrep branch is unit-tested against a stubbed subprocess, so no
test depends on the extra. See DECISION_LOG entry 20.

Documentation for the whole v2 family lands here rather than being split across
the four commits: the README demo script is rewritten in this iteration anyway,
and the prose in README, CLAUDE.md, .env.example and docs/guide interleaves all
four topics too tightly to divide cleanly. The demo is now four beats, with the
live GitHub PR as the one that could not exist in v1, and setup is two steps
instead of three.

Testing: 221 pass at 100% statement and branch coverage; ruff and mypy --strict
clean. Every degradation path — missing binary, timeout, non-zero exit,
unparseable JSON, unsupported event loop, absent ruleset — is exercised against a
stubbed subprocess rather than left to the llm run, which does not count toward
coverage. The committed rules were verified to fire on fixture 003 (DATA-03 and
SEC-01). The wheel was verified to bundle rules/ and data/policies/index.yaml.
Not verified: any run against the real Anthropic API, the real GitHub MCP server,
or a live Langfuse container.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
…leset

Two subsystems added in v2 had their reasoning only in module docstrings and
commit messages, which is the wrong place for it: both are non-obvious enough
that a reader needs the foundations before the code makes sense.

tracing-design.md covers the OTel instrumentation — the span tree, the three
traps it is designed around (a run that outlives its request, context lost
across task boundaries, buffered spans dropped on shutdown), why the kill
switch cannot be one line, and the limitations that are still unverified. It
opens with three facts about spans, because every trap follows from the third
one: a span with no parent starts a new trace, so a lost parent is silent
rather than visible.

semgrep-rules.md covers the investigator's fourth tool from zero — what
semgrep matches that grep cannot, the pattern syntax, the four combinators
with a worked narrowing, the gate's four rules read out loud, and the
maintenance cost a hand-written ruleset carries.

Both are engineering notes, not part of the user/operator guide; each points
at the operator pages that own its configuration.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The guide still described the v1 system in places: live GitHub intake was
undocumented for reviewers, tracing was absent from the page named Monitoring,
and several pages contradicted each other on the investigator's tool set and
the escalation vocabulary.

Corrected facts, each checked against source rather than against the design
notes:

- index.md claimed pull requests come "from fixture directories on disk, not
  from live GitHub", and its glossary described the deleted vector store as
  "embedded once".
- OP-02 listed three investigator tools where there are four, so the
  documented tool-call budget did not account for run_semgrep, and it claimed
  the sandbox is always a fixture's repo/ directory rather than the injected
  workspace root.
- OP-04 stated that specialist and classifier spend "is not estimated
  anywhere". Every model call has carried a cost attribute since tracing
  landed.
- HT-02 and HT-04 listed three escalation reasons where the enum has five.

New content:

- HT-01 gains a live pull-request procedure with its preflight errors, and the
  Investigation panel now documents Budget and Static analysis, including the
  distinction between "scanned, found nothing" and "never scanned".
- OP-04 gains a Traces section: the span tree, the attributes worth filtering
  on, the query that verifies the risk gate, and four trace-level symptoms.
- reference.md gains Policy retrieval and Span vocabulary sections, so an
  operator can write a Langfuse filter query without reading the source.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
An audit of ITER_01_v2..ITER_04_v2 against the shipped code turned up
three real gaps and two misleading comments.

- The run span never carried `gate.pr.source`, so live and fixture runs
  could not be told apart with one Langfuse filter (ITER_03_v2 §04). It
  was only on the MCP tool span, which fixture runs never open.
- `classify` and both specialists still used the blunt head+tail
  `truncate_diff`, not the per-file-aware cut ITER_03_v2 §06 names them
  in. The live path already truncated per-file at load, so this only bit
  an oversized fixture — but the nodes are what the plan specifies.
- The `run_semgrep` child span recorded no hit count (ITER_04_v2 §04).
  `tool_span` now yields its span so the tool can attach one; duration
  was already free from the span itself.
- Comments in `state.py` and `build.py` pointed at
  `saver.with_allowlist(...)`, which is the silent no-op CLAUDE.md warns
  about, not the `use_state_allowlist(saver)` the code actually calls.
- `.env.example` omitted `GATE_SEMGREP_RULES_DIR`.

`risk_score` keeps the blunt truncation on purpose: the plan names the
classifier and specialists and nothing else. See DECISION_LOG entry 21
for that call and two others.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Two findings from the v2 plan-compliance audit were left standing on
purpose, and a decision that is only in the log is easy to lose.

- `risk_score` still uses the blunt diff truncation, because ITER_03_v2
  §06 names the classifier and specialists and widening the change would
  be scope creep.
- The registered branch of `submit_decision`'s lookup has never been
  exercised. The ternary form hides it — coverage.py records no arc for a
  conditional expression — so `fail_under = 100` is green without
  proving that path. The same blind spot applies to every other ternary
  in `src/gate`; this is the one that was found, not the only one.

Deliberately carries no plan frontmatter: the review skill resolves
`depends_on` chains across SKELETON/ITER stems, and an `artifact:` header
here would put a backlog in that path as if it were a section spec.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_03_v2 moved PR loading ahead of `ainvoke` — the `intake` node
fetches nothing now, it only records what the adapter seeded. The demo
still called `graph.ainvoke(initial_state(pr_id, thread_id))`, so every
beat reviewed an empty diff. `examples/` is outside the coverage source,
so the default suite could not see it.

The script now uses the same seam the API does: `select_source` ->
`resolve` -> `load` -> seed `ReviewState` -> `ainvoke`. With that in
place the rest of the v2 family became visible from the demo too:

- each beat runs under a `review_run_span`, so `trace_id` is real and a
  Langfuse deep-link prints (omitted when tracing is off)
- the investigation line carries `semgrep_runs`, and static-analysis
  hits print per rule; ran-but-empty is distinguished from never-ran,
  the same way the UI panel does it
- beat 4 (a live PR by URL, `--pr-url` to override) joins the list,
  skipped with a printed reason when GATE_GITHUB_TOKEN is unset, so the
  default run stays offline and deterministic

README beat 4 gained the headless command now that it exists.

Verified: ruff clean; the fixture adapter hands back a 1484-char diff,
two changed files and a workspace path where the old code passed "".
Not run end to end — that spends API credit.

Refs .agents_workspace/DECISION_LOG.md entry 22

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The diagram set still showed Chroma, a three-tool investigator, and an
intake node that loaded its own PR. Updated to what is actually built:

- system context drops the vector store, gains the GitHub MCP server,
  the clone cache and Langfuse over OTLP
- components show sources/, retrieval, workspace, telemetry and the
  semgrep tool; ingest is gone
- graph topology gains the intake short-circuit, the only edge v2 added
- new diagram for the investigator sandbox (four tools, injected
  workspace root, committed ruleset)
- new sequence diagram for live-intake failure containment — the reason
  PRSource has two methods, with resolve failures landing as HTTP before
  a thread exists and load failures as escalations
- lifecycle sequence shows the resolve/load split and the run span as a
  root linked to the request span
- data model gains PRRef, StaticFinding, trace_id, source,
  workspace_path and semgrep_runs

Key Decisions: the Chroma entry is marked superseded rather than
rewritten, the 2026-07-23 investigator entry carries an "extended by"
pointer now that it says three tools, and four v2 entries are appended
— deterministic lookup, OTLP to self-hosted Langfuse, the PRSource split
plus short-circuit, and semgrep as the fourth tool.

Verified: all eight blocks render with mermaid-cli 11.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_01_v2 §04 said `intake/store.py` was the session-scoped thread
registry and must survive the Chroma removal. It was not — it was the
Chroma accessor, and the registry is `app.state.reviews`. Following the
paragraph literally would have left a module importing `langchain_chroma`
after its only dependency was deleted, which is why the implementation
diverged from it (DECISION_LOG entry 17) and the plan has been wrong
since.

ITER_01_v2 now states what `store.py` was and where the registry
actually lives, keeping the original sentence inline so the correction
is auditable rather than a silent rewrite. ITER_03_v2 §04 names
`app.state.reviews` directly. `revised:` bumped on both.

v1 ITER_05 §04 still lists `intake/store.py` among shipped modules,
which was accurate at v1 and is left alone.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The suite was already green at 100% statement and branch coverage, so
these are the three cases that metric cannot speak to.

- Tracing kill switch: ITER_02_v2 §04 asks for "zero spans and still
  reaches a verdict", and test_tracing.py claimed it in its docstring
  but only asserted a no-op provider. The new integration test installs
  an *exporting* provider first and lets the kill switch overwrite it,
  so zero spans proves the switch won rather than that nothing was
  listening. The run reaches verdict(auto_approved).

- B-02: the registered branch of submit_decision's lookup had no test.
  Coverage records no arc for a conditional expression, so both halves
  of a ternary read as covered once the line runs. Restored the if/else
  — the form that fails fail_under = 100 loudly — and added a test whose
  registration carries a pr_id and source that differ from the
  checkpoint's, asserted on the run span. Killed the mutant: forcing the
  fallback branch fails the test.

- The demo has no coverage floor at all (examples/ is outside the
  coverage source), which is how it shipped reviewing an empty diff.
  tests/integration/test_demo_example.py loads the script by path and
  pins the seeding: a real diff, the changed-file list, and a workspace
  path that exists, before the graph is invoked.

BACKLOG B-02 marked closed with how; B-01 still stands.

226 passed, 100% statement and branch coverage, ruff and mypy --strict
clean. The 14 llm/network evals were not run — they cost money.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The comment explaining why the "View trace" link is omitted rather than
rendered disabled sat above prTitleLink, which has nothing to do with
tracing. Comment text unchanged; no behaviour change.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_04_v2 §03/§04 ask CI to install the pinned scanner wherever the
interpreter supports it, and no leg ever did — so nothing proved the
pin resolves. semgrep 1.172.0 ships cp312/cp313/cp314 wheels, so both
legs qualify.

Safe for the default suite: every semgrep path there is stubbed against
a fake subprocess, and test_asyncio_is_used_directly_so_the_stubs_above
_are_honest guards that the stubs stay honest.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_03_v2 §06 introduced per-file-aware truncation but named only "the
classifier and specialists", so risk_score kept the blunt head+tail cut
and could be handed a diff sliced mid-hunk. The spec wording was the
thing that was wrong, not the omission: the strategy is
node-independent, so §06 now names every node that reads the diff and
carries a dated clarification rather than letting the code drift ahead
of the plan.

Closes BACKLOG B-01. No shipped fixture is near GATE_MAX_DIFF_CHARS
(20 000), so no eval expectation moves.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Decision log entries 23 and 24, and the backlog they leave behind.

Entry 23 explains why two ITER_04_v2 §04 items were left rather than
closed silently: vendoring semgrep-registry rules imports LGPL-2.1
content into an Apache-2.0 repo, and a trace screenshot needs a live
Langfuse container plus a paid run — fabricating one would misrepresent
the artifact.

Entry 24 covers the fourth audit: the CI semgrep gap, and B-01 closed on
an explicit decision that the plan, not the code, was the thing to
correct.

Open: B-03, B-04. Closed: B-01, B-02.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_04_v2 §04 requires the wheel install path to list its required
variables inline, because an installed wheel ships no .env.example to
copy. The paragraph said "with the required variables set in the
environment" and named none, which leaves a reader of that path with
nothing to act on.

It now names ANTHROPIC_API_KEY and GATE_GITHUB_TOKEN and says the rest
of the Configuration table has working defaults.

Found by the fifth plan-compliance audit of the v2 family, recorded as
DECISION_LOG entry 25. That audit found nothing else: src/gate, the UI,
the policy corpus, the ruleset, packaging, .env.example and CI all match
every sections_changed entry across ITER_01_v2..ITER_04_v2. Backlog B-03
and B-04 stay open on the reasoning already recorded in entries 23 and 24.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The user-operator-guide skill gained a page-furniture phase. Bring the guide
up to it.

Every page below the index now carries a breadcrumb, and each subfolder page
ends with a prev/next/index footer. The two chains stay separate: HT-04 and
OP-05 end at the index rather than linking across. The index lists every page
grouped by subfolder with a "read this when" line, and keeps no footer of its
own -- it is the hub.

Two of the new rules caught real faults. The When/Prerequisites/Time blocks
were separated by single newlines, which a renderer collapses into one run-on
paragraph; they are bullet lists now. And OP-01 had a Verify paragraph sitting
between steps 4 and 5 of the post-install check, which restarts the numbering
at 1; step 5 moved above it.

Prose stays wrapped at 100 columns, against the skill's new no-hard-wrap rule.
Every Markdown file in this repo wraps that way, and reflowing only the guide
would split the docs tree between two conventions. DECISION_LOG entry 26 has
the reasoning.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Reading the tracing module raises three questions the code did not answer,
and one of the answers turned out to be wrong in the design note.

- `SpanContext` and `Context` are unrelated types that share a word. One is
  a span's identity and feeds a Link; the other is the container that sets a
  parent. Named in the module docstring and in tracing-design.md §1.
- `current_request_context` must be called in the handler and returns an
  identity rather than a container. Both constraints are now in its
  docstring, alongside why `is_valid` is what makes the kill switch degrade
  without an enabled check.
- `tracer()` reads the module's own `_provider`; the call site now says why
  rather than leaving it to the comment 50 lines up.

The correction: the span tree drew `gate.investigator.run` as a child of
`gate.node.investigate`. It is a peer. `run_investigation` overrides an
*intact* ambient parent, which is the one place `context=` is not repairing a
lost one — so the tree was teaching the misreading that would delete it. The
tree also omitted `gate.llm._InvestigationAnswer`. Verified by exporting the
real tree in memory, not by reading the code.

That shape was unasserted, so a test now pins it, confirmed to fail when the
argument is removed. The stale line references the docstrings shifted are
updated too.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The reference named four review-state fields in a sentence and left the
other fourteen to be discovered from a live response. Since that object is
both the body of GET /api/reviews/{thread_id} and what a checkpoint holds,
a reader had no way to know which fields a failed load still populates, or
which have more than one writer.

Adds a field table with the writing step, the nested object shapes, and the
two channels whose merge rules are not last-write-wins.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
The rest of .agents_workspace/ is tracked — ARCHITECTURE.md, DECISION_LOG.md
and planning/ — so handoff notes were the only part of that record that did
not survive a clone. The directory is empty today, so nothing enters the
repository until the next handoff is written.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Documentation drift from 394be9a: that commit made `tool_span` yield its span
so `run_semgrep` could attach `gate.semgrep.rule_hits`, but the reference's
span vocabulary was never given a row for it.

Also states the guardrail order in the tool closure as a contract — three
properties follow from statement order alone and were readable only in the
code — and adds the missing ruleset directory to the failure reasons behind
`semgrep unavailable`.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
A graph run that raised was caught in `_spawn`, logged as `graph_run_error`
and swallowed — correct, since a detached run must not crash the app, but it
left no verdict, no interrupt and no checkpoint saying why. `_derive_status`
read that shape as `running`, so the UI polled a dead review indefinitely and
the reason — usually a missing or rejected ANTHROPIC_API_KEY — stayed in the
server log.

The failure is now recorded in a session-scoped `app.state.review_errors` map,
ranked between `done` and `awaiting_human` (a run that died at the gate cannot
be resumed), and returned as `error` on the detail response. The page shows a
red `failed` badge, states the reason in the existing banner, and stops
polling. The empty-values branch of `get_review` derives its status too, which
is what covers a run that died before its first checkpoint.

Deliberately in memory, not in the checkpoint: persisting it would mean a new
EscalationReason member, an allowlist entry and a state-version argument to
back a UI banner in a single-user demo. Documented in the reference instead.

Also splits the review-source picker onto one row per source. Side by side the
two radios read as one setting with four controls.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Every UI poll is an HTTP request and every request is its own single-span
trace, so the detail view's cadence is trace volume (tracing-design §7.2). A
review parked at the gate for four minutes cost ~240 request traces around
~12 spans of real work, burying the review trace in the list.

Nothing changes at the gate until the reviewer acts, so poll every 10s there
and keep 2s elsewhere: the same four minutes now cost ~48. This is the half of
§7.2 that touches no OTel surface. `excluded_urls` stays unapplied — the
suggested regex also swallows POST /decision, and filtering spans before §7.3
confirms any span arrives would give a missing trace two causes at once.

Also closes §7.1 by stating the limit where it bites rather than fixing it:
the review page now prints the `gate.thread_id` to search beside the View
trace link, which reaches only the leg the review started on. Not swapped for
a Langfuse search URL — the configured base is a bare host with no project
segment, so that would stack a second unverified guess on the deep-link.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Spans never reached Langfuse. Four defects stood in the way, each with
the same symptom from the application side: the review completes
perfectly and the backend stays empty.

- The endpoint default was wrong. Probing 3.225.2, POST
  /api/public/otel returns 404 and /api/public/otel/v1/traces returns
  200 with auth and 401 without. Langfuse's integration docs name the
  bare path for self-hosted v3.22.0+; on this image it does not exist.
- The compose stack ran three services. Langfuse v3 ingests
  asynchronously through Redis and S3-compatible storage, so the worker,
  redis and minio are load-bearing. Rebuilt from upstream's file at the
  pinned tag rather than hand-written.
- Its ClickHouse healthcheck probed localhost, which busybox wget
  resolves to [::1]; the container has no IPv6, so a healthy database
  failed forever and depends_on held web and worker in Created.
- ENCRYPTION_KEY was unquoted, so YAML read 64 zeros as a number and
  Docker rendered "0". The worker crash-looped on Langfuse's 64-hex
  check while web started anyway.

The review page now links Langfuse's home page and prints the
gate.thread_id search term instead of deep-linking a trace. The URL
shape belongs to Langfuse, not to us — it already moved once, and the
search term reaches both legs of a review that paused at the human
gate, which the deep link never could.

tracing-design.md 7.3 closes with the probe table; 7.1 records the link
decision; the new section 9 documents the stack and the deferred move
to Langfuse v4.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Three corrections found sweeping the repo for the v0.2.0 release, none of
them code:

- The README's Install section still said the project "stays unpublished
  until it is useful for productive work, which means live GitHub intake" —
  the very thing v2 shipped — while the note at the foot of the same file
  already declared the hold lifted. The two now agree: the hold is lifted,
  publishing is a separate decision, and this release does not take it.
- HT-03 said the demo script covers "the three fixture beats only" and that
  the live beat "needs the server and the UI". examples/demo.py has had a
  `live --pr-url` subcommand since 6df31ef. The page now documents four
  beats and the headless live invocation.
- publish.yml.disabled exempted PYSEC-2026-311 in chromadb, a package v2
  removed. A stale --ignore-vuln is worse than none: it silently exempts
  whatever id gets reused. Replaced with the three mcp advisories that
  actually apply to the current lock, each annotated with why it does not
  reach this code and what retires the exemption.

Also corrects the ci.yml comment describing the default pytest selection,
which predates the `network` marker.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
Cuts the v2 plan family as v0.2.0: live GitHub pull-request intake by URL,
OpenTelemetry tracing over OTLP to a self-hosted Langfuse, deterministic
policy retrieval in place of the vector store, and semgrep as the
investigator's fourth tool.

MINOR, not PATCH: the release adds a request field, a graph edge, three
subsystems and five state fields. It is not MAJOR only because the project
is pre-1.0 — the one narrowing change, the removal of PolicyChunk.distance,
is what GATE_STATE_VERSION=2 exists to refuse an old checkpoint over.
pyproject.toml is the only manifest; uv.lock follows it.

The lock also moves cryptography 49.0.0 -> 50.0.0 (PYSEC-2026-3552) and
langgraph-checkpoint-sqlite 3.1.0 -> 3.1.1 (PYSEC-2026-3636), both fixes
inside the existing constraints. It cannot move mcp off its three
advisories: semgrep==1.172.0, the pinned extra and the current latest
release, pins mcp==1.23.3 exactly. All three are server-side and this
project is an stdio client only — see DECISION_LOG entry 32.

Verified: ruff, ruff format, mypy --strict, 228 tests at 100% statement and
branch coverage, and `uv build` producing a 0.2.0 wheel that bundles the
policy corpus, fixtures, UI and semgrep rules.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
v0.2.0 lifted the hold that kept the project off PyPI — the stated
condition was live GitHub intake, which it ships — but deliberately did
not act on it. Readers of the changelog had no way to tell whether that
meant "soon" or "never"; the Install sections say only that publishing is
a separate decision.

Records the intent under [Unreleased], with what actually remains: the
one-time Trusted Publishing setup on pypi.org and renaming
publish.yml.disabled back to .yml.

Generated with [Claude Code](https://claude.com/claude-code) by CEH
@cheneeheng
cheneeheng force-pushed the feat/v2-deterministic-retrieval-tracing-live-intake branch from b6dfbc5 to 8673433 Compare August 12, 2026 21:13
@cheneeheng
cheneeheng merged commit c045f8e into main Aug 12, 2026
2 checks passed
@cheneeheng
cheneeheng deleted the feat/v2-deterministic-retrieval-tracing-live-intake branch August 12, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant