feat(office): bound run history growth with a scheduled retention sweep (#3566)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
---
|
||||
created: 2026-09-09
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-001
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-002
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-003
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-004
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-005
|
||||
system_design:
|
||||
- ../../specs/office/system-design/run-history-retention.md
|
||||
- ../../specs/office/system-design/run-history-retention-operations.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Office Run History Retention
|
||||
|
||||
## Overview
|
||||
|
||||
Office writes `office_routine_runs` and `run_events` rows on every routine firing and
|
||||
run lifecycle transition, and nothing ever deletes them on a schedule. A `*/5 * * * *`
|
||||
routine produces roughly 105,000 `office_routine_runs` rows a year; one reference
|
||||
install had already accumulated 323 consecutive `coalesced` rows from a routine that
|
||||
was doing nothing useful. This plan adds one scheduled sweep, on its own interval
|
||||
(never the 5s Office tick), that bounds `office_routine_runs`, `runs`, and their
|
||||
satellites (`run_events`, `office_run_route_attempts`, `office_run_skills`) by age,
|
||||
with a per-owner floor, identical behavior on SQLite and PostgreSQL, and an operator
|
||||
surface (Settings > System > Data & Logs) that reports policy, counts, previews, and
|
||||
warnings before and while rows are deleted.
|
||||
|
||||
The two halves of the contract are split the same way the specs are split: the sweep
|
||||
itself, its eligibility rules, and engine parity are
|
||||
[run history retention](../../specs/office/system-design/run-history-retention.md)
|
||||
(REQ-001, REQ-002, REQ-005); the settings record, preview marker, health warnings, and
|
||||
System page surface are
|
||||
[run history retention operations](../../specs/office/system-design/run-history-retention-operations.md)
|
||||
(REQ-003, REQ-004).
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- A `internal/office/retention` package: settings store, eligibility/count/delete
|
||||
queries shared by preview and delete, a session-scoped PostgreSQL advisory lock with
|
||||
a SQLite single-process equivalent, a scheduler goroutine on its own interval, and an
|
||||
HTTP handler for `GET`/`PUT /api/v1/system/retention`.
|
||||
- Status-only classification of history vs. live state for both `office_routine_runs`
|
||||
and `runs`, a per-owner floor, oldest-first chunked batch deletion, and satellite rows
|
||||
deleted in the same transaction as their parent run.
|
||||
- A per-table preview (report, delete nothing) on each table's first evaluation, and
|
||||
`health.Issue` warnings before the cap and on sweep/count failure.
|
||||
- A `RetentionSettingsCard` on Settings > System > Data & Logs showing policy, retained
|
||||
counts, preview state, last sweep, and backlog/error warnings.
|
||||
- Expression indexes serving the sweep's filter/order on both engines.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Filesystem/container cleanup (owned by storage maintenance).
|
||||
- Routine or workspace deletion (already deletes runs structurally; unaffected).
|
||||
- Any change to run lifecycle, routine dispatch, or task/session/checkout data.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] [Task 01: Bound run history with a scheduled retention sweep](task-01-bound-run-history-with-retention-sweep.md)
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
id: "01-bound-run-history-with-retention-sweep"
|
||||
title: "Bound Office run history with a scheduled retention sweep"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-001
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-002
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-003
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-004
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-005
|
||||
acceptance_criteria:
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.1
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.2
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.3
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.4
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.5
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.6
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.7
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.8
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.9
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-001.10
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.1
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.2
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.3
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.4
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.5
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.6
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.7
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.8
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.9
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.10
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.11
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.12
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-002.13
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.1
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.2
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.3
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.4
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.5
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.6
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.7
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.8
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.9
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.10
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-003.11
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.1
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.2
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.3
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.4
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.5
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.6
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.7
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.8
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-004.9
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-005.1
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-005.2
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-005.3
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-005.4
|
||||
- AC-OFFICE-RUN-HISTORY-RETENTION-005.5
|
||||
system_design:
|
||||
- ../../specs/office/system-design/run-history-retention.md
|
||||
- ../../specs/office/system-design/run-history-retention-operations.md
|
||||
---
|
||||
|
||||
# Task 01: Bound Office Run History with a Scheduled Retention Sweep
|
||||
|
||||
## Summary
|
||||
|
||||
Add `internal/office/retention`: a settings-backed sweep that ages out
|
||||
`office_routine_runs` and `runs` history (plus their satellites) on its own
|
||||
interval, a per-owner floor, status-only live/history classification, a preview
|
||||
pass before first deletion, engine-identical behavior on SQLite and PostgreSQL,
|
||||
and a `GET`/`PUT /api/v1/system/retention` operator surface with a matching
|
||||
Settings > System > Data & Logs card.
|
||||
|
||||
## In scope
|
||||
|
||||
- Eligibility, count, and delete queries shared by preview and the real sweep,
|
||||
keyed on `COALESCE(completed_at, created_at)` / `COALESCE(finished_at,
|
||||
requested_at)`, oldest-first, chunked across statements.
|
||||
- A session-scoped PostgreSQL advisory lock (with a SQLite single-process
|
||||
equivalent) so only one backend sweeps at a time.
|
||||
- A per-owner floor (newest N rows retained regardless of age) applied to
|
||||
count, preview, and delete identically, re-asserted in the DELETE statement.
|
||||
- Satellite deletion (`run_events`, `office_run_route_attempts`,
|
||||
`office_run_skills`) in the same transaction as their parent run.
|
||||
- `health.Issue` warnings for approaching the backlog cap and for count/sweep
|
||||
failure; a one-time preview per table before any row is deleted.
|
||||
- `RetentionSettingsCard` on Settings > System > Data & Logs: policy, retained
|
||||
counts, preview/backlog state, last sweep, errors.
|
||||
- Expression indexes serving the sweep's filter/order on both engines.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Filesystem/container artifact cleanup (storage maintenance owns that).
|
||||
- Routine/workspace deletion cascades (already delete runs structurally).
|
||||
- Any run lifecycle, routing, or task/session/checkout behavior change.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- History rows (terminal `office_routine_runs`/`runs` statuses) older than the
|
||||
configured window are deleted in batches; live-state rows (`received`,
|
||||
`task_created`, `queued`, `claimed`) are never age-pruned; an unrecognized
|
||||
status fails safe as live state and warns.
|
||||
- The newest `floor` rows per owner survive regardless of age.
|
||||
- Each table's first sweep is a preview: it reports would-delete counts and
|
||||
deletes nothing; a later sweep performs real deletion.
|
||||
- Behavior, including the advisory lock and batch chunking, is identical on
|
||||
SQLite and PostgreSQL.
|
||||
- `GET`/`PUT /api/v1/system/retention` read/write policy and report current
|
||||
counts, preview state, last sweep outcome, and backlog/error warnings.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test -race -count=1 ./internal/office/retention/... ./internal/office/repository/sqlite/...
|
||||
cd apps/backend && KANDEV_TEST_POSTGRES_DSN=<dsn> go test -race -count=1 -v ./internal/office/retention/...
|
||||
cd apps/backend && golangci-lint run ./internal/office/retention/...
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web test -- --run retention-settings-card system-route-copy
|
||||
cd apps/web && pnpm e2e:run -- --project chromium --grep "System retention settings"
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/office/retention/*.go`
|
||||
- `apps/backend/internal/office/repository/sqlite/base_migrations.go`,
|
||||
`retention_indexes_test.go`, `retention_indexes_postgres_test.go`
|
||||
- `apps/backend/internal/backendapp/*` (scheduler/handler wiring)
|
||||
- `apps/web/src/**/retention-settings-card.tsx` and its tests
|
||||
- `docs/specs/office/requirements/run-history-retention*.md`,
|
||||
`docs/specs/office/system-design/run-history-retention*.md`
|
||||
- `docs/public/operations.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- A dialect-sensitive query bug that only reproduces on PostgreSQL (mitigated
|
||||
by an environment-gated Postgres suite covering the advisory lock, the
|
||||
EvalPlanQual TOCTOU window, and batch chunking).
|
||||
- A too-aggressive window or floor deleting rows an operator still needed
|
||||
(mitigated by the default 30-day window, the per-owner floor, and the
|
||||
preview-before-delete behavior).
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `REQ-OFFICE-RUN-HISTORY-RETENTION-001` through `-005`.
|
||||
- [run history retention](../../specs/office/system-design/run-history-retention.md)
|
||||
and
|
||||
[run history retention operations](../../specs/office/system-design/run-history-retention-operations.md).
|
||||
- The reference install's 323 consecutive `coalesced` routine-run rows (28
|
||||
days, one bricked routine) cited in the requirements' Overview.
|
||||
|
||||
## Results
|
||||
|
||||
- Implemented `internal/office/retention` (settings store, `Sweeper`,
|
||||
`Scheduler`, `CensusTracker`, PostgreSQL advisory lock with SQLite
|
||||
equivalent, `Handler` for `GET`/`PUT /api/v1/system/retention`) plus the
|
||||
`RetentionSettingsCard` on Settings > System > Data & Logs.
|
||||
- Full backend gauntlet green: `go build ./...`, `go vet`, `gofmt -l`,
|
||||
`go test -race ./internal/office/retention/...` (SQLite), the
|
||||
PostgreSQL-gated suite against a real scratch instance (125 tests, 0
|
||||
skipped, all PASS, covering the advisory lock, the EvalPlanQual
|
||||
concurrent-resurrection TOCTOU regression, and chunked batch deletes),
|
||||
`golangci-lint run ./...` (0 issues).
|
||||
- Frontend: `pnpm run typecheck`, the retention card and System route-copy
|
||||
Vitest suites, and a scoped Playwright run (`--grep "System retention
|
||||
settings"`, 2 passed).
|
||||
- Delivered as PR [#3566](https://github.com/kdlbs/kandev/pull/3566)
|
||||
("feat(office): bound run history growth with a scheduled retention
|
||||
sweep"), through four Build rounds and four Review rounds; remaining
|
||||
non-blocking test-rigor gaps and CI-wiring follow-ups are tracked on the
|
||||
linked follow-up card rather than blocking this PR.
|
||||
@@ -348,6 +348,34 @@ the Kandev service first provides the clearest maintenance boundary.
|
||||
|
||||
</details>
|
||||
|
||||
## Office run history retention
|
||||
|
||||
Open **Settings > System > Data & Logs** to manage automatic deletion of old
|
||||
Office run history. Deletion is enabled by default. The first sweep starts five
|
||||
minutes after the backend starts or after you enable deletion.
|
||||
|
||||
The first sweep for each history table is a preview. It reports the rows that
|
||||
would be deleted and removes no rows. A later sweep can delete eligible rows.
|
||||
Deletion is permanent. Back up the database before you enable deletion if you
|
||||
need to keep old history outside the configured window.
|
||||
|
||||
The retention window controls the age of rows that can be deleted. The minimum
|
||||
kept per owner control keeps the newest rows for each routine or agent, even
|
||||
when those rows are older than the window. A floor of zero removes this extra
|
||||
protection. Run event, route attempt, and skill rows are deleted with their
|
||||
parent run.
|
||||
|
||||
The page shows the current policy, retained row counts, preview results, the
|
||||
last sweep, and any backlog or errors. Counts continue to update when deletion
|
||||
is disabled, so you can monitor growth before you enable it again.
|
||||
|
||||
To disable automatic deletion:
|
||||
|
||||
1. Open **Settings > System > Data & Logs**.
|
||||
2. Clear **Delete eligible run history**.
|
||||
3. Select **Save changes**.
|
||||
4. Check the retention status. It must show that deletion is disabled.
|
||||
|
||||
## Database operation
|
||||
|
||||
> **Single-owner rule:** SQLite uses one writer connection in WAL mode; only one Kandev backend should own the file. **Factory reset** is destructive and removes managed data after creating a pre-reset backup.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
created: 2026-09-09
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Office Run History Retention Operations Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
[Run history retention](run-history-retention.md) defines which Office run
|
||||
history rows may be deleted and how the sweep that deletes them behaves. This
|
||||
document defines the other half of that contract: what an operator is told
|
||||
before the first row is removed, what they are told while the tables grow, what
|
||||
they can configure, and what they can read about the last sweep.
|
||||
|
||||
The split follows the ownership boundary the retention contract already draws.
|
||||
Office owns the rows and the deletion policy because they are Office primitives.
|
||||
The System pages own the operator surface those decisions are reported through,
|
||||
and that surface is a separate contract with a separate consumer: an operator
|
||||
reading a settings page and a health card, rather than a scheduler deleting
|
||||
rows. Splitting here keeps each document inside its size limit without cutting
|
||||
either contract.
|
||||
|
||||
The requirement IDs continue the retention capability's sequence rather than
|
||||
starting a new one, because these are the same capability's requirements viewed
|
||||
from the operator's side.
|
||||
|
||||
## Terminology
|
||||
|
||||
Terms are defined once, in
|
||||
[run history retention](run-history-retention.md#terminology), and used here
|
||||
with the same meaning. The ones this document leans on most:
|
||||
|
||||
- **Retention sweep**, **preview**, and **retained count**.
|
||||
- **Swept tables** (`office_routine_runs`, `runs`), **reported tables** (those
|
||||
two plus the three run satellites), and **thresholded tables**
|
||||
(`office_routine_runs`, `runs`, `run_events`). "Per table" always names one of
|
||||
these three sets, never an unqualified "table".
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-OFFICE-RUN-HISTORY-RETENTION-003: Warn before deleting, and warn while growing
|
||||
|
||||
**Intent:** An operator learns what retention is about to remove before it
|
||||
removes anything, and learns that history is growing past a threshold while
|
||||
there is still time to widen the window or fix the routine.
|
||||
|
||||
As an operator upgrading an install with a year of run history, I want to be
|
||||
told what the first sweep would delete before it deletes it, so that I can widen
|
||||
the retention window first if that history matters to me.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.1:** The first evaluation of each swept table
|
||||
on a database shall be a preview for that table: it evaluates the policy,
|
||||
reports the number of rows it would delete from that table, and deletes
|
||||
nothing from it. A table that has not completed a preview never deletes,
|
||||
regardless of what any other table has done.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.2:** When a preview sweep would delete
|
||||
at least one row, the system shall emit an operator-visible warning naming
|
||||
each table, its would-delete count, and the configured retention window, and
|
||||
stating that deletion begins at the next scheduled sweep.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.3:** When a preview sweep would delete
|
||||
no rows, the system shall not emit a warning.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.4:** The system shall run a preview at most
|
||||
once per swept table per database. A table's preview shall be recorded only
|
||||
when that table's preview evaluation completed successfully; a table that
|
||||
failed during a sweep in which it was being previewed shall be previewed again
|
||||
on the next sweep rather than deleting. Disabling and re-enabling retention,
|
||||
restarting the backend, or changing any retention setting shall not produce a
|
||||
second preview for a table that has already completed one.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.5:** When a thresholded table's retained
|
||||
count exceeds that table's configured warning threshold, the system shall emit
|
||||
an operator-visible warning naming the table, its retained count, and the
|
||||
threshold. For `office_routine_runs`, the warning shall also name the routine
|
||||
holding the largest share of retained rows and that share, where the share is
|
||||
that routine's retained rows as a proportion of the table's retained count.
|
||||
When two routines hold an equal largest share, the lower routine identifier
|
||||
shall be named.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.6:** When a table reports remaining
|
||||
backlog after a sweep, the system shall emit an operator-visible warning
|
||||
naming the table, stating that retention is behind, and reporting the number
|
||||
of rows deleted in that sweep. A table that was previewed in that sweep shall
|
||||
not produce this warning however many rows were eligible, because a preview
|
||||
deletes nothing by design and retention is therefore not behind; the preview
|
||||
warning of AC-OFFICE-RUN-HISTORY-RETENTION-003.2 reports its eligible count
|
||||
instead.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.7:** When retention is disabled and a
|
||||
table's retained count exceeds its warning threshold, the system shall emit
|
||||
the same threshold warning and shall additionally state that retention is
|
||||
disabled, so a silent unbounded table is distinguishable from one being
|
||||
actively managed.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.8:** Every warning in this requirement
|
||||
shall be emitted through a channel available in a production build, and shall
|
||||
not be observable only through the debug metrics endpoint.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.9:** A preview sweep's would-delete
|
||||
counts shall be the full eligible count per table, not capped by the batch
|
||||
limit, so an operator is told the real size of what is about to be removed.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.10:** When a swept table's recorded preview
|
||||
state cannot be read or parsed, the system shall treat that table as not yet
|
||||
previewed and shall emit an operator-visible warning naming the table.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-003.11:** Retained counts for the thresholded
|
||||
tables shall be produced by a count evaluation that does not require a sweep
|
||||
to have run, so the counts and the threshold warnings required by AC-OFFICE-RUN-HISTORY-RETENTION-003.7 and
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.8 are available on a fresh install and while retention is disabled. The
|
||||
counts shall be refreshed on the sweep interval and reused between refreshes
|
||||
rather than recomputed for each operator page load or health poll. The first
|
||||
count evaluation shall run at startup, before the delay of
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.10 arms the first sweep, so an operator is
|
||||
not shown countless tables for a sweep interval after every restart. When a
|
||||
count evaluation fails, the system shall keep the counts from the last
|
||||
successful evaluation, report them as stale together with the time they were
|
||||
produced, and emit an operator-visible warning. A failed count shall not
|
||||
suppress the threshold warnings derived from the last successful counts, and
|
||||
shall not fail the sweep. Until the first evaluation has completed, and when it
|
||||
fails with no earlier successful evaluation to fall back on, the system shall
|
||||
report the counts as not yet computed and shall not render them as zero, on the
|
||||
same grounds as AC-OFFICE-RUN-HISTORY-RETENTION-004.7: a count nobody has taken
|
||||
must not read as a table that is empty. Success and failure shall be tracked
|
||||
per thresholded table, so one table's failed count neither discards nor marks
|
||||
stale another table's successful one.
|
||||
|
||||
### REQ-OFFICE-RUN-HISTORY-RETENTION-004: Operator configuration and sweep visibility
|
||||
|
||||
**Intent:** Retention is configurable, its values are validated, and the result
|
||||
of the most recent sweep is readable by an operator without reading logs.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.1:** An operator can read and change
|
||||
whether retention is enabled, the retention window per in-scope table, the
|
||||
sweep interval, the retention floor per in-scope table, the batch limit, and
|
||||
the warning threshold per in-scope table.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.2:** Retention shall be enabled by
|
||||
default. Default values shall be: retention window 30 days for both
|
||||
`office_routine_runs` and `runs`; sweep interval 6 hours; retention floor 50
|
||||
rows per owner for both tables; batch limit 5,000 rows per table per sweep;
|
||||
warning threshold 25,000 rows for `office_routine_runs`, 25,000 for `runs`,
|
||||
and 250,000 for `run_events`.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.3:** The system shall reject a setting
|
||||
outside its permitted range with an error naming the field, and shall leave
|
||||
the stored settings unchanged. Permitted ranges are: retention window 1 to
|
||||
3,650 days; sweep interval 1 to 168 hours; retention floor 0 to 10,000; batch
|
||||
limit 100 to 100,000; warning threshold 0 or greater, where 0 disables that
|
||||
table's threshold warning.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.4:** When stored retention settings
|
||||
cannot be read or parsed, the system shall use the documented defaults, emit
|
||||
an operator-visible warning, and continue. Unreadable settings shall not
|
||||
disable retention silently and shall not fail startup.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.5:** A settings change shall take effect
|
||||
without a backend restart, and shall apply from the next scheduled sweep
|
||||
rather than interrupting a sweep in progress. Two concurrent writes resolve
|
||||
last-writer-wins; repeating an identical write changes nothing and returns the
|
||||
same normalized document. A sweep shall read the stored settings at its start
|
||||
rather than relying on a value cached when this process last observed a change,
|
||||
so that where several backend processes share one database a process that did
|
||||
not serve the write still sweeps under the new settings rather than the
|
||||
replaced ones. When that read fails or the stored settings cannot be parsed,
|
||||
the sweep shall be skipped and recorded as skipped rather than run against the
|
||||
documented defaults, because a default window is shorter than a window an
|
||||
operator has widened and sweeping under it would delete the history they
|
||||
configured the system to keep. This does not change
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.4, which governs reading settings for
|
||||
reporting and startup, where using the defaults deletes nothing.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.6:** An operator can read, on the System
|
||||
pages, the outcome of the most recent completed sweep: when it ran, which
|
||||
swept tables it previewed, the rows deleted per reported table, the rows it
|
||||
would have deleted per previewed table, the retained count per thresholded
|
||||
table, whether any table has remaining backlog, and any table that failed.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.7:** When no sweep has run since the
|
||||
backend started, the surface in AC-OFFICE-RUN-HISTORY-RETENTION-004.6 shall
|
||||
say so explicitly rather than render an empty or zeroed result that reads as a
|
||||
sweep that deleted nothing.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.8:** The sweep-result surface shall be
|
||||
readable while retention is disabled, reporting retained counts and the
|
||||
disabled state.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-004.9:** A settings write shall replace the whole
|
||||
settings document. A field the caller omits shall take its documented default
|
||||
rather than its previously stored value, so the same request always produces
|
||||
the same stored document. An unrecognized field, a numeric field whose
|
||||
value is not a whole number, a field present with a JSON `null`, and a field
|
||||
whose value is not of its documented type, shall each be rejected with an error
|
||||
naming the field, leaving the stored settings unchanged. `null` shall be
|
||||
rejected rather than treated as omission: omission means the documented
|
||||
default, so reading `null` the same way would let a client silently replace a
|
||||
configured retention window with a shorter default and destroy history the
|
||||
operator meant to keep.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A persisted history of retention sweeps.** The last sweep's result is held
|
||||
in memory and reported alongside the durable warnings. A growing table
|
||||
recording the work of the job that stops tables growing is the same defect in
|
||||
a new place.
|
||||
- **Per-workspace or per-routine retention overrides.** Settings are
|
||||
instance-wide. The retention floor is already per owner, which covers what a
|
||||
per-routine override would most often be used for.
|
||||
- **Archival or export before deletion.** Deleted history is gone. An install
|
||||
needing it kept sets a longer window or takes a backup.
|
||||
- **Anything the deletion policy owns.** Eligibility, ordering, batching,
|
||||
atomicity, and engine parity are specified in
|
||||
[run history retention](run-history-retention.md) and are not restated here.
|
||||
|
||||
## Prior art
|
||||
|
||||
The receipts for both prior-art legs are recorded once, in
|
||||
[run history retention](run-history-retention.md#prior-art). The finding that
|
||||
bears on this document specifically: neither surveyed product previews before a
|
||||
policy's first deletion, nor warns ahead of the window. Those two behaviors are
|
||||
where this capability goes further, and they are the reason this document
|
||||
exists rather than being a settings page bolted onto a sweep.
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
created: 2026-09-09
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Office Run History Retention Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
Office writes two families of history rows that nothing removes on a schedule.
|
||||
`office_routine_runs` records one row per routine firing. `run_events` records
|
||||
the timeline of every Office run, append-only, alongside the `runs` queue row
|
||||
and its per-run satellites. The only deletions today are manual and structural:
|
||||
deleting a routine removes its runs, and deleting a workspace removes everything
|
||||
belonging to it. An install that never deletes a routine or a workspace grows
|
||||
forever.
|
||||
|
||||
The growth is driven by a clock, not by usage. A routine on a `*/5 * * * *`
|
||||
schedule fires 105,120 times a year, and each firing writes a routine-run row
|
||||
whether or not it does anything. On the reference install, one routine had
|
||||
accumulated 323 consecutive `coalesced` rows over 28 days while producing no
|
||||
work at all: that is the curve with the loop broken, and a working loop also
|
||||
writes the run row, its `run_events` timeline, and its route-attempt rows.
|
||||
|
||||
This document bounds those tables. It defines what is history and may be
|
||||
deleted, what is live state and must never be deleted on age, when the deletion
|
||||
runs, and that it behaves identically on both database engines. What an operator
|
||||
is told before the first row is removed, what they are warned about while the
|
||||
tables grow, and what they can configure and read is the paired contract in
|
||||
[run history retention operations](run-history-retention-operations.md).
|
||||
|
||||
Office owns this contract because the rows are defined by Office primitives: the
|
||||
routine dispatch ledger, the run queue, and the run event timeline. The System
|
||||
pages own the operator surface it reports through. The filesystem and container
|
||||
cleanup owned by [storage
|
||||
maintenance](../../system-page/requirements/storage-maintenance.md) is a
|
||||
separate capability that never touches database rows.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Retention sweep** (or **sweep**): one pass that evaluates every in-scope
|
||||
table against the configured policy and deletes the eligible rows.
|
||||
- **History row**: a row recording something that already happened, which no
|
||||
live decision reads. History rows are eligible for deletion.
|
||||
- **Live-state row**: a row a live decision still reads, regardless of age.
|
||||
Never eligible for age-based deletion.
|
||||
- **Recovery-protected run**: a failed `runs` row named by an active
|
||||
`office_agent_pause_recoveries.failed_run_id`; retention keeps it until the
|
||||
recovery row is consumed or discarded.
|
||||
- **Run satellite row**: a row keyed by a `runs` row's identifier and owned by
|
||||
it: a `run_events`, `office_run_route_attempts`, or `office_run_skills` entry.
|
||||
- **Retention window**: the age past which a history row becomes eligible,
|
||||
measured from the row's own completion time.
|
||||
- **Completion time**: `COALESCE(completed_at, created_at)` for a routine-run
|
||||
row and `COALESCE(finished_at, requested_at)` for a `runs` row. Both fallback
|
||||
columns are `NOT NULL`, so completion time is never null.
|
||||
- **Retention floor**: most-recent history rows kept per owner regardless of
|
||||
age, so a rarely-firing routine or rarely-woken agent keeps visible history.
|
||||
- **Batch limit**: the maximum rows one sweep deletes from one table.
|
||||
- **Preview**: a swept table's first evaluation on a database; it counts what it
|
||||
would delete from that table and deletes nothing. Tracked per swept table.
|
||||
- **Retained count**: the number of rows a table currently holds — a property of
|
||||
the table, not of a sweep, defined whether or not a sweep has ever run and
|
||||
whether or not retention is enabled.
|
||||
- **Swept tables**: the two tables retention selects rows from by policy,
|
||||
`office_routine_runs` and `runs`.
|
||||
- **Reported tables**: the five tables a sweep can delete rows from and reports
|
||||
deleted counts for: the two swept tables plus `run_events`,
|
||||
`office_run_route_attempts`, and `office_run_skills`.
|
||||
- **Thresholded tables**: the three tables carrying a warning threshold,
|
||||
`office_routine_runs`, `runs`, and `run_events`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-OFFICE-RUN-HISTORY-RETENTION-001: History is bounded and live state is not
|
||||
|
||||
**Intent:** Bound `office_routine_runs`, `runs`, and the run satellite tables by
|
||||
age and by an owner-scoped floor, while guaranteeing that no row another
|
||||
decision still reads is removed because it is old.
|
||||
|
||||
As an operator running Office continuously, I want old run history removed
|
||||
automatically, so a scheduled routine does not grow the database without limit.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.1:** A routine-run row is a history row
|
||||
only when its status is one of `skipped`, `coalesced`, `failed`, `done`, or
|
||||
`cancelled`. When a routine-run row's status is `received` or `task_created`,
|
||||
the system shall treat it as a live-state row and shall not delete it on age,
|
||||
at any age.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.2:** A `runs` row is a history row when its
|
||||
status is `finished`, `failed`, or `cancelled`. `cancelled` is terminal: its
|
||||
only writer moves a row there from `queued` or `claimed` and stamps the
|
||||
completion timestamp in the same statement. When a `runs` row's status is
|
||||
`queued` or `claimed`, the system shall treat it as a live-state row and shall
|
||||
not delete it on age, at any age, including a run parked for a future routing
|
||||
retry. A failed run referenced by an active
|
||||
`office_agent_pause_recoveries.failed_run_id` is also live state for
|
||||
retention and shall remain until that recovery row is consumed or discarded.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.3:** When a history row's completion time is
|
||||
older than that table's retention window, the system shall make it eligible
|
||||
for deletion. Completion time is defined in Terminology. A row's
|
||||
classification as history shall depend on its status alone and shall not
|
||||
additionally require `completed_at` or `finished_at` to be set; a terminal row
|
||||
with an unset stamp is history, dated by its fallback column, and ages out
|
||||
rather than being retained forever.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.4:** The system shall retain the newest
|
||||
history rows of each owner up to that table's retention floor even when they
|
||||
are older than the retention window. The owner is the routine for
|
||||
`office_routine_runs` and the agent profile for `runs`. Newest is completion
|
||||
time descending, with the row identifier descending as the tiebreak.
|
||||
Completion time is never null, so this ordering is total on both engines and
|
||||
does not depend on either engine's default placement of nulls. The identifier
|
||||
gives a stable order for equal timestamps and is not claimed to be
|
||||
chronological.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.5:** When the system deletes a `runs`
|
||||
row, it shall delete that run's satellite rows in the same database
|
||||
transaction, and no satellite row shall remain that references a `runs` row
|
||||
the system has deleted.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.6:** The system shall not delete
|
||||
`run_events` rows for a run it is not deleting in the same transaction, at any
|
||||
age.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.7:** A routine's status shall not exempt
|
||||
its history from retention. When a routine is `paused`, its history rows are
|
||||
evaluated by the same policy as an `active` routine's.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.8:** The system shall not delete, alter,
|
||||
archive, or cancel a task, a session, a task checkout, or a workspace as part
|
||||
of a retention sweep. A routine-run row naming a task in `linked_task_id` may
|
||||
be deleted while that task continues to exist.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.9:** A retained `coalesced` routine-run
|
||||
row may name a `coalesced_into_run_id` whose row has already been deleted.
|
||||
`coalesced_into_run_id` records provenance and is not a referential
|
||||
constraint; the system shall not delete a coalesced row because its target was
|
||||
deleted, and shall not retain a target because a coalesced row names it.
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-001.10:** When a row in a swept table holds a
|
||||
status belonging to neither that table's history set nor its live-state set,
|
||||
the system shall treat it as a live-state row, shall not delete it at any age,
|
||||
and shall emit an operator-visible warning naming the table and the
|
||||
unrecognized status, through the channel required by AC-OFFICE-RUN-HISTORY-RETENTION-003.8 in [run history
|
||||
retention operations](run-history-retention-operations.md). The warning shall be
|
||||
produced by the count evaluation required by
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-003.11 rather than by the deletion path, which
|
||||
cannot observe a status it does not select; it shall therefore be emitted on an
|
||||
install where retention is disabled and no sweep runs. When one table holds more
|
||||
than one unrecognized status, the system shall emit a single warning for that
|
||||
table naming every unrecognized status in ascending lexicographic order with its
|
||||
row count.
|
||||
|
||||
### REQ-OFFICE-RUN-HISTORY-RETENTION-002: The retention sweep
|
||||
|
||||
**Intent:** Run retention on its own schedule, off the run-claiming hot path, in
|
||||
bounded batches, with one sweep at a time and no dependence on database cascade
|
||||
behavior.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.1:** The system shall run retention on a
|
||||
dedicated schedule whose interval is configurable in hours, and shall not
|
||||
perform retention work on the Office run-processing tick.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.2:** When a scheduled sweep is due while a
|
||||
previous sweep is still running, the system shall skip the due sweep rather
|
||||
than run two concurrently or queue it, and shall record that it was skipped.
|
||||
Recording a skip shall not replace the reported result of the last completed
|
||||
sweep.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.3:** A sweep shall delete at most the batch
|
||||
limit of rows from `office_routine_runs` and at most that many from `runs`. A
|
||||
preview evaluation shall never be reported as having remaining backlog: it
|
||||
deletes nothing by design, so "retention is behind" is not true of it however
|
||||
many rows were eligible.
|
||||
The limit counts those rows only; every satellite row of a deleted run is
|
||||
removed regardless of the limit. When more rows were eligible than the limit
|
||||
allowed, the system shall complete the sweep, report that table as having
|
||||
remaining backlog, and continue on the next scheduled sweep. Within a table
|
||||
the sweep shall select its batch in completion-time ascending order, with the
|
||||
row identifier ascending as the tiebreak, so the oldest eligible rows are
|
||||
removed first and both engines select the same batch.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.4:** The system shall re-assert every
|
||||
eligibility condition in the deletion itself, not only when selecting
|
||||
candidates. A run that returns to `queued` between selection and deletion,
|
||||
which a scheduled retry does by clearing `finished_at`, shall not be deleted
|
||||
by that sweep. The re-asserted conditions shall include the retention floor as
|
||||
well as status and age.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.5:** Each batch shall be atomic: after a
|
||||
sweep is interrupted by shutdown or error, every batch that was applied is
|
||||
complete, including its satellite rows, and no batch is partially applied.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.6:** Once a table holds no eligible row,
|
||||
a further sweep against unchanged settings shall delete nothing from that table
|
||||
and report zero deletions for it. This criterion is scoped to that drained
|
||||
state and does not contradict AC-OFFICE-RUN-HISTORY-RETENTION-002.3: while a
|
||||
table still reports remaining backlog, the next sweep is required to delete its
|
||||
next batch, so "deletes nothing further" is not claimed of a backlogged table.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.7:** When a table's sweep fails with a
|
||||
database error, the system shall record the failure for that table, continue
|
||||
with the remaining in-scope tables, and retry the failed table on the next
|
||||
scheduled sweep. A retention failure shall not fail backend startup, stop the
|
||||
Office scheduler, or abort the remainder of the sweep. A batch abandoned
|
||||
because its deletion could not be applied consistently shall be recorded as a
|
||||
failure for that table rather than as backlog. Every table whose rows that
|
||||
batch's transaction addressed shall report zero rows deleted for that sweep, so
|
||||
no table reports rows that the rollback restored.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.8:** When retention is disabled, the
|
||||
system shall run no sweep and delete no row, and shall still report retained
|
||||
counts and threshold warnings.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.9:** When every in-scope table is empty
|
||||
or holds no eligible row, the sweep shall complete reporting zero deletions,
|
||||
without error and without a warning.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.10:** The first sweep after the backend
|
||||
starts shall run after a short fixed delay rather than after a full sweep
|
||||
interval, so that an install restarted more often than the interval still runs
|
||||
retention. Later sweeps use the configured interval.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.11:** A sweep shall compute one cutoff
|
||||
instant at its start and evaluate every table against that instant, so two
|
||||
tables in one sweep cannot disagree about what "older than the window" means.
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.12:** On PostgreSQL, where several backend
|
||||
processes can share one database, sweep exclusivity shall hold across
|
||||
processes and not only within one: a backend that cannot acquire the retention
|
||||
lock shall skip its due sweep exactly as it would for a sweep already running
|
||||
in its own process. On SQLite one backend process owns the database file, so
|
||||
the in-process guard is sufficient.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-002.13:** Sweeps shall be scheduled fixed-delay:
|
||||
the next sweep is armed when the previous one finishes, so a sweep running
|
||||
longer than the interval delays its successor rather than causing an immediate
|
||||
second one. A settings change re-arms the delay from the moment of the change.
|
||||
Enabling retention that was disabled arms the next sweep at the same short
|
||||
delay as AC-OFFICE-RUN-HISTORY-RETENTION-002.10 rather than at a full interval.
|
||||
Each backend shall periodically reread the shared settings record, including
|
||||
while retention is disabled, so a backend that did not serve a settings write
|
||||
still adopts enablement and interval changes.
|
||||
|
||||
### REQ-OFFICE-RUN-HISTORY-RETENTION-005: Integrity and database engine parity
|
||||
|
||||
**Intent:** Retention leaves the database consistent and behaves identically on
|
||||
both supported engines, including where the schema differs between them.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-005.1:** The system shall produce the same
|
||||
observable retention outcome on SQLite and on PostgreSQL for the same settings
|
||||
and the same starting rows: the same rows deleted, the same rows retained, the
|
||||
same reported counts, and the same warnings. This shall hold on the backlog
|
||||
path as well, because batch selection order is fixed by named columns in
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.3 rather than left to the engine.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-005.2:** The system shall delete satellite
|
||||
rows explicitly and shall not depend on a foreign-key cascade to remove them.
|
||||
Neither engine declares a foreign key from a satellite table to `runs`.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-005.3:** After any sequence of sweeps, the
|
||||
concurrency gate that finds a routine's active run by dispatch fingerprint
|
||||
shall return exactly what it would have returned had no sweep run.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-005.4:** After any sequence of sweeps, the
|
||||
lookup that closes out a routine run when its linked task reaches a terminal
|
||||
step shall not resolve a deleted row to a different routine's run. A deleted
|
||||
row resolves to nothing.
|
||||
- **AC-OFFICE-RUN-HISTORY-RETENTION-005.5:** Retention shall not change the
|
||||
behavior of deleting a routine or deleting a workspace. Those paths continue
|
||||
to remove every row they remove today, including rows retention has not yet
|
||||
reached.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Each exclusion below is a decision, not an oversight.
|
||||
|
||||
- **Automation run history (`automation_runs` and its tables).** Owned by the
|
||||
automation system, and its published contract is that history is removed only
|
||||
by an explicit per-run or delete-all action. It has the same unbounded-growth
|
||||
gap and needs its own requirement; changing it here would silently break a
|
||||
documented promise.
|
||||
- **`office_activity_log`, inbox dismissals, and approval rows.** The
|
||||
[inbox requirement](inbox.md) already states these accumulate indefinitely and
|
||||
excludes activity-log retention. Reopening it belongs to that contract.
|
||||
- **`office_cost_events`.** Deleting cost rows changes reported spend and budget
|
||||
enforcement: a decision about financial records, not about storage.
|
||||
- **Detecting a stuck routine.** The 323-row reference case came from a routine
|
||||
that fired correctly and did nothing useful for 28 days. Bounding its history
|
||||
does not detect it; a detector for that state is a scheduler or stall
|
||||
visibility concern.
|
||||
- **Reclaiming file bytes after deletion.** Deleting rows does not shrink a
|
||||
SQLite file. `VACUUM` is already an operator action on the System pages and a
|
||||
sweep does not trigger it.
|
||||
- **Archival or export before deletion.** Deleted history is gone. An install
|
||||
needing it kept sets a longer window or takes a backup.
|
||||
- **Per-workspace or per-routine retention overrides.** Settings are
|
||||
instance-wide here. The retention floor is already per owner, which covers what
|
||||
a per-routine override would most often be used for.
|
||||
- **A persisted history of retention sweeps.** The last sweep's result is held
|
||||
in memory and reported alongside the durable warnings. A growing table
|
||||
recording the work of the job that stops tables growing is the same defect in
|
||||
a new place.
|
||||
- **Tables outside Office.** Nothing here changes task, session, workflow,
|
||||
plugin, or auth storage.
|
||||
|
||||
## Prior art
|
||||
|
||||
Receipts for both legs. The design document carries what each finding changed.
|
||||
|
||||
**Our own wiki: not consulted, tool unavailable.** The `@henry` pin resolved
|
||||
`~/.obsidian-wiki/config` to `config.henry`, giving
|
||||
`OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki` and
|
||||
`QMD_WIKI_COLLECTION=wiki`. Neither retrieval path ran: `qmd` and
|
||||
`obsidian-wiki` are absent from `PATH`, no QMD MCP tool is registered in this
|
||||
session, and the vault directory returns `Operation not permitted` both
|
||||
sandboxed and unsandboxed, a macOS file-access restriction on this process
|
||||
rather than a missing vault. The grep fallback is blocked the same way, so this
|
||||
leg is a tooling gap, not evidence the wiki is silent on retention.
|
||||
|
||||
**What other products shipped: consulted.** Queried the `saas-kb` server
|
||||
(`search_fsm_docs`, `category: "ai_sdlc"`) three times: run-history retention and
|
||||
database growth; session history retention and automatic deletion; and
|
||||
scheduled-automation run-history limits. Relevance was low across all three,
|
||||
itself a finding about corpus coverage. Two useful hits: **GitLab Duo** deletes
|
||||
sessions 30 days after last activity, which anchors the default window here; and
|
||||
**the Claude apps gateway** documents four tables with per-table windows
|
||||
enforced by one hourly sweep, marking one table "until deleted via the API"
|
||||
instead of giving it a window, which is the split this document draws between
|
||||
history rows and live-state rows.
|
||||
|
||||
Neither previewed before a policy's first deletion, nor warned ahead of the
|
||||
window. Those are where this capability goes further, because an upgrade that
|
||||
silently deletes a year of history on its first sweep is the failure mode a
|
||||
shipped default carries.
|
||||
@@ -0,0 +1,379 @@
|
||||
---
|
||||
status: current
|
||||
system: office
|
||||
requirements:
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-003
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-004
|
||||
---
|
||||
|
||||
# Office Run History Retention Operations System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design covers the operator half of run history retention: the settings
|
||||
record, the per-table preview marker, the reporting value, the health check, and
|
||||
the read/write System page surface. The sweep itself, the eligibility
|
||||
predicates, the batching, and the engine-parity guarantees are designed in [run
|
||||
history retention](run-history-retention.md).
|
||||
|
||||
The two documents share one component, `internal/office/retention`, and one
|
||||
scheduler goroutine. They are split because they are two contracts with two
|
||||
consumers: a scheduler deleting rows, and an operator reading a page. The split
|
||||
also keeps each document inside the specification size limit without cutting
|
||||
either contract.
|
||||
|
||||
Adjacent contracts read and constrained but not owned:
|
||||
|
||||
- `internal/health` — `Checker`, `Issue`, and the `/api/v1/system/health`
|
||||
response the System page's health card renders.
|
||||
- `internal/system/settings.Store` — the key/value settings table, already used
|
||||
by storage maintenance under one JSON key with normalization on read.
|
||||
|
||||
## Component: the operator surface
|
||||
|
||||
### Settings
|
||||
|
||||
One `settings` key, `office_run_retention`, holding a JSON document,
|
||||
read through a `Get`/`Save` pair with `Normalize` on both, matching
|
||||
`internal/system/storage/settings.go`. Unparseable content returns the defaults
|
||||
plus a sentinel error the caller turns into a health issue, never a boot failure
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.4).
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"sweep_interval_hours": 6,
|
||||
"batch_limit": 5000,
|
||||
"routine_runs": { "window_days": 30, "floor_per_owner": 50, "warn_rows": 25000 },
|
||||
"runs": { "window_days": 30, "floor_per_owner": 50, "warn_rows": 25000 },
|
||||
"run_events": { "warn_rows": 250000 }
|
||||
}
|
||||
```
|
||||
|
||||
`run_events` carries only a threshold: its lifetime is its run's, so it has no
|
||||
window and no floor of its own. Ranges and rejection behavior are
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.2 and .3; validation returns a field-named
|
||||
error and writes nothing, as `validateRange` does for storage maintenance.
|
||||
Writes are last-writer-wins through `Store.Save`; `CompareAndSwap` is not used
|
||||
because these are operator-scale settings edited from one page, and an identical
|
||||
repeated write is indistinguishable from no write
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.5).
|
||||
|
||||
**Each sweep re-reads the settings from the store at its start, and skips if that
|
||||
read fails.** The buffered
|
||||
wake channel that re-arms the interval is process-local, so on a deployment where
|
||||
several backends share one PostgreSQL database — the deployment
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.12 exists for — it only ever reaches the
|
||||
process that served the `PUT`. A backend relying on a cached effective value
|
||||
could win the sweep lock still holding the policy the operator has just replaced
|
||||
and delete rows the new policy retains. Re-reading costs one indexed key lookup
|
||||
per sweep interval, against the risk of deleting history under a window the
|
||||
operator already widened. The wake channel keeps its job — re-arming the timer
|
||||
promptly in the process that saw the change — and stops being the only path by
|
||||
which a change reaches a sweep.
|
||||
|
||||
A failed or unparseable re-read **skips the sweep** rather than falling back to
|
||||
the documented defaults. Everywhere else in this design an unreadable settings
|
||||
document yields the defaults and a health issue
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.4), which is right for reporting and for
|
||||
startup because neither deletes anything. It is wrong here: the default window is
|
||||
30 days, an operator who widened theirs to 3,650 has by definition configured
|
||||
something longer, and falling back would delete the decade of history they
|
||||
configured the system to keep. Reading settings to *show* them fails open;
|
||||
reading them to *delete by* fails closed.
|
||||
|
||||
|
||||
### The preview marker
|
||||
|
||||
A second `settings` key, `office_run_retention_preview_completed`,
|
||||
holding a JSON object keyed by swept table name whose values are the timestamp
|
||||
at which that table's preview completed:
|
||||
`{"office_routine_runs": "...", "runs": "..."}`.
|
||||
|
||||
The marker is **per table, not per database**. A single global flag is unsafe
|
||||
against AC-OFFICE-RUN-HISTORY-RETENTION-002.7, which lets one table fail while
|
||||
the sweep as a whole still completes: if `runs` errored during the preview sweep
|
||||
and `office_routine_runs` succeeded, a global flag would be written anyway and
|
||||
`runs` would delete for real on the next sweep having never shown an operator a
|
||||
would-delete count — the exact failure this capability exists to prevent. With a
|
||||
per-table marker, `runs` is simply previewed again next sweep
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.4).
|
||||
|
||||
A table's entry is written only when that table's preview evaluation completed
|
||||
successfully, is never cleared by a settings change or a restart, and is not
|
||||
written by a deleting sweep. A table whose preview finds nothing still gets its
|
||||
entry, so its next sweep deletes normally.
|
||||
|
||||
If the key is present but unparseable, every swept table is treated as **not yet
|
||||
previewed** and `office_retention_preview_unreadable` is raised
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.10). The asymmetry is deliberate: a
|
||||
spurious re-preview deletes nothing and costs one sweep, whereas assuming a
|
||||
preview had completed permits a first deletion no operator ever saw. The safe
|
||||
direction is the one that cannot delete.
|
||||
|
||||
The preview's per-table counts are uncapped by the batch limit
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.9): capping them would report 5,000 to an
|
||||
operator holding 200,000 eligible rows, which is exactly the number the warning
|
||||
exists to convey.
|
||||
|
||||
|
||||
### Reporting
|
||||
|
||||
An in-memory `LastSweep` value replaced wholesale at the end of each sweep that
|
||||
actually ran: start and finish times, and per **reported** table the deleted
|
||||
count, a backlog flag, and an error string. Deleted counts are counted from
|
||||
**committed** transactions only: when a `runs` batch is abandoned and rolled back
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.7), the three satellite tables whose rows
|
||||
that transaction addressed report **zero** deleted for that sweep, not the counts
|
||||
their statements returned before the rollback. The failure is recorded against
|
||||
`runs`, but the satellites must not show rows the rollback restored — the one
|
||||
surface an operator has for "what actually happened" would otherwise be wrong
|
||||
precisely in the failure case it exists for. Per **swept** table it also carries
|
||||
whether that table was previewed in this sweep and, when it was, its
|
||||
`WouldDelete` count — without that field the preview's headline numbers, which
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-003.2 and
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-003.9 require an operator to see, would have
|
||||
nowhere to be read from. Because the preview marker is per table, `preview` is a
|
||||
per-table flag rather than one flag for the sweep. Skips are held separately and
|
||||
never overwrite this value. Nothing is persisted
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.6, and the "no persisted sweep history"
|
||||
exclusion: a growing table recording the work of the job that stops tables
|
||||
growing is the same defect in a new place). Before the first sweep the value is
|
||||
absent, and the surface says so rather than rendering zeros
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.7).
|
||||
|
||||
**Retained counts do not come from `LastSweep`.** They are a property of the
|
||||
table, not of a sweep, and three ACs need them when no sweep has run at all:
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-003.7 (threshold warning while retention is
|
||||
disabled), AC-OFFICE-RUN-HISTORY-RETENTION-004.8 (surface readable while
|
||||
disabled), and AC-OFFICE-RUN-HISTORY-RETENTION-002.8 (disabled means no sweep,
|
||||
yet counts are still reported). A separate `RetainedCounts` value therefore
|
||||
holds one count per thresholded table — produced by the census described next —
|
||||
plus, for `office_routine_runs`, the top routine by retained rows for
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-003.5's attribution.
|
||||
|
||||
**The count evaluation is a status census, not a bare `COUNT(*)`.** For each
|
||||
swept table it issues one
|
||||
`SELECT status, COUNT(*) FROM <table> GROUP BY status`. The retained count is the
|
||||
sum of those rows, so the census costs one scan rather than two, and the same
|
||||
result set is what detects a status in neither the history nor the live-state set
|
||||
and raises `office_retention_unknown_status:<table>`
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-001.10). That detector has to live here rather
|
||||
than in the sweep: the sweep's predicate selects `status IN (<history statuses>)`
|
||||
and therefore structurally cannot observe a status it does not select.
|
||||
`run_events` is thresholded but not swept and has no status column, so it keeps a
|
||||
plain `COUNT(*)`.
|
||||
|
||||
It is refreshed on the sweep interval by the same scheduler goroutine — on that
|
||||
schedule whether or not retention is enabled, since a disabled install is
|
||||
precisely the one whose tables grow unattended, and is also the only install
|
||||
where an unrecognized status would otherwise never be noticed — and served from
|
||||
memory in between.
|
||||
|
||||
**The first evaluation runs at `Start`, not at the first sweep.** The first sweep
|
||||
is deliberately delayed five minutes (AC-OFFICE-RUN-HISTORY-RETENTION-002.10);
|
||||
hanging the first count off that tick would leave every restart with five minutes
|
||||
of absent counts, and a fresh install with none at all until it had swept once —
|
||||
while AC-OFFICE-RUN-HISTORY-RETENTION-003.7 and -004.8 require the threshold
|
||||
warning and the surface to work on exactly those installs.
|
||||
|
||||
It runs **on the scheduler goroutine, not on the caller of `Start`**. The census
|
||||
is three unbounded scans of the largest tables in the database, and the install
|
||||
that most needs them is the one where they take longest; blocking `Start` on them
|
||||
would make bounding these tables a cause of slow boots. `Start` returns
|
||||
immediately, the tables read *not yet computed* until the first census lands
|
||||
seconds later, and that state is already required and already renders honestly.
|
||||
|
||||
`RetainedCounts` is therefore **tri-state per thresholded table**, not a number
|
||||
that defaults to zero: *not yet computed*, *fresh as of T*, or *stale as of T*.
|
||||
Zero is a real measurement and must not be how "nobody has counted yet" renders —
|
||||
the same argument AC-OFFICE-RUN-HISTORY-RETENTION-004.7 makes for `LastSweep`,
|
||||
and the reason AC-OFFICE-RUN-HISTORY-RETENTION-003.11 states it. The three states
|
||||
are tracked **per table**, so one table's failing query neither discards nor
|
||||
staleness-marks another's successful one; a failure with no prior success leaves
|
||||
that table at *not yet computed* rather than fabricating a zero.
|
||||
|
||||
The evaluation is explicitly **not** computed per health poll or per page load:
|
||||
`/api/v1/system/health` is polled by every open browser tab, and issuing three
|
||||
unbounded `COUNT(*)`s against the largest tables in the database on each poll
|
||||
would make this feature a cause of the load it exists to prevent
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.11).
|
||||
|
||||
Three surfaces, in descending durability:
|
||||
|
||||
1. **Structured logs.** One `info` line per sweep. One `warn` line per condition
|
||||
in REQ-OFFICE-RUN-HISTORY-RETENTION-003, carrying the table, the counts, and
|
||||
the threshold. Always on, in every profile.
|
||||
2. **Health issues.** The package implements `health.Checker` with
|
||||
`Name() = "Office run retention"` and `Category() = "office"`, returning a
|
||||
`health.Issue` per active condition with
|
||||
`FixURL = "/settings/system/data-storage"`, which is the live route
|
||||
registered in `apps/web/src/settings-routes.tsx` — note the suffix, as
|
||||
`/settings/system/data` is not a registered path and `/settings/system/database`
|
||||
is only a redirect to it. `internal/health/checks_test.go` pins fix URLs to
|
||||
live routes ("expectedGitHubFixURL is the live route in ..."), and this one
|
||||
is pinned the same way. This is the production-visible surface
|
||||
required by AC-OFFICE-RUN-HISTORY-RETENTION-003.8 and is why the debug
|
||||
metrics endpoint is not it: `/debug/vars` is gated on the dev profile.
|
||||
Issue ids are stable and one per condition:
|
||||
`office_retention_preview_pending`, `office_retention_backlog:<table>`,
|
||||
`office_retention_threshold:<table>`, `office_retention_disabled:<table>`,
|
||||
`office_retention_settings_invalid`, `office_retention_failed:<table>`,
|
||||
`office_retention_preview_unreadable`, and
|
||||
`office_retention_unknown_status:<table>`.
|
||||
`Check` returns issues sorted by id, matching `storage.Runtime.Check`, so the
|
||||
health card's order is stable across polls.
|
||||
For `office_routine_runs`, the threshold issue's message names the routine
|
||||
holding the largest share of retained rows and that share
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.5) — attribution computed from the same
|
||||
retained-count query, not a second detector.
|
||||
3. **expvar.** Counters under `office_retention_*` following
|
||||
`office/scheduler/metrics_vars.go`. Development convenience only; nothing in
|
||||
REQ-OFFICE-RUN-HISTORY-RETENTION-003 depends on it.
|
||||
|
||||
|
||||
### HTTP and frontend
|
||||
|
||||
`GET /api/v1/system/retention` returns effective settings, `LastSweep`, the
|
||||
separately-held skip record, and `RetainedCounts`.
|
||||
|
||||
`PUT /api/v1/system/retention` **replaces the whole document**; it is not a
|
||||
merge patch. A field the caller omits takes its documented default rather than
|
||||
its stored value, so the same request body always yields the same stored
|
||||
document and a repeated identical write is genuinely a no-op — which is what
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.5's "identical repeated write changes
|
||||
nothing and returns the same normalized document" requires. A merge would break
|
||||
that: under a merge, whether a request is a no-op depends on what was stored
|
||||
before it. An unrecognized field, a numeric field whose value is not a whole
|
||||
number, a field present with a JSON `null`, and a field whose value is of the
|
||||
wrong type, are each rejected with a field-named 400 and nothing is written
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.9); rejecting rather than ignoring an
|
||||
unknown field means a client that misspells `window_days` is told so instead of
|
||||
silently getting the default. `null` needs saying because this endpoint is a full
|
||||
replace: since an omitted field deliberately means "take the default", the
|
||||
tempting reading of `null` is the same one, and that reading turns
|
||||
`{"runs": {"window_days": null}}` into a silent 3,650-to-30-day reduction that
|
||||
destroys a decade of history on the next sweep. Decode into pointer fields and
|
||||
reject an explicit null, rather than into value fields where `null` and omission
|
||||
are indistinguishable. Successful writes return the normalized document.
|
||||
|
||||
Both routes are admin-scoped like the other System routes and are readable while
|
||||
retention is disabled (AC-OFFICE-RUN-HISTORY-RETENTION-004.8).
|
||||
|
||||
One card on **Settings > System > Data & Logs**, the page served at
|
||||
`/settings/system/data-storage` and rendered by
|
||||
`apps/web/components/settings/system/data-logs-settings.tsx`, beside
|
||||
`database-stats-card.tsx`: the enable toggle, the numeric fields, and the
|
||||
last-sweep readout. It follows the storage-maintenance cards' shape. All new
|
||||
copy goes through `t()` and must ship in `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw`;
|
||||
`pnpm run i18n:check` and the new-code ratchet gate the build. Health issue
|
||||
titles and messages stay English, matching every other backend-produced
|
||||
`health.Issue`.
|
||||
|
||||
|
||||
## Ordering, concurrency, and failure
|
||||
|
||||
| Question | Answer | AC |
|
||||
|---|---|---|
|
||||
| Concurrent settings writes | Last-writer-wins; an identical repeat changes nothing | 004.5 |
|
||||
| Settings unreadable | Defaults used, health issue raised, sweep proceeds | 004.4 |
|
||||
| Settings out of range | Rejected with the field named, stored settings unchanged | 004.3 |
|
||||
| Retention disabled | No sweep, no deletion; counts and threshold warnings still reported | 002.8, 003.7, 004.8 |
|
||||
| Preview scope | Per swept table, not per database; a table that failed its preview is previewed again | 003.4 |
|
||||
| Preview marker unreadable | Treated as not previewed; re-preview deletes nothing | 003.10 |
|
||||
| Retained counts | Table property from a `GROUP BY status` census; first evaluation at `Start`, then on the sweep interval; served from memory; computed even while disabled | 003.11 |
|
||||
| Counts before the first evaluation, or first evaluation fails | Reported *not yet computed* per table, never as zero; state tracked per thresholded table | 003.11, 004.7 |
|
||||
| Unknown status detected | By the census, not the sweep predicate; one issue per table listing every unrecognized status in ascending order | 001.10 |
|
||||
| Preview on a table with more eligible rows than the batch limit | Reports the full eligible count; never reported as backlog | 003.6, 003.9 |
|
||||
| Settings changed on another backend | Each sweep re-reads settings at its start, so a process that did not serve the write still uses the new policy | 004.5 |
|
||||
| Settings unreadable at sweep start | Sweep skipped and recorded as skipped; never run under the shorter default window | 004.5, 004.4 |
|
||||
| Batch abandoned and rolled back | Satellite tables report zero deleted, not their pre-rollback statement counts | 002.7, 004.6 |
|
||||
| Settings write shape | Full replace; omitted field takes its default; unknown field 400 | 004.9 |
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests in `internal/office/retention`, plus the frontend checks below.
|
||||
|
||||
- The first sweep on a seeded database deletes nothing and reports a
|
||||
would-delete count; the second deletes (003.1, 003.4).
|
||||
- A preview on a database with more eligible rows than the batch limit reports
|
||||
the full eligible count (003.9).
|
||||
- A table that errors during the sweep in which it was being previewed is
|
||||
previewed again on the next sweep instead of deleting, while a sibling table
|
||||
that succeeded proceeds to delete (003.4).
|
||||
- A preview marker that is present but unparseable causes a re-preview, not a
|
||||
deletion, and raises `office_retention_preview_unreadable` (003.10).
|
||||
- Retained counts and the threshold warning are produced on a fresh install
|
||||
before any sweep, and while retention is disabled (003.7, 003.11, 004.8), and
|
||||
a health poll does not issue a table count.
|
||||
- Counts are available immediately after `Start`, without advancing any clock to
|
||||
the first sweep's delay (003.11). A test that waits out the delay would pass
|
||||
against an implementation that hangs the first count off the sweep tick, which
|
||||
is the defect this asserts against.
|
||||
- Before the first evaluation, and when the first evaluation fails with no
|
||||
earlier success, the surface reports the table as *not yet computed* and not as
|
||||
zero (003.11, 004.7).
|
||||
- With one thresholded table's count query failing and the other two succeeding,
|
||||
the two keep fresh counts and only the failing one is marked, and the threshold
|
||||
warnings derived from the successful counts still fire (003.11).
|
||||
- A swept table holding a status in neither status set raises
|
||||
`office_retention_unknown_status:<table>` from the census while retention is
|
||||
**disabled** and no sweep has ever run (001.10, 003.11).
|
||||
- A preview on a table with more eligible rows than the batch limit reports the
|
||||
full eligible count and raises no backlog warning (003.6, 003.9).
|
||||
- A settings change written through one store handle is used by a sweep driven
|
||||
from a second handle that never saw the change notification (004.5).
|
||||
- A backend census refresh adopts a settings change written by another backend,
|
||||
including when retention was disabled, and arms the appropriate sweep timer
|
||||
(002.13, 004.5).
|
||||
- A sweep whose settings read fails is skipped and recorded as skipped, and
|
||||
deletes nothing under the default window (004.5).
|
||||
- A `runs` batch abandoned after its retry reports zero deleted for the three
|
||||
satellite tables rather than their pre-rollback counts (002.7, 004.6).
|
||||
- A recorded skip leaves the previous `LastSweep` readable and unchanged (002.2,
|
||||
004.7).
|
||||
- A settings write omitting a field stores that field's default; a write with an
|
||||
unknown field or a fractional number is rejected 400 naming the field and
|
||||
stores nothing; the same write applied twice is a no-op (004.9).
|
||||
|
||||
Commands:
|
||||
|
||||
```
|
||||
cd apps/backend
|
||||
go test ./internal/office/... -race -count=1
|
||||
KANDEV_TEST_POSTGRES_DSN=<dsn> go test -race ./internal/office/... -count=1
|
||||
cd apps/web && pnpm run typecheck && pnpm run i18n:check
|
||||
```
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **One global "preview completed" flag.** Simpler, and unsafe: because
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.7 lets one table fail while the sweep
|
||||
completes, a global flag is written even when a table never got its preview,
|
||||
and that table then deletes for real having shown the operator nothing. The
|
||||
per-table marker costs one JSON object.
|
||||
- **Compute retained counts inside the health check.** Direct, and it puts three
|
||||
unbounded `COUNT(*)`s on a route every open browser tab polls. Refreshing on
|
||||
the sweep interval and serving from memory gives the same number without
|
||||
making the bound-the-tables feature a source of load on those tables.
|
||||
- **A merge-patch settings write.** Under a merge, whether a request is a no-op
|
||||
depends on what was stored before it, which
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.5 forbids.
|
||||
- **Deletion off by default.** Safe, and it means the gap stays open on every
|
||||
install that never visits the settings page. The per-table preview gives the
|
||||
same protection without that outcome.
|
||||
- **Report warnings only through `/debug/vars`.** That endpoint is gated on the
|
||||
dev profile, so on a production build the warnings would not exist
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-003.8).
|
||||
|
||||
## Prior art, applied
|
||||
|
||||
`internal/system/storage` supplies the settings storage and normalization
|
||||
pattern, the hours-based interval with min and max bounds, and the
|
||||
`health.Checker` route to a production-visible warning; `storage.Runtime.Check`
|
||||
also sorts its issues by id, which this check matches so the health card's order
|
||||
is stable across polls.
|
||||
|
||||
Neither GitLab Duo nor the Claude apps gateway previews before a policy's first
|
||||
deletion or warns ahead of the window. Those are this capability's additions,
|
||||
and they are the whole reason this document is separate from the sweep's.
|
||||
@@ -0,0 +1,571 @@
|
||||
---
|
||||
status: current
|
||||
system: office
|
||||
requirements:
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-001
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-002
|
||||
- REQ-OFFICE-RUN-HISTORY-RETENTION-005
|
||||
---
|
||||
|
||||
# Office Run History Retention System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design adds one scheduled sweep that deletes aged Office run history from
|
||||
five tables. It changes no run lifecycle, no routine dispatch decision, and no
|
||||
task, session, or checkout.
|
||||
|
||||
Office owns the sweep because the eligibility rules are defined by Office
|
||||
primitives. The settings record, the health check, the reporting value, and the
|
||||
System page surface are the operator half of the same capability and are
|
||||
designed in [run history retention
|
||||
operations](run-history-retention-operations.md), which is where every
|
||||
requirement in REQ-OFFICE-RUN-HISTORY-RETENTION-003 and -004 is satisfied.
|
||||
|
||||
Adjacent contracts read and constrained but not owned:
|
||||
|
||||
- `internal/health` — `Checker`, `Issue`, and the `/api/v1/system/health`
|
||||
response the System page's health card renders.
|
||||
- `internal/system/settings.Store` — the key/value settings table, already used
|
||||
by storage maintenance under one JSON key with normalization on read.
|
||||
- `internal/runs/repository/sqlite` — the `runs` queue and `run_events` access
|
||||
methods.
|
||||
- `internal/office/repository/sqlite` — the schema owner for `runs`,
|
||||
`run_events`, `office_run_route_attempts`, `office_run_skills`, and
|
||||
`office_routine_runs`.
|
||||
|
||||
## Measured starting state
|
||||
|
||||
Read from the reference install's SQLite database on 2026-09-09. These numbers
|
||||
set the defaults and are the baseline any regression test can be written
|
||||
against.
|
||||
|
||||
| Table | Rows | Notes |
|
||||
|---|---|---|
|
||||
| `office_routine_runs` | 326 | 323 `coalesced`, 3 `task_created` |
|
||||
| `runs` | 53 | |
|
||||
| `run_events` | 340 | across 53 runs, mean 6.4 per run |
|
||||
| `office_run_route_attempts` | 55 | |
|
||||
| `office_run_skills` | 382 | |
|
||||
|
||||
The 323 `coalesced` rows span 2026-08-03 to 2026-08-31 and belong to a single
|
||||
routine that is now `paused`. Orphan `run_events` today: zero, because nothing
|
||||
has ever deleted a `runs` row.
|
||||
|
||||
## Schema facts this design depends on
|
||||
|
||||
Verified by reading the schema owners, not assumed.
|
||||
|
||||
- `office_routine_runs` (`office/repository/sqlite/base.go`) has
|
||||
`FOREIGN KEY (routine_id) REFERENCES office_routines(id) ON DELETE CASCADE`
|
||||
on both engines, and SQLite opens with `_foreign_keys=on`
|
||||
(`internal/db/sqlite.go`). Its two indexes are partial and serve the dispatch
|
||||
gate, not an age scan: `idx_office_routine_runs_active_fingerprint`
|
||||
(`WHERE status = 'task_created'`) and `idx_office_routine_runs_linked_task`
|
||||
(`WHERE linked_task_id != ''`).
|
||||
- `run_events`, `office_run_route_attempts`, and `office_run_skills` declare
|
||||
**no foreign key to `runs`** on either engine. Confirmed in
|
||||
`office/repository/sqlite/base.go` and in the PostgreSQL conformance snapshot
|
||||
`internal/persistence/storeconformance/testdata/upgrades/v0.93.0/postgres.sql`,
|
||||
where `run_events` has only a primary key and one index. A cascade would
|
||||
therefore delete nothing; satellite deletion must be explicit
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-005.2).
|
||||
- `run_events.seq` is assigned by `AppendRunEvent` as
|
||||
`COALESCE(MAX(seq) + 1, 0)` scoped to the run. Deleting a live run's whole
|
||||
timeline restarts its sequence at zero, and the run detail view's incremental
|
||||
tail reads `WHERE seq > afterSeq`. This is why
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-001.6 forbids event deletion outside the
|
||||
transaction that deletes the run.
|
||||
- `runs` indexes are `idx_run_status_requested (status, requested_at)` and the
|
||||
partial unique `idx_run_idempotency`. Nothing indexes `finished_at`.
|
||||
- `office_agent_pause_recoveries.failed_run_id` identifies failed runs that an
|
||||
active pause recovery still needs. It has no foreign key, so retention uses a
|
||||
correlated `NOT EXISTS` predicate and the supporting
|
||||
`idx_office_agent_pause_recoveries_failed_run` index.
|
||||
- `ScheduleRetry` (`runs/repository/sqlite/runs.go`) sets
|
||||
`status = 'queued', finished_at = NULL` on an existing run, keyed by id with
|
||||
**no status guard in its `WHERE` clause**. Any terminal run can therefore
|
||||
become live again at any moment, which is the race
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.4 closes. Because the guard is absent, a
|
||||
`cancelled` row is resurrectible on exactly the same terms as a `failed` one,
|
||||
so admitting `cancelled` to the history set adds no new race — it is covered
|
||||
by the same re-assertion.
|
||||
- `CancelRunsWhere` (`runs/repository/sqlite/cancel.go`) is documented as "the
|
||||
single writer of the terminal cancel state on the runs table". It sets
|
||||
`status = 'cancelled', cancel_reason = ?, finished_at = ?` and is guarded by
|
||||
`status IN ('queued', 'claimed')`, so a cancelled row is always terminal and
|
||||
always carries a completion timestamp. It is production-reachable through
|
||||
`CancelRunsForTasks` from `office/service/tree_controls.go` and
|
||||
`office/repository/sqlite/participants.go`. It writes the literal string, so
|
||||
`office/models/enums.go`'s four-value `RunStatus` block does not enumerate it:
|
||||
the database has five `runs` statuses, not four. This is why
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-001.2 classifies `cancelled` as history and
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-001.10 makes any sixth value fail safe.
|
||||
- Every production writer of a terminal `runs` status stamps the completion
|
||||
timestamp: `FinishRun` sets `finished_at = now`, `MarkRunFailed` sets
|
||||
`finished_at = COALESCE(finished_at, now)`, and `CancelRunsWhere` sets it
|
||||
outright. Only the test helper `SetRunStatusForTest` can leave a terminal row
|
||||
with a null `finished_at`. The `COALESCE(finished_at, requested_at)` fallback
|
||||
in AC-OFFICE-RUN-HISTORY-RETENTION-001.3 is therefore defensive rather than a
|
||||
routine path — but it is also what makes the ordering key non-null, which is
|
||||
what keeps the floor deterministic across engines (see below).
|
||||
- `runs.requested_at` and `office_routine_runs.created_at` are both
|
||||
`TIMESTAMP NOT NULL`, so the completion-time expression is total. This matters
|
||||
for parity, not just tidiness: SQLite and PostgreSQL differ in where they sort
|
||||
nulls by default, so an `ORDER BY` over a nullable timestamp would rank the
|
||||
floor differently on the two engines from identical data.
|
||||
- `CleanExpired` (`runs/repository/sqlite/runs.go`) already deletes terminal
|
||||
`runs` rows older than a cutoff and **has no production caller** — only
|
||||
tests reach it. It deletes only `runs`, so wiring it as-is would orphan every
|
||||
satellite row. It is superseded by the batched, satellite-aware delete below
|
||||
rather than reused.
|
||||
|
||||
## Component: `internal/office/retention`
|
||||
|
||||
One package holding policy, settings, the sweep, and the health check.
|
||||
|
||||
### Ownership and lifecycle
|
||||
|
||||
A single goroutine owner modelled on `internal/system/storage.Scheduler`: a
|
||||
`Start(ctx)` that is a no-op when already running, a `Stop()` that cancels and
|
||||
joins, and a buffered wake channel so a settings change re-arms the interval
|
||||
without interrupting a sweep in progress
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-004.5). `Stop` is joined from the same place
|
||||
that stops the Office scheduler.
|
||||
|
||||
The loop is deliberately not the Office run-processing tick
|
||||
(`office/service/scheduler_integration.go`, `DefaultTickInterval = 5s`). That
|
||||
tick already carries `RecoverStale` and `ReapStaleCheckouts` unthrottled and is
|
||||
the run-claim hot path; on SQLite a bulk delete there contends with the single
|
||||
writer that claims runs, 17,280 times a day, for an input that changes on a
|
||||
scale of days (AC-OFFICE-RUN-HISTORY-RETENTION-002.1).
|
||||
|
||||
A `sweeping bool` guarded by the same mutex makes a due sweep a skip rather than
|
||||
a second goroutine (AC-OFFICE-RUN-HISTORY-RETENTION-002.2). That guard is
|
||||
process-local, which is sufficient on SQLite, where the database file is owned
|
||||
by one backend process. On PostgreSQL, where several backends can share one
|
||||
database, it is not: two backends would each see `sweeping == false` and sweep
|
||||
the same rows concurrently.
|
||||
|
||||
The sweep therefore also takes a PostgreSQL advisory lock, and it must be a
|
||||
**session-scoped, non-blocking** one, which is a different shape from every
|
||||
existing advisory lock in this repository:
|
||||
|
||||
```
|
||||
conn := db.Conn(ctx) // one dedicated connection
|
||||
SELECT pg_try_advisory_lock(:retention_key) // boolean, returns immediately
|
||||
... whole sweep, every table, every batch, on the pool ...
|
||||
SELECT pg_advisory_unlock(:retention_key) // on that same connection
|
||||
conn.Close() // deferred
|
||||
```
|
||||
|
||||
Both properties are load-bearing and neither is optional:
|
||||
|
||||
- **Session-scoped, not transaction-scoped.** The sweep is multi-transaction by
|
||||
construction: AC-OFFICE-RUN-HISTORY-RETENTION-002.5 makes each batch its own
|
||||
transaction, and AC-OFFICE-RUN-HISTORY-RETENTION-002.7 requires one table's
|
||||
failure to leave another table's committed deletions intact, which forbids
|
||||
wrapping the sweep in a single transaction. A `pg_advisory_xact_lock` is
|
||||
released when its transaction ends, so it would protect one batch and then let
|
||||
a second backend in between tables — the interleaving
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.12 exists to prevent. The lock is held on a
|
||||
connection checked out for the sweep and released in a `defer`; the batches
|
||||
themselves continue to use the pool.
|
||||
- **`try`, not the blocking form.** AC-OFFICE-RUN-HISTORY-RETENTION-002.12 says a
|
||||
backend that cannot acquire the lock *skips*. `pg_advisory_xact_lock` and
|
||||
`pg_advisory_lock` wait instead of failing, which would convert a concurrent
|
||||
sweep into a queued one and eventually stall the scheduler behind a long sweep.
|
||||
`pg_try_advisory_lock` returns `false` immediately; on `false` the backend
|
||||
records a skip and returns, exactly as for a local concurrent sweep.
|
||||
|
||||
This deliberately departs from the established repo pattern
|
||||
`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`
|
||||
(`office/repository/sqlite/participants.go`, `internal/secrets/sqlite_store.go`,
|
||||
`internal/workflow/repository/phase2_sqlite.go`), and the departure is the point:
|
||||
each of those call sites performs its entire unit of work inside the one
|
||||
transaction that holds the lock, and each *wants* to wait rather than skip. The
|
||||
sweep can do neither. The key is derived the same way, `hashtextextended` over a
|
||||
constant distinct from every key those sites use, so retention never contends
|
||||
with participant-seat, secret-transfer, or workflow-phase locking.
|
||||
|
||||
**If the lock connection drops mid-sweep**, PostgreSQL releases the session's
|
||||
advisory locks as part of ending the session, so a crashed or partitioned backend
|
||||
cannot wedge retention permanently — that self-healing is the reason for a
|
||||
session lock rather than a lease row in the `settings` table, which would need its
|
||||
own expiry and its own stale-holder rule. The cost is that the surviving sweep no
|
||||
longer holds exclusivity without knowing it, so the sweep **verifies the lock
|
||||
connection is still alive between tables** and, if it is not, stops before the
|
||||
next table, records that table as skipped rather than failed, and returns. It
|
||||
does not attempt to re-acquire mid-sweep: a re-acquisition after another backend
|
||||
has taken the lock would produce exactly the concurrent sweep this protects
|
||||
against. Batches already committed stay committed, which
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.5 permits.
|
||||
|
||||
A skip is recorded as its own value — a skip counter and a last-skip timestamp —
|
||||
and does **not** overwrite `LastSweep`. `LastSweep` holds the last sweep that
|
||||
actually ran, so a burst of skips cannot blank the operator's view of the last
|
||||
real result (AC-OFFICE-RUN-HISTORY-RETENTION-002.2, and
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-004.7, which forbids rendering a
|
||||
never-swept-looking surface when a sweep has in fact run).
|
||||
|
||||
The first sweep after `Start` is armed at a fixed 5-minute delay rather than a
|
||||
full interval (AC-OFFICE-RUN-HISTORY-RETENTION-002.10). Arming at the interval,
|
||||
as the storage scheduler does with its 24-hour default, means an install
|
||||
restarted more often than the interval never sweeps at all; 5 minutes keeps
|
||||
startup clear of schema init and run recovery without depending on uptime. Each
|
||||
sweep computes `time.Now().UTC()` once and passes that instant to every table,
|
||||
so two tables in one sweep cannot disagree about the cutoff
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.11).
|
||||
|
||||
Scheduling is **fixed-delay, not fixed-rate**: the next sweep is armed when the
|
||||
previous one returns, so a sweep that overruns its interval delays its successor
|
||||
instead of causing an immediate second one (which the concurrency guard would
|
||||
only skip anyway, turning a slow sweep into a stream of skips). A settings
|
||||
change re-arms the delay from the moment of the change rather than from the last
|
||||
sweep, and enabling retention that was disabled arms at the same 5-minute delay
|
||||
as a fresh start rather than at a full interval — otherwise an operator who
|
||||
enables retention on a 168-hour interval waits a week to see whether it works
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.13).
|
||||
|
||||
The census timer also re-reads shared settings. It runs while retention is
|
||||
disabled, so other backends discover enablement and interval changes and re-arm
|
||||
their timers.
|
||||
|
||||
### Eligibility, expressed once
|
||||
|
||||
Two predicates, each defined in exactly one place and reused by the count, the
|
||||
preview, and the delete.
|
||||
|
||||
**Routine runs.** History statuses are `skipped`, `coalesced`, `failed`, `done`,
|
||||
`cancelled`. `received` and `task_created` are absent by construction, which is
|
||||
what makes AC-OFFICE-RUN-HISTORY-RETENTION-005.3 hold: the dispatch gate
|
||||
`GetActiveRunForFingerprint` reads only `status = 'task_created'`, so no sweep
|
||||
can change its answer.
|
||||
|
||||
```
|
||||
DELETE FROM office_routine_runs
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY routine_id
|
||||
ORDER BY COALESCE(completed_at, created_at) DESC, id DESC
|
||||
) AS rn
|
||||
FROM office_routine_runs
|
||||
WHERE status IN (<history statuses>)
|
||||
) ranked
|
||||
WHERE rn > :floor
|
||||
AND completion_time < :cutoff
|
||||
ORDER BY completion_time ASC, id ASC
|
||||
LIMIT :batch
|
||||
)
|
||||
AND status IN (<history statuses>)
|
||||
AND COALESCE(completed_at, created_at) < :cutoff
|
||||
```
|
||||
|
||||
where the ranked subquery also projects
|
||||
`COALESCE(completed_at, created_at) AS completion_time`.
|
||||
|
||||
Two orderings appear here and they are not the same ordering; conflating them is
|
||||
the defect this section exists to prevent.
|
||||
|
||||
- The **`ORDER BY` inside `ROW_NUMBER()`** ranks rows *within* a partition so the
|
||||
floor keeps the newest per owner: `completion_time DESC, id DESC`
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-001.4).
|
||||
- The **`ORDER BY` on the outer select** decides *which* eligible rows a
|
||||
batch-limited sweep takes: `completion_time ASC, id ASC`, oldest first
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.3). The window function's ordering does
|
||||
not reach the outer `LIMIT`, so without this clause the engine is free to
|
||||
return any subset and the two engines may drain a backlog differently from
|
||||
identical data — which would contradict
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-005.1 and make the parity test below flake for
|
||||
a reason unrelated to a genuine engine difference.
|
||||
|
||||
`id` is a UUID and so is not chronological; it is used only as a total, stable
|
||||
tiebreak for equal timestamps, in both orderings.
|
||||
|
||||
The trailing `AND` clauses are the re-assertion required by
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.4. For this table the whole ranked subquery
|
||||
is re-evaluated inside the `DELETE`, so the floor is re-asserted atomically along
|
||||
with status and age; the two-phase `runs` path below has to do that explicitly.
|
||||
|
||||
Window functions are available on both engines: SQLite 3.54.0 through
|
||||
`github.com/mattn/go-sqlite3 v1.14.33`, verified by running this exact
|
||||
`ROW_NUMBER() OVER (PARTITION BY routine_id ...)` against the reference
|
||||
database.
|
||||
|
||||
**Runs.** History is `status IN ('finished','failed','cancelled')`, partitioned
|
||||
by `agent_profile_id`, ranked `COALESCE(finished_at, requested_at) DESC, id DESC`
|
||||
and batch-ordered `COALESCE(finished_at, requested_at) ASC, id ASC`. A failed
|
||||
run named by an active `office_agent_pause_recoveries.failed_run_id` is
|
||||
protected by a `NOT EXISTS` clause in this predicate. Count, selection, and
|
||||
delete use the same clause, so a preview cannot promise deletion of a run that
|
||||
the recovery flow still needs. There is no
|
||||
`finished_at IS NOT NULL` conjunct: requiring one would make a terminal row with
|
||||
an unset stamp immortal and unobservable, which
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-001.3 forbids. `queued` and `claimed` are
|
||||
absent, which covers a routing-parked run whose `earliest_retry_at` is far in
|
||||
the future (AC-OFFICE-RUN-HISTORY-RETENTION-001.2).
|
||||
|
||||
A status in neither set is treated as live state and raises
|
||||
`office_retention_unknown_status:<table>`
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-001.10). Both sets are closed and asserted
|
||||
against `office/models/enums.go` plus the literal-SQL writers in a test, so a
|
||||
sixth status cannot enter the database without failing that test — the failure
|
||||
mode that hid `cancelled` in the first place.
|
||||
|
||||
**That warning needs a producer, and the eligibility predicate cannot be it.**
|
||||
The predicate selects `status IN (<history statuses>)`, so a row holding an
|
||||
unrecognized status is never selected, never counted, and never seen: a fail-safe
|
||||
whose only detector is a CI test fires on the developer's machine and stays
|
||||
silent on the install that actually has the row. The producer is instead the
|
||||
**status census** — one
|
||||
|
||||
```
|
||||
SELECT status, COUNT(*) FROM <swept table> GROUP BY status
|
||||
```
|
||||
|
||||
per swept table, run by the retained-count evaluation described in [run history
|
||||
retention operations](run-history-retention-operations.md#reporting). Three
|
||||
consequences follow from siting it there rather than in the sweep, and each one
|
||||
closes a hole:
|
||||
|
||||
- The census **subsumes the retained count** rather than adding a second scan:
|
||||
the retained count is the sum of the census rows, so one `GROUP BY` yields
|
||||
both. (`run_events` is thresholded but not swept and has no status column, so
|
||||
it keeps a plain `COUNT(*)`.)
|
||||
- It runs **whether or not retention is enabled**, because the count evaluation
|
||||
does (AC-OFFICE-RUN-HISTORY-RETENTION-003.11). A disabled install is precisely
|
||||
where an unrecognized status would otherwise never be noticed, since
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.8 means no sweep runs there at all.
|
||||
- It sees a status **because the row exists**, not because the row was
|
||||
selectable, which is the property the eligibility predicate structurally
|
||||
cannot have.
|
||||
|
||||
One issue per table, not one per status: a table with several unrecognized
|
||||
statuses raises the single id `office_retention_unknown_status:<table>` whose
|
||||
message lists every unrecognized status **in ascending lexicographic order** with
|
||||
its row count. Ordering is named because the message is compared across health
|
||||
polls; an unordered list would make a stable condition look like a changing one.
|
||||
|
||||
### Deleting a run
|
||||
|
||||
Per batch, one transaction, satellites first, run last:
|
||||
|
||||
1. Select up to `batch_limit` eligible run ids, excluding runs named by an
|
||||
active `office_agent_pause_recoveries.failed_run_id`.
|
||||
2. `DELETE FROM run_events WHERE run_id IN (...)`
|
||||
3. `DELETE FROM office_run_route_attempts WHERE run_id IN (...)`
|
||||
4. `DELETE FROM office_run_skills WHERE run_id IN (...)`
|
||||
5. `DELETE FROM runs WHERE id IN (:selected_ids) AND id IN (<ranked eligible
|
||||
subquery>)` — the re-assertion is the **whole ranked subquery**, not just the
|
||||
status and cutoff conjuncts, so the retention floor is re-evaluated at delete
|
||||
time along with them. Unlike the single-statement `office_routine_runs`
|
||||
delete, this path selected its ids in a separate earlier statement, so a
|
||||
`ScheduleRetry` in between can re-rank a partition and push a row that was
|
||||
`rn > floor` at selection to `rn <= floor` now. Re-asserting only status and
|
||||
age would delete a row that has since become floor-protected
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.4).
|
||||
|
||||
Step 5 can delete fewer rows than steps 2 to 4 addressed, when a `ScheduleRetry`
|
||||
resurrected a run between selection and delete. That is the correct outcome for
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-002.4 only if the whole batch rolls back rather
|
||||
than leaving a live run without its timeline. **The transaction is therefore
|
||||
rolled back and retried once with a fresh selection when the step 5 row count
|
||||
does not match the selected id count**; a second mismatch rolls back again and
|
||||
abandons the batch.
|
||||
|
||||
An abandoned batch is recorded as a **failure** for that table, raising
|
||||
`office_retention_failed:<table>`, not as backlog
|
||||
(AC-OFFICE-RUN-HISTORY-RETENTION-002.7). The distinction is not cosmetic:
|
||||
backlog means work correctly deferred by the batch limit and is expected on a
|
||||
large install, so routing this case there would file the one genuinely dangerous
|
||||
outcome under the one routine one. The table is retried on the next scheduled
|
||||
sweep either way, but only the failure classification tells an operator that a
|
||||
deletion could not be applied consistently.
|
||||
|
||||
Step 5 can only ever delete *fewer* rows than steps 2 to 4 addressed, never
|
||||
more, because it is bounded by the same selected id set. This is the one place
|
||||
where the naive implementation silently corrupts a live run, and it is the
|
||||
reason satellite deletion is not a separate statement outside the transaction.
|
||||
|
||||
`run_events` is never addressed by any predicate other than membership in this
|
||||
id set (AC-OFFICE-RUN-HISTORY-RETENTION-001.6). There is no age-based delete on
|
||||
`run_events`.
|
||||
|
||||
### Indexes to add
|
||||
|
||||
Retention adds indexes that serve the sweep.
|
||||
|
||||
- `idx_office_routine_runs_retention ON office_routine_runs(routine_id, status, (COALESCE(completed_at, created_at)) DESC, id DESC)`
|
||||
- `idx_runs_retention ON runs(agent_profile_id, status, (COALESCE(finished_at, requested_at)) DESC, id DESC)`
|
||||
- `idx_office_agent_pause_recoveries_failed_run ON office_agent_pause_recoveries(failed_run_id)`
|
||||
|
||||
Added through the existing `office/repository/sqlite` schema path so both the
|
||||
fresh-install `CREATE` and the upgrade path get them, and recorded in the
|
||||
conformance fixtures.
|
||||
|
||||
## Ordering, concurrency, and failure
|
||||
|
||||
| Question | Answer | AC |
|
||||
|---|---|---|
|
||||
| Sweep ordering across tables | `office_routine_runs`, then `runs` with its satellites. Independent sets; the order is fixed only so results and logs are reproducible. | 002.6 |
|
||||
| Floor ordering | `COALESCE(completed_at, created_at) DESC, id DESC` / `COALESCE(finished_at, requested_at) DESC, id DESC` | 001.4 |
|
||||
| Two sweeps due at once | Second is skipped, not queued, and recorded as skipped | 002.2 |
|
||||
| Sweep vs. live writer | Selection is a snapshot; status, age **and floor** are re-asserted in the delete; a batch whose step 5 count disagrees is rolled back | 002.4, 002.5 |
|
||||
| Sweep interrupted | Each batch is one transaction; applied batches are whole, the interrupted one is not applied | 002.5 |
|
||||
| First sweep after startup | Armed at a fixed 5-minute delay, not a full interval | 002.10 |
|
||||
| Cutoff instant | Computed once per sweep, shared by every table | 002.11 |
|
||||
| Re-run once no eligible rows remain | Deletes nothing further, reports zero. While backlog remains, the next sweep deletes the next batch by 002.3 — the two are not in tension because 002.6 is scoped to the drained state | 002.6, 002.3 |
|
||||
| One table errors | That table is recorded failed; remaining tables continue; retried next sweep; startup unaffected | 002.7 |
|
||||
| Empty tables | Zero deletions, no error, no warning | 002.9 |
|
||||
| Deleted coalesce target | Allowed; `coalesced_into_run_id` is provenance, read by nothing | 001.9 |
|
||||
| Routine paused | Irrelevant to eligibility | 001.7 |
|
||||
| Cancelled run | History, like `finished`/`failed`; its writer stamps the completion timestamp | 001.2 |
|
||||
| Terminal row, null completion stamp | Still history; dated by `created_at` / `requested_at` | 001.3 |
|
||||
| Status in neither set | Treated as live state, never deleted, warned | 001.10 |
|
||||
| Which rows a batch-limited sweep takes | `completion_time ASC, id ASC` — oldest first, named columns | 002.3, 005.1 |
|
||||
| Batch abandoned after retry | Recorded as that table's failure, not as backlog | 002.7 |
|
||||
| Preview finds more eligible rows than the batch limit | Not backlog. A preview deletes nothing by design, so "retention is behind" would be false; it reports the full eligible count instead | 002.3, 003.6, 003.9 |
|
||||
| Sweep skipped | Recorded separately; never overwrites the last real `LastSweep` | 002.2, 004.7 |
|
||||
| Two backends, one PostgreSQL | Session-scoped `pg_try_advisory_lock` held on a dedicated connection for the whole sweep; the loser skips without waiting | 002.12 |
|
||||
| Lock connection drops mid-sweep | PostgreSQL releases the lock with the session; the sweep stops before the next table and records it skipped, and does not re-acquire | 002.12 |
|
||||
| Scheduling model | Fixed-delay from the end of the previous sweep; a settings change re-arms from the change | 002.13 |
|
||||
|
||||
## Testing
|
||||
|
||||
Unit and repository tests in `internal/office/retention` and
|
||||
`internal/office/repository/sqlite`, plus the persistence gates.
|
||||
|
||||
Behaviors that must have a test, because each is a way the naive implementation
|
||||
is wrong:
|
||||
|
||||
- A `task_created` routine run older than any window survives, and
|
||||
`GetActiveRunForFingerprint` still finds it (001.1, 005.3).
|
||||
- A `queued` run with `finished_at` cleared by `ScheduleRetry` survives (001.2).
|
||||
- A run resurrected between selection and delete leaves both the run and its
|
||||
full `run_events` timeline intact (002.4, and the rollback above).
|
||||
- After a sweep, no `run_events`, `office_run_route_attempts`, or
|
||||
`office_run_skills` row references a missing `runs` row (001.5, 005.2).
|
||||
- No `run_events` row of a surviving run is ever deleted (001.6).
|
||||
- A routine with 3 history rows all older than the window keeps all 3 under a
|
||||
floor of 50; a routine with 200 keeps exactly 50 (001.4).
|
||||
|
||||
- Two sweeps triggered concurrently produce one sweep and one recorded skip
|
||||
(002.2).
|
||||
- A sweep hitting the batch limit reports backlog and the next sweep continues,
|
||||
and its satellite rows are deleted in full rather than capped (002.3, 003.6).
|
||||
|
||||
- A terminal row whose completion timestamp is unset is dated by `created_at`
|
||||
or `requested_at` and ages out rather than being retained forever (001.3).
|
||||
This test is only satisfiable because AC-OFFICE-RUN-HISTORY-RETENTION-001.2
|
||||
classifies history by status alone; an implementation that also required
|
||||
`finished_at IS NOT NULL` would retain the row forever and fail here.
|
||||
- A `cancelled` run older than the window is deleted together with its satellite
|
||||
rows, and a `cancelled` run inside the floor is retained (001.2). Seed it
|
||||
through the real cancel path, not by writing the status directly, so the test
|
||||
fails if that path stops stamping the completion timestamp.
|
||||
- A `runs` row holding a status in neither the history set nor the live-state set
|
||||
survives every sweep at any age and raises
|
||||
`office_retention_unknown_status:runs` (001.10). Three companions: the same row
|
||||
raises the same issue with retention **disabled**, where no sweep runs at all;
|
||||
a table holding two unrecognized statuses raises **one** issue listing both in
|
||||
ascending order with their counts; and a test asserts the two status sets
|
||||
together cover every value in `office/models/enums.go` *and* every status
|
||||
literal written by SQL in `internal/runs/repository/sqlite`, so a future sixth
|
||||
value cannot be added silently — the exact gap through which `cancelled` was
|
||||
missed.
|
||||
- With more eligible rows than the batch limit, the sweep deletes the oldest
|
||||
eligible rows first, and the same seed data yields the identical deleted set on
|
||||
SQLite and PostgreSQL (002.3, 005.1). Without the outer `ORDER BY` this test is
|
||||
the one that fails.
|
||||
- A row that becomes floor-protected between selection and delete is not deleted
|
||||
by the `runs` path (002.4).
|
||||
|
||||
- A batch abandoned after its retry is reported as that table's failure and not
|
||||
as backlog (002.7).
|
||||
|
||||
- Two backends against one PostgreSQL run one sweep, and the loser records a
|
||||
skip (002.12). The seed must give the winner **more than one table** to sweep,
|
||||
so that a transaction-scoped lock — which would release between tables and let
|
||||
the loser in — fails this test rather than passing it. A companion test drops
|
||||
the winner's lock connection mid-sweep and asserts it stops before the next
|
||||
table and does not re-acquire.
|
||||
- A `runs` batch abandoned after its retry reports zero rows deleted for
|
||||
`run_events`, `office_run_route_attempts` and `office_run_skills`, not the
|
||||
counts the rolled-back statements addressed (002.7, 004.6).
|
||||
|
||||
Commands:
|
||||
|
||||
```
|
||||
cd apps/backend
|
||||
go test ./internal/office/... ./internal/runs/... -race -count=1
|
||||
go run ./cmd/sqlguard ./internal
|
||||
go test -race ./internal/persistence/storeconformance -count=1
|
||||
KANDEV_TEST_POSTGRES_DSN=<dsn> go test -race ./internal/office/... \
|
||||
./internal/persistence/storeconformance -count=1
|
||||
cd apps/web && pnpm run typecheck && pnpm run i18n:check
|
||||
```
|
||||
|
||||
The PostgreSQL line is not optional. The repository's dialect-sensitive suites
|
||||
self-skip when `KANDEV_TEST_POSTGRES_DSN` is unset, so a green local run without
|
||||
it proves nothing about AC-OFFICE-RUN-HISTORY-RETENTION-005.1. The engine-parity
|
||||
test asserts identical deleted sets, retained sets, and counts from identical
|
||||
seed data on both engines.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Wire the existing `CleanExpired` and stop.** One line, and it orphans every
|
||||
`run_events` row it passes, permanently and invisibly, because no foreign key
|
||||
on either engine would clean up after it.
|
||||
- **Add `ON DELETE CASCADE` to the satellite tables instead.** A schema change
|
||||
to three tables on two engines, requiring a table rebuild on SQLite, to avoid
|
||||
three `DELETE` statements. It also hides the deletion from the code that has
|
||||
to count it for the sweep report.
|
||||
- **Age-prune `run_events` directly.** The obvious implementation, and the one
|
||||
that breaks the run detail view's incremental tail and can restart a live
|
||||
run's sequence at zero. Forbidden by 001.6.
|
||||
- **Run the sweep on the 5s Office tick.** Rejected in 002.1.
|
||||
- **Count-only retention, keep newest N per owner with no window.** Bounds the
|
||||
table but makes "how long is my history kept" unanswerable for an install
|
||||
with mixed routine frequencies. The floor covers the case count-only is good
|
||||
at; the window covers the case it is bad at.
|
||||
- **Deletion off by default.** Safe, and it means the gap stays open on every
|
||||
install that never visits the settings page. The per-table preview, designed
|
||||
in [run history retention
|
||||
operations](run-history-retention-operations.md), gives the same protection
|
||||
without that outcome.
|
||||
- **Persist sweep history.** Named in the operations requirement's exclusions.
|
||||
|
||||
- **Rely on the window function's `ORDER BY` to order the batch.** It ranks rows
|
||||
inside each partition; it does not order the rows the outer `LIMIT` draws
|
||||
from. Leaving the outer select unordered makes a backlog sweep's deleted set
|
||||
engine-dependent and quietly breaks
|
||||
AC-OFFICE-RUN-HISTORY-RETENTION-005.1 only on installs large enough to exceed
|
||||
the batch limit — the installs that need this feature most.
|
||||
|
||||
## Prior art, applied
|
||||
|
||||
**Wiki: unavailable** — receipt in the requirement document. Nothing here should
|
||||
be read as departing from a wiki position, because none could be consulted.
|
||||
|
||||
**`internal/automation/run_retention.go`** is the closest in-repo precedent, and it
|
||||
prunes worktrees rather than rows. Three things are taken from it: retention scoped
|
||||
per owner rather than globally, so one noisy owner cannot evict a quiet one's
|
||||
only record; a bounded sweep window so a backlog drains across sweeps instead
|
||||
of walking the whole table each time; and re-checking liveness immediately
|
||||
before the destructive act, which appears here as the re-asserted predicate and
|
||||
the batch rollback. One thing is deliberately not taken: it hangs its sweep off
|
||||
a finalization hook, which ties cleanup frequency to firing frequency and leaves
|
||||
a stopped routine's history untouched forever. This design uses a clock.
|
||||
|
||||
**`internal/system/storage`** supplies the scheduler shape, the settings
|
||||
storage and normalization pattern, the hours-based interval with min and max
|
||||
bounds, and the `health.Checker` route to a production-visible warning.
|
||||
|
||||
**GitLab Duo** and **the Claude apps gateway** are surveyed in the requirement
|
||||
document's Prior art. What this design takes from them: the 30-day default
|
||||
window, and the history-vs-live-state split (their per-table windows with one
|
||||
table marked "until deleted via the API" rather than given a window). Neither
|
||||
previews before the first deletion; that addition is ours, and it exists because
|
||||
this ships enabled by default onto installs that already hold history.
|
||||
Reference in New Issue
Block a user