Skip to content

feat(judge): refuse the invocation a protected span appears in, and cap what crosses - #264

Merged
wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0
Aug 11, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Closes the DoD bullets the 2026-08-11 conformance audit demoted CLOUD-135 from
Done for. PR #247 landed the config types, Attribution, and the pure builder;
bullets 1, 3, 4 and 5 were not landed, and one landed behaviour contradicts the
issue's own recorded decision.

⚠️ This reverses a landed design decision — please review that first

The landed module withheld protected spans individually, behind a config key:

[judge]
over_protected = "raw"   # or "pointer"

CLOUD-135's Rejected alternatives says, verbatim:

A committed opt-in key for protected egress — a widening surface (§8 keeps
configuration narrow) that purchases no enforcement power. If a consumer ever
needs it, that is a new recorded decision, not a latent key.

over_protected = "raw" is that key. Decision 2 says instead that a protected
member refuses the whole invocation as a usage error (exit 1), naming the
rule id and the count. This PR implements the decision and removes the key.

There is a second reason beyond conformance: per-span withholding quietly changes
what the judge is judging. The row named a set of files; silently sending a subset
means the verdict is about content the config never described. Refusal is the only
posture that keeps both the verdict honest and the bytes home.

Consumer #1 declares no [judge] table, so no committed config changes. A config
still carrying the key now gets exit 1 from deny_unknown_fields rather than
parsing green — asserted, because an author who wrote over_protected = "raw"
should hear that it no longer does anything rather than keep believing protected
content crosses.

lint.rs's judge-over-protected-unstated smell goes with it: a smell over a
question the engine now answers structurally could never fire.

What else landed

The cap (bullet 3). max_payload_bytes, default 16 KiB, refuses whole —
never truncates. A truncated payload would have the judge return a verdict about
a prefix while the record claims it judged the row. effective_cap is
tighten-only, and the direction is worth stating because it reads backwards: for
a budget, smaller is stricter, so §8's "may not weaken" means "may not raise".
resolve does not layer [judge] at all today — strictly stronger than
tighten-only — so the clamp is the semantics waiting for the day it does.

The rule class (bullet 1). The DoD names three classes: rule, content,
pointer. Only content and pointer existed. RuleText is its own type because
it is the one class that is not repo content — the config author's own committed
words — so it carries no egress question and always crosses. Separating it is what
makes "the constructor admits exactly three classes" checkable by reading the
signature.

The invocation record (bullet 5). InvocationRecord — rule id, byte count,
SHA-256, matched-file count, disposition — byte-stable and pointer-only, asserted
to carry none of the payload's bytes. Assembled hands back the serialized bytes
alongside it so the record cannot describe different bytes than the ones sent.

Ordering is load-bearing. assemble decides protection before any byte
enters a payload value, and checks the cap on the assembled bytes so it bounds
what would actually cross.

Not in scope

The stdin channel (decision 4) is CLOUD-56's wiring — this module reaches nothing
and hands back bytes. §7 already assigns the binary-level assertions there.

Tests

Twelve module tests: one protected span refuses the whole invocation and the
diagnostic carries the rule and count but neither the bytes nor the path; a span
with no provenance refuses too; a clean payload byte-scans positive for the
criteria and the matched bytes and negative for three planted sentinels; the raw
opt-in admits exactly the named class; nothing crosses by default while the rule
class still does; the cap boundary is inclusive and one byte over refuses whole;
the clamp is tighten-only in both directions; the record carries no payload bytes;
two assemblies are byte-identical.

Refs: CLOUD-135

@linear-code

linear-code Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
CLOUD-135 Define the judge's payload-privacy boundary (what may be sent to a model)

Why

Batten's output law reduces content to pointers (CLOUD-92); the judge (CLOUD-56) is the one component built to send repo content to a model. Ungoverned, that is an egress path inside the tool whose purpose is keeping content out of model context. The bound is set by what the egress buys: the judge's verdict is advisory-only and structurally unable to block (decision, 2026-08-07 evidence base), so no payload is justified by enforcement power — the boundary defaults to refuse, and every byte that crosses must be explicitly admitted, capped, and recorded. This issue closes the previously open positions (classes, redaction, local models, protected-content default) as decisions carried by a computable mechanism: a payload-assembly module that is the only way a judge payload can exist.

Decisions

  1. Closed payload-class taxonomy. A judge payload is built from exactly three classes: rule (the judge row's own committed id and criteria text from batten.toml), content (bytes of working-tree files matched by the row's glob), and pointer (path:line spans, counts, SHA-256 hashes). The constructor's input types admit nothing else — no environment values, no transcript text, no findings-store content, no config beyond the row, no git history. Exclusion is structural, not filtered: the module exposes no API that accepts arbitrary bytes into a payload.
  2. Protected bytes never cross. A matched file that is a member of the resolved protected set (PathSet::contains — the same membership batten hook's protected-path gate uses) refuses the whole invocation as a usage error (exit 1 at the caller), naming the rule id and the protected-match count.
  3. No local-model carve-out. Whether a configured command reaches the network is not computable from config, so the boundary is uniform over every judge command; locality is the operator's choice of run, invisible to the boundary.
  4. Cap and channel. Per-row max_payload_bytes, engine default 16 384 (the precedent of the argv-batching bound MAX_FILES_BYTES), tighten-only under the §8 raise-only clamp — a batten.local.toml may lower it, never raise it. Over cap refuses whole, never truncates. The payload crosses on the judge command's stdin — never argv (world-readable process state), never a temp file.
  5. Pointer-only invocation record. Every assembly — refused or crossed — yields a byte-stable record: rule id, payload byte count, payload SHA-256, matched-file count, disposition. The record type and its serialization are defined here; CLOUD-56 registers it through the findings store. Stdout stays silent on a clean run (§6).

Rejected alternatives

  • Redact-and-send for protected content — a redaction derives from the protected bytes, and no computable check certifies what it leaks; against an advisory-only verdict the residual risk buys nothing. Refusal is the only verifiable posture.
  • A committed opt-in key for protected egress — a widening surface (§8 keeps configuration narrow) that purchases no enforcement power. If a consumer ever needs it, that is a new recorded decision, not a latent key.
  • A local-model exemption — an exemption keyed on a property the engine cannot check is policy by assertion.
  • Truncate-to-cap — a truncated payload silently judges different content than the row named.

Definition of done

  • A payload-assembly module in crates/batten: the single constructor of judge payloads, typed to the three classes.
  • Assembly-time admission: exact per-path membership of every matched file against the resolved protected set, checked before any file content is read; one protected member refuses the invocation with a pointer-only diagnostic.
  • max_payload_bytes semantics per judge row: default 16 384, layered tighten-only; over-cap refuses whole. (The key's schema serialization lands with CLOUD-56's row shape; the semantics and enforcement live here.)
  • The invocation-record type with byte-stable serialization, pointer-only fields.
  • Module fixtures assert the sentinel property: content planted in an environment variable, an unmatched file, and a protected file never appears in an assembled payload.

Acceptance (fixture-level, each falsifiable)

  • A fixture tree where the row's glob matches one protected file: assembly refuses; the diagnostic carries the rule id and count 1; no payload value exists.
  • A clean fixture: the assembled payload byte-scans positive for the row's criteria and the matched file's bytes, and negative for three planted sentinels (environment variable, unmatched file, protected file).
  • A payload exactly at max_payload_bytes assembles; one byte over refuses whole.
  • A lowered cap applies; a raised cap is refused under the raise-only clamp.
  • Two assemblies of the same fixture yield byte-identical payloads and byte-identical records.
  • Record serialization byte-scans negative for the same sentinels and for matched-file content — the record never carries payload bytes.
  • No consumer identifier enters crates/batten (rule 1).

Refinement — Ready (three admissible payload classes by type; protected membership refuses whole; caps tighten-only; stdin channel; pointer-only byte-stable record)

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

  • Source of truth (§1). The payload-boundary module in crates/batten. The protected set stays the consumer's committed list in batten.toml (already resolved by Sets::from_config); this issue adds no second copy of it.
  • Computable predicate (§2). Not expressible as a batten.toml rule: it is an engine capability — the capability gap CLOUD-56 links as its blocker, which is how this lands in the engine rather than the bash layer. The predicates are exact: class admission by constructor input type, protected admission by PathSet::contains per matched path, cap admission by byte comparison. No inference anywhere. The gate is the fixture suite in mise run test:cargo, an hk gate step run by mise run ci.
  • Effect (§3). No new command; no effect-table change. Assembly is internal to the enforce-only judge path; its refusals surface as usage errors (exit 1) through the caller.
  • Output & exit (§5). Diagnostics and the invocation record are pointer-only (rule id, counts, hash) and byte-stable; refusal is exit 1 at the caller — a statement about the invocation, not a policy verdict — and nothing here produces exit 2.
  • Commit / bump (§6). feat → patch until 0.1.0.
  • Test obligation (§7). Module-level fixture tests (the Acceptance list) land here. The module is unreachable from any verb until CLOUD-56 wires it, so the binary-level assertions — protected-path refusal surfacing as exit 1 from batten enforce, and no payload bytes in any engine output — are enumerated in CLOUD-56's acceptance and land with it; naming them in both places is what keeps neither issue landing without the other.
  • Blockers (§8). None — implementable now. This issue blocks CLOUD-56: no payload may be assembled before what-may-cross is bounded. relatedTo CLOUD-92 (the pointer-only output law, extended here to the input direction), CLOUD-93 (the gate/judge line), CLOUD-82 (the drain budget the record renders under), CLOUD-133 (guard-decision telemetry: sibling record vocabulary, kept distinct).

Stated assumptions

  1. The taxonomy assumes the v1 judge domain is glob-matched working-tree files (CLOUD-56). A transcript-scoped judge would need a new class decision — a new issue, not a widening here.
  2. SHA-256 is pinned for record byte-stability; the property needed is collision resistance, not the specific algorithm.
  3. The record registers through the findings store once CLOUD-56 wires it; until then the type and serialization are the artifact.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 11, 2026 09:24
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 0a1851f into main Aug 11, 2026
11 checks passed
@wenzowski
wenzowski deleted the claude/phase-3-sequential-landing-h26kx0 branch August 11, 2026 09:30
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