Scrooge & Marley · 1 Cornhill, London · Est. 1836
📋 Explanation · Proposed Extension 03 of 03

THE COUNTING ROOM

Clerk accounts, case assignment, scoped dashboard, and a daily brief — for a firm with more than one person in it
Version 1.0 | March 2026 | Reviewed by Dev the Dev
Version 1.0 | March 2026 | Reviewed by Dev the Dev
What this document is A technical specification for The Counting Room — the Scrooge & Marley clerk dashboard and daily brief extension. Covers the business case, clerk auth design, case assignment model, server-side data scoping requirements, daily brief content and cron design, database schema, and effort estimate. Requires The Ledger. Benefits significantly from The Dead Letter Office.

1. The business case

The Firm's current admin system has one seat: the administrator. There is no concept of a clerk — a person who handles a subset of cases, receives a subset of correspondence, and is responsible for a defined portion of the Firm's outstanding work. Every engagement is managed by whoever last looked at the dashboard. This is adequate for one person. It is not adequate for a firm that has been in continuous operation since 1836.

"A clerk who does not know what he owes by noon will not collect it by five. The brief is not optional."

The Counting Room adds a clerk layer beneath the administrator. Each clerk has an assigned caseload, a restricted view of the dashboard, and a daily brief generated each morning that tells them exactly what requires attention before close of business. They cannot see what they have not been assigned. They cannot change what they have not been permitted to change.

2. What a clerk can and cannot do

ActionClerkAdministrator
View assigned cases ✓ Yes ✓ Yes (all cases)
View unassigned cases ✗ No ✓ Yes
Record a payment received ✓ Yes (assigned cases only) ✓ Yes
Add a case note ✓ Yes (assigned cases only) ✓ Yes
Approve a letter in queue ✓ Yes (their queue only, non-pre-legal) ✓ Yes (all)
Change debt terms (principal, rate, status) ✗ No ✓ Yes
Assign or reassign cases ✗ No ✓ Yes
Create or edit letter templates ✗ No ✓ Yes
View the despatch log ✓ Yes (their cases only) ✓ Yes (all)
Create or deactivate clerk accounts ✗ No ✓ Yes

3. Authentication design

The existing admin auth uses a single password in an environment variable. It cannot support multiple named users. The clerk auth is a parallel track — it does not replace or modify the existing admin pattern.

Clerks authenticate at /clerk/login with email and password. On success, a clerk_session cookie is set containing the clerk's UUID (signed, httpOnly, 7-day expiry). Middleware checks for a valid clerk_session on all /clerk/* routes. The existing admin_session check is unchanged.

Passwords are hashed with bcrypt before storage. The lib/admin-auth.ts pattern is extended with a parallel lib/clerk-auth.ts that extracts and validates the clerk UUID from the clerk_session cookie. Every clerk-facing API route calls getClerkId() from this lib before executing any query.

Data scoping is a security requirement, not a UX convenience Every clerk-facing API route must join against case_assignments before returning any debt data. Filtering in the frontend is not sufficient — a clerk who can fetch unassigned cases by constructing a direct API call has broken the system's central promise. The scoping query below is the minimum acceptable pattern. Do not omit it.

The scoping pattern

-- Every clerk API query must follow this pattern
-- Replace the SELECT list with the columns required for the specific endpoint

SELECT d.*
FROM debts d
INNER JOIN case_assignments ca
  ON ca.debt_id = d.id
  AND ca.clerk_id = $1   -- $1 = clerk UUID from clerk_session cookie
WHERE d.id = $2;         -- $2 = requested debt ID (if single record)

-- For list endpoints, omit the WHERE clause on debt ID:
SELECT d.id, d.status, d.principal_pence, d.creditor_name,
       db.name AS debtor_name
FROM debts d
INNER JOIN case_assignments ca ON ca.debt_id = d.id AND ca.clerk_id = $1
INNER JOIN debtors db ON db.id = d.debtor_id
ORDER BY d.updated_at DESC;

4. The daily brief

At 07:00 each business day, a Vercel cron job generates a daily brief for each active clerk. The brief is delivered by email via Resend and is available on the clerk's dashboard. It is generated fresh each morning. It reflects the state of the clerk's caseload at the moment of generation, not at midnight.

The brief contains five sections, evaluated in this order. Each section lists only what is relevant to that clerk's assigned cases.

Section 1 — Payments due today

Cases with a scheduled payment due on today's date. For each: debtor name, amount due, expected payment method. Ordered by amount descending.

Section 2 — Missed payments (1–7 days overdue)

Cases with a missed scheduled payment in the last seven days. For each: debtor name, amount overdue, days since due date, current dunning assignment status. Ordered by days overdue descending.

Section 3 — Letters awaiting approval

Letters in the queue assigned to this clerk that have not been approved. For each: debtor name, template name, register, days in queue. Does not include pre-legal letters — those remain with the administrator.

Section 4 — Cases flagged by the administrator

Cases the administrator has explicitly flagged for this clerk's attention. For each: debtor name, flag text, date flagged.

Section 5 — Cases with no activity in 14 days

Active cases in the clerk's caseload with no payment received, no note added, and no letter despatched in the last 14 calendar days. A reminder, not a reprimand. Ordered by last activity date ascending — the most dormant first.

If all five sections are empty, the brief reads a single line:

Nothing requires your attention today.

The Firm considers this a satisfactory outcome.

5. Database schema

-- Clerk accounts
CREATE TABLE IF NOT EXISTS clerks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,         -- bcrypt, cost factor 12
  active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  last_login TIMESTAMPTZ
);
ALTER TABLE clerks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_clerks" ON clerks
  FOR ALL USING (true) WITH CHECK (true);

-- Case assignments — one clerk per debt at any time
CREATE TABLE IF NOT EXISTS case_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  clerk_id UUID NOT NULL REFERENCES clerks(id) ON DELETE RESTRICT,
  assigned_at TIMESTAMPTZ DEFAULT NOW(),
  assigned_by TEXT NOT NULL,           -- 'admin' or admin identifier — audit trail
  UNIQUE(debt_id)                      -- enforces single assignment
);
ALTER TABLE case_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_assignments" ON case_assignments
  FOR ALL USING (true) WITH CHECK (true);

-- Case notes — immutable once written
CREATE TABLE IF NOT EXISTS case_notes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE RESTRICT,
  clerk_id UUID REFERENCES clerks(id), -- NULL if written by admin
  note TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW() -- no updated_at — notes are not edited
);
ALTER TABLE case_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_notes_write" ON case_notes
  FOR INSERT WITH CHECK (true);
CREATE POLICY "service_role_notes_read" ON case_notes
  FOR SELECT USING (true);
-- No UPDATE or DELETE — notes are permanent

-- Administrator flags on cases (visible to assigned clerk in brief)
CREATE TABLE IF NOT EXISTS case_flags (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  debt_id UUID NOT NULL REFERENCES debts(id) ON DELETE CASCADE,
  flag_text TEXT NOT NULL,
  flagged_at TIMESTAMPTZ DEFAULT NOW(),
  resolved BOOLEAN DEFAULT false,
  resolved_at TIMESTAMPTZ
);
ALTER TABLE case_flags ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_flags" ON case_flags
  FOR ALL USING (true) WITH CHECK (true);

-- Daily briefs — generated and stored for the record
CREATE TABLE IF NOT EXISTS daily_briefs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  clerk_id UUID NOT NULL REFERENCES clerks(id) ON DELETE CASCADE,
  brief_date DATE NOT NULL,
  content_html TEXT NOT NULL,          -- rendered at generation time, immutable
  generated_at TIMESTAMPTZ DEFAULT NOW(),
  delivered BOOLEAN DEFAULT false,
  resend_message_id TEXT,
  UNIQUE(clerk_id, brief_date)         -- one brief per clerk per day
);
ALTER TABLE daily_briefs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service_role_briefs" ON daily_briefs
  FOR ALL USING (true) WITH CHECK (true);

6. New routes

Clerk routes

Admin routes (additions to existing dashboard)

API routes

7. Effort and dependencies

ItemDetail
Effort estimateHigh — 16–20 days
New database tables5 (clerks, case_assignments, case_notes, case_flags, daily_briefs)
New clerk routes5 pages
New admin routes2 pages
New API routes11, plus 1 cron endpoint
External dependenciesResend (brief delivery). The Ledger (required). The Dead Letter Office (recommended — clerk letter queue requires it).
New environment variablesCRON_SECRET (if not already added for Dead Letter Office)

The schema and routes are methodical rather than complex. The effort is concentrated in two places. First, the parallel auth track — building a second credential system that does not interfere with the existing admin pattern requires careful middleware design. Second, the data scoping — every clerk-facing query must enforce assignment at the database layer. There are eleven clerk API routes; each one must include the scoping join. A single missed join is a data breach.

The daily brief cron is simpler than it appears. Five queries, one HTML template, one Resend call per clerk. The UNIQUE(clerk_id, brief_date) constraint on daily_briefs makes the cron idempotent — running it twice produces one brief, not two.

Build order within this extension Build and test clerk auth and the scoping pattern before adding any other clerk functionality. Every subsequent feature depends on the scoping being correct. A clerk dashboard built before the scoping is tested is a clerk dashboard that may leak data.