Skip to content

feat(transcript): read a completed session transcript as an optional check input - #259

Merged
wenzowski merged 1 commit into
mainfrom
wenzowski/cloud-95-analyze-completed-session-transcripts-as-a-check-input
Aug 11, 2026
Merged

wenzowski merged 1 commit into
mainfrom
wenzowski/cloud-95-analyze-completed-session-transcripts-as-a-check-input

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-95.

Why

Declared-done-with-work-not-landed, deny-then-bypass, a stop with a non-empty frontier, an unprompted self-write — all invisible to synchronous mediation, all fully recorded after the fact. Four issues (CLOUD-97/98/219/267) need the same parse, so it lands once here.

The find that shaped the module

A host records its hook runs structurally. An attachment line carries hookEvent, toolUseID and — critically — exitCode. So "was this call denied" is exit_code == ExitCode::Violation: the same §7 table hook emits on, read from the other side. Not a substring match on an error message.

That is what makes CLOUD-98's deny-then-bypass a computable gate rather than a classifier, and its other half (dangerouslyDisableSandbox: true) is likewise a JSON boolean in the retained input object. Every event here comes from a typed field:

event typed source
tool call assistant → content[].type == "tool_use" → id, name, input
tool result user → content[].type == "tool_result" → tool_use_id, is_error
hook decision attachment → hookEvent, toolUseID, exitCode
turn boundary message.role

Three states, and the first two are the design

state behaviour
unconfigured — no [transcript] key silent; emits no -J key at all
absent — configured, nothing readable reported on both channels, exit 0
present but undecodable UsageError, exit 1, pointer-only

Collapsing the first two would fire on every minimal config, which is how a report teaches people to ignore it — lint.rs's "absent is not empty", applied.

The absent report rides two channels deliberately. output::message is ladder-gated, so --silent erases the stderr half; if that were the only channel, a skipped gate would exit 0 with nothing said — the false green this engine exists to catch. The -J document has no Mode to consult, so it cannot be silenced. the_absent_report_survives_silent_because_it_rides_the_data_channel pins it.

Exit 1, not 2

The Ready block writes `Usage` (`2`) — name and number contradicting each other inside one parenthesis. The name wins: §7 spends 2 on the policy verdict alone, and a parse failure reaching a mediating harness as a deny is the inversion that contract forbids. Corrected on the issue; the class is filed as CLOUD-307.

Free text is never interpreted and never emitted

A transcript holds every command, every file body and every prompt of a session — the richest source of secrets the engine can be pointed at. Counts carries numbers; Record carries a line. The fixture plants a sentinel in each free-text position the host records (thinking, a hook's stderr, a tool result's body) and no_transcript_text_reaches_any_output_stream asserts none reaches either stream on either channel.

This also aligns with judge.rs for free: a span with no path provenance classifies NoProvenance, whose is_protected() is true. Nothing here synthesizes a path — doing so would silently downgrade transcript text to Unprotected.

Forward compatibility is a requirement

The format is a host's and it moves. One captured session carried six top-level type values and eleven attachment.type values, most meaningless to any predicate. An unrecognized line yields no events rather than an error — a host shipping a new record type must not turn every downstream gate red. A line that is not JSON at all is the one refusal.

Deliberately not in scope

  • No findings emitted. FindingKind::Sequence and findings::Observation::NotObserved{RuleSkipped} both already exist with no producer; they stay the reserved seam CLOUD-97/98/219/267 occupy. This issue is the substrate they consume, per its own §1.
  • run_rules' runner signature is unchanged. It is a fn(&[Rule], &Path) with nowhere to carry a transcript, so the capability resolves beside it. Widening it to pass an input no rule reads yet would be scaffolding built before the thing it scaffolds.
  • check stays Effect::Read — no store write, no spawn.
  • No read cap. Nothing on main bounds input reads (budget.rs bounds instruction files only); inventing one here is new surface.

Verification

  • mise run fmt (full hk gate): 33/33 steps green, zero failures.
  • 9 in-module unit tests; 6 new E2E over the compiled binary covering all three states, byte-stability across two runs, the --silent -J property, and the opacity assertion.
  • All four states smoked directly against the built binary before the tests were written.
  • module-map-check, memories-check, schema-check, config-lint green; mem:core row added; schema/batten.schema.json regenerated.

Generated by Claude Code

@linear-code

linear-code Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
CLOUD-95 Analyze completed session transcripts as a `check` input (optional, pointer-only)

Why
Several divergence signals (declared-done vs. landed; deny-then-bypass; reflexive self-writes) are invisible to synchronous hook mediation but fully recorded in the host's completed session transcript — every tool call with its args, every result, every hook decision. Batten already runs a batch check (exit 1 on violation); the transcript is simply a third input alongside git state and mediated calls. This is also the substrate CLOUD-77's divergence-latency metric presumes: "time from divergence to correction" is only measurable over a completed span. Factor it out once so A/D and B's sub-rule don't each re-derive transcript parsing (mirrors CLOUD-36's shared-primitive pattern).

Rejected alternative
Have each signal parse the transcript itself. Rejected — duplicates a host-schema coupling in N places and makes the optional-capability degradation inconsistent.

Approach — deterministic, structural, explainable
The predicates this substrate feeds (declared-done, deny-then-bypass, reflexive self-write) read structure, not natural language: tool names, tool-call args (e.g. dangerouslyDisableSandbox: true is a JSON boolean), stop_reason / hook decisions, and role/turn boundaries. Parse the transcript envelope with serde into a typed event stream and evaluate predicates on typed fields + git state. The free-text payload stays opaque — never interpreted, only pointer-referenced (rule #4). Where a specific rule needs to match within text, use bounded deterministic tools (exact match, regex, aho-corasick, a grammar) — never learned inference. Every predicate must be readable by a reviewer and attributable in a guard record (feeds config_epoch, CLOUD-32); "reproducible but unexplainable" does not clear that bar.

Rejected alternative — in-process neural NLP (e.g. rust-bert)
Rejected on principle, not weight. (1) It is a model judgement, not a predicate — violates rule #3 even if pinned to be bit-reproducible, because explainability, not just determinism, is the bar; nobody can read the weights. (2) Output is not byte-stable across model/library versions (violates rule #4). (3) It is a parallel LLM plus a libtorch/ONNX dependency and distributable weights, cutting against single-binary-first (CLOUD-16/CLOUD-65). Anything that genuinely requires the meaning of free text is, by definition, not a computable gate: route it to the pluggable, config-selected, opt-in LLM judge (CLOUD-56, gated on CLOUD-24), recorded as non-deterministic and used only on rules explicitly marked unenforceable-by-predicate — never embedded in core.

Definition of done

  • A parser reads a completed session transcript from a host-provided path into a typed event stream (tool call, args, result, hook decision, user/assistant turn boundaries).
  • The format is pinned and fixture-tested against captured sample transcripts; unknown/absent transcripts degrade to "capability absent", never a crash or a false pass.
  • Exposed as an optional capability (CLOUD-45): present only where the host emits a parseable log; repo-agnostic (path/format is host-specific, not consumer-specific, so rule ci: check in the main-branch protection ruleset #1 holds).
  • All downstream output is pointer-only and byte-stable (rule ci: draft-first, verify-locally, land-by-fast-forward discipline #4): counts + session/turn pointers, never transcript content (transcripts may hold sensitive text).

Acceptance

  • A fixture transcript parses to the expected event stream; a truncated/absent one reports capability-absent.
  • Re-running over the same transcript yields byte-identical output.
  • No transcript text appears in any output; only pointers and counts.
  • Source contains no repo-specific identifiers.

Refinement — Ready (a typed transcript event stream as an optional check input; the parse capability grows the engine)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The one authoritative artifact is the typed event-stream model in crates/batten (a transcript module: tool call, args, result, hook decision, turn boundary). The host transcript format is external input, pinned by captured fixtures — never re-typed as a second schema; downstream rules (CLOUD-97, CLOUD-98, CLOUD-219) consume the typed stream, never the raw log.
  • Computable predicate (§2). Not expressible as a batten.toml rule: today's rule model is single-file forbid/command over globs, with no transcript input — this issue is itself the engine capability that closes that gap. Mechanism: a serde parser in crates/batten from a host-provided path to the typed stream, surfaced to batten check as an optional capability. Its own gate is the fixture test suite run by mise run test inside the hk gate and CI — a parse regression is a failing test, exit non-zero.
  • Effect (§3). No new command. The capability rides under check, whose declared read effect it must preserve: transcript reading is inspection-only, writes nothing, and stays structurally incapable of mutation (house style §5).
  • Output & exit (§5). Pointer-only and byte-stable: counts and session/turn pointers, never transcript bytes (transcripts may hold sensitive text). Capability absent (no host transcript at the configured path) → dependent rules are skipped and reported as capability-absent on stderr, exit 0; a transcript that is present but undecodable → Usage (2), refused loudly, never a silent pass.
  • Commit / bump (§6). feat → minor.
  • Test obligation (§7). E2E over the compiled binary (crates/batten/tests/cli.rs): (a) a fixture transcript yields the expected pointer-only output, byte-identical across two runs; (b) an absent transcript reports capability-absent and exits 0; (c) a truncated fixture exits 2. Event-stream decoding unit-tested in-module against the captured fixtures.
  • Blockers (§8). None — implementable against the current crate. relatedTo CLOUD-45 (this follows its optional-capability degradation contract; parsing does not wait on it) and CLOUD-36 (downstream rules pair the stream with git-state primitives; the parser itself reads no git state).

Review in Linear

…check input

Several divergence signals are invisible to synchronous mediation and fully
recorded after the fact: declared-done with work not landed, a mediated deny
followed by an enforcement-disabled retry, a stop with a non-empty frontier,
an unprompted self-write. Four issues need the same parse, so it is factored
out once here rather than re-derived four times.

Every event comes from a TYPED field, never from reading prose. The find that
shapes the module: a host records its hook runs structurally, with the code
they returned, so "was this call denied" is exit_code == ExitCode::Violation
-- the same table `hook` emits on, read from the other side -- rather than a
substring match on an error message. A predicate built on prose would be a
classifier wearing a gate's clothes. The other half of the bypass predicate
is likewise a JSON boolean in the retained tool input, not a phrase.

Free text is never interpreted and never emitted. A transcript holds every
command, every file body and every prompt of a session, which makes it the
richest source of secrets the engine can be pointed at, so Counts carries
numbers and Record carries a line. A test plants a sentinel in each free-text
position the host records and asserts none reaches either stream.

Forward compatibility is a requirement rather than a nicety: the format is a
host's and it moves. One captured session carried six top-level type values
and eleven attachment subtypes, most meaningless to any predicate. So an
unrecognized line yields no events instead of an error -- a host shipping a
new record type must not turn every downstream gate red. A line that is not
JSON at all is the one refusal.

Three states, and the first two are the design. Unconfigured means the
repository does not use the capability and is silent; absent means it was
configured and is unavailable, so dependent rules did not run and that is
reported; present-but-undecodable is a usage error. Collapsing the first two
would fire on every minimal config, which is how a report teaches people to
ignore it.

The absent report rides both channels deliberately. output::message is
ladder-gated, so --silent would erase the only signal that a gate was
skipped, and a skipped gate exiting 0 in silence is the false green this
engine exists to catch. The -J document has no Mode to consult, so it cannot
be silenced, and a test pins that --silent -J still carries the state.

Exit 1 for an undecodable transcript, not 2. The issue's own block writes
`Usage` (`2`), where the name and the number contradict each other; the name
wins. Section 7 spends 2 on the policy verdict alone, so a parse failure must
never reach a mediating harness as a deny.

No findings are emitted. FindingKind::Sequence and Observation::NotObserved
both already exist with no producer, and they stay the reserved seam the four
consuming issues occupy. run_rules' runner signature is likewise unchanged:
the capability resolves beside it, because widening a fn pointer to carry an
input no rule reads yet would be scaffolding built before the thing it holds.

Refs: CLOUD-95

Claude-Session: https://claude.ai/code/session_01GQARxMUusqnDj4fSaJpdJL
@wenzowski
wenzowski marked this pull request as ready for review August 11, 2026 07:47
@wenzowski
wenzowski force-pushed the wenzowski/cloud-95-analyze-completed-session-transcripts-as-a-check-input branch from 8a3b958 to 1858746 Compare August 11, 2026 07:47
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 1858746 into main Aug 11, 2026
6 checks passed
@wenzowski
wenzowski deleted the wenzowski/cloud-95-analyze-completed-session-transcripts-as-a-check-input branch August 11, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants