Skip to content

feat(drain)!: bound the advisory payload by a per-rule cap and a token budget - #438

Merged
wenzowski merged 2 commits into
mainfrom
wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget
Aug 14, 2026
Merged

wenzowski merged 2 commits into
mainfrom
wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes CLOUD-82.

CLOUD-79 landed the drain's delivery half; this is its emission half. The
renderer had no ceiling — a rule firing on two hundred identities inside one
changed scope spent two hundred lines of the agent's context, and nothing
asserted a bound on the payload at all. cycle is now three named stages
(select → cap → clamp), because the cap is a decision about a set: a renderer
that emitted as it walked could not know an eleventh identity for the same rule
was still coming.

Two bounds, and the store can tell them apart

Bound What it is a statement about Journalled as
[drain] cardinality_cap (default 10) the rule — a check firing on eleven distinct identities in one changed scope is a rule-health signal, not a to-do list over-cardinality-cap, the counter CLOUD-78's sampled review reads
[drain] token_budget (default 1024) this boundary — the finding is unchanged, the payload just had no room drain-suppressed, reconsidered next drain

A rule over the cap contributes one rule R: K+ findings line and no entries. A
clamped payload closes with budget: N findings withheld. The estimate is
budget::estimate_tokens, the same one [budget] gates instruction files with,
rather than a second estimator that could disagree about what a token is.

Both keys sit on the existing [drain] table and inherit its unlayered
treatment: they bound the engine's own output, so there is no raise-only reading
for a local file to weaken. 0 is the honest bottom of each range, not a
disabled feature.

Salience is independent of the count, structurally

Lines are ordered by tier, then rule, then fingerprint. The occurrence count is
deliberately not a sort key — that is CLOUD-80's no-escalation law made
structural rather than remembered, and it is what
a_rising_count_never_moves_a_finding_ahead_of_a_stronger_tier pins.

The group re-raise

Renders old->new against what this session's last drain actually said
(WakeState::counts), because the store carries no count anchor and the honest
anchor for "worth saying again" is what the agent was last told. Only emitted
identities are remembered, so a capped rule anchors nothing and cannot make the
next drain's re-raise silent. A count that fell renders plainly: a ratchet is not
a re-raise, because re-raising on incremental fixing punishes the fix.

One identity's occurrences are a count by construction, so a 500→501 re-raise is
one line with one in-scope pointer — 501 pointers is unreachable, not merely
avoided.

§7

Renderer unit tests in src/drain.rs, end-to-end over the compiled binary in
tests/advisory_drain.rs (not tests/cli.rs, which the Ready block named
before that file was under concurrent edit):

  • (a) budget: the rendered payload is at or under the configured budget, and the
    same assertion over the unclamped set fails — so the fixture is shown to
    actually overflow;
  • (b) K+1 identities in one rule render exactly one summary line and no entries;
    E2E, two caps in batten.toml produce two payloads, so the key is live;
  • (c) a 500→501 fixture emits 500->501 and one in-scope pointer;
  • (d) two renders of one snapshot are byte-identical whatever the input order;
  • (e) a rising count reorders nothing and leaves the record's tier untouched.

Plus: a zero budget says nothing rather than one thing; the capped and the
clamped sets are disjoint, so no identity is journalled under two reasons.

Notes

  • Marked !: DrainConfig/WakeState gain fields and cycle/drained change
    arity. batten.toml is unchanged for anyone who declared no new key.
  • Operator diagnostics (volume, per-reason suppression counts) go to the verbose
    batten: rung, never into the agent payload. Routing them into systemMessage
    proper rides CLOUD-44's per-host emitter shims, which the Ready block states
    does not block this.
  • fuzz/Cargo.lock catches up to v0.0.70, which the release commit left behind.

Generated by Claude Code

@linear-code

linear-code Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
CLOUD-82 Keep advisory drain output pointer-only under a token budget

Why
The advisory drain must emit pointer-only information and stay within a test-asserted token budget. Operator diagnostics should go through a zero-token channel for the agent.

Acceptance

  • Drain output is pointer-only
  • Drain output stays within a test-backed token budget
  • Operator diagnostics are emitted through systemMessage or equivalent zero-token operator channel

Refinement from the identity decision (CLOUD-123) — this issue owns the cardinality cap:

  • Per-rule cardinality cap + summary-line-on-overflow live here (previously ownerless): if one rule exceeds K distinct identities in a drain, emit rule R: K+ findings as one pointer-only summary line, never K entries.
  • A group re-raise (count increase on a multiset identity) emits identity + old→new count + pointers only for instances inside current changed-scope hunks, capped by this budget — never the full instance list (a 500→501 re-raise must not emit 501 pointers).
  • Duplicate-occurrence counts never escalate a severity tier or shorten a repeat interval (CLOUD-80's no-escalation law).
  • Per-rule suppression-rate counter feeds rule-health telemetry (Tricorder's probation model) via CLOUD-78's sampled review.

Refinement — Ready (the drain's emission contract: pointers, caps, and a test-asserted token budget)

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

  • Source of truth (§1). The drain renderer in crates/batten plus one budget key in the batten.toml config types — the budget is config, defined once in the loader's schema; the renderer is the only place emission shape is decided.
  • Computable predicate (§2). Not expressible as a batten.toml rule — the rule model scans files, and this bounds the engine's own output — so it grows the drain capability. Every acceptance criterion is a test predicate: the rendered payload's token estimate stays at or under the configured budget; a rule exceeding K distinct identities renders one rule R: K+ findings summary line; a group re-raise emits identity, old→new count and only in-scope pointers. Gated by §7 under mise run test (hk gate + CI).
  • Effect (§3). No new command and no declared-effect change; rendering is a pure function of the store snapshot.
  • Output & exit (§5). Pointer-only (identity hash, path:line, counts), byte-stable and salient-first under the budget; a 500→501 re-raise never emits the instance list. Operator diagnostics (volume, timing, per-rule suppression rates) go to the host's zero-token operator channel — systemMessage on the claude-code emitter — never into the agent payload.
  • Commit / bump (§6). feat → minor.
  • Test obligation (§7). (a) a budget test: the token estimate of the rendered payload is at or under the configured budget, and the test fails when it is over; (b) K+1 identities in one rule render exactly one summary line; (c) a re-raise fixture emits old→new count and only pointers inside current changed-scope hunks; (d) two renders of one snapshot are byte-identical; (e) a duplicate-count increase leaves tier and repeat interval unchanged. Renderer unit tests plus E2E over the compiled binary (tests/cli.rs) for the emitted bytes.
  • Blockers (§8). blockedBy CLOUD-79 (this is the emission half of that drain — there is no payload to bound until it exists); relatedTo CLOUD-44 — the zero-token operator channel rides the per-host emitter shims, but budget and pointer discipline are asserted over the rendered payload with no host attached, so CLOUD-44 does not block. Refinable now; implement after CLOUD-79 lands.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 14, 2026 02:56
@wenzowski
wenzowski force-pushed the wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget branch from 31cbf2e to 28d8812 Compare August 14, 2026 03:04
@wenzowski
wenzowski marked this pull request as draft August 14, 2026 03:44
@wenzowski
wenzowski force-pushed the wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget branch from 28d8812 to a41fc38 Compare August 14, 2026 03:57
…n budget

The drain rendered one line per surfaced identity with no ceiling: a rule
firing on two hundred identities inside one changed scope spent two hundred
lines of the agent's context, and nothing asserted a bound on the payload at
all. `cycle` is now three named stages — select, cap, clamp — because the cap
is a decision about a SET: a renderer that emitted as it walked could not know
an eleventh identity for the same rule was still coming.

Two bounds, measuring different things, and the store can tell them apart. The
per-rule cardinality cap collapses a rule over `K` distinct identities to one
`rule R: K+ findings` line and journals what it withheld as
`over-cardinality-cap` — a statement about the rule, and the counter
rule-health telemetry reads. The token budget clamps the payload as a whole,
measured with `budget::estimate_tokens` rather than a second estimator, and
journals what it dropped as `drain-suppressed` — a statement about this
boundary, reconsidered on the next one.

Between them, lines are ordered salient-first by tier, then rule, then
fingerprint. The occurrence count is deliberately not a sort key: that is
CLOUD-80's no-escalation law made structural rather than remembered.

A group re-raise renders `old->new` against what this session's last drain
actually said, which the wake state now remembers per emitted identity. The
store carries no count anchor, and the honest anchor for "worth saying again"
is what the agent was last told. A count that fell renders plainly — a ratchet
is not a re-raise, because re-raising on incremental fixing punishes the fix.

Volume and suppression counts go to the operator on the verbose rung, never
into the agent payload; routing them into `systemMessage` proper rides
CLOUD-44's per-host emitter shims.

BREAKING CHANGE: `DrainConfig` gains `cardinality_cap` and `token_budget` and
`WakeState` gains `counts`, so neither is constructible from its fields alone
without `..Default::default()`; `drain::cycle` takes the config and the
previous drain's counts, and `WakeState::drained` takes the completed cycle
rather than a bare digest. `[drain]` in `batten.toml` is unchanged for anyone
who declared no new key — both default.

Refs: CLOUD-82
…nd means

The module map said the cardinality cap and the token budget were CLOUD-82's,
which was true while they did not exist. It now says what they are, and — the
part a reader cannot recover from the code without tracing both call sites —
why the two withheld sets are journalled under different reasons: one is a
statement about the rule, the other about this boundary.

Refs: CLOUD-82
@wenzowski
wenzowski marked this pull request as ready for review August 14, 2026 04:23
@wenzowski
wenzowski force-pushed the wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget branch from a41fc38 to 991973b Compare August 14, 2026 04:23
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 991973b into main Aug 14, 2026
9 checks passed
@wenzowski
wenzowski deleted the wenzowski/cloud-82-keep-advisory-drain-output-pointer-only-under-a-token-budget branch August 14, 2026 04:34
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