Retention Disposition Sweep + Inbox Notification — Design
Date: 2026-06-29
Status: Approved (design); pending implementation plan.
Goal
Proactively notify records managers, via the in-app inbox, when documents newly reach the
end of their retention period and become due for disposition — so a record manager doesn't
have to remember to open the disposition console to find them.
Context: what already exists (do NOT rebuild)
Records Retention is ~90% built. The roadmap markers for "policy catalogue" and "disposition
schedules" are stale (this spec includes fixing them). Already shipped and working:
- Per-document records controls —
documents.legal_hold,documents.retention_until,
documents.doc_type(migration 00015), enforced in the soft-delete/purge transaction
(kernel.ErrPolicyBlocked); managed fromEditAttributesModal(legal hold + retention floor). - Retention policy catalogue —
retention_policies(doc_type →retention_months/
is_permanent, migration 00049) withdisposition_action∈ {review, destroy, transfer}
(migration 00050). Auto-fillsretention_untilon publish (fill-only; a manual value wins).
Full admin CRUD (/retention-policies,RetentionTab.tsx). - Disposition console —
TrashPage'sDispositionSection(records.admin-gated) lists
due-for-disposition records with their prescribed action and offers dispose-to-Trash
(POST /documents/{id}/dispose) and extend-retention (PUT /documents/{id}/retention-extension).
Backend:dms.ListDueForDisposition(ctx, limit) ([]domain.DispositionItem, error).
The only missing piece is a proactive signal: nothing tells a records manager that new
records have become due. That is what this spec adds.
Approach
A daily scheduled sweep finds records that have newly become due for disposition and sends
each records manager one digest inbox notification ("N records are due for disposition —
review them in Trash"), then marks those records so they are not re-notified. The standing
backlog remains visible in the /trash disposition console; the notification is a one-time
push per due-transition, not a daily nag.
Rejected alternative: re-nag the digest every day while the queue is non-empty (no migration
needed, but noisier — contradicts the "don't want too much" requirement).
Principles honored: inbox only (no new page, no dashboard tile); recipients defined by
role/permission (records managers), not the system-admin area; notify-once (low noise).
Components
1. Data model — migration 00069_disposition_notified_at.sql
-- +goose Up
-- Marks when a records manager was last notified that this document became due for
-- disposition. NULL = not yet notified for the current due-transition. Cleared when the
-- record's retention is extended (so a record that becomes due again later re-notifies).
ALTER TABLE documents ADD COLUMN disposition_notified_at timestamptz;
-- +goose Down
ALTER TABLE documents DROP COLUMN disposition_notified_at;
2. DMS service + repo
ListDueForDispositionUnnotified(ctx, now, limit) ([]domain.DispositionItem, error)— the
existing due query (published,retention_until <= now, not legal-held, not deleted) AND
disposition_notified_at IS NULL. Newest-first; the page cap is logged when hit (no silent
truncation), mirroringrunExpiryReminder.MarkDispositionNotified(ctx, docIDs []string, at time.Time) error— sets
disposition_notified_at = atfor the given ids (singleUPDATE ... WHERE id = ANY($1)).ExtendRetention(existing) — additionally clearsdisposition_notified_at = NULLin the
same transaction that pushes outretention_until, so an extended record re-notifies if it
becomes due again. (Dispose needs no change — a disposed record leaves the due query.)
3. Recipient resolution — new rbac query
SubjectsWithPermission(ctx, action string) ([]rbac.Subject, error) over the existing
effective_perms read model:
SELECT DISTINCT subject_kind, subject_id FROM effective_perms
WHERE permission_key = $1 OR permission_key = 'admin';
The OR permission_key = 'admin' mirrors Can() exactly (the wildcard break-glass grant), so
the recipient set equals the set that can actually open the console. The sweep then expands
position subjects to their holder user-ids (existing directory position-holder lookup) and
unions with user subjects → the deduplicated set of records-manager user-ids.
4. Scheduled job — runDispositionReminder in cmd/obscura-server/jobs.go
Mirrors runExpiryReminder. Registered in wire.go:
schedulerSvc.Register("retention.disposition_sweep", 24*time.Hour, fn). The notify-once marker
makes the job idempotent, so it is also run once at startup (immediate reconciliation after a
deploy, and deterministic for verification).
Body:
1. docs := dms.ListDueForDispositionUnnotified(ctx, now, cap). If empty → return.
2. subjects := rbac.SubjectsWithPermission(ctx, "records.admin"); expand to recipient user-ids
(system task / ACL-bypass, like the expiry reminder).
3. For each recipient, notify.Notify(ctx, Recipient{UserID, Email}, Message{Kind:
"records_disposition", Title: "Records due for disposition", Body: "<N> record(s) have reached
the end of their retention period and are due for disposition. Review them in Trash."}).
One digest per recipient (count = len(docs)); not per-record.
4. dms.MarkDispositionNotified(ctx, ids, now).
Ordering / edge notes:
- Mark the records (step 4) only after the notify pass (step 3) and only if there was at
least one recipient. A per-recipient notify error is logged and does not abort the sweep.
- No records manager exists (zero recipients): do NOT mark — leave the records unnotified so a
later-provisioned records manager still receives the backlog digest on a subsequent sweep. The
sweep is a harmless no-op meanwhile (records stay visible in the console regardless).
5. Delivery
The existing notify service writes to the in-app notification inbox (ListNotifications /
MarkNotificationRead). No dashboard tile, no email-specific work beyond what notify.Notify
already does (it carries the recipient email for channels that use it).
Roadmap reconciliation (in-scope cleanup)
Flip the stale ⬜ markers in ROADMAP.md: "Retention policy catalogue" and "Disposition
schedules" → ✅ (built); add the disposition sweep + inbox notification as the delta this work
adds.
Out of scope
- A transfer-to-archive destination (the
transferdisposition action remains a label). - Automatic disposal (disposal is always a manual, gated action — unchanged).
- Per-record or daily-recurring nags (notify-once by design).
- Any change to the policy catalogue, per-document controls, or the disposition console UI.
Testing / verification
Per repo discipline: never go test (it writes the live demo Postgres). Verify via:
go build ./... && go vet ./...;cd webunaffected (no UI change).- Deployed e2e (the sweep runs at startup, idempotent): create a document with a
doc_typewhose
policy gives a short/past retention (or setretention_untilto the past) and publish it so it
enters the due query; restart/redeploy so the startup sweep runs; assert (a) the records-admin
holder's inbox (GET /notifications) shows onerecords_dispositiondigest, (b) the document's
disposition_notified_atis set, (c) a second sweep run does NOT add a second notice (dedup),
(d) afterExtendRetention, the marker clears. - After every deploy assert
/me enabled_modules == [correspondence, watermarking, ai, esign]
and clean up the test document.