# Behaviour dynamic fields + inventory-into-graph — Stage 6 expansion design

> Executor design doc, 2026-07-17. Covers the two mid-Stage-6 operator directives
> (`docs/DECISIONS.md` §§ "Behaviour directive: per-node DYNAMIC fields" and
> "Behaviour directive 2: the prompt inventory MIGRATES into Behaviour"). They
> COMPOSE: directive 1 makes node fields data; directive 2 says the hardcoded
> prompt sections are the seed of that data. Built on top of the shipped
> routing-as-data core (design doc `behaviour-structural-editing-design.md`).
> The seeded-equivalence golden pin (gate bar 8) is the guardrail throughout:
> the UNEDITED seed must render byte-identical.

## 1. The unified field model

A node's content becomes an ordered list of fields, each a labelled block:

```ts
interface BehaviourField {
  id: string;        // stable slug, unique within the node; the seed uses the
                     // section id ("identity", "goal", …) so a rename keeps id
  label: string;     // the heading rendered in the prompt (UPPERCASED as today)
  value: string;     // the text
  render: "labelled" // `${LABEL}\n${value}` (the default)
        | "raw";     // value verbatim, no heading (today's legacyText)
  locked?: boolean;  // F6 rail — renders, never editable (no add/remove/rename)
  live?: boolean;    // runtime value (contact name, after-hours…) — read-only,
                     // shown where it acts, not stored as prose
  source?: string;   // for live fields: which runtime input fills it
}
```

- **Absent `fields` ⇒ legacy render.** A node with no stored `fields` renders
  through today's exact code path (`renderCaseText` / the hardcoded
  `buildSystemPromptSections`). This is what keeps the pin green: the seed does
  NOT persist `fields`, so an unedited config is byte-identical. `fields`
  materializes only when the operator edits/adds/renames.
- **Present `fields` ⇒ dynamic render.** The node renders its field list in
  order; `render:"raw"` fields drop the heading; empty values are skipped
  (matching today's "only non-empty blocks render").
- **The default field list is DERIVED, not stored** — the UI shows the seed
  fields (from the fixed schema / section constants) as editable rows; the first
  edit materializes the full list into storage. Materialize-on-edit is what lets
  "rename/remove a built-in field" work without a migration and without moving
  the pin.

## 2. Directive 1 — per-node dynamic fields (node CONTENT)

Applies to the Global node and every case/branch. Seed field list per node =
today's fixed schema (`IDENTITY/TONE/HOUSE RULES` for Global; `GOAL/STEPS/
COLLECT/NEVER/EXAMPLES` + raw legacy for a case). Rendering the seed list
reproduces `renderCaseText` byte-for-byte (pinned). `buildSystemPromptSections`
already consumes the RENDERED operator block (`clientFlow`) via the
`operator-steering` section, so dynamic fields flow into assembly with no new
section id — the inventory pin stays honest.

**"Company" → "Global":** the stored KEY stays `company` (load-bearing:
`normalizeAgentBehaviour` reads `o["company"]`, `renderCaseText(company, …)`).
Only the UI LABEL and doc language change to "Global". (Flag: renamed LABEL,
kept KEY — a key rename would be a non-additive migration for no behavioural
gain.)

## 3. Directive 2 — the hardcoded prompt sections become seeded fields

Today `buildSystemPromptSections` emits ~24 sections from string constants. The
directive splits them:

| Class | Sections | Becomes |
|---|---|---|
| **Editable content** | identity, language, goal, services, triage, job-seekers, lead-capture, never-silent, lead-temperature, qualification-slots, extra-contact, pivots, tone, duties, non-duties | **seeded editable fields** — the constant is the seed value; the field renders the section |
| **Locked rails (F6)** | boundaries, taxonomy, safety-rail, output-contract, never-silent?/fail-safe | render inside Behaviour, `locked:true`, no edit affordance |
| **Live data** | contact, returning, after-hours, lead-profile | `live:true` read-only nodes shown where they act |
| **Engine mechanic** | offering-choices (tappable) | mechanic stays code; its user-facing TEXTS become editable fields |

**Seed source of truth.** A new `PROMPT_SEED` map (shared) holds each section's
id → { label, defaultText, class }. `buildSystemPromptSections` reads the
operator's config where present, else `PROMPT_SEED` — so for the unedited seed
the assembled bytes are unchanged (pin green), and an edited section renders the
operator's text. This replaces "constants inline" with "constants as the seed of
config", exactly the directive's ask.

**Inventory pin, rewritten (kept, not deleted).** Today `prompt-inventory.test`
asserts every emitted section id has an inventory ENTRY. It becomes: every
emitted section id maps to a `PROMPT_SEED` entry OR a `LOCKED_RAILS`/live node —
i.e. the inventory is now the SEED MAP, and the pin asserts the seed covers
every section the assembler can emit. The 24-card "fixed in code" showcase grid
is deleted from the UI (its content now lives in the graph); the pin proves
nothing silently drops out of the graph.

## 4. Persistence (additive, no migration)

Still one `agentBehaviour` Setting blob. Additive: each node gains an optional
`fields?: BehaviourField[]`; when absent, the seed renders. No column, no
destructive change — `normalizeAgentBehaviour` already tolerates absent keys.
`behaviourPromptVersion` moves when any field text changes (it hashes the
rendered flows) and stays byte-identical for the unedited seed.

## 5. Seeded-equivalence pin — the non-negotiable

Every step is gated by: `buildSystemPrompt(unedited seed)` byte-identical across
the whole matrix (external × channels × states, internal, comment, moderation).
The golden snapshot from the core Stage 6 is the bar. Any migration of a section
into a seed field must reproduce its exact bytes (label casing, spacing,
channel-conditional inclusion) for the seed. Red-first per section.

## 6. Increment plan + cost

Large — lands as sub-increments AFTER the shipped core (which is green +
deployed). TDD + pin-green at each step:

1. **Field model + Global rename (engine):** `BehaviourField`, dynamic
   `renderCaseText`, materialize helper, normalize; pin green. (settings.ts,
   routing.ts — mine.)
2. **Section→seed migration (engine):** `PROMPT_SEED`, `buildSystemPromptSections`
   reads config-or-seed, rails `locked`, live nodes; rewrite the inventory pin to
   the seed map. (system-prompt.ts, prompt-inventory.ts — mine.) Highest
   byte-identity risk; per-section red-first.
3. **Web:** Global rename, dynamic field editor (add/remove/rename/reorder),
   inventory grid retired into the graph, locked rails + live nodes rendered.
   (Behaviour.tsx — mine; **agent-api.ts — COLLIDES with agent-scope-3**.)
4. **Controller schema:** accept `fields` in the behaviour PATCH.
   (**agent-admin.controller.ts + test — COLLIDES with agent-scope-3.**)
5. Deploy loop + publish + live e2e (edit a migrated field, see the prompt
   change; rename a field; confirm a rail can't be edited).

**Cost delta vs. the shipped core:** roughly a second body of work of similar
size to the core — the field model is contained, but migrating ~15 sections
under byte-identity + rewriting the inventory pin + the field-editor UI is the
bulk. No new LLM spend (deterministic; one playground turn for the e2e under
standing approval).

## 7. Cross-seat collision (BLOCKER for steps 3–4)

`agent-scope-3` has UNCOMMITTED edits in the shared working tree to
`agent-admin.controller.ts`, `agent-admin.controller.test.ts`, `agent-api.ts`
(+ new `ThresholdEditor.*`). Steps 3–4 need those exact files. I cannot edit or
commit them without clobbering agent-scope-3's in-flight work. **Sequencing
needed** (coordinator): either agent-scope-3 lands their controller/api work
first and I rebase my field-schema hunks on top, or we split the files. Steps
1–2 (engine, my exclusive files) proceed regardless.
