Retention Disposition Sweep + Inbox Notification — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A daily background sweep that sends records managers a one-time inbox digest when documents newly become due for disposition.
Architecture: Backend-only, no web changes. Add a disposition_notified_at marker to documents; a DMS query for due-but-unnotified records + a mark method (and clear-the-marker on retention extend); a new rbac SubjectsWithPermission over the effective_perms read model to resolve records managers; a scheduled job (cmd/obscura-server/jobs.go) mirroring the existing runExpiryReminder, registered daily and force-run at boot for idempotent reconciliation. Delivery reuses the existing notify service → in-app inbox.
Tech Stack: Go (chi, pgx v5, goose migrations), the scheduler (internal/scheduler), notify (internal/notify), rbac (internal/rbac), directory (internal/directory).
HARD DISCIPLINE (every task): NEVER run go test — it writes the LIVE demo Postgres. Verify via cd go && go build ./... && go vet ./... + curl + the deployed e2e (Task 6). There are no unit-test steps in this plan for that reason. Deploy ONLY from the repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura. After every deploy assert /me enabled_modules == [correspondence,watermarking,ai,esign]. Commit per task locally on main; do NOT push unless asked. Keep the demo intact; clean up test artifacts.
File Structure
go/migrations/00069_disposition_notified_at.sql— create. Addsdocuments.disposition_notified_at.go/internal/dms/adapters/retention_policy_pg.go— modify. AddListDueForDispositionUnnotified+MarkDispositionNotified.go/internal/dms/adapters/pg.go— modify.SetRetentionUntilalso clearsdisposition_notified_at.go/internal/dms/app/service.go— modify. Add the two repo methods to theRepositoryinterface; addListDueForDispositionUnnotified+MarkDispositionNotifiedservice methods.go/internal/rbac/app/ports.go— modify. AddSubjectsWithPermissionto theAuthorizerinterface.go/internal/rbac/adapters/pg.go— modify. ImplementSubjectsWithPermissiononStore.go/cmd/obscura-server/jobs.go— modify. AddrunDispositionReminder.go/cmd/obscura-server/wire.go— modify. Register the daily task + force-run at boot.ROADMAP.md— modify. Reconcile retention markers.
Task 1: Migration — disposition_notified_at
Files:
- Create: go/migrations/00069_disposition_notified_at.sql
- [ ] Step 1: Write the migration
-- +goose Up
-- Marks when records managers were last notified that this document became due for
-- disposition. NULL = not yet notified for the current due-transition. Cleared when the
-- record's retention floor is moved (SetRetentionUntil), so a record that is extended and
-- later becomes due again re-notifies. The disposition sweep reads/writes this; it is NOT
-- added to documentColumns/scanDocument (the sweep filters on it via WHERE, never scans it).
ALTER TABLE documents ADD COLUMN disposition_notified_at timestamptz;
-- +goose Down
ALTER TABLE documents DROP COLUMN disposition_notified_at;
- [ ] Step 2: Verify it builds (migrations are embedded)
Run: cd go && go build ./...
Expected: exit 0 (the migration is //go:embed-bundled; a syntax error in the file fails the embed build).
- [ ] Step 3: Commit
cd /home/efran/remote-development/obscura
git add go/migrations/00069_disposition_notified_at.sql
git commit -m "feat(retention): migration 00069 — documents.disposition_notified_at"
Task 2: DMS — due-but-unnotified query, mark, and clear-on-extend
Files:
- Modify: go/internal/dms/adapters/retention_policy_pg.go (after ListDueForDisposition, ~line 132)
- Modify: go/internal/dms/adapters/pg.go:460-466 (SetRetentionUntil)
- Modify: go/internal/dms/app/service.go (Repository interface ~line 74; new service methods near ListDueForDisposition ~line 567)
- [ ] Step 1: Add the two repo methods in
retention_policy_pg.go(immediately after the existingListDueForDisposition):
// ListDueForDispositionUnnotified is ListDueForDisposition restricted to records that have
// NOT yet had a disposition reminder sent (disposition_notified_at IS NULL) — the working set
// for the daily sweep, so a record is notified once per due-transition, not every run.
func (s *Store) ListDueForDispositionUnnotified(ctx context.Context, now time.Time, limit int) ([]domain.Document, error) {
return s.queryDocuments(ctx,
`SELECT `+documentColumns+` FROM documents
WHERE deleted_at IS NULL
AND status = 'published'
AND legal_hold = false
AND retention_until IS NOT NULL
AND retention_until <= $2
AND disposition_notified_at IS NULL
ORDER BY retention_until ASC
LIMIT $1`, limit, now)
}
// MarkDispositionNotified stamps disposition_notified_at=at for the given documents so the
// sweep does not re-notify them. Idempotent; a no-op for an empty id list.
func (s *Store) MarkDispositionNotified(ctx context.Context, ids []string, at time.Time) error {
if len(ids) == 0 {
return nil
}
if _, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE documents SET disposition_notified_at = $2 WHERE id = ANY($1)`, ids, at); err != nil {
return fmt.Errorf("dms mark disposition notified: %w", err)
}
return nil
}
- [ ] Step 2: Clear the marker when retention moves. In
pg.go, changeSetRetentionUntil(line 462) so extending/setting retention re-arms the notification:
func (s *Store) SetRetentionUntil(ctx context.Context, docID string, until *time.Time) error {
tag, err := s.db.Exec(ctx).Exec(ctx,
// Clear disposition_notified_at: a moved retention floor is a fresh due-transition, so the
// record should notify again if/when it becomes due under the new date.
`UPDATE documents SET retention_until = $2, disposition_notified_at = NULL WHERE id = $1`, docID, until)
if err != nil {
return fmt.Errorf("dms set retention: %w", err)
}
// ... keep the existing rows-affected/NotFound handling below unchanged ...
(Preserve the rest of the function body exactly — only the SQL string changed.)
- [ ] Step 3: Extend the Repository interface in
service.go. Directly below the existingListDueForDispositiondeclaration (~line 74), add:
// ListDueForDispositionUnnotified is ListDueForDisposition restricted to records not yet
// reminded (disposition_notified_at IS NULL). The sweep's working set.
ListDueForDispositionUnnotified(ctx context.Context, now time.Time, limit int) ([]domain.Document, error)
// MarkDispositionNotified stamps disposition_notified_at for the given documents.
MarkDispositionNotified(ctx context.Context, ids []string, at time.Time) error
- [ ] Step 4: Add the service methods in
service.go, immediately afterListDueForDisposition(~line 567):
// ListDueForDispositionUnnotified returns due records that have not yet had a disposition
// reminder sent — the daily sweep's working set. Records-management scope (not ACL-filtered);
// the sweep runs as a system task.
func (s *Service) ListDueForDispositionUnnotified(ctx context.Context, limit int) ([]domain.Document, error) {
return s.repo.ListDueForDispositionUnnotified(ctx, s.clock.Now(), normalizeListLimit(limit))
}
// MarkDispositionNotified stamps the given documents as reminded (now), so the sweep does not
// re-notify them until their retention is moved (which clears the marker).
func (s *Service) MarkDispositionNotified(ctx context.Context, ids []string) error {
return s.repo.MarkDispositionNotified(ctx, ids, s.clock.Now())
}
- [ ] Step 5: Verify
Run: cd go && go build ./... && go vet ./internal/dms/...
Expected: exit 0, 0.
- [ ] Step 6: Commit
cd /home/efran/remote-development/obscura
git add go/internal/dms/
git commit -m "feat(retention): due-but-unnotified query + mark + clear-marker-on-retention-set"
Task 3: RBAC — resolve subjects holding a permission
Files:
- Modify: go/internal/rbac/app/ports.go:36-43 (the Authorizer interface)
- Modify: go/internal/rbac/adapters/pg.go (add a Store method; AdminPermission = "admin" is already defined at line 29)
- [ ] Step 1: Add to the
Authorizerinterface (ports.go), inside thetype Authorizer interface { ... }block:
// SubjectsWithPermission returns the distinct subjects (users and positions) that hold the
// given permission key — either granted directly OR via the wildcard 'admin' break-glass
// grant (matching Can's resolution). Used by background jobs to find, e.g., records managers.
SubjectsWithPermission(ctx context.Context, permKey string) ([]kernel.Subject, error)
(kernel is already imported in ports.go for kernel.Principal. If not, add "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel".)
- [ ] Step 2: Implement on
Storeinrbac/adapters/pg.go(place nearhasGlobalPerm, ~line 65):
// SubjectsWithPermission returns the distinct (kind,id) subjects holding permKey OR the wildcard
// AdminPermission, read from the denormalized effective_perms model — the same model Can() reads,
// so the recipient set equals the set Can() would allow.
func (s *Store) SubjectsWithPermission(ctx context.Context, permKey string) ([]kernel.Subject, error) {
rows, err := s.db.Exec(ctx).Query(ctx,
`SELECT DISTINCT subject_kind, subject_id FROM effective_perms
WHERE permission_key = $1 OR permission_key = $2`, permKey, AdminPermission)
if err != nil {
return nil, fmt.Errorf("rbac subjects with permission: %w", err)
}
defer rows.Close()
var out []kernel.Subject
for rows.Next() {
var sub kernel.Subject
if err := rows.Scan(&sub.Kind, &sub.ID); err != nil {
return nil, fmt.Errorf("rbac subjects with permission scan: %w", err)
}
out = append(out, sub)
}
return out, rows.Err()
}
(Confirm s.db.Exec(ctx) is how this Store gets its querier — match the surrounding methods like hasGlobalPerm. kernel and fmt are already imported in this file.)
- [ ] Step 3: Verify
Run: cd go && go build ./... && go vet ./internal/rbac/...
Expected: exit 0, 0. (The build also proves Store still satisfies Authorizer — Server.authz rbacapp.Authorizer is assigned rbacStore in wire.go.)
- [ ] Step 4: Commit
cd /home/efran/remote-development/obscura
git add go/internal/rbac/
git commit -m "feat(rbac): SubjectsWithPermission — resolve holders of a permission (incl. wildcard admin)"
Task 4: The sweep job + scheduling
Files:
- Modify: go/cmd/obscura-server/jobs.go (add runDispositionReminder + imports; reuse the existing appendUnique)
- Modify: go/cmd/obscura-server/wire.go (register near the expiry reminder ~line 268; force-run after EnsureRegistered ~line 318)
- [ ] Step 1: Add imports to
jobs.go. The current import block hasauthapp,dmsapp,dmsdomain,kernel,notifyapp,notifydomain,subscriptionapp,subscriptiondomain,slog,context. Add:
"fmt"
"time"
directoryapp "github.com/Virtue-Digital-Indonesia/obscura/internal/directory/app"
rbacapp "github.com/Virtue-Digital-Indonesia/obscura/internal/rbac/app"
- [ ] Step 2: Add the job to
jobs.go(afterrunExpiryReminder, beforeappendUnique):
// dispositionReminderPageSize bounds how many due records one sweep processes; it matches the
// dms list cap and is logged when hit so older due records are never silently starved.
const dispositionReminderPageSize = 200
// runDispositionReminder nags records managers about records that have NEWLY become due for
// disposition. It finds due-but-unnotified records, resolves the records managers (holders of
// records.admin or the wildcard admin, positions expanded to their holders), sends each ONE
// digest inbox notification, then marks the records so they are not re-notified. Idempotent via
// the marker, so it is safe to run at every boot. Runs as a system task (the due query is not
// ACL-filtered), mirroring runExpiryReminder.
func runDispositionReminder(
ctx context.Context,
dms *dmsapp.Service,
authz rbacapp.Authorizer,
dir *directoryapp.Service,
notify *notifyapp.Service,
auth *authapp.Service,
logger *slog.Logger,
) error {
docs, err := dms.ListDueForDispositionUnnotified(ctx, dispositionReminderPageSize)
if err != nil {
return err
}
if len(docs) == 0 {
return nil
}
if len(docs) == dispositionReminderPageSize {
logger.Warn("disposition reminder hit its page cap; older due records may be deferred this run", "cap", dispositionReminderPageSize)
}
// Records managers = subjects holding records.admin (or the wildcard admin); expand positions
// to their current holders.
subs, err := authz.SubjectsWithPermission(ctx, "records.admin")
if err != nil {
return err
}
recipients := []string{}
for _, sub := range subs {
switch sub.Kind {
case kernel.SubjectUser:
recipients = appendUnique(recipients, sub.ID)
case kernel.SubjectPosition:
assignees, aerr := dir.ListAssignees(ctx, sub.ID, time.Now())
if aerr != nil {
logger.Error("disposition reminder position expand failed", "position", sub.ID, "err", aerr)
continue
}
for _, a := range assignees {
recipients = appendUnique(recipients, a.UserID)
}
}
}
if len(recipients) == 0 {
// No records manager exists yet — leave the records UNNOTIFIED so a later-provisioned
// manager still gets the backlog digest. The records stay visible in the /trash console.
logger.Info("disposition reminder: records due but no records manager to notify", "due", len(docs))
return nil
}
title := "Records due for disposition"
body := fmt.Sprintf("%d record(s) have reached the end of their retention period and are due for disposition. Review them in Trash.", len(docs))
for _, uid := range recipients {
if uid == "" {
continue
}
email := ""
if u, gerr := auth.GetUser(ctx, uid); gerr == nil {
email = u.Email
}
if nerr := notify.Notify(ctx,
notifydomain.Recipient{UserID: uid, Email: email},
notifydomain.Message{Kind: "records_disposition", Title: title, Body: body}); nerr != nil {
logger.Error("disposition reminder notify failed", "user", uid, "err", nerr)
}
}
// Mark after the notify pass (there was >=1 recipient). A per-recipient notify error is logged
// but does not re-arm the whole set (the console is the safety net; future due records re-notify).
ids := make([]string, 0, len(docs))
for _, d := range docs {
ids = append(ids, d.ID)
}
if merr := dms.MarkDispositionNotified(ctx, ids); merr != nil {
return merr
}
return nil
}
- [ ] Step 3: Register the task in
wire.go, directly after thedms.expiry_reminderregistration (~line 270):
schedulerSvc.Register("dms.disposition_reminder", 24*time.Hour, func(ctx context.Context) error {
return runDispositionReminder(ctx, dmsSvc, rbacStore, dirSvc, notifySvc, authSvc, logger)
})
(rbacStore is the rbac adapter, assignable to rbacapp.Authorizer; dirSvc is the directory service; both are already in scope at this point in wire.go.)
- [ ] Step 4: Force-run at boot in
wire.go, immediately after theschedulerSvc.EnsureRegistered(ctx)block (~line 316-318):
// Reconcile on boot: force the disposition sweep due so a freshly-deployed/restarted instance
// catches up immediately (idempotent — the notify-once marker means it only notifies records
// that newly became due). Best-effort; a daily tick covers it otherwise.
if err := schedulerSvc.TriggerNow(ctx, "dms.disposition_reminder"); err != nil {
logger.Warn("could not trigger initial disposition sweep", "err", err)
}
- [ ] Step 5: Verify
Run: cd go && go build ./... && go vet ./...
Expected: exit 0, 0.
- [ ] Step 6: Commit
cd /home/efran/remote-development/obscura
git add go/cmd/obscura-server/jobs.go go/cmd/obscura-server/wire.go
git commit -m "feat(retention): daily disposition-due sweep + inbox digest, run-at-boot (idempotent)"
Task 5: ROADMAP reconciliation
Files:
- Modify: ROADMAP.md (the "Records Retention (one coupled workstream)" section)
- [ ] Step 1: Flip the stale markers. Find the two
⬜rows:
| Retention **policy catalogue** (records‑type → period) | ⬜ | `doc_type → policy` bridge; replaces the placeholder admin tab |
| Disposition schedules (end‑of‑life: auto‑delete / archive / route‑to‑review) | ⬜ | Built with the catalogue |
Replace with:
| Retention **policy catalogue** (records‑type → period) | ✅ | `retention_policies` (doc_type → months/permanent); auto‑fills `retention_until` on publish; admin CRUD (`RetentionTab`) |
| Disposition schedules (review / destroy / transfer) | ✅ | `disposition_action` per policy; **disposition console** on `/trash` (dispose‑to‑Trash / extend); **daily sweep → inbox digest** for records managers when records newly become due. Disposal is always manual + gated |
- [ ] Step 2: Commit + upload (the CLAUDE.md convention: any
.mdchanged this session is uploaded).
cd /home/efran/remote-development/obscura
git add ROADMAP.md
git commit -m "docs: reconcile ROADMAP — retention catalogue + disposition schedules ✅ (+ disposition sweep)"
curl -F "file=@ROADMAP.md" https://x056.think.val.id/upload
Task 6: Final gate — deploy + self-driven e2e
Files: none (verification).
- [ ] Step 1: Deploy from the repo root and wait for readiness:
cd /home/efran/remote-development/obscura
docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura
Loop on POST /api/v1/auth/dev-login {"email":"admin@obscura.local"} returning a token (not healthz). Then assert GET /api/v1/me enabled_modules == ['ai','correspondence','esign','watermarking'].
- [ ] Step 2: Set up a due record. As admin (dev-login), create a document, then make it due directly in Postgres (test scaffolding — not the feature path):
# create
DOC=$(curl -s -XPOST .../api/v1/documents -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"RET-E2E-<ts>","classification":"public","doc_type":"ret-e2e"}' | jq -r .id)
# make it due + unnotified (published, retention floor 2 days in the past)
docker exec deploy-postgres-1 psql -U obscura -d obscura -c \
"UPDATE documents SET status='published', retention_until = now() - interval '2 days', disposition_notified_at = NULL WHERE id='$DOC';"
- [ ] Step 3: Trigger the sweep (poke the task due; the RunDue loop ticks every 30s) and wait:
docker exec deploy-postgres-1 psql -U obscura -d obscura -c \
"UPDATE scheduled_tasks SET next_run_at = now() WHERE name='dms.disposition_reminder';"
# wait ~35s for one RunDue tick
- [ ] Step 4: Assert the digest landed + the record is marked.
GET /api/v1/notificationsas admin (the route behindListNotificationsinhandlers_notify.go— confirm the exact path) → a notification withkind == "records_disposition", title "Records due for disposition", body containing "1 record(s)".-
SELECT disposition_notified_at FROM documents WHERE id='$DOC'→ NOT NULL. -
[ ] Step 5: Assert dedup. Poke
next_run_at = now()again, wait ~35s, re-GET /notifications→ the count ofrecords_dispositionnotices is unchanged (the marked record is no longer in the unnotified working set). -
[ ] Step 6: Assert extend clears the marker.
PUT /api/v1/documents/$DOC/retention-extensionbody{"months":12}(records.admin; admin holds wildcard) → 2xx.-
SELECT retention_until, disposition_notified_at FROM documents WHERE id='$DOC'→retention_untilis ~12 months out,disposition_notified_atis NULL. -
[ ] Step 7: Clean up + final assert. Purge the test doc (
DELETE /documents/$DOCthenDELETE /documents/$DOC/purge), delete any orphan rows if needed, and optionally clear the admin's test notifications. Re-assertenabled_modulesis the 4 modules. No commit unless a fix was needed (commit a fix asfix(retention): <what> (sweep e2e)).
Self-review notes (author)
- Spec coverage: §Components 1 → T1; §2 (unnotified query / mark / extend-clears) → T2; §3 (recipient resolution) → T3; §4 (scheduled job + run-at-boot) → T4; §5 (delivery via notify) → T4 (
notify.Notify); roadmap reconciliation → T5; §Testing → T6. ✅ - Type consistency: repo methods
ListDueForDispositionUnnotified(ctx, now, limit)/MarkDispositionNotified(ctx, ids, at)declared identically in the interface (T2 step 3) and the Store (T2 step 1); service wrappers dropnow/at(supplied froms.clock).SubjectsWithPermission(ctx, permKey) ([]kernel.Subject, error)identical inAuthorizer(T3.1) andStore(T3.2). Job calls match:dms.ListDueForDispositionUnnotified(ctx, cap),dms.MarkDispositionNotified(ctx, ids),authz.SubjectsWithPermission(ctx, "records.admin"),dir.ListAssignees(ctx, id, time.Now()),notify.Notify(ctx, Recipient{UserID,Email}, Message{Kind,Title,Body}).kernel.SubjectUser/kernel.SubjectPositionare the subject-kind constants. - No-test rationale: the repo forbids
go test(writes the live demo DB), so verification is build/vet (every task) + the deployed e2e (T6). This is the established pattern for this codebase. - Blast radius:
disposition_notified_atis intentionally NOT added todocumentColumns/scanDocument/theDocumentstruct — the sweep filters on it via WHERE and never reads it back, so existing document queries are untouched.