worktree-store-scan-on-demand
Scan on demand: an app asks for one object to be scanned, reads the verdict back, and optionally gets a signed webhook when it is done. Stacked on worktree-store-virus-scanning, which is already merged.
The sweep half answers "is this object clean?" within the hour. For an upload that is useless — a user waits, or the app serves bytes nothing has looked at. This is the other half.
What is here
Tasks 1–17 of docs/superpowers/plans/2026-09-02-scan-on-demand.md. The design is docs/superpowers/specs/2026-09-02-scan-on-demand-design.md (draft 7, seven review rounds).
POST .../store/scan/object— enqueue, roleService, behind the consent chainGET .../store/scan/object?key=&wait=— the verdict, with a long-poll- a drain goroutine on its own 30s clock, with a boot rebuild
- signed callbacks to the app's platform host, delivered by a bounded worker pool
- per-app callback keys, deployment and per-app rotation, metering
The two properties worth reviewing
The console must never read a tenant's bytes without consent. Provisioned, not trashed, member requested, not attested client-encrypted, operator granted, deployment switch on. One function, objstore.QueuePermitted, called by both doors and by the drain — and the drain re-checks per object, not per page. A review found it checking once per page and then scanning up to fifty objects under that one answer, so a revoked grant or a re-attestation was honoured only from the next tick.
A callback must not claim what it cannot justify. The final review found the drain stamping every outcome with the engine and signature database this tick is configured with, before knowing whether the engine had run — so a verdict read from a week-old tag was POSTed, signed, as though today's engine had produced it. The status door already refused to make that guess; the two surfaces disagreed and the signed one was lying. Provenance is now filled in only where the engine actually ran.
Reviews, and what they cost
Three rounds, and every one found something the tests had not:
| | | |---|---| | consent checked once per page, not per object | up to 49 more objects read under withdrawn consent | | the path validator had a second door | /%5c%5cevil/x and /%2f%2fevil/x — encoded spellings of shapes it already refused | | drainOne swallowed a refusal it had just observed | items deleted that §7.5 says to keep, and a verdict with no result object behind it | | webhook_gen did not prevent what it claimed | PurgeApp deletes the record, so a reclaimed app name derived the same key | | signed callbacks claimed engine provenance | a signature database that never judged the object |
The fourth was found by the implementing model reading its own instructions and disagreeing with them, which is why it was asked to.
Two things a reviewer should know
The tests were the weak part, not the code. Several asserted the shape of a result rather than the property in their name — TestDeliveryDoesNotBlockTheTick never ran a tick; TestNeverPostsUnsigned returned before the signer was consulted; TestCallbackBodyCarriesRecorded marshalled its own literal. Each is rewritten to fail when its behaviour is removed, and the main paths are mutation-tested.
The wire format is frozen by TestCallbackWireFormatGoldenVector. The docs hand those bytes to app authors to reimplement in other languages, and every tenant holds a key derived by that function; change the derivation and every callback everywhere stops verifying at once. A deliberate v2 adds a signature alongside v1 rather than editing the constants.
Not merged-ready without one thing
The tenant-facing documentation is not written, and the final review argues it should land with the merge rather than after. The moment this merges, the enqueue door accepts callback_path from any Service credential and the console will POST signed bodies to apps that have no documented way to verify them. That page needs VerifyCallback and must say that the delivered secret is hex while the MAC is keyed on the raw bytes — an author who feeds the environment string straight into HMAC fails every verification, and the next move after that is to stop verifying.
Drafted copy is held out of these commits pending review; the plan's tail lists it, along with what is known-and-unfixed (no at on a tag verdict, no reason on skipped, skipped bytes metered, consent checked once per long-poll).
🤖 Generated with Claude Code
Discussions
- No discussions on this branch yet.
Tasks
- No tasks yet.
Activity
- paul@keymail.dev pushed fc549d170 docs: receiving scan results, the page an app author needs
- paul@keymail.dev pushed f64485d5f plan: tasks 14-17 are in, and what is still waiting on a person
- paul@keymail.dev pushed bf5204383 scan: the blocker a final review found, and three tests that proved nothing
- paul@keymail.dev pushed dc148c9cc console+cli: scan metering and the queue-depth surfaces
- paul@keymail.dev pushed e4f3a7b02 console: callback delivery, bounded and backstopped by the door
- paul@keymail.dev pushed a0be6f4bb console: webhook key delivery and rotation
- paul@keymail.dev pushed 90ea50b17 console: per-app callback signing, and the verifier apps will run
- paul@keymail.dev pushed f089085e8 spec+plan: what a design review found in the half not yet written
- paul@keymail.dev pushed 23fa8eaee scan: what three reviews found in tasks 11-13
- paul@keymail.dev pushed ba07db58f console: the scan status door, long-polling and honest
- paul@keymail.dev pushed 8105c88cc console: the scan drain, its own goroutine and its own clock
- paul@keymail.dev pushed 976ff88ea plan: task 11 is landed, and what it taught task 12
- paul@keymail.dev pushed 90adce43f console: the scan enqueue door, behind the consent chain
- paul@keymail.dev pushed fe5a0999f Merge the scanning branch's own main-merge into the stack
- paul@keymail.dev pushed 36eb8911b console: the callback path validator, before anything can store one
- paul@keymail.dev pushed 2d4d0abf8 objstore: the consent chain answers with a name, not a bool