Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Entries are newest-last within a release, matching the order they were written.
- **the one edge-declaration path that still deferred its error.** `add_conditional_edge` passed the router and its mapping straight through to LangGraph, so a mapping pointing at a node nobody added was accepted, an empty mapping was accepted, and the first run to take that branch died on `self.ends[key]` — a bare `KeyError` raised from inside LangGraph's branch machinery, naming neither the graph, the source node, nor the router that produced the key. Everywhere else this kernel fails at declaration: an undeclared write raises at `add_node`, a write to a field the schema does not have raises at `add_node`, a cycle is refused at `compile()`. The mapping's targets were knowable all along. They are checked now, at `add_conditional_edge`, with an empty mapping refused and every unreachable target named alongside the key that leads to it; a router that annotates what it returns — a `Literal`, an `Enum` — has those members held against the mapping's keys, using the same hash lookup LangGraph will use, so the check predicts the failure rather than approximating it. A router that annotates nothing is still not second-guessed: predicting an arbitrary function's return value is not a check, and inventing a requirement would be worse than the gap. That last case is no longer a `KeyError`, though — the router is wrapped so an unmapped key raises `GraphRoutingError` naming the node, the key and the keys that were declared, which is what the rest of the kernel raises for a transition it cannot make. The wrapper keeps the router's name and annotations, because LangGraph names the branch after the one and infers the branch's input schema from the other.
- a **reused `--run-id` silently welded two runs into one record.** Every executing command appends to its `--trace` file — by design, since `grapharc diff` reads two runs out of one file — and nothing checked whether the id the operator passed was already in there. Running the same `plan` twice with one `--trace`/`--run-id` pair produced a single "run" whose `metrics` summed both runs' tokens and node counts, whose `viz` drew the second path welded onto the end of the first, and whose `replay` reconstructed a chimera; the operator got no signal at any point, and the trace is documented as the record the metrics cannot disagree with. The file being appendable was never the defect — the id being reused was, so the guard sits at the start of the run rather than in the recorder: `plan`, `run` and `agent` (both executors) refuse an explicit `--run-id` that already has events in the target trace, with exit 2 naming the id, the count and the file, before a single event is written. Fail closed rather than auto-renaming, because a run id is the name an operator will look the run up under later and picking a different one silently is the same class of surprise. Generated ids are untouched — fresh by construction, so they pay for no scan — and different ids in one file stay exactly as they were.
- the planner's system prompt **withheld the edge policy**, so a model had to learn it one refusal at a time. The prompt states the catalog, the START/END literals and the structural rules, and its own comments say why — "stating the rule up front is cheaper than three wasted rounds" — but the rule models actually trip over was the one it never stated. Observed with qwen3:8b against the incident registry: the goal said "find the cause and propose a fix", the policy denied `*->deploy`, and the planner proposed an edge into `deploy` in all three rounds (`edge_denied`; `edge_denied` + `cycle`; `edge_denied`) until the loop stopped `admission_refused` — about 3.5 minutes of local inference spent discovering one sentence, and a run that reads as a model failure when it is an information failure. The refusal came back every round and `edge_denied` names the check, not the rule, so "no edge may enter `deploy`, ever" was never on the page. `EdgePolicy.disclosure()` and `NodePolicy.disclosure()` now render a policy's deny rules as one line each (`edges into 'deploy' are denied by policy — do not propose them`), `PlannerNode(edge_policy=…, node_policy=…)` puts them directly under the catalog, and the shipped loop builders hand the planner the same policy *object* the checker holds, so the prompt cannot describe a policy the gate is not applying. Allow rules and the default are left out — they say what is permitted, which the catalog already covers — and so is `ask`, whose remedy is an approval rather than a different proposal. The refusal side is enriched to match: `EdgeRule` carries the `reason` `NodeRule` already had, `PolicyEngine.edge_policy()` compiles it out of the document instead of dropping it on the floor, and `policy/edge_denied` quotes it, so a planner reads why and not only what. **None of this is enforcement.** No check consults the disclosure, the admission gate is byte-identical, and a model that ignores what it was told is refused exactly as one that was never told — pinned by a test that compares the rejections of a disclosed and an undisclosed planner field by field, and by the shipped demo, whose scripted round 1 still proposes the denied deploy and is still refused.
- the `/live` **token was accepted in the query string on every route**, and a URL is the one place a secret cannot be taken back from: the uvicorn request line, the nginx access log, browser history, and the referrer of anything the page opens. The index made it worse by writing the token into every link it rendered, so clicking a trace filed the secret in history a second time. It is refused off `/live/api/stream` now — that route keeps it because a browser `EventSource` cannot set a header and has no other way in — with a 401 whose reason says *where* to put the token rather than that it is wrong. A browser gets a sign-in page instead of a bare 401 and trades the token for a cookie: a SHA-256 digest of it rather than the token itself, `HttpOnly`, `SameSite=Strict`, scoped to `/live`, and always ASCII, so a non-ASCII secret survives the latin-1 header encoding that a `Bearer` header cannot. Links carry no token at all. The residual exposure — the SSE request line — is now named in the cookbook next to `--live-token`, with what to scrub. Every confinement the reader already enforced is untouched: `../`, `%2e%2e%2f`, absolute paths, NUL bytes and symlinked traces are the same 404s, and a hostile token is still a 401 rather than a crash. (#41)
- the live page was **blind for the whole planning phase**, which is where a governed run spends its budget and does its refusing. `plan`, `admission` and `round` events were on disk — 2,081 tokens spent before any node ran, in the report — and the page rendered none of them, because it keys the graph off the `topology` event that only lands once a round is admitted and materialised. A run refused on every round produces no topology at all, so the most governance-relevant run there is showed nothing from start to "finished". The snapshot now carries a `planning` block folded from those same events (no new trace events): per round, the proposal size, the admission status, the checks that failed and the rejection codes, the planner tokens, and whether it executed; plus the loop's stop reason and detail when it stopped without a graph. The page renders it as a panel, and a round that has begun and not closed reads as *active* rather than idle — a planner mid-inference writes nothing for a minute at a time, which is exactly the "is it thinking or is it wedged?" the report describes. A run that never planned has no `planning` field and renders exactly as before. (#47)
- a finished trace **rendered as a done deal**: instantly all-green, with the amber `running` styling unreachable for every run that is already over — and for any live run whose nodes finish between two SSE polls. `?replay=1` on the stream walks the recorded events in timestamp order and emits the snapshots the run would have sent, so a node is amber for its recorded window and green after; `&speed=N` divides the wall clock and the whole replay is capped at 40 seconds, so a 40-minute incident trace is watchable. Frames are rebuilt by the same snapshot code a live stream uses, pointed at a prefix of the file, and depend on no clock: a trace replayed twice renders identically. Without the parameter nothing changed. (#48)

## 0.1.3

Expand Down
38 changes: 37 additions & 1 deletion docs/cookbook/06-serving-and-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,23 @@ status — each time the file grows. The server recomputes the snapshot;
the page only renders it. Add `&run=ID` to pin one run in a file that holds
several; without it the view follows the newest.

A planner run has no graph for as long as it takes the model to propose one,
so the snapshot also carries a `planning` block — round number, proposal size,
admitted or rejected with the checks that failed, planner tokens spent — and
the page renders it as a panel from the `plan`, `admission` and `round` events
already in the trace. A run refused on every round (`admission_refused`) never
produces a topology at all; it shows its rounds and its stop reason instead of
an empty page. A round that has begun and not closed also counts as activity,
because a planner mid-inference writes nothing for a minute at a time.

`GET /live/view?trace=REL&replay=1` replays a finished trace instead of
rendering its final state: the recorded events are walked in timestamp order
and each one emits the snapshot a live run would have sent, so nodes go amber
then green in the order and at the pace they really ran. `&speed=N` divides the
wall clock, and a whole replay is capped at 40 seconds however slow the
recording was, so yesterday's 40-minute incident trace is watchable. Without
the parameter nothing changes: one snapshot per file change, as before.

This composes with the Slack bot, which gives every tracing command a trace
path under its working directory: run `grapharc serve --live-root` over that
same directory, set `GRAPHARC_SLACK_LIVE_URL`, and the bot posts a
Expand All @@ -1338,7 +1355,26 @@ response; the exposure is what `viz` already prints. The bind stays
`127.0.0.1` unless you say otherwise; binding wider prints a warning, because
reachability is meant to come from a tunnel or tailnet in front, optionally
with `--live-token TOKEN` (or `GRAPHARC_LIVE_TOKEN`) required on every
`/live` request. The diagram renders with mermaid.js from a pinned CDN; with
`/live` request.

**Where that token is allowed to travel matters.** A URL is copied into places
with much weaker access control than the traces it protects: the uvicorn
request line, an nginx access log, browser history, and the referrer of
anything the page links out to. So the token goes in an
`Authorization: Bearer TOKEN` header, or in the cookie that `POST /live/auth`
sets when you paste it into the sign-in page a browser gets instead of a 401.
`?token=` is accepted on `/live/api/stream` and nowhere else — a browser
`EventSource` cannot set a header, so that one route has no alternative — and
any other `/live` route refuses a query-string token with a 401 that says so
rather than accepting the secret into your logs. The tradeoff that remains:
the SSE request line still carries the token, so if you terminate TLS at nginx
and log request URIs, scrub `token=` from that one path (or log
`$request_method $uri` rather than `$request`). The cookie is a digest of the
token, not the token, is `HttpOnly` and `SameSite=Strict`, and is scoped to
`/live`. Sign-in and the SSE exemption both apply only when a token is
configured at all; without one, nothing about `/live` is authenticated.

The diagram renders with mermaid.js from a pinned CDN; with
no CDN reachable the page falls back to the raw Mermaid source plus the same
mermaid.live fragment link the Slack bot posts.

Expand Down
4 changes: 3 additions & 1 deletion docs/cookbook/07-slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ contents — what the page shows is what `viz` and `metrics` already show.
Reachability is deliberately your problem, not the bot's: the bot never opens
a port (that is the whole point of Socket Mode), and `serve` still binds
loopback by default. Put a tailnet or tunnel (Tailscale, cloudflared) in front
for the person on the phone, and add `--live-token` if the URL is guessable.
for the person on the phone, and add `--live-token` if the URL is guessable —
the person then signs in once on the page rather than carrying the token in
the link, which is what keeps it out of access logs and browser history.
Details in [06-serving-and-ops.md](06-serving-and-ops.md).

## A `plan` that reads
Expand Down
Loading
Loading