think
16px
820px

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.sqlcreate. Adds documents.disposition_notified_at.
  • go/internal/dms/adapters/retention_policy_pg.gomodify. Add ListDueForDispositionUnnotified + MarkDispositionNotified.
  • go/internal/dms/adapters/pg.gomodify. SetRetentionUntil also clears disposition_notified_at.
  • go/internal/dms/app/service.gomodify. Add the two repo methods to the Repository interface; add ListDueForDispositionUnnotified + MarkDispositionNotified service methods.
  • go/internal/rbac/app/ports.gomodify. Add SubjectsWithPermission to the Authorizer interface.
  • go/internal/rbac/adapters/pg.gomodify. Implement SubjectsWithPermission on Store.
  • go/cmd/obscura-server/jobs.gomodify. Add runDispositionReminder.
  • go/cmd/obscura-server/wire.gomodify. Register the daily task + force-run at boot.
  • ROADMAP.mdmodify. 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 existing ListDueForDisposition):
// 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, change SetRetentionUntil (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 existing ListDueForDisposition declaration (~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 after ListDueForDisposition (~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 Authorizer interface (ports.go), inside the type 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 Store in rbac/adapters/pg.go (place near hasGlobalPerm, ~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 AuthorizerServer.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 has authapp, 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 (after runExpiryReminder, before appendUnique):
// 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 the dms.expiry_reminder registration (~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 the schedulerSvc.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 .md changed 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/notifications as admin (the route behind ListNotifications in handlers_notify.go — confirm the exact path) → a notification with kind == "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 of records_disposition notices 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-extension body {"months":12} (records.admin; admin holds wildcard) → 2xx.
  • SELECT retention_until, disposition_notified_at FROM documents WHERE id='$DOC'retention_until is ~12 months out, disposition_notified_at is NULL.

  • [ ] Step 7: Clean up + final assert. Purge the test doc (DELETE /documents/$DOC then DELETE /documents/$DOC/purge), delete any orphan rows if needed, and optionally clear the admin's test notifications. Re-assert enabled_modules is the 4 modules. No commit unless a fix was needed (commit a fix as fix(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 drop now/at (supplied from s.clock). SubjectsWithPermission(ctx, permKey) ([]kernel.Subject, error) identical in Authorizer (T3.1) and Store (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.SubjectPosition are 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_at is intentionally NOT added to documentColumns/scanDocument/the Document struct — the sweep filters on it via WHERE and never reads it back, so existing document queries are untouched.