# Client Stage Taxonomy — Source-of-Truth Spec

**Author:** Loom
**Date:** 2026-05-25
**Status:** DESIGN — pending Larry review before Forge/Cord hand-off
**Sponsor:** Jimmie Needles

---

## TL;DR (Read This First)

1. **One primary stage per client, eight stages total.** Mutually exclusive. Plus a separate `flags` field for parallel work that doesn't dominate the client's current state.
2. **Hybrid transitions — mostly auto, manual only at lifecycle boundaries.** Bookkeepers touch stage ~2–3 times per week across the whole book, not 40 times.
3. **Dashboard leads with exceptions, not a roster.** Stuck clients, missing flags, OOO owners surface at the top. The full list is below the fold.
4. **Source of truth = Atlas `clients` table + `stage_history` audit log.** Updated via API, cron jobs, Slack commands, and agent calls.
5. **Agent-operable from day one.** Jimmie's strategic goal is running most of J2 with agents (see [`feedback_agent_operable_by_default`](../../memory/feedback_agent_operable_by_default.md)). Every API endpoint and update channel is designed for agents as first-class operators, not bolted on later. Phase 1 ships with humans + cron driving most updates; Phase 2 wires agents into the same endpoints with scoped permissions. **Same schema, same audit trail — agents and humans use the identical interface.**

---

## 1. Stage Taxonomy

### Primary Stages (mutually exclusive — a client is in exactly one)

**Decision 2026-05-25 (Jimmie):** Advisory delivery is a FLAG, not a stage. Advisory-tier clients stay in `weekly` (or whatever their bookkeeping cadence dictates) with `advisory_due` set when a package is in flight. If lived experience shows we need a dedicated stage later, adding one is a ~30-min Forge migration. Start simple.

| Stage | Definition | Typical Duration |
|-------|------------|------------------|
| `onboarding` | New client, not yet in recurring rhythm. Chart of accounts, opening balances, app stack, kickoff. | 2–4 weeks |
| `cleanup` | Historical cleanup project. Billable scope distinct from recurring work. Not yet (or temporarily not) in monthly rhythm. | 4–12 weeks |
| `weekly` | Default steady state. Weekly roundup cadence, categorize-as-you-go, ad-hoc bill pay. Advisory deliverables in flight ride here with `advisory_due` flag. | Most of the month |
| `eom_close` | Month-end close in progress for the just-finished period. Recs, accruals, prepaids, payroll JE. | Days 1–8 of month |
| `eom_review` | Close done by bookkeeper, awaiting senior review + client package delivery. | Days 6–10 of month |
| `paused_client` | Client-side hold. Owner non-responsive, payment lapse, mid-dispute, awaiting their decision. Goes to bookkeeper for relationship follow-up. | Variable |
| `paused_internal` | J2-side hold. Capacity, intentional defer, internal scope question. Goes to Reid as a capacity signal. | Variable |
| `offboarding` | Wind-down. Final invoice, file transfer, app-stack revocation. | 2–4 weeks |

### Flags (parallel state — zero or more per client, independent of primary stage)

| Flag | When Set | Why It's a Flag, Not a Stage |
|------|----------|------------------------------|
| `sales_tax_due` | Filing window open this period | Most clients have it; doesn't dominate the primary cadence |
| `1099_prep` | Jan–Feb only | Seasonal overlay on top of normal cadence |
| `year_end` | Dec close period | Overlay on `eom_close`, not a replacement |
| `tax_prep_active` | Rex / external preparer has the books | Bookkeeping continues in parallel |
| `advisory_due` | Monthly package owed this period | Predictable for advisory-tier clients |
| `stuck` | Set automatically when `stage_entered_at` exceeds threshold | Drives exception surfacing — not a manual state |
| `client_blocking` | Waiting on client for docs/answers | Distinguishes "we're slow" from "they're slow" |

### Why this shape

- **Mutually exclusive primary stage** lets the dashboard answer "what is the *one* thing happening with this client right now?" in one cell. That's what Jimmie needs at a glance.
- **Flags handle the messy reality** — sales tax, year-end, tax prep, advisory work happen *in parallel* with the bookkeeping cadence. Folding them into the primary stage would either explode the taxonomy or force false choices.
- **`stuck` as a flag, not a stage** — preserves the *true* state (still in close, just late) while flagging the exception. If we made "stuck" a stage, we'd lose the info about where they're actually stuck.
- **`client_blocking` flag** — critical for J2 dashboarding. A client stuck in `eom_close` because the bookkeeper hasn't gotten to it is a J2 problem. A client stuck in `eom_close` because they haven't sent bank statements is a client problem. Different escalation paths.

---

## 2. Transition Rules

### Hybrid model — defaulting to automatic, manual only at lifecycle boundaries.

| Transition | Trigger Type | Rule |
|------------|--------------|------|
| `weekly` → `eom_close` | **Auto / time-based** | Last business day of month, 6:00 AM CT — all clients in `weekly` flip to `eom_close` |
| `eom_close` → `eom_review` | **Auto / event-based** | When all bank + CC accounts for the period are reconciled in QBO AND close checklist complete in Atlas |
| `eom_close` → `eom_review` (manual override) | **Manual** | Bookkeeper can flip via Slack `/stage` if event detection misses |
| `eom_review` → `weekly` | **Manual** | A user with `senior_reviewer` permission signs off. Per-client `eligible_reviewers` override allowed. As of 2026-05-25: Jimmie is the only senior reviewer; designed to expand later (Ledger or specific bookkeepers per client tier). For advisory-tier clients, `advisory_due` flag is set at this point and clears when the package is acknowledged or on day-16 auto-clear. |
| `advisory_due` flag set | **Auto / event-based** | On `eom_review` → `weekly` transition for advisory-tier clients |
| `advisory_due` flag cleared | **Auto / time-based** | Day 16 of month — assumes delivery acknowledged (manual override available) |
| Any → `paused_client` | **Manual** | Bookkeeper marks when client goes unresponsive / payment lapses / dispute. Requires reason. |
| Any → `paused_internal` | **Manual** | Jimmie or senior marks when J2 capacity / internal hold. Requires reason. Pings Reid. |
| `paused_client` / `paused_internal` → `weekly` / `cleanup` | **Manual** | Resume to prior cadence (uses `returning_to_stage` field). |
| Any → `offboarding` | **Manual** | Jimmie only |
| `onboarding` → `weekly` or `cleanup` | **Manual** | After first clean close OR if cleanup scope identified |
| `cleanup` → `weekly` | **Manual** | When cleanup scope formally closed |
| `stuck` flag set | **Auto** | When `now() - stage_entered_at` exceeds stage-specific SLA (see below) |

### Stuck thresholds (per stage)

| Stage | SLA (days in stage) | After SLA |
|-------|---------------------|-----------|
| `eom_close` | 10 (so day 11 of month) | `stuck` flag set |
| `eom_review` | 4 from entry | `stuck` flag |
| `advisory_due` flag (not a stage) | 6 days since flag set | `stuck` flag |
| `onboarding` | 30 | `stuck` flag + Jimmie alert |
| `cleanup` | client-specific budget × 1.25 | `stuck` flag + Jimmie alert |
| `paused_client` | 30 | `stuck` flag + check-in prompt to bookkeeper |
| `paused_internal` | 14 | `stuck` flag + alert to Reid (capacity signal) |

### Why hybrid, not pure-anything

- **Pure manual** → bookkeepers forget, data rots, dashboard becomes a lie. Failure mode of every workflow tool J2 has tried before.
- **Pure auto** → can't capture "this client is being weird, hold the close" or "they paused us last Friday." Brittle.
- **Hybrid with auto defaults** → the *system* drives the calendar (1st of month, 11th of month), bookkeepers only touch when something exceptional happens. Estimated **2–3 manual updates per week across 40 clients**, not 40+.

---

## 3. Edge Cases

| Case | Resolution |
|------|------------|
| **Client has both active cleanup AND ongoing weekly work** (Afton, Tri-County Tire) | Primary stage = `cleanup` if cleanup is the active billable focus. Flag `recurring_active` to indicate weekly work continuing in parallel. Cord shows this as a sub-row on the dashboard. |
| **New onboard needs cleanup before going recurring** | `onboarding` → `cleanup` → `weekly`. Two-hop is fine. |
| **Multi-tier client (recurring + advisory + tax)** | Primary stage tracks bookkeeping cadence. Advisory work surfaces via `advisory_due` flag (no dedicated stage). Tax prep surfaces via `tax_prep_active` flag. |
| **December close** | Primary = `eom_close`, flag = `year_end`. The flag triggers different checklist items in Atlas. |
| **Sales tax due dates vary by client** | Per-client `sales_tax_schedule` field on client record. Cron job sets `sales_tax_due` flag in the appropriate window. |
| **Client pauses mid-close** | `eom_close` → `paused_client` (or `paused_internal` if J2's the blocker). When resumed, returns to `eom_close` (not `weekly`) if the close is incomplete. Atlas tracks via `returning_to_stage`. |
| **Bookkeeper goes OOO** | Stage doesn't change. `stage_owner` field gets reassigned (manual or auto-rebalance from Atlas). Dashboard surfaces "owner OOO" as an exception. |
| **Two bookkeepers on one client** | `stage_owner` is the primary; add `stage_collaborators` array. Dashboard shows the primary. |
| **Client off-cycle (e.g., quarterly close, not monthly)** | Per-client `close_cadence` field (`monthly`, `quarterly`, `annual`). Auto-transitions respect cadence. Quarterly clients only flip to `eom_close` on Mar/Jun/Sep/Dec last-business-day. |
| **Client in chronic "stuck" — recurring lateness** | If stuck > 3 cycles in a row → flag `chronic_late`. Distinct surface in dashboard. Goes to Reid for client-tier or scope review. |

---

## 4. Schema Change Brief — to Forge

**Goal:** Make Atlas the source of truth for client stage. No spreadsheets, no Notion, no Slack scrollback.

### Additions to `clients` table

```
stage              ENUM not null   -- onboarding, cleanup, weekly, eom_close,
                                       eom_review, paused_client, paused_internal, offboarding
stage_entered_at   TIMESTAMP       -- when current stage began
stage_owner        FK -> users     -- primary bookkeeper for this stage
stage_collaborators ARRAY<FK>      -- secondary bookkeepers
stage_notes        TEXT            -- optional context (why paused, what cleanup scope, etc.)
flags              ARRAY<ENUM>     -- sales_tax_due, 1099_prep, year_end, tax_prep_active,
                                       advisory_due, stuck, client_blocking, chronic_late,
                                       recurring_active
close_cadence      ENUM            -- monthly, quarterly, annual (default monthly)
sales_tax_schedule ENUM            -- none, monthly, quarterly, annual
service_tier       ENUM            -- recurring_only, recurring_advisory, fractional_cfo
returning_to_stage ENUM nullable   -- for resume-from-paused semantics
eligible_reviewers ARRAY<FK> null  -- per-client override of who can sign off eom_review;
                                       null = fall back to global senior_reviewers list
```

### Permissions / global config

```
users.senior_reviewer    BOOLEAN     -- can sign off eom_review by default for any client
                                          where eligible_reviewers is null. Initial seed:
                                          Jimmie = true. Adding Ledger or others later is a
                                          one-field update — no schema change.
```

### New table — `stage_history`

```
id                 PK
client_id          FK -> clients
from_stage         ENUM
to_stage           ENUM
flag_added         ENUM nullable   -- if this row is a flag change, which flag was set
flag_removed       ENUM nullable   -- if this row is a flag change, which flag was cleared
transitioned_at    TIMESTAMP
actor_type         ENUM            -- human | agent | system
actor_id           STRING          -- 'user:jimmie', 'agent:tally', 'system:cron'
channel            ENUM            -- api | slack | nlp_relay | cron | event_webhook
on_behalf_of       STRING nullable -- e.g., 'user:jimmie' when Kade relays a verbal command
triggered_by       STRING nullable -- e.g., 'qbo_reconciliation_complete', 'echo_no_reply_7d'
trigger_type       ENUM            -- auto_time, auto_event, manual, manual_override, agent_action
reason             TEXT nullable
```

### Permissions table — `agent_permissions`

Permissions are **data, not code**. Adding a new agent or expanding scope is a row insert, not a deploy.

```
id                 PK
actor_id           STRING          -- 'agent:tally', 'agent:echo', 'user:jimmie', etc.
permission         ENUM            -- see permission enum below
scope              JSON nullable   -- optional constraints (e.g., {"stage_from": ["eom_close"],
                                       "stage_to": ["eom_review"]})
granted_at         TIMESTAMP
granted_by         STRING
revoked_at         TIMESTAMP nullable
notes              TEXT
```

**Permission enum (atomic, composable):**

```
can_read_clients              -- baseline: see the table
can_read_stage_history        -- can see audit trail
can_set_stage                 -- can change primary stage at all (subject to scope)
can_set_flag                  -- can add/remove flags at all (subject to scope)
can_sign_off_eom_review       -- equivalent to old senior_reviewer flag
can_offboard                  -- can move any client to offboarding
can_admin                     -- Forge / Larry only — full table write
```

Most agent permissions will be `can_set_flag` with a `scope` JSON restricting WHICH flags (e.g., Echo's scope is `{"flags": ["client_blocking"]}`). This keeps the enum small and the scoping flexible.

### API endpoints needed

**Auth model:** every endpoint requires a bearer token. Tokens are issued per actor (`user:jimmie`, `agent:tally`, `agent:echo`, etc.). On each call, Atlas:
1. Resolves the token → actor_id
2. Checks `agent_permissions` for the required permission + scope
3. If allowed: writes the change, stamps `stage_history` with `actor_id` and `channel`
4. If denied: returns 403 with the specific permission that's missing (no silent failure)

**Write endpoints:**

- `PATCH /clients/:id/stage` — requires `can_set_stage` (with scope check on `from`/`to`). Body: `{to_stage, reason, triggered_by?, on_behalf_of?}`. Logs to history.
- `POST /clients/:id/flags/:flag` — requires `can_set_flag` (with scope check on which flag). Body: `{reason, triggered_by?}`. Logs to history.
- `DELETE /clients/:id/flags/:flag` — same permission as above.
- `POST /clients/:id/stage_history/:row_id/rollback` — requires `can_admin`. Doesn't delete the history row; appends a new compensating row marked `trigger_type=rollback` and restores prior state. Preserves audit trail.

**Read endpoints (for agents):**

- `GET /clients?stage=X&owner=Y&flag=Z&stuck=true&service_tier=A` — composable filters. Ledger queries `?stage=eom_close&owner=agent:ledger`. Echo queries `?flag=client_blocking&age>7d`. Cord queries the full set for the dashboard.
- `GET /clients/:id` — single client snapshot including all flags, owner, last `stage_history` entry.
- `GET /clients/:id/stage_history?limit=20` — chronological audit drill-down, for any agent that needs context before acting.
- `GET /agents/:actor_id/queue` — derived view: "what's on this agent's plate right now," based on owner + relevant flags + permissions. Used by Tally, Ledger, Echo to drive their own work.

### Cron jobs needed

- **`stage_autoroll_eom_open`** — last business day of month, 6:00 AM CT. All `weekly` clients (respecting `close_cadence`) → `eom_close`.
- **`flag_autoclear_advisory_due`** — day 16, 6:00 AM CT. Clear `advisory_due` flag from any client where it's still set.
- **`stage_stuck_detector`** — hourly. Sets `stuck` flag on any client exceeding SLA (including `advisory_due` flag age).
- **`flag_sales_tax_due`** — daily. Reads each client's `sales_tax_schedule` + filing calendar; sets/clears `sales_tax_due`.
- **`flag_seasonal`** — daily. Sets `1099_prep` Jan 1–Feb 15; sets `year_end` Dec 1–Jan 15.

### Slack command

- `/stage <client_short_name> <new_stage> [reason]` — manual transition from Slack. Logs to `stage_history` with `transitioned_by = slack:<user>`.
- `/stage <client_short_name>` (no args) — read-only: shows current stage, owner, flags, time-in-stage.
- `/flag <client> +/-<flag>` — flag add/remove.

### Acceptance criteria for Forge

- All 40 active clients seeded with a starting stage (Larry + Jimmie review the seed before go-live).
- All transitions logged in `stage_history` — no silent flips.
- Slack command latency < 2s.
- Cron jobs idempotent (re-running doesn't double-flip).
- Dashboard query (`GET /clients?stage=...&stuck=true`) under 500ms for the full client book.

### ROI estimate

| Cost | Estimate |
|------|----------|
| Forge build | 2–3 days |
| Cord dashboard build | 1–2 days |
| Bookkeeper onboarding to new flow | 1 hour × team |
| **Ongoing maintenance** | ~30 min/month |

| Benefit | Estimate |
|---------|----------|
| Jimmie status-check time saved | 30 min/week × 50 = 25 hr/yr |
| Stuck-client surprises eliminated | 1 hr/month × 12 = 12 hr/yr |
| Bookkeeper "where are we on X" Slack pings reduced | 2 hr/week × 50 = 100 hr/yr |
| **Total** | **~137 hr/yr saved** |

Clears the 10-hr/quarter threshold by 10x. Greenlit.

---

## 5. Dashboard Read Brief — to Cord

**Goal:** Two-tab Sheet (or Quadratic) — Projects on one tab, Clients on another. Client tab leads with exceptions; full roster below.

### Source

- Atlas API: `GET /clients?include=stage,flags,owner,stage_history`
- Refresh: every 15 minutes, OR webhook from Atlas on any stage/flag change (Forge can fire webhooks if Cord prefers push)

### Client tab — top section: EXCEPTIONS (read-first zone)

| Column | Source |
|--------|--------|
| Client | `clients.name` |
| Stage | `clients.stage` |
| Days in stage | `now() - stage_entered_at` |
| Why flagged | derived: `stuck`, `chronic_late`, `client_blocking`, OOO owner, etc. |
| Owner | `stage_owner.name` |
| Action | derived: "Nudge client" / "Reassign" / "Review with Jimmie" |

Filter: any client with `stuck`, `chronic_late`, `paused_client > 30 days`, any `paused_internal`, or OOO owner shows here.

### Client tab — bottom section: full roster

| Column | Source |
|--------|--------|
| Client | `clients.name` |
| Service tier | `clients.service_tier` |
| Stage | `clients.stage` |
| Days in stage | derived |
| Flags | `clients.flags` (rendered as chips/emoji) |
| Owner | `stage_owner.name` |
| Close cadence | `clients.close_cadence` |
| Notes | `clients.stage_notes` |

Default sort: stage, then days-in-stage descending.
Conditional formatting: red if `stuck`, amber if approaching SLA, green otherwise.

### Project tab

Source: `atlas.projects` (whatever Forge's projects table has — Cord and Forge align on columns). Out of scope for this spec.

### Acceptance criteria for Cord

- Exceptions section shows zero entries on a healthy day (and that's the signal).
- Stage colors readable at a glance — no decoding needed.
- Stale-data indicator: timestamp of last refresh visible top-right.
- Mobile-readable (Jimmie checks phone constantly).

---

## 6. What This Spec Does NOT Cover

- **Project taxonomy** — projects tab is Cord's lane once Forge confirms what's in the projects table. Different spec.
- **The actual close checklist contents** — Ledger owns checklist content per stage. Hand off separately.
- **Bookkeeper assignment / load balancing** — Reid + Nolan question (who owns which clients), not Loom.
- **Capacity planning** — once stage data flows for 60 days, we can VSM cycle times. Phase 2.

---

## 7. Open Questions for Larry / Jimmie

Per the one-question-at-a-time rule, these go to Larry to ask Jimmie in sequence:

1. ~~**Q1 (blocking):** Is `advisory_delivery` a real distinct stage, or do advisory-tier clients just have an `advisory_due` flag while staying in `weekly`?~~ **RESOLVED 2026-05-25 — flag-only. Add stage later if needed.**
2. ~~**Q2:** `paused` — one stage or two?~~ **RESOLVED 2026-05-25 — split into `paused_client` and `paused_internal`. J2-paused alerts Reid as capacity signal.**
3. ~~**Q3:** Who is the senior reviewer in `eom_review`?~~ **RESOLVED 2026-05-25 — Jimmie only for now. Schema designed extensible: `users.senior_reviewer` flag + per-client `eligible_reviewers` override. Adding Ledger or others later is a permission flip, not a migration.**

**All blocking questions resolved. Spec is locked and ready for Forge.**

---

## 8. Next Actions

| Who | What | By When |
|-----|------|---------|
| Larry | Review this spec; surface Q1–Q3 to Jimmie one at a time | Before Forge gets the brief |
| Jimmie | Answer Q1–Q3 | This week |
| Loom | Update spec with answers; finalize | Same day as answers |
| Forge | Implement schema + endpoints + cron + Slack command | 2–3 days after spec final |
| Cord | Build dashboard against Atlas API | 1–2 days after Forge ships |
| Loom | Time-study cycle times 60 days post-launch; feed into automation backlog | 2026-07-25 |
