Files
LibreMail-Bug-Report-Ingest/internal/lifecycle/lifecycle.go
T
JMR-devandClaude Opus 4.8 cb7d9c67a0 #10 Report lifecycle/status metadata (pending/removed/published)
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>
2026-07-02 15:25:11 -05:00

146 lines
6.5 KiB
Go

// Package lifecycle manages the status of stored bug-reports — pending, removed,
// or published (#10) — on top of the storage.ObjectStore seam.
//
// # Status model
//
// Status is encoded in the object key, not inside the (encrypted) object body:
// every report lives at reports/<status>/<id> (see storage.ReportKey). A report
// is born pending at ingest (storage.Sink) and moves to exactly one terminal
// state:
//
// pending ──MarkRemoved──▶ removed (a maintainer pulled it, #11)
// pending ──MarkPublished▶ published (it became a GitHub issue, #15)
//
// Because status is the key prefix, "list the pending reports" (used by the
// weekly job, #13) is a single prefix listing with no secondary index that could
// drift out of sync, so it returns exactly the pending reports by construction.
//
// # Transitions move ciphertext only
//
// A transition copies the stored object — which is an opaque AES-256-GCM frame
// (internal/crypto) — to the destination status key and deletes the source key.
// The bytes are never decrypted, re-encrypted, or otherwise inspected; no key is
// needed to change status. This keeps the plaintext exposure of the pipeline
// unchanged and lets an unprivileged component (e.g. the maintainer removal tool)
// change status without the decryption key.
//
// # Atomicity, idempotency, retry-safety
//
// Object stores offer no multi-key transaction, so a transition is a copy (Put
// destination) followed by a delete (source), in that order. The ordering is
// chosen to never lose a report: if the process dies after the Put but before the
// Delete, the object is momentarily visible under *both* statuses, but no data is
// lost. Re-running the same transition converges — the Put rewrites identical
// bytes and the Delete removes the leftover source — so a transition is safe to
// retry unconditionally. Deleting an already-absent key is a no-op in every
// ObjectStore, so the retry does not spuriously fail.
//
// A transition whose source is absent is treated as idempotent success when the
// object is already at the destination (an earlier attempt completed), and as an
// ErrUnknownReport otherwise (the id was never pending, or is in a different
// terminal state). Callers therefore may retry MarkRemoved/MarkPublished freely.
package lifecycle
import (
"context"
"errors"
"fmt"
"strings"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
// ErrUnknownReport is returned by a transition when no report with the given id
// exists in the expected source status (and it is not already at the
// destination). It wraps nothing sensitive: the id is an opaque handle.
var ErrUnknownReport = errors.New("lifecycle: unknown pending report id")
// Manager reads and transitions report status over an ObjectStore. It is
// stateless beyond the store reference and safe for concurrent use to the same
// extent the underlying store is.
type Manager struct {
store storage.ObjectStore
}
// New returns a Manager backed by store. In production store is a *storage.R2Store
// bound to the same bucket the ingest Sink writes to; in tests and cmd/devserver
// it is a *storage.MemoryStore.
func New(store storage.ObjectStore) *Manager {
return &Manager{store: store}
}
// ListPending returns the ids of all reports currently pending, in ascending
// order (the id's leading UTC timestamp makes this oldest-first, which is the
// order the weekly publish job wants). Given a mix of pending, removed, and
// published reports it returns exactly the pending ones — the removed and
// published reports live under different key prefixes and are not listed.
func (m *Manager) ListPending(ctx context.Context) ([]string, error) {
prefix := storage.StatusPrefix(storage.StatusPending)
keys, err := m.store.List(ctx, prefix)
if err != nil {
return nil, fmt.Errorf("lifecycle: list pending: %w", err)
}
ids := make([]string, 0, len(keys))
for _, k := range keys {
ids = append(ids, strings.TrimPrefix(k, prefix))
}
return ids, nil
}
// GetPending returns the stored (still-encrypted) frame for a pending report, or
// storage.ErrNotFound if no such pending report exists. The publish job (#14/#15)
// uses it to fetch a report's ciphertext before decrypting and publishing it;
// this package does not decrypt.
func (m *Manager) GetPending(ctx context.Context, id string) ([]byte, error) {
return m.store.Get(ctx, storage.ReportKey(storage.StatusPending, id))
}
// MarkRemoved transitions a pending report to removed (#11), excluding it from
// future publish runs. It is idempotent: removing an already-removed report
// succeeds. Removing an id that is not pending (unknown, or already published)
// returns ErrUnknownReport.
func (m *Manager) MarkRemoved(ctx context.Context, id string) error {
return m.transition(ctx, id, storage.StatusPending, storage.StatusRemoved)
}
// MarkPublished transitions a pending report to published (#15) after it has been
// posted as a GitHub issue, so the next run does not re-publish it. It is
// idempotent: publishing an already-published report succeeds. Publishing an id
// that is not pending (unknown, or already removed) returns ErrUnknownReport.
func (m *Manager) MarkPublished(ctx context.Context, id string) error {
return m.transition(ctx, id, storage.StatusPending, storage.StatusPublished)
}
// transition moves report id from status to a new status by copying the encrypted
// object to the destination key and deleting the source key. See the package doc
// for the atomicity/idempotency/retry model.
func (m *Manager) transition(ctx context.Context, id string, from, to storage.Status) error {
fromKey := storage.ReportKey(from, id)
toKey := storage.ReportKey(to, id)
data, err := m.store.Get(ctx, fromKey)
if errors.Is(err, storage.ErrNotFound) {
// Source absent. Either an earlier attempt already completed the move (the
// object is at the destination -> idempotent success), or the id was never
// in the source status (-> unknown).
if _, derr := m.store.Get(ctx, toKey); derr == nil {
return nil
}
return fmt.Errorf("%w: %q", ErrUnknownReport, id)
}
if err != nil {
return fmt.Errorf("lifecycle: read %s report: %w", from, err)
}
// Copy the opaque ciphertext to the destination, then drop the source. Put
// before Delete so an interruption never loses the report; the operation is
// idempotent, so a retry converges.
if err := m.store.Put(ctx, toKey, data); err != nil {
return fmt.Errorf("lifecycle: write %s report: %w", to, err)
}
if err := m.store.Delete(ctx, fromKey); err != nil {
return fmt.Errorf("lifecycle: delete %s report: %w", from, err)
}
return nil
}