Skip to content

fix(retention): refuse a prune that would delete most of a table (#1644)#1674

Open
obasilakis wants to merge 5 commits into
devfrom
feature/1644-retention-blast-radius-guard
Open

fix(retention): refuse a prune that would delete most of a table (#1644)#1674
obasilakis wants to merge 5 commits into
devfrom
feature/1644-retention-blast-radius-guard

Conversation

@obasilakis

Copy link
Copy Markdown
Contributor

Summary

#1638 made the retention defaults safe. It added no guard on the prune itself. This adds the backstop that doesn't care how a bad window arrived.

Before each of the 7 window-driven destructive prunes, count the candidate set (bounded) and if it exceeds 1000, refuse — log ERROR, raise an ops alarm, delete nothing. An admin unblocks it with one call.

The threat isn't hypothetical. PUT /api/settings/ops/config is Dict[str, str]if key in OPS_SETTINGS_DEFAULTS → bare upsert. No type, range, clamp, or audit. Garbage fails safe ("abc" → 0 → sweep disabled); a small valid integer is the catastrophic input, and no range check catches 5 typed for 50.

Two corrections to the issue's own premises

Design decisions worth reviewing

decision why
Stateless detection, not a last-seen-window watermark The watermark row is deletable via the same endpoints that cause the bug → silently disarms the guard. learnings.md #1638 Lesson 2. The ack is state, but deleting an ack fails safe.
Absolute counts, no percentage No denominator exists for execution_log (its predicate includes execution_log IS NOT NULL); a total costs a full scan and divides by zero on an empty table; and a percentage inverts on the agent sweep — 3 purged agents ≈ 0% of any table but 3 destroyed volume sets. Steady state is a trickle; any anomaly is large. Table size is irrelevant.
Threshold is a constant, not a setting Started as an operator knob. Nobody can reason about the value, and the panel needed a caption explaining bigger is worse — a control that must explain which way is safe is the wrong control. Worse: a mutable constant read at action time gating a destructive op is #1638 one level up. Deleting the knob deleted its clamp, endpoint, blocklist entry, and a whole fail-closed branch.
Ack is the gate; the queue item is only an alarm create_item is a blind INSERT … ON CONFLICT DO NOTHING — a load-bearing item would wedge shut permanently once it hit any terminal state (Clear All → cancelled), and prune_terminal_items would delete the approval at 90 days: the sweep deleting its own authorization. Decorative ⇒ both dissolve.
Counts share each prune's predicate by construction A mirrored copy drifts. Matters most for operator_queue, whose predicate derives a second cutoff internally (max(responded, terminal)).
Agents floor at 0 Every purge destroys Docker volumes (#1581). Unrecoverable ⇒ always acked.

Fail-closed everywhere. Count throws, ack lookup throws → refuse. A guard that fails open is worse than no guard: it manufactures confidence.

Also fixed (found while investigating)

  • 🐛 RETENTION_OPS_KEYS held 5 of 7 windows — so /ops/reset deleted agent_reports_retention_days and operator_queue_retention_days while returning "retention windows unchanged", and both were invisible to GET /api/settings/retention and boot logging.
  • 🐛 Retention inputs had border-gray-300 without border — no width applied, so browsers rendered default number-input chrome. Now matches the app-wide convention (96 other inputs use it).
  • 🐛 A 422 dumped FastAPI's raw Pydantic array into the page. Added utils/apiError.js + pre-flight validation. ~198 other callsites share this latent bug — left alone.

⚠️ Security gap filed separately

trinity-ops-agent#232 (private — public SECURITY.md forbids disclosing here). require_admin never calls reject_agent_principal, and an agent key resolves to its owner carrying the owner's role. On a default install (Cornelius/trinity-system are admin-owned) an agent's own MCP key can PUT /api/settings/execution_row_retention_days {"value":"1"} through the unblocklisted catch-all. This PR does not close it — but the ack endpoint applies reject_agent_principal explicitly rather than trusting require_admin, so the gate isn't reachable that way (verified: 403).

Test Plan

  • pytest tests/unit/test_1644_retention_guard.py26 pass (dual-backend SQLite + PG)
  • Full unit suite: 4133 passed. The one failure (test_1474_read_boundary_z) is pre-existing — verified identical on stashed dev.
  • Verified live: clamp rejects 999999999; catch-all 422s the guard key; ack 409s on window mismatch; agent principal with role="admin" gets 403.
  • Frontend builds; npm run check:tokens passes.

Expected behaviour worth knowing

A legitimate first-enable of retention on a mature install will trip the guard once. That's intended — the guard can't distinguish a large legitimate backlog from a mistyped window, so it asks once. That's what keeps it a small predicate instead of a "was this narrowed?" detector.

Fixes #1644

🤖 Generated with Claude Code

#1638 fixed the specific way a *default change* destroyed data; it added no
guard on the prune itself. Every other route to a destructive window stayed
open — `PUT /api/settings/ops/config` has zero validation, and a future default
regression or a direct DB write has no writer to validate at all.

`services/retention_guard.py` gates all 7 window-driven destructive prunes:
count the candidate set (bounded, `LIMIT threshold+1` -> O(threshold)), and if
it exceeds 1000, REFUSE, log ERROR on the green->red transition, and raise an
operator-queue alarm. An admin unblocks it via POST /api/settings/retention/
acknowledge — bound to the window in force (409 on mismatch) and single-use, so
one approval can never authorize a larger delete later at the same window.

Design notes (each one is load-bearing):

- Stateless detection, not a last-seen-window watermark: that row would be
  deletable through the same endpoints that cause the bug, silently disarming
  the guard (learnings.md #1638, Lesson 2). The ack IS state, but deleting an
  ack fails safe.
- Absolute counts, no percentage: there is no denominator for execution_log
  (its predicate includes `execution_log IS NOT NULL`), a total costs a full
  scan and divides by zero on an empty table, and a percentage inverts on the
  agent sweep — 3 purged agents are ~0% of any table but 3 destroyed volume
  sets. Steady state is a trickle and any anomaly is large, so table size is
  irrelevant.
- Fail-closed everywhere: any error refuses. A guard that fails open is worse
  than no guard because it manufactures confidence.
- The ack endpoint is the gate; the queue item is only an alarm. `create_item`
  is a blind INSERT ... ON CONFLICT DO NOTHING, so a load-bearing queue item
  would wedge shut permanently once it reached any terminal state (Clear All ->
  cancelled), and prune_terminal_items would delete the approval at 90 days —
  the sweep deleting its own authorization.
- The threshold is a fixed constant, NOT a setting. It was briefly an operator
  knob: nobody can reason about the right value, and the panel needed a caption
  explaining that bigger is worse. Worse, a mutable constant read at action time
  gating a destructive op is #1638 one level up. Deleting the knob deleted its
  clamp, endpoint, blocklist entry, and a whole fail-closed branch.
- Each count SHARES its prune's predicate by construction, so the number the
  guard reports is the number that would go. This matters most for
  operator_queue, whose predicate derives a second cutoff internally.
- The alarm hosts on the reserved sentinel `_retention-guard` (uncreatable —
  sanitize_agent_name strips the leading `_`), excluded from canary L-03's
  orphan scan: it is not a ghost agent. Its context carries counts and
  identifiers only, never sample rows (canary G-04's lesson).
- Agents floor at 0: every purge destroys Docker volumes (#1581).

Also fixed, found while investigating:

- RETENTION_OPS_KEYS held 5 of 7 windows, so `/ops/reset` DELETED
  agent_reports_retention_days and operator_queue_retention_days while
  reporting "retention windows unchanged", and both were invisible to
  GET /api/settings/retention and to boot-time logging.
- The documented "5000 rows/cycle cap" does not exist: 6 of 7 prune accessors
  drain the entire candidate set in one call (`chunk_size` bounds each
  transaction). #1638 deleting 5352 rows in one startup sweep is the proof.
  Corrected the comment, the docstring, and architecture.md.
- The Retention panel's inputs specified `border-gray-300` without the `border`
  class, so no border width applied and browsers fell back to default
  number-input chrome. Fixed to the app-wide convention (96 other inputs).
- A 422 rendered FastAPI's raw Pydantic error array into the page; added
  utils/apiError.js and pre-flight validation.

Known gap, filed separately (trinity-ops-agent#232): `require_admin` does not
call `reject_agent_principal`, so an admin-owned agent's own MCP key can set a
retention window via the unblocklisted `PUT /api/settings/{key}`. The ack
endpoint applies `reject_agent_principal` explicitly rather than relying on
`require_admin`, so it is not reachable that way.

Fixes #1644
…-blast-radius-guard

# Conflicts:
#	docs/memory/architecture.md
#	src/backend/db/agents.py
 guard

The blast-radius guard floors the soft-deleted-agent purge at 0 (every purge
destroys Docker volumes, #1581), so `_sweep_soft_deleted_agents` now reaches
`purge_agent_ownership` only with a candidate count and an acknowledgement.
These tests mock `db` wholesale, so the count came back as a MagicMock —
uncomparable to an int, which the guard correctly treats as an error and
fails closed, skipping the purge the tests assert on.

Also patches `retention_guard.db`: the guard has its own `from database import db`
binding, so patching `cleanup_service.db` alone leaves it reading the real
database mid-test.
Caught by tests/lint_sys_modules.py. A bare pop leaks the eviction into every
later test in the session; monkeypatch restores it at teardown.
…-blast-radius-guard

# Conflicts:
#	tests/registry.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant