# 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` |
| **Last update** | derived from most recent `stage_history` row: `actor_id` + `transitioned_at`. Render with an icon distinguishing 👤 human / 🤖 agent / ⚙️ system. |
| Notes | `clients.stage_notes` |

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

**Why surface the actor:** as agents take over more updates, Jimmie needs to glance at the dashboard and instantly know "an agent moved this" vs. "a human moved this." Builds trust during the transition and surfaces agent errors fast.

### 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 |
