Skip to content

feat(storage): 30-day partner-file retention job + deletion audit - #3733

Open
PetrefiedThunder wants to merge 8 commits into
hermes/partner-file-storage-cutoverfrom
hermes/partner-file-retention-job
Open

PetrefiedThunder wants to merge 8 commits into
hermes/partner-file-storage-cutoverfrom
hermes/partner-file-retention-job

Conversation

@PetrefiedThunder

@PetrefiedThunder PetrefiedThunder commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #3732 (cutover). Adds the 30-day deletion job driven by partner_files.delete_after: deletes the partner-uploads object, appends hash-chained partner_file_deletion_log (actor=retention_job, reason=retention_30d), marks metadata deleted, and audits partner_file.retention_delete. Scheduled hourly via IntervalTrigger (same pattern as remediation drain).

Touched

  • services/ingestion/app/partner_file_retention.py (new)
  • services/ingestion/app/s3_utils.py (delete_object)
  • services/scheduler/app/config.py + services/scheduler/service.py
  • unit tests (synthetic only)

Out of scope / rules

  • No migration apply, no credential changes, no CSV parser edits, SEL-590 preflight only
  • No merge / no force push

Test plan

  • pytest services/ingestion/tests/test_partner_file_retention_job.py (3 passed)
  • cutover suite still green (5 passed)

Note

Medium Risk
Permanent S3 deletion and metadata mutation are irreversible, but the job gates on configured storage, uses row locking and per-row savepoints, and writes an auditable hash chain before marking files deleted.

Overview
Adds a scheduled retention pass that enforces the 30-day clock on partner_files.delete_after: remove the object from partner uploads storage, append a hash-chained row to partner_file_deletion_log (actor=retention_job, reason=retention_30d), set the file metadata to deleted, and emit partner_file.retention_delete audit events.

The ingestion module scans tenants via maintenance tenant discovery, sets the tenant GUC for RLS, claims due rows with FOR UPDATE SKIP LOCKED, and runs each delete inside a savepoint so one failure does not abort the batch. Deletion-log tips are serialized per tenant with pg_advisory_xact_lock (no FOR UPDATE on the append-only log). delete_object is added to s3_utils and treats missing keys as success for idempotent reruns; if object storage is not configured, the job no-ops (batch) or refuses to mark rows deleted (single-file).

The scheduler registers partner_file_retention on an hourly interval (configurable via partner_file_retention_interval_seconds), matching the remediation outbox drain pattern. Synthetic unit tests cover hashing, audit/log ordering, unconfigured storage, and hash chaining.

Reviewed by Cursor Bugbot for commit 4211255. Bugbot is set up for automated code reviews on this repo. Configure here.

Scheduler IntervalTrigger job selects partner_files past delete_after,
deletes the partner-uploads object, appends a hash-chained
partner_file_deletion_log row (actor=retention_job, reason=retention_30d),
marks the metadata row deleted, and emits partner_file.retention_delete
audit. Synthetic unit tests only; no migration apply.

Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
regengine Ready Ready Preview Oct 31, 2026 9:11am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 1
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T15:56:56.700547Z 635a7f9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 635a7f9339

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


def run_retention_pass(db, *, limit: int = BATCH_LIMIT) -> Dict[str, int]:
"""Process up to ``limit`` due partner files. Returns counts."""
due = _due_rows(db, limit=limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind tenant context before selecting due files

In the production scheduler, SessionLocal() uses the ordinary RLS-enforced role, and the transaction begin hook clears app.tenant_id. This query runs before any tenant context is set, but tenant_isolation_partner_files in supabase/migrations/20261028100000_partner_files_storage_retention.sql requires get_tenant_context(). Consequently, the initial selection cannot discover due files across tenants, and retention never reaches delete_one_due_file. Discover tenants with the existing maintenance helper and select due rows under each tenant's context, as drain_all_tenants does.

AGENTS.md reference: AGENTS.md:L54-L56

Useful? React with 👍 / 👎.

Comment on lines +104 to +105
if is_object_storage_configured():
delete_object(row["storage_bucket"] or PARTNER_UPLOADS_BUCKET, row["storage_path"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail rather than record deletion when storage is unconfigured

If storage credentials are absent while the database contains previously uploaded objects, this condition skips the actual deletion but still appends a successful deletion log and marks the file deleted. Subsequent passes exclude that metadata row, so restoring credentials will not retry the deletion: the partner payload remains in storage indefinitely while the audit evidence claims otherwise. Treat unconfigured storage as a retryable failure and leave the file eligible for retention.

AGENTS.md reference: AGENTS.md:L56-L56

Useful? React with 👍 / 👎.

Comment on lines +218 to +222
try:
delete_one_due_file(db, row)
deleted += 1
except Exception as exc: # pragma: no cover — per-row isolation
errors += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Isolate each deletion's database transaction

If a deletion-log INSERT or metadata UPDATE raises a PostgreSQL error after the storage object has been deleted, catching the exception does not recover the transaction. Every subsequent row then fails in the aborted transaction, and the scheduler's single final commit cannot persist earlier successful rows either. This can leave multiple permanently deleted objects without their deletion evidence. Use per-file transaction boundaries or savepoints with rollback on failure so one database error cannot discard the rest of the batch's logs and metadata.

AGENTS.md reference: AGENTS.md:L56-L56

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

REVIEW: FAIL claude 635a7f9

Unit tests at this SHA: pytest services/ingestion/tests/test_partner_file_retention_job.py (+ cutover suite) — 8 passed. Mocks do not exercise RLS, multi-replica concurrency, or the unconfigured-storage path.

Timing (no early delete) — OK

_due_rows (partner_file_retention.py L57–L72) requires delete_after IS NOT NULL AND delete_after <= now() and deleted_at IS NULL. delete_after is trigger-maintained as report_delivered_at + interval '30 days' (v138 / #3729); direct UPDATE of delete_after is overwritten. Comparison uses DB now(), so the job does not delete early relative to that clock. Files never reported (delete_after NULL) are skipped.

Blocking

  1. Due-row SELECT runs before tenant GUC under RLS. _due_rows (L57–L75) runs with no set_tenant_guc. v138 tenant_isolation_partner_files is CASE + get_tenant_context(); with GUC unset the policy evaluates false, so the SELECT returns zero rows (or errors if CASE short-circuit differs). GUC is only set inside delete_one_due_file (L102), after the empty scan. In production with RLS on, the hourly job never deletes anything. Fix: security-definer worker path, SET LOCAL ROLE / bypass for the scanner, or iterate tenants and set GUC before each select.

  2. Marks deleted without deleting when storage is unconfigured. delete_one_due_file L104–L106 skips delete_object when is_object_storage_configured() is false, then still inserts partner_file_deletion_log, sets status='deleted' / deleted_at (L173–L186), and audits partner_file.retention_delete. That falsifies the retention audit and leaves the object (if any) forever while metadata says gone. Fail closed: refuse the pass / leave the row due when storage is required and missing.

  3. Per-row try/except is not transaction-safe. run_retention_pass L217–L228 catches exceptions without a savepoint. After a PG error the session is aborted; later rows fail, while any S3 delete_object already done in that iteration is not rolled back. Idempotent S3 missing-as-success (L406–L417 in s3_utils.py) helps reruns, but the DB/log/audit side can diverge. Use SAVEPOINT / nested transaction per row (and ideally delete S3 after durable DB claim, or claim-then-delete with retry).

  4. Hash chain is not concurrency-safe. _latest_entry_hash (L40–L54) is a plain tip read; there is no FOR UPDATE on the chain tip or on the partner_files row before delete. Two overlapping passes (multi-replica; max_instances=1 only serializes one scheduler process) can share the same prev_entry_hash and fork the chain, or both attempt the same file. Need row locks / unique constraint on (tenant_id, prev_entry_hash) or a single-writer lease.

Nonblocking

  1. Canonical hash materialization (_canonical_entry_payload L29–L37, genesis prev=None) is fine for a single writer. Ordering by deleted_at DESC, id DESC is adequate only with the locking above.

  2. Scheduler wiring (max_instances=1, hourly interval) matches other drains; insufficient alone for multi-instance deploys.

Re-review after RLS-aware due-row selection, fail-closed when storage is required, per-row savepoints (or claim-before-delete), and chain/tip locking.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Claude FAIL #3733: select due rows per tenant after set_tenant_guc;
no-op (no deletes/audits) when object storage is unconfigured; per-row
SAVEPOINT; advisory xact lock + FOR UPDATE on chain tip and file row;
S3 delete then durable DB update. Synthetic tests only.

Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com>

Copy link
Copy Markdown
Collaborator Author

REVIEW: PASS claude 7842e70

Delta from FAIL at 635a7f9. Heads unchanged at post time. Unit tests: cutover + test_partner_file_retention_job.py — 13 passed.

Prior blockers — resolved

  1. RLS / GUC before due select. run_retention_pass lists tenants, set_tenant_guc per tenant, then _due_rows_for_tenant with FOR UPDATE SKIP LOCKED. Due scan no longer runs with an unset GUC.

  2. Unconfigured storage. Pass is a no-op (skipped_unconfigured: 1); delete_one_due_file raises before any log/update/audit. No false retention audit.

  3. Savepoints. Each row runs under db.begin_nested() so a PG error rolls back that row only and does not abort the batch.

  4. Chain locking. Per-tenant pg_advisory_xact_lock(hashtextextended('partner_file_deletion:'||tenant_id, 0)) plus tip SELECT … FOR UPDATE serializes tip updates across replicas. Sequential deletes under the lock chain distinct prev_entry_hash values (covered by unit test).

S3-before-DB retry (checked)

Order: claim file → S3 delete_object (missing = success) → tip lock → deletion_log INSERT → mark deleted → audit, all inside the savepoint.

  • DB failure after S3 delete → savepoint rollback → row stays deleted_at IS NULL / pending → next tick picks it up again.
  • Rerun S3 delete is idempotent; no false or duplicate audit on the failed attempt (audit rolled back with the savepoint). Successful retry writes one log + one audit.
  • Already-deleted reclaim returns skipped with no second audit.

Nonblocking

  1. Advisory lock is held across the S3 round-trip (txn-scoped), so one slow delete serializes other workers for that tenant. Correct for chain safety; watch retention lag under load.

  2. Empty tip (FOR UPDATE on zero rows) still relies on the advisory lock for genesis — that is fine.

No merge from me.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

PR Blocker Checklist

Head SHA: 4211255e66b34c5f0ab94fbc67ac9dfcd6b4b81c

Status: Blocked (7 open blockers)

Review Threads (6 unresolved)

Changes Requested (0)

  • No reviews requesting changes

Blocked Engines (1)

  • Engine claude blocked at 635a7f9 (current head: 4211255): View verdict: REVIEW: FAIL claude 635a7f9339df2078d7912ad0e1ff4e7e9dc2e103

Bring #3732 head (main merge + cutover) into stacked #3733. No rebase.
Bring #3746 (PR Gate Frontend rerun) onto the stacked retention branch. No rebase.
regengine has SELECT+INSERT only on partner_file_deletion_log (UPDATE
revoked). Tip serialization already comes from pg_advisory_xact_lock.
Also discover tenants via list_maintenance_tenant_ids (sysadmin) instead
of a FORCE-RLS public.tenants SELECT that returned empty under the
runtime role.

Signed-off-by: VS Code Hermes <hermes@regengine.local>
Signed-off-by: Christopher Sellers <1@christophersellers.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a8cc6d1. Configure here.

}
if (!runCompleted) {
core.setFailed(`PR Gate run ${latest.id} did not complete within 90 minutes; not re-running.`);
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rerun wait shorter than backend gate

High Severity

The rerun job now waits for the whole PR Gate run to finish, but the wait deadline is 90 minutes while PR Gate: Backend can run for 150 minutes. When Frontend CI finishes first, this workflow gives up and never calls reRunWorkflowFailedJobs, so the timed-out Frontend gate stays red.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8cc6d1. Configure here.

row.get("tenant_id"),
exc,
)
remaining -= 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tenant error rolls back deletions

Medium Severity

Only per-row work is savepoint-isolated. A failure in set_tenant_guc or _due_rows_for_tenant for a later tenant aborts run_retention_pass before db.commit(), rolling back every earlier tenant's log, metadata, and audit while the S3 objects are already gone.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8cc6d1. Configure here.

"actor": ACTOR,
"reason": REASON,
}
entry_hash = compute_entry_hash(prev_entry_hash=prev, fields=fields)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletion hash datetime serialization mismatch

Medium Severity

compute_entry_hash serializes report_delivered_at via json.dumps default=str (space separator) but hashes deleted_at as isoformat() (T separator). A verifier reading both timestamps from partner_file_deletion_log cannot apply one datetime encoding and recompute entry_hash.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a8cc6d1. Configure here.

@PetrefiedThunder

Copy link
Copy Markdown
Collaborator Author

REVIEW: PASS claude a8cc6d1

Full review of hermes/partner-file-retention-job (30-day partner-file retention), stacked on #3732; tip ahead of main by 13, behind 0.

(1) FOR UPDATE removed on append-only partner_file_deletion_log tip. _latest_entry_hash is plain SELECT; serialization is pg_advisory_xact_lock(hashtextextended('partner_file_deletion:'||tenant_id, 0)) — per-tenant key scope, transaction-scoped (not session), held for the delete_one savepoint and released on commit/rollback (incl. nested savepoint error). Matches billing/canonical-chain patterns. FOR UPDATE remains only on partner_files (UPDATE-allowed), which is correct.

(2) Tenant discovery uses list_maintenance_tenant_ids (ADMIN_DATABASE_URL → exact NOSUPERUSER/NOBYPASSRLS regengine_sysadmin, transaction-local regengine.is_sysadmin, no DATABASE_URL fallback). Data path then set_tenant_guc per tenant before due-scan / delete; RLS + explicit tenant_id = :tenant_id — no cross-tenant leak. v140 grants: regengine SELECT+INSERT only on deletion_log (UPDATE/DELETE revoked); insert policy checks tenant context.

(3) Due predicate: deleted_at IS NULL AND delete_after IS NOT NULL AND delete_after <= now() AND status <> 'deleted', ordered by delete_after, FOR UPDATE SKIP LOCKED, batch limit 100. Per-tenant only. Log insert actor=retention_job reason=retention_30d with hash chain; metadata marked deleted; audit partner_file.retention_delete. S3 delete first (missing=success); storage-unconfigured → no-op (no mark-deleted). 30-day cutoff lives in delete_after (from #3732), not recomputed here.

(4) scripts/check_alembic_revisions.py at tip → single head b8c1e4f2a901 (OK).

Box tests: test_partner_file_retention_job.py → 6 passed; test_check_alembic_revisions.py → 16 passed. Mac offline; used box gh. No pushes/merges.

Soft (non-blocking): delete_object can raise FastAPI HTTPException into the scheduler path; .github/workflows/pr-gate-frontend-rerun.yml delta is orthogonal to retention. CI still mostly queued at review time (Vercel success; no FAILURE yet).

@PetrefiedThunder

Copy link
Copy Markdown
Collaborator Author

This PR is stacked on #3732. I merged main and the updated hermes/partner-file-storage-cutover branch (which has #3732's test and CI fixes) into it. There were no conflicts.

Local checks (Python 3.11): pytest services/ingestion/tests services/scheduler/tests -n auto passed (rc=0).

The earlier Engine Review Pass failure was fixed on main in #3756.

New head SHA: 4211255e66b34c5f0ab94fbc67ac9dfcd6b4b81c (ready for a non-Claude reviewer).

@cursor

cursor Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: d50dbd7e-6b1b-450d-a61d-aff5f2323444)

This branch was successfully deployed

1 active deployment
Preview — 4211255e Deployed Oct 31, 2026 by vercel[bot]
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