Skip to content

feat(v2-refactor): per-validator time + match budgets (Move C) + secrets/cloudresources ctx - #110

Merged
rustic merged 5 commits into
mainfrom
feat/v2-movec-validator-budget
Jul 2, 2026
Merged

rustic merged 5 commits into
mainfrom
feat/v2-movec-validator-budget

Conversation

@sectoramen

Copy link
Copy Markdown
Contributor

What

Move C of the v2 consolidation: operators can set a per-validator execution budget — a wall-clock TimeLimit and/or a MatchLimit — enforced at the single dispatch chokepoint. Both opt-in; zero value = disabled = byte-identical (golden passes with zero UPDATE_GOLDEN).

⚠️ Stacked PR. Base is main so CI runs, but this branch also contains #107 and #109's commits (they precede it in the stack). Review only the top 2 commits:

Two commits here

1. ctx-teeth: secrets + cloudresources cooperative cancellation

Extends ctx-polling to the last two loop-driven validators so a time budget has teeth on 12/13 (METADATA stays exempt — not token-loop-driven, already per-item ctx). Both get ValidateContentCtx + an entry check before their whole-content pre-loop passes.

2. Move C budgets

  • New execguard.ValidatorBudget{TimeLimit, MatchLimit}, carried on ctx via execguard.WithBudgets (no fan-out signature churn; empty map = parent ctx unchanged).
  • Time: derives context.WithTimeout(ctx, limit) per validator → a runaway self-terminates at its own budget (all line-loop validators poll it). Tighter of {parent, budget} wins.
  • Match: truncates a successful over-cap slice (order preserved) + returns ErrMatchBudgetExceeded with the kept matches. Never truncates an error-partial slice.
  • Keyed by logical check name ("SSN"), not Go type — required threading the name from SetupValidators → RegisterDocumentValidator → fan-out (new namedValidator). Side benefit: panic/error logs now name the check, not *ssn.Validator.
  • Surfacing: fixed the document bridge / processDualPath / RunValidators to (a) keep truncated/partial matches on budget/deadline outcomes and (b) propagate a representative budget/ctx error so ScanResult.Incomplete fires. Hard validator errors keep the historical discard-and-log behavior. No budgets configured ⇒ none of these paths change ⇒ byte-identical.
  • Config: ContentScanConfig.ValidatorBudgets + JobConfig.ValidatorBudgets. Library-surface only; no CLI flag yet (separate UX decision).

Verification

  • Real stack e2e: time budget reclaims a runaway validator in ~30ms and surfaces DeadlineExceeded though the parent ctx never expired; match budget caps 50→5 kept through the full nested stack; under-cap unmutated.
  • Real binary (core.ScanContent, 200k dense lines): match cap → 5 matches + Incomplete; time budget → reclaimed + Incomplete; disabled → 200000 matches, complete.
  • ctx-teeth: per-validator ctx_cancel tests + negative controls (secrets 19.5s, cloudresources) confirm the polls are wired.
  • Unit tests use the block-on-ctx.Done anti-flake pattern (no wall-clock races). Full suite + -race green; golden byte-identical.

🤖 Generated with Claude Code

@sectoramen sectoramen added the v2-refactor Part of the v2 architecture consolidation (see docs/proposals/V2_ARCHITECTURE.md) label Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

✅ No sensitive data detected in the changed files.


🤖 This comment was automatically generated by Ferret Scan

Andrea Di Fabio added 5 commits July 1, 2026 16:01
… line loops

Phase 3 (slice 1): make the three audit-measured worst-offender validators
reclaimable mid-scan. Each now implements execguard.ContextAwareValidator via a
new ValidateContentCtx that polls the context once per line and, on
deadline/cancel, returns the matches gathered so far plus ctx.Err(). The
existing ValidateContent becomes a backward-compatible shim over
ValidateContentCtx(context.Background(), ...), so the public contract and all
output are byte-identical for the common (uncancelled) path.

Because execguard.ValidateContent already dispatches to ContextAwareValidator
when implemented (#98), the live scan path routes through the ctx-aware form
automatically — a runaway multi-line scan is now reclaimed promptly instead of
running to completion (SSN reclaim: 0.01s vs 53.68s before).

New shared primitive execguard.LineLoopCancelled polls ctx.Err() every 256
iterations (and on i==0), keeping the per-line cost effectively free while
bounding worst-case over-run to <256 lines after a budget fires.

Scope note: this fixes runaway *multi-line* scans. The O(n^2) single-long-line
case is Phase 3 slice 2 (shared LineScan primitive) and unchanged here.

Tests: per-validator ctx_cancel_test.go covering (a) cancelled-context reclaim
returns <500ms with context.Canceled on 500k-line input, (b) the background
shim is behavior-preserving. Negative controls confirmed the reclaim tests
genuinely fail without the poll (IP 5.18s, phone 16.16s, SSN 53.68s, all
returning nil err). Full suite + -race green; golden corpus byte-identical
(zero UPDATE_GOLDEN).
…oop validators

Phase 3 (slice 3): completes the ctx-polling coverage started in #107 (SSN/IP/
phone). The remaining seven line-loop validators now implement
execguard.ContextAwareValidator via a new ValidateContentCtx that polls
execguard.LineLoopCancelled once per line and returns on deadline/cancel; the
existing ValidateContent becomes a context.Background() shim. Because
execguard.ValidateContent (from #98) dispatches to ContextAwareValidator when
implemented, the live scan path routes through the ctx-aware form automatically,
so a runaway multi-line scan in ANY of the 13 validators is now reclaimed
promptly instead of running to the 5-minute job deadline.

- Direct-loop validators (loop in ValidateContent): email, creditcard, vin
  (preserve finishTiming observer, record {cancelled,match_count} on cancel);
  passport, personname (no observer; personname returns pre-dedup partials).
- Delegating validators (loop in a helper): socialmedia and intellectualproperty
  thread ctx into detectPatternsByLine (socialmedia also into its batch variant
  and the *WithErrorHandling wrapper); the loop stops early on cancel, the
  partial per-line matches are still clustered/reconstructed, and ValidateContentCtx
  surfaces ctx.Err().

Behavior-preserving: the common uncancelled path is byte-identical — the golden
corpus passes with zero UPDATE_GOLDEN. Public ValidateContent contract unchanged.

Tests: per-validator ctx_cancel_test.go — (a) a cancelled context reclaims a
500k-line scan in <500ms returning context.Canceled, (b) the background shim
equals ValidateContentCtx(Background). socialmedia uses newConfiguredValidator so
the loop actually runs (the unconfigured validator returns before it). A negative
control (neutralizing LineLoopCancelled) confirmed all 7 reclaim tests genuinely
fail without the poll (4-35s to completion vs <=0.02s), proving each validator's
poll is wired to the live path. Full suite + -race green.

Stacked on #107 (feat/v2-phase3-ctx-polling), which introduces
execguard.LineLoopCancelled.
…es validators

Phase 3 (ctx-teeth): extends ctx-polling to the two remaining line/loop-driven
validators so a per-validator time budget (Move C, next) has teeth on 12 of 13
validators. Only METADATA stays exempt — it is not text-token-loop-driven (audit
measured it safe ~0.3s) and already polls ctx per metadata item on its own path.

Both now implement execguard.ContextAwareValidator via a ValidateContentCtx that
returns partial matches + ctx.Err() on deadline/cancel; ValidateContent becomes a
context.Background() shim, so the live scan path routes through the ctx-aware form
automatically (execguard.ValidateContent dispatch from #98).

- secrets: poll once per line in the line loop (SECRETS' DoS shape is many dense
  lines — per-line shell/entropy work). Plus an ENTRY check before the
  whole-content passes (context analysis + multi-line secret scan) that run ahead
  of the polled loop, so an already-cancelled scan bails before paying for them.
- cloudresources: poll once per candidate span in the scoring loop, plus an entry
  check before the whole-content regex pass + containment dedup. (Independent of
  #108's per-line negative-keyword hoist — different lines; both can land in
  either order.)

Behavior-preserving: golden corpus byte-identical, zero UPDATE_GOLDEN. Public
ValidateContent contract unchanged.

Tests: per-validator ctx_cancel_test.go — cancelled context reclaims a large scan
promptly returning context.Canceled; background shim equals ValidateContentCtx.
cloudresources gains the standard raceEnabled helper pair and a -race-relaxed
ceiling (the pre-loop regex work exceeds 500ms under the race detector's 5-20x
overhead — the entry check keeps the real path near-instant; the ctx.Err()
assertion is the true guard). Negative control confirmed both reclaim tests fail
without the poll (secrets 19.5s, cloudresources nil-err). Full suite + -race green.

Stacked on #109 (feat/v2-phase3-ctx-polling-rest).
…d chokepoint (Move C)

Move C: give operators a per-validator execution budget — a wall-clock TimeLimit
and/or a MatchLimit — enforced at the single dispatch chokepoint
(execguard.ValidateContent). Both are opt-in; the zero value is DISABLED and the
scan is byte-identical to today (golden corpus passes with zero UPDATE_GOLDEN).

Mechanism (new internal/execguard/budget.go):
- ValidatorBudget{TimeLimit, MatchLimit}, carried on the context via
  execguard.WithBudgets(ctx, map[checkName]ValidatorBudget) — no fan-out
  signature churn. An empty/nil map returns the parent ctx unchanged.
- TIME: when TimeLimit>0, ValidateContent derives context.WithTimeout(ctx, limit)
  as the ctx handed to the validator. Since all line-loop validators poll it
  (Phase 3 / secrets+cloudresources ctx-teeth), a runaway validator self-
  terminates at its OWN budget, tighter than the 5-min job deadline. context
  .WithTimeout never extends a parent deadline, so the tighter bound always wins.
- MATCH: when MatchLimit>0 and a SUCCESSFUL result exceeds it, the slice is
  truncated (emission order preserved) and ErrMatchBudgetExceeded is returned
  alongside the kept matches. Never truncates a partial slice returned with an
  error (that path already means "incomplete").

Budgets are keyed by the LOGICAL CHECK NAME ("SSN", "IP_ADDRESS"), the operator-
facing name that matches --checks — not the Go type. This required threading the
check name from Detector.SetupValidators through RegisterDocumentValidator into
the document fan-out (new namedValidator{name,v}); the chokepoint is now called
with "SSN" instead of "*ssn.Validator". Side benefit: panic/error diagnostics now
name the check, not the Go type.

Surfacing (the plumbing that makes budgets observable): the document bridge and
processDualPath previously (a) DISCARDED a validator's matches whenever it
returned any error and (b) SWALLOWED per-validator errors on the normal-
completion path. Both are fixed for budget/deadline outcomes ONLY: truncated/
partial matches are kept, and a representative budget/ctx error is propagated so
core.ScanContent sets ScanResult.Incomplete (+reason). parallel.RunValidators
likewise now preserves partial matches for these outcomes. Hard validator errors
keep the historical discard-and-log behavior. No budgets configured => none of
these errors arise => byte-identical.

Config: ContentScanConfig.ValidatorBudgets (ScanContent) and JobConfig
.ValidatorBudgets (worker pool), both attached at the ctx origin. Library-surface
only for now; no CLI flag yet (a separate UX decision).

Verified through the REAL stack: e2e tests prove a time budget reclaims a runaway
validator in ~30ms and surfaces DeadlineExceeded even though the PARENT ctx never
expired; a match budget caps 50->5 matches kept through the full nested stack;
under-cap is unmutated. A standalone program driving core.ScanContent on 200k
dense lines confirms end-to-end: match cap => 5 matches + Incomplete; time budget
=> reclaimed + Incomplete; disabled => 200000 matches, complete. New unit tests in
budget_test.go use the block-on-ctx.Done anti-flake pattern (no wall-clock races).
Full suite + -race green; golden byte-identical.

Stacked on the secrets+cloudresources ctx-teeth commit and #109.
@sectoramen
sectoramen force-pushed the feat/v2-movec-validator-budget branch from 2fdd0f5 to 7524437 Compare July 1, 2026 20:03
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

✅ No sensitive data detected in the changed files.


🤖 This comment was automatically generated by Ferret Scan

@rustic
rustic merged commit 7824eb4 into main Jul 2, 2026
14 checks passed
@rustic
rustic deleted the feat/v2-movec-validator-budget branch July 2, 2026 19:12
rustic added a commit that referenced this pull request Oct 22, 2026
…data-capable file is scanned without METADATA (#797)

* fix(validators): keep findings and disclose the cut-short when a metadata-capable file is scanned without METADATA

With METADATA not selected, any coverage cut-short on the document path of an office, PDF,
image, audio or video file discarded every finding for the file and the disclosure with it:
exit 0 under --fail-on-incomplete, empty stderr, no SARIF notification, no redacted copy. The
router gives those files a metadata item, the metadata path failed for want of a validator,
the cut-short made the document path fail too, and the both-paths-failed branch returned nil
with the cut-short formatted away by %v; the legacy fallback then re-ran the file, dropped
its partials and returned nil.

- An unselected METADATA is a no-op: ProcessMetadataContentCtx returns (nil, nil) and the
  dual path does not start the metadata goroutine.
- Both paths failing returns every match either path kept and both errors wrapped with %w.
  The metadata goroutine keeps cut-short matches, as the document goroutine does.
- The metadata path returns its own cut-shorts through firstBudgetError; a METADATA panic
  was logged and dropped even with METADATA selected.
- The legacy fallback keeps cut-short partials and returns the cut-short instead of nil.
- --validator-budget METADATA=<d> (and all=<d>) is honoured: both metadata dispatch sites use
  the check name, and execguard.LoopBudget turns the time budget into one deadline for the
  metadata loop, checked before each item (#110's polling exemption stands). CapMatches,
  shared with ValidateContent, applies a match budget to the union of items.
- One predicate throughout: execguard.IsCoverageCutShort.

Tests: a sentinel x METADATA on/off x plaintext/.docx table derived from IsCoverageCutShort's
source with go/ast, the METADATA panic, a parent deadline mid-metadata, the METADATA budget,
the legacy fallback; cmd and pkg/scan contract tests over in-test containers; a golden recall
case (FileCase.ValidatorBudgets, forwarded through a new core.ScanConfig.ValidatorBudgets) and
three decoys that stay byte-identical.

No finding's text, line or type changes, so suppression hashes are unchanged. Affected files
go from exit 0 to 3 under --fail-on-incomplete; runs with no cut-short are byte-identical.

Closes #771

* fix(metadata): round the field-type boost before adding it, so arm64 and amd64 agree on confidence

`match.Confidence += boost * 100` is a multiply-add the arm64 compiler fuses
into one FMA with a single rounding; amd64 rounds the product first. For an
office AUTHOR_INFO finding with a 0.3 boost the two differ in the last digit
(55.000000000000014 on amd64, 55.00000000000001 on arm64), so a golden
snapshot regenerated on Linux failed on the macos-latest (darwin/arm64) leg
of #797's first CI run. The explicit float64 conversion forces the rounding
the Go spec permits fusion to skip. No band or finding changes; amd64 output
is unchanged (golden regeneration changes 0 files).

Refs #771

* fix(validators): report a METADATA budget the last item overran, and make the cut-short tests clock-independent

The metadata loop checked its budget deadline only before each item, so a
deadline that fired while the LAST item was running had no later item to
stop and was never reported. Most files have one metadata item, so a
METADATA budget was disclosed only when it had already expired before the
item started: a budget the item overran was the silently ignored budget the
flag promises not to have (#115). The loop now checks once more after the
last item; the item's findings are kept and the overrun is disclosed.

The Windows leg of #797's first CI run failed six cmd subtests and two
in-package subtests, all built on a 1ns budget being expired at the first
check. That holds on a nanosecond clock (Linux, macOS), where
context.WithTimeout sees the deadline already passed and cancels
synchronously, and not on Windows, whose clock ticks at 0.5-15.6 ms: the
same call arms a timer, and work that finishes within the tick never sees
it. The repo's one existing 1ns test (TestCutShortCoverageIsTheOtherOverlapTerm)
already knew this and skips.

- cmd: the five document-path budget subtests use a fixture with the same ten
  addresses plus about 1 MB of neutral filler, so the cut validator is still
  polling tens of milliseconds after the timer fires on any platform (PHONE
  adds ~600 ms, SSN ~210 ms on the dev host, against a 15.6 ms worst-case
  tick). Expectations are unchanged. The METADATA-item subtest, whose one
  item takes microseconds and which no fixture can stretch, asserts on fine
  clocks and skips on Windows with the reason when the budget did not fire.
- internal/validators: "expired before the first item" (1ns) is replaced by
  "fires during the last item" (50 ms budget, 400 ms item), which is the new
  behaviour and fails without the post-loop check; the legacy-dispatch test
  uses a MatchLimit, which needs no clock to prove the lookup name.

Refs #771

* test(cmd): budget PHONE, not SSN, in the precommit-profile cut-short run so Windows observes it

The precommit_profile subtest of TestMetadataCapableFileKeepsFindingsOnACutShort failed on
windows-latest after eb700de with rc 0 and the ten findings intact: the budget never showed.
It was the one budget run in the test on SSN rather than PHONE.

On a coarse clock a 1ns budget is a timer, and the cut validator has to still be polling its
context (every 256 lines) when the timer fires. SSN's pass over the 1 MB filler takes about
35 ms on the dev host (the filler holds no digit runs for it to examine): under --profile
precommit it observes a 30 ms budget and misses a 40 ms one. PHONE's pass takes 400-600 ms
and observes a 400 ms budget; the four PHONE=1ns runs in the same test passed on the same
Windows run. The Windows tick plus the runtime's timer dispatch exceeded SSN's span.

Both validators are in the built-in profile's list and neither reports anything on the
fixture, so the expected outcome (rc 3, ten EMAIL findings) is unchanged. On main the new row
still reproduces the issue: rc 0, 0 findings. The fixture comment now names the margin per
validator so a later edit does not budget a fast one again.

---------

Co-authored-by: union <union@example.invalid>
Co-authored-by: Lee Myers <ichilegend@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-refactor Part of the v2 architecture consolidation (see docs/proposals/V2_ARCHITECTURE.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants