Skip to content

Keep a detection's rule identity, and let a guard be shut down - #164

Open
patchstackdave wants to merge 2 commits into
mainfrom
fix/detection-revision-and-lifecycle
Open

Keep a detection's rule identity, and let a guard be shut down#164
patchstackdave wants to merge 2 commits into
mainfrom
fix/detection-revision-and-lifecycle

Conversation

@patchstackdave

Copy link
Copy Markdown
Contributor

Four fixes on the reporting and refresh paths. Each is a case of something reporting success while doing nothing useful.

A detection named the wrong rule bundle

The detection reporter was built once with the rule-bundle ETag read at boot and never heard about a refresh. So after a guard picked up new rules, every later detection still claimed the old bundle — and anyone following one up would open a rule document that may not even contain the rule that fired.

The identity is now mutable, and re-stamped after an accepted swap. Only after one: a refresh that fell back to cached or bundled rules kept the previous rules, so it keeps the previous identity, which is the answer that is still true.

A reporting-only guard could not be stopped

The stop method was installed only when a refresh loop or a block log existed. A guard configured for detection reporting alone — reporting on, no refresh interval, no API key — had a live reporter that nothing could reach: its final batch depended on its own timer, and a graceful shutdown was impossible.

There is now one protection.stop(), always present, that stops the refresh loop, the block-log reporter and the detection reporter, and flushes what is buffered. stopRefresh() stays as an alias.

Reporting with no credential posted into a refusal

The detections endpoint is site-addressed and requires a verified, site-bound token. With reportDetections: true and no credential resolvable — common on runtimes with no filesystem, where .patchstackrc.json does not exist — the guard queued detections, posted them, and was refused, spending an outbound request per batch while the config said reporting was on.

Now it starts no reporter, warns once, and reports detectionReporting: 'unavailable-no-credential', which is a different statement from 'off'. When reporting is on, detectionHealth() returns local counts — attempted, acknowledged, refused or unreachable, dropped for queue pressure — plus the last acknowledgement, so a delivery path that refuses everything can be told apart from an app where no rule fired. Nothing extra is sent to produce those counts.

The refresh poller did not back off

Rule resolution deliberately absorbs an API or network failure into cached or bundled rules, because protection has to keep running. The consequence was that the poll tick never threw, the scheduler counted every poll as healthy, and every installed guard kept knocking at its normal interval for the whole length of an outage — then all came back at once when it ended.

Rule resolution now reports whether the rules came from the source. The loop backs off on that as well as on a thrown error, the push endpoint answers refreshed: false when nothing arrived, and refresh() resolves with { ok, reason? } rather than rejecting — a caller's manual refresh must not start failing because the platform is down and the cached rules held.

Also

Source comments that still described a rules or detections fetch as something the server accepts without a credential now describe the contract that is in force: site-addressed endpoints require a verified, site-bound token, and only first-time provisioning is anonymous.

Tests

npm test (1308 passed) and npm run typecheck. Each new guard is mutation-checked: remove the post-swap re-stamp, the credential gate, the always-installed stop, the delivered/refused split, or the backoff, and exactly the test naming that guarantee fails.

The new documentation covers the added surface: detectionReporting, detectionHealth(), stop(), and the credential requirement for reporting.

🤖 Generated with Claude Code

Four fixes to the reporting and refresh paths, all cases of something
reporting success while doing nothing useful.

**A detection named the wrong rule bundle.** The reporter was handed the
ETag read at boot and never heard about a refresh, so once a guard picked
up new rules every later detection still claimed the old bundle. Anyone
following that up would open a rule document that may not even contain
the rule that fired. The identity is now mutable and re-stamped after an
accepted swap — and only after one, so a refresh that fell back to cached
rules keeps the previous identity, which is the one still true.

**A reporting-only guard could not be stopped.** The stop method was
installed only when a refresh loop or a block log existed, so a guard
configured for detection reporting alone had a live reporter nothing could
reach: its last batch depended on a timer, and a clean shutdown was
impossible. There is now one `stop()`, always present, that stops the
loop, the block log and the reporter and flushes what is buffered.
`stopRefresh()` is kept as an alias.

**Reporting with no credential posted into a refusal.** The detections
endpoint needs a verified, site-bound token. With `reportDetections: true`
and no credential resolvable, the guard now starts no reporter, warns
once, and says `detectionReporting = 'unavailable-no-credential'` rather
than spending a request per batch on nothing. `detectionHealth()` reports
local counts — attempted, acknowledged, refused, dropped — and the last
acknowledgement, so a delivery path that refuses everything is
distinguishable from an app where no rule fired.

**The refresh poller did not back off.** Resolving rules deliberately
absorbs an API or network failure into cached or bundled rules, so the
tick never threw and the scheduler read a fleet-wide outage as a healthy
poll — every installed guard kept knocking at its normal interval for as
long as it lasted. Rule resolution now reports whether the rules came
from the source, the loop backs off on that, and `refresh()` resolves with
the same answer instead of rejecting.

Also corrects source comments that still described a rules or detections
fetch as something the server accepts without a credential.

Tests: the new guards are each mutation-checked — remove the re-stamp, the
credential gate, the always-installed stop, the delivered/refused split,
or the backoff, and exactly the test naming that guarantee fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderbuds

coderbuds Bot commented Aug 21, 2026

Copy link
Copy Markdown

Adds rule revision tracking and unified shutdown for detection reporting.

🎯 Quality: 88% Excellent · 📦 Size: Extra Large — strongly consider breaking this down

🛡️ Standards: no pre-flight fit check ran for this change — wire assess-change-fit into your coding agents to catch size before opening.

📈 This month: Your 116th PR — above team average · Averaging Good

See how your team is trending →

The bundle ETag says which bundle a detection came from, and it changes
whenever anything in that bundle changes — a rule added, one withdrawn, the
site's enforcement flipped. So it cannot answer the question a reviewer
actually has about one rule: are these counts evidence about the document
this rule has now?

Each served rule can carry its own `source_revision`, and a detection now
forwards it. Forwarded, not derived: the side that served the rule knows
which document it is, and a value computed here would be this client's
opinion of it. Null when the bundle carried none — a customer's own rule
has no revision — because "cannot say" has to stay distinguishable from a
revision that no longer matches.

The field is an identifier, like the rest of the payload, and the shipped
disclosure names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patchstackdave

Copy link
Copy Markdown
Contributor Author

One addition here, from the round-two finding on the platform side: detection counts were being judged against the whole bundle ETag, which changes whenever anything in the bundle changes — an unrelated rule added or withdrawn, site enforcement flipped — so a rule's own history was marked stale for reasons that said nothing about that rule.

A served rule can now carry its own source_revision, and a detection forwards it as rule_revision. Forwarded rather than derived: the side that served the rule knows which document it is, and a value computed here would be this client's opinion of it. Null when the bundle carried none, because "cannot say" has to stay distinguishable from a revision that no longer matches.

It is an identifier like the rest of the payload, and the shipped disclosure names it — the payload-contract test fails if a field is emitted without being described.

npm test: 1312 passed. Mutation-checked: derive the value from the bundle ETag instead of the rule and the tests naming that guarantee fail.

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