# Client Stage API Contract — Phase 1

**Author:** Forge
**Consumer:** Riv (Slack `/stage` and `/flag` commands, plus the Python `atlas_client.py` extension for agent SDK in Phase 2)
**Date:** 2026-05-25
**Status:** Live in dev (port 3001 smoke-tested). Awaiting PM2 reload (elevated PS) to ship to prod port 3000.

## Companion docs

- Loom spec (conceptual): `Team Inbox/Loom/client_stage_taxonomy_spec.md`
- Forge audit + plan: `Team Inbox/Forge/2026-05-25 - Forge to Larry - Client Stage Phase 1 Audit + Plan.md`
- Smoke test results: `Team Inbox/Forge/client-stage-smoke-test.md`
- Migration: `Atlas/app/scripts/migrate_010_client_stage.mjs`

## Base URL & auth

`http://localhost:3000/api`, bearer token in `Authorization` header, JSON in / JSON out.

**Two token modes — both supported simultaneously:**

| Mode | When to use | What the audit log records |
|------|-------------|---------------------------|
| **Legacy shared** (`ATLAS_INGEST_TOKEN`) | Existing callers in `Team/Riv/kade_slack/`, ingest scripts, the chat UI. Backwards-compatible. | `actor_id = 'system:legacy'`. Bypasses permission checks (Phase 1 only — Riv migrates Phase 2). |
| **Per-actor token** (entries in `actor_tokens`) | New Slack-bot work, agent calls, eventually all callers. | `actor_id = <whatever the token maps to>`. Permission scan in `agent_permissions` enforced. |

A new token is issued with `node scripts/issue_actor_token.mjs <actor_id>` (Riv to build this helper as part of Phase 2 plumbing — or use a one-off SQL insert per the smoke-test runbook).

**Optional headers:**
- `X-Atlas-Channel: slack` — Riv's bot should send this on every call so `stage_history.channel` reflects the surface (slack vs api vs nlp_relay). Defaults to `api` when absent.

## Stage + flag vocabulary

Constants defined in `lib/stage.ts` (TS) — Riv's Python bot should mirror them:

```
STAGES   = onboarding | cleanup | weekly | eom_close | eom_review |
           paused_client | paused_internal | offboarding

FLAGS    = sales_tax_due | 1099_prep | year_end | tax_prep_active |
           advisory_due | stuck | client_blocking | chronic_late |
           recurring_active

CHANNELS = api | slack | nlp_relay | cron | event_webhook

TRIGGER_TYPES = auto_time | auto_event | manual | manual_override |
                agent_action | rollback
```

The DB enforces these via CHECK constraints — bad values get a 400 with the valid list named.

---

## Endpoints

### `GET /api/clients`

List + filter clients with their full stage state.

**Query params (all optional, composable):**

```
?stage=eom_close              exact stage match
?flag=client_blocking         flag is in the flags array
?owner=user:jimmie            exact stage_owner match
?service_tier=fractional_cfo  exact service_tier match
?stuck=true                   shorthand for flag=stuck
?age_days_gt=10               days_in_stage > N
?include_inactive=true        include active=false (default: active only)
```

**Permission:** `can_read_clients` (baseline — every actor needs this).

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "count": 52,
  "clients": [
    {
      "id": 34,
      "name": "Afton Electric LLC",
      "slug": "afton-electric-llc",
      "active": true,
      "stage": "weekly",
      "stage_entered_at": "2026-05-25T14:49:20.813Z",
      "stage_owner": null,
      "stage_collaborators": [],
      "stage_notes": null,
      "flags": [],
      "close_cadence": "monthly",
      "sales_tax_schedule": "none",
      "service_tier": "recurring_only",
      "returning_to_stage": null,
      "eligible_reviewers": null,
      "version": 0,
      "doc_count": 12,
      "days_in_stage": 0,
      "last_actor_id": null,
      "last_occurred_at": null,
      "last_actor_type": null
    }
  ]
}
```

---

### `GET /api/clients/:slug`

Single client snapshot. `:slug` accepts either a numeric id or a slug.

**Permission:** `can_read_clients`.

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "client": { /* same shape as list entries, plus 'notes' and 'gdrive_folder' */ },
  "last_stage_event": { /* most recent stage_history row, or null */ }
}
```

**404** if neither id nor slug matches.

---

### `PATCH /api/clients/:slug/stage`

Manual stage transition. Use for Riv's `/stage` Slack command.

**Request body:**

```jsonc
{
  "to_stage":          "eom_review",            // required
  "expected_version":  3,                        // required — clients.version at read time
  "reason":            "close packaged",         // optional, free text
  "triggered_by":      "qbo_reconciliation_complete",  // optional, for event-driven callers
  "on_behalf_of":      "user:jimmie",            // optional — when an agent relays a verbal command
  "trigger_type":      "manual",                 // optional, default 'manual'; valid: manual|manual_override|agent_action|auto_event
  "external_id":       "slack-msg-1716470400"    // optional — idempotency key; channel+external_id is UNIQUE
}
```

**Permissions:**
- `can_set_stage` (subject to scope evaluation against `from_stage`/`to_stage`)
- `can_offboard` ALSO required when `to_stage = 'offboarding'`

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "client": { /* full client object, version bumped */ },
  "transitioned_from": "eom_close",
  "transitioned_to": "eom_review"
}
```

**Error responses:**

| Code | When |
|------|------|
| `400` | `to_stage` invalid, `expected_version` missing/wrong type, `client is already in that stage` |
| `401` | Bearer token missing or unrecognized |
| `403` | Permission denied. Body includes `missing_permission` (e.g. `"can_set_stage"`) and `actor_id` — surface this verbatim to the user. |
| `404` | Client not found |
| `409` | Version conflict (someone else updated this client since you read it). Body includes `actual_version` + `expected_version`. **Client should re-fetch and retry.** |

**Idempotency:** if `external_id` is reused with the same `channel`, the existing history row is matched and `reason` is updated rather than a duplicate row written.

---

### `POST /api/clients/:slug/flags/:flag`  and  `DELETE /api/clients/:slug/flags/:flag`

Set/clear a single flag. Used for `/flag` Slack command and for agent-driven flag updates (Echo's `client_blocking`, Tally's `ap_status`, etc.).

**Request body (both methods, all optional):**

```jsonc
{
  "reason": "wellybox shows 0 receipts for May",
  "triggered_by": "echo_no_reply_7d",
  "on_behalf_of": "user:jimmie",
  "external_id": "echo-2026-05-25-001"
}
```

(Empty body — `{}` or no body at all — is OK.)

**Permission:** `can_set_flag` with scope evaluated against `:flag`. Scope JSON shapes that work:
```
{"flags": ["client_blocking", "ap_status"]}        — symmetric: can set or remove these
{"flags_set": ["client_blocking"]}                  — asymmetric: can only SET this one
{"flags_remove": ["client_blocking"]}               — asymmetric: can only CLEAR this one
```

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "client": { /* full client object */ },
  "flag": "client_blocking",
  "op": "set"          // or "remove"
}
```

**No-op response (still 200):**

```jsonc
{
  "ok": true,
  "client": { /* unchanged */ },
  "no_op": true,
  "message": "flag 'client_blocking' was already set"
}
```

**Error responses:** same shape as the stage endpoint. 400 if `:flag` isn't a recognized flag name; 403 if scope excludes this flag.

---

### `GET /api/clients/:slug/stage_history`

Chronological audit drill-down.

**Query params:**
```
?limit=20      default 20, max 200
?before=<ISO>  paginate backwards; rows older than this timestamp
```

**Permission:** `can_read_clients`.

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "client_id": 1,
  "count": 4,
  "history": [
    {
      "id": 4,
      "client_id": 1,
      "from_stage": "eom_close",
      "to_stage": "weekly",
      "flag_added": null,
      "flag_removed": null,
      "occurred_at": "2026-05-25T15:15:06.393Z",
      "actor_type": "system",         // human | agent | system
      "actor_id": "system:legacy",    // 'user:jimmie', 'agent:tally', 'system:cron:stage_autoroll_eom_open', etc.
      "channel": "api",               // api | slack | nlp_relay | cron | event_webhook
      "on_behalf_of": null,
      "triggered_by": null,
      "trigger_type": "rollback",
      "reason": "smoke test rollback",
      "payload": { "rolls_back_history_id": 1 },
      "external_id": null
    }
  ]
}
```

---

### `POST /api/clients/:slug/stage_history/:row_id/rollback`

Revert a single historical change. Appends a new history row (trigger_type=`rollback`) — never deletes.

**Permission:** `can_admin`.

**Request body (all optional):**

```jsonc
{ "reason": "echo wrongly flagged client_blocking" }
```

**Behavior:**
- If the referenced row was a stage move: restores `from_stage` (but only if the client is still in `to_stage` — otherwise 400).
- If the referenced row was a flag set: removes that flag (no-op if already removed).
- If the referenced row was a flag remove: re-adds that flag (no-op if already present).
- Referenced row is NOT deleted.

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "client": { /* full client object */ },
  "rolled_back_history_id": 1
}
```

---

### `GET /api/agents/:actor_id/queue`

What's on this actor's plate. Self-query allowed; cross-actor query requires `can_admin`.

`:actor_id` must be URL-encoded (`agent%3Aledger` for `agent:ledger`).

**Permission:** `can_read_clients` (for the caller) AND either self-query OR `can_admin`.

**Built-in queue rules (Phase 1):**

| actor_id | Returns |
|----------|---------|
| `agent:ledger` | active clients in `eom_close` or `eom_review`, sorted by stage_entered_at |
| `agent:tally` | active clients with `client_blocking` or `ap_status` flag |
| `agent:echo` | active clients with `client_blocking` flag (Echo escalation queue) |
| `agent:reid` | active clients in `paused_internal` (capacity backlog) |
| _anything else_ | active clients where `stage_owner = :actor_id` (default for bookkeepers + unknown actors) |

**Response `200 OK`:**

```jsonc
{
  "ok": true,
  "actor_id": "agent:ledger",
  "count": 3,
  "clients": [ /* trimmed client rows: id, name, slug, stage, stage_entered_at, flags, stage_owner */ ]
}
```

Phase 2: agents can extend with their own routes if the built-in queue doesn't fit.

---

## Slack command translation patterns (for Riv's Python bot)

Riv's `kade_slack` pattern: an LLM intent-parses the inbound message, the bot calls the appropriate Atlas REST endpoint, the bot echoes a confirmation in Slack.

**`/stage <client> <stage> [reason]`** — typed as e.g. `/stage afton eom_review books are done`

1. Resolve `<client>` → slug via existing `kade_slack` client matcher
2. `GET /api/clients/<slug>` → read current stage + version
3. `PATCH /api/clients/<slug>/stage` with `to_stage=<stage>`, `expected_version=<version>`, `reason=<reason>`, `X-Atlas-Channel: slack`
4. On 409: re-fetch + retry once; if still 409, surface the conflict to the user
5. On 403: surface `missing_permission` + `actor_id` verbatim — the user knows what to ask for
6. On success: echo `"Afton: eom_close → eom_review"` in the channel

**`/stage <client>`** (no args) — read-only summary

1. `GET /api/clients/<slug>`
2. Format: `"Afton — weekly, owned by user:jimmie, 3 days in stage. Flags: [advisory_due]. Last touched by system:cron:flag_seasonal."`

**`/flag <client> +<flag> [reason]`** — set a flag

1. `POST /api/clients/<slug>/flags/<flag>` with body `{reason, "X-Atlas-Channel": "slack"}`
2. 403 → surface `missing_permission` (the user / agent doesn't hold scope)
3. Success → echo `"Afton: +client_blocking"`

**`/flag <client> -<flag>`** — clear a flag

1. `DELETE /api/clients/<slug>/flags/<flag>` — same shape

## NLP relay pattern (for Kade bot in Phase 2)

When Jimmie DMs Kade `"mark Afton as paused"`:

1. Kade interprets intent → "set stage to paused_client for client matching 'Afton'"
2. Kade calls `PATCH /api/clients/afton-electric-llc/stage` with:
   - `X-Atlas-Channel: nlp_relay`
   - Body: `{"to_stage": "paused_client", "expected_version": ..., "on_behalf_of": "user:jimmie", "trigger_type": "manual"}`
3. Audit row records `actor_id='agent:kade'`, `channel='nlp_relay'`, `on_behalf_of='user:jimmie'` — chain of authority preserved
4. Kade echoes confirmation back to Jimmie in Slack

Per the locked spec §6.7: high-stakes stages (`offboarding`) should require an explicit confirm echo before the API call. Flags can fire without confirm.

## Rate-limiter expectations (Phase 2)

Atlas does NOT rate-limit in Phase 1. The route handlers are designed to handle reasonable parallelism via optimistic locking + transactions. Phase 2 adds a gateway-level limiter; defaults per Loom spec §6 are 30/min for flag-heavy actors (Tally, Echo) and 10/min for stage-heavy actors (Ledger, Reid).

## What Phase 1 does NOT include (Riv's Phase 2 scope)

- `atlas-agent-sdk` (Python + TS shared library)
- Per-actor token issuance helper script
- Webhook plumbing for QBO/WellyBox/email events
- Rate limiter at gateway
- Kade NLP relay
- Wiring individual agents into the SDK

All of those are additive against the endpoints defined here — no breaking changes expected.

---

## Acknowledgement

Riv: please ack this contract by replying in `Team Inbox/Riv/`. If anything is wrong, flag now, not after the Slack bot is built. Same loop we ran for Kade v0.2.
