Skip to content

Durable per-user usage rollup and GDPR/PIPL content erasure#5

Merged
CMGS merged 9 commits into
mainfrom
feat/usage-rollup-erasure
Jul 16, 2026
Merged

Durable per-user usage rollup and GDPR/PIPL content erasure#5
CMGS merged 9 commits into
mainfrom
feat/usage-rollup-erasure

Conversation

@CMGS

@CMGS CMGS commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the two remaining audit/billing gaps identified against the reference architecture: long-horizon per-user usage that survives ledger pruning, and the right-to-erasure hook for retained content.

Durable minute rollup

  • New usage_rollup table — per-(minute, tenant, user, model) buckets on all three Store backends — fed by a 60s background task (spawn_usage_rollup).
  • Each pass recomputes from the rollup watermark or the trailing 20-minute window, whichever reaches further back: the first run rolls a pre-existing ledger whole, and a stalled task catches up on its own.
  • A minute is rolled only after a one-minute settle window, so no in-flight billing write can land in an already-rolled minute — which is what makes the max-upsert sound: buckets only ever grow, and a recompute over a partially pruned ledger can never shrink one. Negative prices are rejected at config load so cost sums stay monotone.
  • usage_by_user serves rolled minutes from buckets plus the raw ledger tail (watermark split, minute-aligned bounds), so GET /admin/usage/users stays correct after ledger_max_rows pruning and repeated queries can't drift.
  • Postgres elects one replica per pass via an advisory xact lock; periodic tasks skip missed ticks.

Content erasure (GDPR/PIPL)

  • DELETE /admin/audit/content?user= erases every retained trace of one end user: retained prompt/response rows, generated batch-result messages (batch_results gained a user_id column), and queued batch inputs — blanked in place across all batch states, so a pending/running item fails at execution instead of running erased content. The content_erase audit entry commits in the same transaction as the deletion.
  • Batch inputs are pruned the moment a batch reaches terminal status (same transaction as the status write), so submitted prompt text does not outlive the run.
  • Executors defend the capture-to-dispatch window: distributed backends re-read each item's stored row immediately before dispatch and fail closed on any error; local backends check an erasure marker (latest instant per tenant/user, upserted) against the batch's submit instant, so a mid-batch erasure stops the user's remaining items while later batches run normally.
  • New DELETE /v1/files/{id} (OpenAI-compatible): files are tenant-owned assets, deleted with one guarded id+tenant statement; SQLite file ids now derive from the non-rewinding AUTOINCREMENT sequence, closing an id-reuse window.
  • Documented contract: erasure reaches attributed content; ledger rows and security events carry no content and are kept; anonymous submissions have no erasure subject.

Review

Converged through six adversarial review rounds on the full diff: 18 findings raised, 13 fixed across five fix(review) commits, 4 pushbacks accepted, 1 self-labeled contrived residual accepted (unreachable outside unmerged intermediate states). Key invariants (settle window, monotone buckets, fail-closed dispatch, submit-instant erasure baseline) are documented in code comments and docs/governance.md.

Testing

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings clean; cargo test: 320 passed, 0 failed.
  • Real Postgres (docker postgres:16-alpine, --test-threads=1): gw-state 45, gw-handler 31, gw-server 83 — exercising the advisory-lock rollup, watermark union, transactional erase + audit, in-place item blanking through a real pending batch, pre-dispatch snapshots, and the guarded file delete.
  • Scenario tests pin each fixed finding: first-run backfill, prune-never-shrinks, erasure across batch states, post-erasure batches unaffected, id non-recycling, tenant-scoped erase/delete.

CMGS added 9 commits July 16, 2026 13:21
Rollup: the advance window now reaches back to the rollup watermark when it
trails the 20-minute backfill (first run rolls a pre-existing ledger whole; a
stalled task catches up), and bucket upserts take the per-column max so a
recompute over a partially pruned ledger can never shrink a bucket. Postgres
elects one replica per pass via an advisory xact lock; periodic tasks skip
missed ticks. usage_by_user bounds are minute-aligned on entry so repeated
queries can't drift as the watermark advances.

Erasure: batch inputs are deleted at terminal status; batch results carry the
effective user and their messages are erased with retained content; the
content_erase audit entry commits in the same transaction as the deletion.
DELETE /v1/files/{id} (OpenAI-compatible) lets tenants remove uploaded files,
which stay tenant-owned assets outside per-user erasure.
…tx pruning

A minute is rolled only after a one-minute settle window, so no in-flight
billing write can land in an already-rolled minute — the precondition that
makes the max-upsert sound. Erasure now blanks a user's queued batch items in
place (they fail at execution instead of running erased content), reaching
pending/running batches. File deletion is one guarded DELETE (id + tenant) and
SQLite file ids derive from the non-rewinding AUTOINCREMENT sequence, closing
an id-reuse/check-then-delete cross-tenant window. Fenced/unfenced terminal
status writes prune batch inputs in the same transaction.
A batch executor re-reads each item's stored copy immediately before dispatch
(backends that persist items), so an erasure landing while the batch sat
queued stops the item instead of running the pre-load snapshot. Config
validation rejects negative prices — cost accounting (and the rollup's
max-upsert) relies on per-column monotone sums.
…l mid-batch items

A distributed executor now fails an item closed when the pre-dispatch re-read
errors or finds no row — a stale pre-load copy never dispatches. Local
backends (which don't persist items) record an erasure marker in the erase
transaction; the executor checks it per item against the batch's start time,
so an erasure landing mid-batch stops the user's remaining items while
batches submitted after the erasure run normally.
The marker comparison point is now the submit instant (when items are
captured), not the spawned executor's first poll, closing the pre-poll
erasure window. Markers keep only the latest instant per (tenant, user) —
an upserted unique row on SQLite, a map in memory — so repeated erasures
can't grow unbounded and the per-item check is a keyed lookup.
…dentity, CI isolation

The ledger cap now spares rows the rollup hasn't folded (created at or past
the watermark) on all backends, so a burst can briefly exceed ledger_max_rows
instead of losing usage forever. Distributed enqueue persists the EFFECTIVE
user (owner overrides the hint), so execution, billing, and erasure key on
one identity; the Postgres migration backfills pre-upgrade batch_results
ownership from their surviving item rows. Erasure markers and the submit
baseline moved to millisecond precision, so erase-then-resubmit within one
second is no longer misjudged. The PG store test runs in a private database
(the rollup watermark is global per database and the shared one serves
parallel tests) instead of truncating shared tables — the CI parallel-run
failure.
@CMGS
CMGS merged commit dd0b04d into main Jul 16, 2026
7 checks passed
@CMGS
CMGS deleted the feat/usage-rollup-erasure branch July 16, 2026 08:39
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