# Phase 1 — Agent rebuilt: traces, review queue, posts, triggers

> **Kickoff: written and approved by Fable, 2026-07-09.** This is the work
> order for the Opus executor. Prompt B in `plans/rethink-execution.md`
> starts the session. Do not improvise scope; a fork that contradicts this
> plan or the target picture is a stop-and-ask.

## Read before touching anything

`CLAUDE.md` · `HANDOFF.md` · `plans/rethink-target-picture.md` (§5 especially
5.2, 5.3, 5.5, 5.6, and §9) · **`design/tokens.md` (approved, law)** ·
`plans/rethink/phase-0-review-findings.md` ·
`plans/rethink/phase-0-leadrat-spike.md` · the mocks in
`design/tiles/round-4/` (they are the target UI) and `round-3/`.

Harness frontend playbooks, read on first use: `B1` CSS architecture,
`B2` design tokens, `C1` component architecture, `A1` semantic HTML.

## Scope

Phase 1 rebuilds the **Agent** section on the approved tokens, and adds the
backend capabilities that make it honest. It ships in two halves with a Fable
gate between them.

- **1A** — hotfix batch, tokens in code, turn-trace persistence, Activity.
- **1B** — confidence + review queue, Posts + interpretation, Triggers.

**Non-goals in Phase 1**: Behaviour editor (the hybrid canvas), Knowledge,
Playground, Model tabs. They are Phase 1C, planned separately once 1A/1B are
live and used. Home, Analytics, Automation are later phases. Do not start
them.

**Live proving ground is Instagram**, per the target picture. WhatsApp is
still disabled by Meta; nothing in this phase may block on it.

## Standing rules (restated because this phase touches all of them)

- TDD for logic. Write the failing test, watch it fail, implement.
- Per-package tests only: `cd <pkg> && npx vitest run --pool=forks
  --poolOptions.forks.maxForks=2`. **Never bare `pnpm test`** (PID cap).
- Backend deploy loop after every backend change: build shared (if touched),
  build api/worker, `pm2 restart`, `curl localhost:3100/health`.
- **No LLM or eval spend without explicit operator go-ahead.** Stage 4 needs
  it; ask before running, and cost it first.
- Additive migrations. Commit and push each verified step. Keep `HANDOFF.md`
  current.
- Every screen uses `design/tokens.*`. No ad-hoc styling, no new colors.
- All user-facing copy passes the `avoid-ai-writing` check. No em dashes in
  prose. The brand is always "Silver Oak Properties".

---

## Stage 0 — Hotfix batch (do this first, it is small)

Eight findings from the code review plus one from the LeadRat spike. Each
gets a failing test first. Several existing tests **pin the wrong behaviour**;
correct them, do not delete them.

From `phase-0-review-findings.md`, the fix-now table:

1. **Login rate limiter XFF bypass** (`auth/login-rate-limit.guard.ts:18`,
   high). Trust `req.ip` via Fastify `trustProxy`, or take the last XFF hop.
   Update `login-rate-limit.guard.test.ts:17`, which asserts the bug.
2. **Webhook stored-but-never-enqueued loss**
   (`webhooks/webhook-ingest.service.ts:83`, high). On P2002, load the row;
   if `signatureValid` and not processed, enqueue before acking. Do not let
   `signatureValid=false` rows claim a dedup key.
3. **Campaign re-schedule fires at the old time**
   (`campaigns/campaign-send.service.ts:260`, high). Stable
   `jobId: campaign-start:<id>` plus remove-before-add. The resume queue at
   line 498 already shows the pattern.
4. **Reminder sends have no idempotency across retries**
   (`campaigns/batch-send.service.ts:78`, high). Dedupe id per item, recorded
   before return.
5. **Worker internal-API timeout below the Meta send timeout**
   (`worker/src/lib/internal-api.ts:14`). Raise above the 15s Meta ceiling
   (30s) and add an atomic status claim in `sendOneRecipient`.
6. **`waId` mapping path is dead** (`shared/campaigns/resolve-mapping.ts:25`).
   Alias to `channelUserId`. Fix the test fixtures that mock the stale shape.
7. **Envelope dedup keys only the first batched item**
   (`shared/meta/webhook-parser.ts:57`). Key over all event ids. Land with 2.
8. **`null` JSON body 500s ingest**
   (`webhooks/webhook-ingest.service.ts:64`). Guard for a non-null object.

From `phase-0-leadrat-spike.md`:

9. **LeadRat notes overwrite, they do not append** (`leadrat/leadrat.service.ts:113`,
   high, live data loss). Add `getLead(crmId)` on the client
   (`GET /api/v1/lead/{id}` exists and was verified). Make `appendNote` a
   read-modify-write: read `notes`, append a timestamped line, PUT the merged
   string. Also fix or guard `updateLead`, whose partial patch 400s because
   `PUT /api/v1/lead/{id}` is a full replace requiring `Name` and `ContactNo`.
   Do not run this against production LeadRat; unit-test with a faked client.

**Verify**: per-package suites green; api + worker + shared build; deploy
loop; `/health` ok. Commit each fix separately.

---

## Stage 1 — Tokens in code

Encode `design/tokens.md` as `apps/web/src/styles/tokens.css` (CSS custom
properties, one tier: semantic names only, no raw hex outside the file), and
retire the old `global.css` gold/navy palette **as those screens get rebuilt**,
not in a big bang. The Agent section is the first consumer.

Build the shared primitives the mocks use, in `apps/web/src/components/ui/`:
`Card`, `Pill` (text always present), `SegmentedControl` (reuse the existing
a11y radiogroup), `Table` chrome, `Field`/`Input`, `Chip`, `StatTile`,
`EmptyState`. One spec each, straight from the token sheet.

Favicon and small contexts use `design/assets/logo-mark.png`; the sidebar
uses `logo.png` at 34px with the EB Garamond wordmark.

**Verify**: web build; existing web suite green; a Storybook-less visual
check is fine (screenshot the Agent route before and after). No screen may
regress to un-tokenised colors.

---

## Stage 2 — Turn-trace persistence (the foundation)

Everything else in the rethink reads this. Today a turn exists only as one
log line.

Additive migration. Suggested shape, adjust with reason:

- **`agent_turns`** — `id`, `channel`, `conversationId`, `contactId`,
  `caseKey`, `branchKey` (nullable), `inboundText`, `sentText`,
  `desiredText` (nullable, see stage 4), `decision`
  (`replied|skipped|queued|hidden|canned`), `decisionReason`, `confidence`
  (nullable float), `model`, `promptVersion`, `tokensIn`, `tokensOut`,
  `cachedTokens`, `costUsd`, `latencyMs`, `triggerId` (nullable),
  `createdAt`. Index `(channel, createdAt)` and `(conversationId)`.
- **`agent_turn_knowledge`** — `turnId`, `chunkId`, `score`, `snippet`
  (snapshot the text; a later KB edit must not rewrite history).
- **`agent_turn_tools`** — `turnId`, `name`, `input` jsonb, `output` jsonb,
  `ok`, `errorCode`.

Write it from the one place every external turn already funnels through
(`runAgentTurn` in shared, surfaced by `AgentService`). Persist for **every**
agent interaction: DM turns, comment decisions, silence and skip decisions,
trigger-matched canned replies. Playground turns persist too, flagged so they
never pollute analytics.

Cost note: this is columns and rows, not tokens. It adds **zero** LLM cost.

While you are in `agent.service.ts`, fix review finding 9 (the per-contact
turn queue releases before the reply is sent and persisted) and finding 10
(`crm_lead`'s profile-name fallback clobbers a collected name,
`shared/agent/tools.ts:257`) and finding 11 (the send result is discarded, so
a failed send is silent, `flows/basic-flow-engine.ts:246`). They live in
exactly this code and the trace makes them visible.

**Verify**: unit tests on the writer; an integration test that one simulated
inbound produces one `agent_turns` row with its knowledge and tool children;
migration applies cleanly on a scratch DB; deploy loop; then send one real
Instagram comment on the test account and read its row back.

---

## Stage 3 — Activity screen

`/agent/activity`, built on the round-1 and round-4 mocks. Stream on the left
(time, channel chip, contact and snippet, outcome pill with its one-line
reason), trace panel on the right.

The trace panel is **one component**, reused by Activity, the review queue,
and later the Playground. It renders, in this order: what it saw (post
caption and its interpretation for comments, conversation window, lead
profile), knowledge retrieved (the actual snapshot text with scores), the
decision (trigger check, policy, safety rails, language, confidence versus
threshold), actions (reply sent, tool calls with payloads), and a footer with
model, tokens, cost, latency.

Filters: channel, outcome, date. Plus **export selected traces** as JSON,
which is the operator's improvement loop (target picture §5.2). No LLM calls
in the export path.

**Verify**: web tests for the stream, filters, and the trace component;
build; publish; then open the real Instagram traces on the live site.

### Gate 1A

Deploy, publish, and tell the operator it is ready for the Fable gate. Do not
start 1B before that verdict.

---

## Stage 4 — Confidence and the review queue

**Read target picture §5.3 twice.** This is the subtlest part of the rethink
and the mock (`design/tiles/round-4/review-queue.html`) is the contract.

Semantics, per surface:

- **DM (WhatsApp, Instagram)** — the contact is **never** kept waiting. The
  bot always sends a safe reply immediately: it gives what it knows and omits
  the claim it cannot ground, offering a human follow-up. The fuller answer
  it wanted to give is stored as `desiredText` and queued for a **label**.
  Reviewer actions are **Accept** (records "this was right", never sends) and
  **Reject with a reason**. Nothing is dispatched to the contact, ever.
- **Comment (Instagram)** — holding is acceptable. A low-confidence public
  reply is held for approve / edit and send / reject. An uncertain deletion
  **hides the comment immediately** (safe default) and queues delete or
  restore.
- Across both: content making claims or promises beyond the KB is never
  auto-sent publicly.

Model: **`review_items`** — `id`, `turnId`, `surface` (`dm|comment`), `kind`
(`label|approve|moderate`), `status`
(`waiting|accepted|rejected|approved|sent|deleted|restored`), `reason` (enum),
`reasonText`, `decidedByUserId`, `decidedAt`. Aging and depth feed Home later.

Reject reasons, starting set from the mock: invented a figure, outside our
expertise, wrong tone, too long, other with free text. Operator may revise;
make the enum easy to extend.

**How to produce the safe-versus-desired pair** — this is a real design fork,
and the recommended answer:

The turn asks the model for a structured result: `{reply, confidence,
unsupportedClaims[]}`. If `confidence` clears the threshold and
`unsupportedClaims` is empty and no constraint matched, send `reply` and stop
(the common path, one completion, no extra cost). Otherwise `reply` becomes
`desiredText`, and a **second, cheap completion** rewrites it into the safe
reply with the unsupported claims removed and a human-follow-up offer added.
Only the minority of turns pay for the second call.

**This needs an eval run and therefore operator spend approval.** Cost it,
ask, and only then run. The rule-based floors (constraint matched, no
supporting knowledge chunk) are free and must work without the model's
self-report; do not let confidence be the only gate.

**Verify**: unit tests for the surface semantics (a DM low-confidence turn
sends immediately *and* queues a label; a comment low-confidence turn sends
nothing; an uncertain deletion hides first); the label store round-trips;
web tests for the queue and the two actions; deploy; then **prove it on a
real Instagram comment** on the test account.

---

## Stage 5 — Posts and caption interpretation

Fetch the account's posts and captions from the Graph API. For each new post,
interpret the caption **once** (caption text only, no media interpretation,
settled), cache it, and let the operator edit that interpretation. The edited
version is what enters context for that post's comment replies, and the trace
must show which interpretation was used and whether it was operator-edited
(the mock shows an "edited by operator" badge).

Model: **`posts`** (`id`, `channel`, `externalId`, `caption`, `permalink`,
`postedAt`) and **`post_interpretations`** (`postId`, `text`, `source`
(`model|operator`), `updatedAt`, `updatedByUserId`).

Pipeline: new post seen via webhook or first comment, or a periodic sync.
Interpretation is one small LLM call per post: **spend-gated**, batch it, ask
first.

---

## Stage 6 — Triggers

Keyword and phrase triggers on comments and DMs, the ManyChat replacement.
Scope is **global or a specific post** (both, settled). Actions: run an AI
flow, send a canned reply, start a DM flow, or **stay silent** (explicit
suppression so an external automation can own that reply).

Model: **`triggers`** (`id`, `channel`, `scope` (`global|post`), `postId`
nullable, `match` (keyword list or phrase), `action`, `payload` jsonb,
`enabled`, `createdBy`). Trigger evaluation is deterministic and runs before
the LLM; a matched trigger is recorded on the turn (`triggerId`) and shows in
the trace, as the round-1 mock's "trigger: price list" row does.

**Verify**: unit tests for match, precedence (post-scoped beats global), and
the silent action; a real trigger fired on the live Instagram account.

### Gate 1B

Deploy, publish, update `HANDOFF.md` with evidence, and hand back for the
Fable gate. Phase 1C (Behaviour editor, Knowledge, Playground, Model) gets
its own plan.

---

## Definition of done for Phase 1

`pnpm lint`, `pnpm typecheck`, per-package tests, `pnpm build` all pass;
migrations applied cleanly to the live DB; no secret committed; backend
rebuilt, restarted, health-checked; web built and published by the operator;
every new screen uses the token sheet; the agent's real Instagram behaviour
observed and its traces read back on the live site; `HANDOFF.md` current;
every step committed and pushed.

## Gate 1B verdict — PASS (conditional) · Fable, 2026-07-10

Verified independently, not from the executor's claims: live site serves the
claimed bundle (`index-k6ckW6Ms.js`); `review.ts` implements the §5.3 contract
exactly (rule floors always on and independent of the self-report, confidence
never the only gate nor a bypass, fail-safe rewrite); comment path holds
low-confidence replies, hides-first on uncertain deletions, never deletes;
triggers are deterministic, evaluated before the LLM, post-scope beats global,
silent action works, Arabic-tested; re-ran the suites myself (shared
review/triggers/run-turn 45, api review + ig-comment 31 — all green).

Honest scope cuts accepted: DM trigger leg inert until IG DMs exist (flagged);
self-report OFF pending the eval run.

**Conditions before Phase 1C kickoff** (1C is planned "once 1A/1B are live and
USED" — they are live, not yet used):
1. Operator wires Instagram: connection (token + igBusinessId, label
   `primary`) + `comments` webhook subscription + signing-secret reconcile.
2. Shadow traffic observed in Activity; posts synced; captions interpreted
   (operator-clicked, ≈$0.20); at least one real trigger fired.
3. Operator approves the Stage 4 eval (≈$3–5) → `selfReport` on if it passes.

When those hold, Fable writes `phase-1c-plan.md` (Behaviour editor, Knowledge,
Playground, Model) informed by real usage.

## Open items the executor must not decide alone

- Eval and LLM spend for stage 4's second completion and stage 5's caption
  interpretation. **Ask the operator, with a cost estimate.**
- The `rating` field on LeadRat (temperature as a real field instead of the
  `[HOT]` note prefix). One probe with a full-replace payload will settle the
  enum; it is a Phase 1 nice-to-have, not a blocker.
- Any change to the review-queue semantics. They came from the operator
  directly, and the mock is the contract.
