# Structural Behaviour editing — Stage 6 design (agent-scope-2)

> Executor design doc, 2026-07-17. Built under the operator's round-9 rulings
> (`docs/DECISIONS.md` § *round-9 reactions F1–F6*), which OVERRIDE the kickoff
> recommendations where they differ: **F3 branches IN v1**, **F4 canvas-direct
> editing**. The kickoff (`agent-scope-2-kickoff.md` Stage 6) and the round-9
> README's flagged ambiguities bind this document. Gate 2 judges the build
> against the kickoff + this doc.

## 1. What becomes data

The DM router only. Today (`apps/api/src/agent/agent.service.ts:175-179`) the
router is two lines of code: WhatsApp sender found in the staff directory →
internal, else → external. The job-seeker switch is NOT routing — it is prompt
content inside external mode (`system-prompt.ts` `jobSeekersSection`), decided
by the model, and F1 keeps it that way (dashed "model detects" chip, never a
predicate). The IG comment lane is NOT routing either and never becomes it
(§7). Custom cases (F2) and branches (F3) extend the same external scaffold.

## 2. Routing-config schema (predicates-as-data, F1)

New shared module `packages/shared/src/agent/routing.ts`:

```ts
/** The fixed deterministic vocabulary (F1). Nothing else is expressible. */
export type RoutingPredicate =
  | { kind: "channel-is"; channel: "whatsapp" | "instagram" }
  | { kind: "sender-is-staff" }              // CRM directory, active sales
  | { kind: "text-matches"; keywords: string[] } // any-of, case-insensitive substring
  | { kind: "slot-present"; slot: string };  // a collected lead-profile field exists

export interface RoutingBranch {
  key: string;    // slug, unique within its case
  label: string;
  /** AND of predicates; branches evaluated in order, first match wins;
   *  no match → the case runs on its base content. Same fixed vocabulary. */
  entersWhen: RoutingPredicate[];
}

export interface RoutingCase {
  key: string;        // "internal" | "jobSeeker" | "client" | custom slug
  label: string;
  builtin: boolean;   // built-ins: disable-only (F2); custom: add/remove
  enabled: boolean;
  /** True only for jobSeeker: no deterministic predicates exist; the model
   *  decides mid-conversation. Rendered as the dashed chip; the router
   *  evaluation SKIPS it; disabling it removes the JOB SEEKERS prompt section. */
  modelDetect?: boolean;
  fallback?: boolean; // true only for "client"; always last, always enabled
  predicates: RoutingPredicate[]; // AND within the case
  branches: RoutingBranch[];      // F3 — content refinement, deterministic entry
}

export interface AgentRouting {
  version: 1;
  /** Ordered. First matching enabled case wins; fallback last. */
  cases: RoutingCase[];
}
```

Notes pinned by tests:

- **Predicate rails.** `sender-is-staff` can only ever be true for WhatsApp
  senders — the consultant directory is phone-keyed (code comment at
  `agent.service.ts:172-175`). This stays a code fact; the seeded internal
  case pairs it with `channel-is whatsapp` exactly as Tile A shows.
  `text-matches` is case-insensitive substring over the inbound text — no
  regex, so no ReDoS and nothing the vocabulary chip cannot say honestly.
  `slot-present` reads the contact's stored `leadState` field names.
- **Branch entry uses the SAME four-predicate vocabulary.** The operator ruled
  the vocabulary closed; no `slot-equals` is added. If reacted use shows branch
  entry needs value matching, that is a consult, not an improvisation.
- **Limits (Zod at the boundary):** ≤ 20 cases, ≤ 8 predicates per rule,
  ≤ 8 branches per case, ≤ 12 keywords of ≤ 60 chars each, keys
  `[a-z0-9-]{1,40}` and not a reserved key, labels ≤ 60 chars.

## 3. Where it persists

**Inside the existing `agentBehaviour` Setting row** (key/value JSON,
`schema.prisma:854`) — additively: a new optional `routing` block beside
`company`/`cases`, and `cases` opens from the four fixed keys to
`Record<string, CaseBehaviour>` (built-ins always present; custom case content
lives beside them under its slug). **No migration at all** — the settings table
is schemaless JSON and `normalizeAgentBehaviour` already tolerates absent
fields.

Why not a new table or a second setting key: case *structure* (rule, order,
enabled) and case *content* (goal/steps/never…) are created and deleted
together — two stores would allow torn states (a rule pointing at no content,
orphan content) and force two-phase saves. One blob keeps the save atomic, the
merge logic in one place (`mergeBehaviour`), and F5's "next turn uses current
config" for free — the engine already re-reads the setting every turn.

When `routing` is absent, `normalizeAgentBehaviour` substitutes
`SEEDED_ROUTING` — the exact data image of today's code router:

```
1. internal   builtin  channel-is whatsapp AND sender-is-staff
2. jobSeeker  builtin  modelDetect (skipped by the router; prompt section)
3. client     builtin  fallback — everything else, cannot disable/delete/move
```

No custom case is seeded — explicitly none named "Golden Visa" (round-9 README
ambiguity 5). Comment never appears in `routing.cases` (§7).

## 4. Engine consumption

- **`resolveRoutedCase(routing, ctx)`** — pure, in `routing.ts`:
  `ctx = { channel, senderIsStaff, text, slotsPresent: string[] }` → the first
  enabled non-`modelDetect` case whose predicates all hold, else the fallback;
  then the winning case's first matching branch (or null). Returns
  `{ caseKey, branchKey }`.
- **`AgentService.handleTurn`** loads the behaviour setting, builds ctx
  (staff lookup only when an enabled case carries `sender-is-staff` on a
  matching channel — WhatsApp-only, as today; contact `leadState` only when
  some enabled case uses `slot-present`), and dispatches:
  `internal` → `handleInternalTurn` (unchanged), anything else →
  `handleExternalTurn` with the routed `{ caseKey, branchKey }`.
- **`runAgentTurn`** gains `opts.routedCase?: { caseKey, branchKey }`. For a
  custom case it renders the operator block from THAT case's content —
  `renderCaseText(company, cases[caseKey])`, with the matched branch's filled
  fields overlaid field-by-field (a branch field set replaces the case's field,
  exactly like case-over-company inheritance today) — and records
  `caseKey`/`branchKey` on the trace (both columns already exist:
  `ports.ts:171`, `run-turn.ts:306-307`). The client case renders exactly as
  today.
- **`buildSystemPromptSections`** gains `jobSeekerEnabled?: boolean`
  (default true): `false` omits `jobSeekersSection` — the ONLY honest meaning
  of disabling a model-detect case. Everything else in the external scaffold
  (identity, language, services, triage, lead capture, boundaries, tone,
  volatile blocks) renders for custom cases exactly as for client — a custom
  case is steering on the same rails, which is what Tile C's fields promise.
- **Disabled semantics (F2/F5):** disabled `internal` → staff resolution is
  skipped → fallback. Disabled `jobSeeker` → section omitted. Disabled custom →
  rule not evaluated → fallback. All take effect on the next turn because the
  config is read per turn — no versioning, no migration of mid-flight
  conversations (F5).
- **`behaviourPromptVersion`** must not move for an unedited config: it keeps
  the legacy `2-<hash>` form when routing is seed-equivalent and no custom
  cases exist; otherwise it becomes `3-<hash>` over the rendered flows of ALL
  cases (sorted by key) plus the routing structure JSON. Pinned both ways.

## 5. Seeded equivalence pin (gate bar 8)

Two-layer pin, **committed red-first BEFORE the refactor** and kept green after:

1. **Prompt bytes:** a golden test (`system-prompt.golden.test.ts` pattern:
   `channel-profile.golden.test.ts`) snapshots `buildSystemPrompt` for a matrix
   — external × {whatsapp, instagram} × {bare, full behaviour texts, returning,
   afterHours, leadState}, internal, comment, moderation. Captured from the
   CURRENT code, committed green, and the refactor may not move a byte.
2. **Routing decisions:** `agent.service` tests pin — under the seeded config:
   WhatsApp staff → internal path; WhatsApp non-staff → external; Instagram
   sender never staff-resolved; trace `caseKey` values unchanged ("external"
   stays "external" for the client path — the seeded client case records
   `external`, NOT `client`, so Activity/Models/spend buckets and the 1D trace
   history stay continuous). `behaviourPromptVersion` byte-identical for a
   stored blob with no `routing` key.

## 6. The rails register (F6) — and where the fail-safe lock lives

New shared module `packages/shared/src/agent/rails.ts`:

```ts
export interface LockedRail {
  id: string;                       // prompt-section id, or an engine-contract id
  kind: "prompt-section" | "engine-contract";
  label: string;
  note: string;                     // why it can never open
}
export const LOCKED_RAILS: LockedRail[] = [
  { id: "taxonomy",  kind: "prompt-section",  ... },  // moderation action set
  { id: "boundaries", kind: "prompt-section", ... },  // figures/hand-off/decline rail
  { id: "fail-safe", kind: "engine-contract", ... },  // degraded reply, never silence
];
```

- **The fail-safe lock lives in this register, not the prompt inventory**
  (round-9 README ambiguity 3, resolved here): fail-safe is an engine error
  contract (`DEGRADED_REPLY`, `EXHAUSTION_REPLY`, `isFailsafeReason`), not a
  prompt section, so it cannot be an inventory row keyed by section id. The
  inventory derives its `locked` marking for prompt-section rails FROM the
  register; the Behaviour UI renders engine-contract rails as additional
  locked rows sourced from the register. A test pins register membership =
  exactly the operator's three.
- Per the operator's F6 ruling the locked set is **moderation action set
  (`taxonomy` — the delete/escalate/leave policy; its JSON output shape is
  additionally enforced by the parser in code), `boundaries`, and fail-safe**.
  Tile E's other candidates (`safety-rail`, `output-contract`, `never-silent`)
  stay `fixed-in-code` — not editable in this build, but marked "fixed today",
  not "locked forever". The ruling, not the tile, is authority.
- Inventory dynamism: custom cases and branches render through the existing
  `operator-steering` section id, so the `prompt-inventory.test.ts` pin (every
  emitted section id has an entry) extends by running the assembly with a
  custom-case + branch config in the matrix. The inventory entry text is
  updated to say case content INCLUDING custom cases renders there.

## 7. The comment lane stays triggers-first and un-routable

Pinned three ways (round-9 README ambiguity 4):

- `normalizeAgentRouting` strips any case keyed `comment` and any attempt to
  reorder around the lane; the vocabulary cannot express a comment route
  (`channel-is` offers DM channels only).
- `ig-comment.service.ts` is untouched by this package.
- The canvas renders the comment lane as today — fixed, no affordances — with
  the triggers-first truth line. Comment case CONTENT stays editable exactly
  as today (it is `cases.comment` prose); its liveness stays owned by the
  channel gates (`instagram:comments*`), not by routing — one fact, one home.

## 8. Canvas-direct editing (F4, Tile C variant 2)

`Behaviour.tsx` — the canvas becomes the structural editor; the side panel
keeps owning content fields:

- **Add:** a `+` affordance on the Router node → popover: label + initial
  `text-matches` keywords → creates the case (rule + empty content) at the
  position above the fallback; content is then edited in the side panel.
- **Edit rule:** clicking a routed node's rule line opens a popover with the
  predicate chips (add from the fixed vocabulary, remove, edit values), plus
  Enable/Disable for non-fallback cases and Delete for custom cases
  (confirm dialog). Branches: a branch lane under the selected case node
  (Tile D variant 2 shape) with the same popover pattern for `entersWhen`;
  branch content fields appear in the side panel when a branch is selected.
- **Reorder:** drag the routed case nodes (HTML5 drag), with keyboard
  move-up/move-down buttons in the popover as the accessible equivalent.
  The fallback is pinned last and un-draggable; the jobSeeker node renders in
  the case column as the dashed "model detects" chip, NOT part of the ordered
  rule evaluation and NOT draggable — its position cannot pretend to matter
  (canvas honesty).
- **Honesty:** every affordance rendered is wired; built-ins render no delete;
  the comment lane renders none; the truth line changes to say routing rules
  ARE executed as shown, first match wins, comments stay triggers-first — the
  old "routing is decided in code, not here" line goes, because it stops being
  true (kickoff Stage 6 item 3).
- Save = PATCH `/agent/config` (extended `behaviour.routing` + open-keyed
  `cases`); toast keeps "the next turn uses it" (F5). New UI is authored on
  `ui/` primitives + `--cm-*` tokens; no new `routes/agent/styles.ts` entries.

## 9. API surface

`agent-admin.controller.ts`, additive:

- `behaviourSchema.cases` → `z.record(slugKey, caseSchema)` (built-in keys
  always accepted; unknown keys must be slugs).
- `behaviourSchema.routing` → the Zod image of §2 with §2's limits.
- Server-side invariants (rejected with 400, tested): fallback present, last,
  enabled, undeletable; built-ins present and undeletable; `comment` never in
  routing; a custom routed case must have a content entry (created together);
  deleting a custom case deletes its content; `modelDetect` only on
  `jobSeeker`.
- `GET /agent/config` returns the normalized behaviour incl. `routing` (seeded
  when absent) — the UI never sees "no routing".

## 10. Spend, deploy, proof

- **Zero new LLM spend paths.** The router is deterministic; the only spend in
  Stage 6 is the end-to-end playground proof under the standing approval.
- Deploy: shared build → api build (worker untouched unless typecheck says
  otherwise) → pm2 restart → health; web build → publish (standing authority).
- **End-to-end proof:** create a custom case live in the UI, route a
  playground turn into it via its `text-matches` predicate, see the trace
  carry the custom `caseKey`; then DELETE the case, returning the live config
  to seed (nothing named Golden Visa is left behind, per ambiguity 5).

## 11. Build order (committed increments, TDD)

1. This doc.
2. Equivalence pin first: golden prompt matrix + router-decision pins against
   CURRENT code (green pre-refactor).
3. Shared engine: `routing.ts` (schema, normalize, seed, resolver),
   `rails.ts`, behaviour normalize extension, prompt hooks
   (`jobSeekerEnabled`, routed-case rendering), `behaviourPromptVersion`
   rule, inventory pin extension. Shared suite + build.
4. API: `handleTurn` consumption, controller schema + invariants, trace keys.
   Api suite + build, deploy loop, health.
5. Web: canvas-direct editor + branch lane + rails/inventory display. Web
   suite + build, publish.
6. Live e2e proof + stage log + HANDOFF.
