Add a status layer over the ObjectStore so each stored report has a
lifecycle state, encoded in its object key as reports/<status>/<id>:
pending (new reports), removed (#11), published (#15). Encoding status in
the key prefix means "list pending" is a single prefix listing with no
secondary index to drift, so it returns exactly the pending reports by
construction.
Storage:
- Extend ObjectStore with List(ctx, prefix) and Delete(ctx, key); implement
in MemoryStore (host) and the js/wasm R2Store. R2Store.List drives the R2
binding's list() directly to page a prefix (the syumai helper takes no
options), so a status with >1000 objects is still enumerated exactly.
- The ingest Sink now writes new reports under reports/pending/<id>, so
accepted reports enter the lifecycle as pending. The <id> is stable across
transitions.
lifecycle package:
- Manager over an ObjectStore: ListPending, GetPending(id), MarkRemoved(id),
MarkPublished(id). A transition copies the opaque ciphertext frame to the
destination status key and deletes the source key — bytes are never
decrypted or re-encrypted; no key is needed to change status.
- Copy-then-delete is idempotent and retry-safe: Put(dest) before Delete(src)
never loses a report, a retry converges (re-Put identical bytes, Delete the
leftover source), and a transition of an id not in the source status returns
ErrUnknownReport (unless it is already at the destination -> idempotent nil).
Tests (host, MemoryStore, no TinyGo):
- List-pending exactness across a mix of pending/removed/published.
- pending->removed and pending->published leave the pending set, appear under
the target, and move byte-identical ciphertext that still decrypts.
- Idempotent retry and convergence from an interrupted (both-keys) state.
- Unknown/terminal-state ids error sensibly; new Sink reports list as pending.
Closes#10
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>