think
16px
820px

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 controlsdocuments.legal_hold, documents.retention_until,
    documents.doc_type (migration 00015), enforced in the soft-delete/purge transaction
    (kernel.ErrPolicyBlocked); managed from EditAttributesModal (legal hold + retention floor).
  • Retention policy catalogueretention_policies (doc_type → retention_months /
    is_permanent, migration 00049) with disposition_action ∈ {review, destroy, transfer}
    (migration 00050). Auto-fills retention_until on publish (fill-only; a manual value wins).
    Full admin CRUD (/retention-policies, RetentionTab.tsx).
  • Disposition consoleTrashPage's DispositionSection (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), mirroring runExpiryReminder.
  • MarkDispositionNotified(ctx, docIDs []string, at time.Time) error — sets
    disposition_notified_at = at for the given ids (single UPDATE ... WHERE id = ANY($1)).
  • ExtendRetention (existing) — additionally clears disposition_notified_at = NULL in the
    same transaction that pushes out retention_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 transfer disposition 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 web unaffected (no UI change).
  • Deployed e2e (the sweep runs at startup, idempotent): create a document with a doc_type whose
    policy gives a short/past retention (or set retention_until to 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 one records_disposition digest, (b) the document's
    disposition_notified_at is set, (c) a second sweep run does NOT add a second notice (dedup),
    (d) after ExtendRetention, the marker clears.
  • After every deploy assert /me enabled_modules == [correspondence, watermarking, ai, esign]
    and clean up the test document.