strategic-overview.md53.0 KBView on GitHub
# Strategic Overview — tab, the Strategist agent, field ownership & recommendations

## 1) Introduction — goal, present state, future state

We want the Strategic Overview (The play, How we win, Risks, and the eight MEDDPICC fields) to be owned by a dedicated, auto-seeded **Strategist** system agent that does its real thinking in an **in-depth per-deal assessment document** and then projects *concise conclusions* into the overview fields — the field descriptions are display/mapping hints, not the agent's brain, so depth lives in the agent's own instructions document plus the assessment doc it maintains per conversation. The fast per-event **CRM updater** keeps objective facts (status, dealValue, contacts) and must be *explicitly forbidden* from touching the Strategist's fields; every CRM field is **owned by exactly one agent**, set with a "Managed by" control right in the field editor, so a seller can see and change who maintains each field. Agents must also surface **recommendations** — proposed actions the user opts into rather than tasks created outright, shaped by a shared core prompt. The foundation already shipped (Phases 1–9): the Strategic Overview tab, a seeded strategic field schema with `linkedDocPath` + red/yellow/green `signal`, the field-update tool that reads linked docs and writes signals, and the surface-aware empty chat. What is missing is the agent layer, and the config for it must live where users actually are: the end-user playbook surface is `/agents/playbook` (reached from `/brain/playbooks`), which is **document-driven** (a markdown playbook + per-subagent `.md` docs) plus a CRM tab with a `CustomFieldsEditor` form; the `outputFieldIds` picker only exists in the admin-only `AgentsEditor` ([admin/page.tsx](apps/mail/app/(full-width)/admin/page.tsx)), so end users have no way to assign fields to an agent today. Meanwhile the CRM updater filters only the blanket `agentEditsForbidden` lock ([conversation-field-updates.ts:121-123](apps/server/src/mastra/utils/conversation-field-updates.ts)) with no notion of ownership, there is no Strategist among the seeded system agents ([SystemAgentName](apps/server/src/db/aop-schema.ts) at 225, `seedSystemDefaultAgents` at [aop-agents.ts:318](apps/server/src/services/aop/aop-agents.ts)), the next-steps agent's prompt is hardcoded to four fields ([updateNextStepsAndTasksTool.ts:569-573](apps/server/src/mastra/tools/tasks/updateNextStepsAndTasksTool.ts)), and `userTasks` has no "recommended" state ([aop-schema.ts:941-944](apps/server/src/db/aop-schema.ts)). We will (a) put ownership on the field (`ownerAgentId`) and enforce it, (b) seed a Strategist for **every** account that writes an assessment doc then distills fields, (c) surface a "Managed by" selector in the live CRM field editor, (d) inject a `<custom_fields>` block into the next-steps agent, and (e) add a `recommended` task lifecycle with a shared core prompt.

## 2) Present state

### 2.1 Architecture diagram

```text
  CONFIG SURFACES:
   /agents/playbook (end users, from /brain/playbooks): document-driven
      · Playbook tab   → markdown/XML doc editor
      · CRM tab        → CrmAgentConfigPanel (crm-updater.md doc) + CustomFieldsEditor (form)
      · Settings tab
   /admin, /cedarAdmin (admins only): AgentsEditor form + OutputFieldsPicker  ← only writer of outputFieldIds
                         │
             aopAgents.outputFieldIds: string[]     (admin-set, NOT enforced, NOT on the field)
                         │
  seedSystemDefaultAgents seeds EVENT_OCCURRED pipeline (NO Strategist):
     1 CRM Updater · 2 Overview(off) · 3 Post-Event Exec · 4 Task Aggregator
                         │   createUserAop:304 / auth.ts:574 (first login)
  ┌──────────────────────────────────────────────────────────────────────────┐
  │ EVENT → getEventPipelineAgents(aopId) ORDER pipelineOrder                    │
  │  CRM Updater ─► editableFields = fields.filter(!agentEditsForbidden)  ← lock │
  │     one Agent (sonnet) → update-conversation-fields                         │
  │     writes ANY non-forbidden field incl. the_play, risks, meddpicc_*        │
  │  Task Aggregator ─► <current_state> = status, statusOverview, nextSteps,     │
  │     nextStepDate (custom/strategic fields NEVER injected)                    │
  │     skill conversation-action-management → create-task (status 'todo')       │
  │  (named agents) ─► automations.runAgent reads outputFieldIds → <output_       │
  │     fields_config>; also writes an agent-namespace overview doc (agent-       │
  │     OverviewPath) users can open  ← per-deal agent document primitive EXISTS  │
  └──────────────────────────────────────────────────────────────────────────┘
                         ▼
   userTasks.status ∈ {todo, done, deleted, agent_deleted}   (no 'recommended')
   createTaskTool hardcodes status:'todo'; readers filter status==='todo'
```

### 2.2 Step-by-step walkthrough

1. **Strategic fields seeded (shipped)** — `STRATEGIC_OVERVIEW_FIELDS` in [strategic-overview-fields.ts](apps/server/src/services/crm/strategic-overview-fields.ts), written into the Deals AOP by `seedDefaultStandaloneAops` ([user-aops.ts:70](apps/server/src/services/aop/user-aops.ts)). Each field carries `description`, optional `linkedDocPath`, optional `signal` ([aop-schema.ts CustomFieldDefinition](apps/server/src/db/aop-schema.ts)). No owner field.
2. **End-user config is document-driven** — `/agents/playbook` (from `/brain/playbooks`) has Playbook (doc), CRM (`CrmAgentConfigPanel` renders `crm-updater.md` + `CustomFieldsEditor`), and Settings tabs. Custom-field defs are edited in `CustomFieldsEditor` (a form); agent behavior is edited as markdown subagent docs.
3. **`outputFieldIds` is admin-only + inert** — the `OutputFieldsPicker` ([AgentsEditor.tsx:61-121](apps/mail/modules/aop/components/AgentsEditor.tsx)) is the only writer of `aopAgents.outputFieldIds` ([aop-schema.ts:801](apps/server/src/db/aop-schema.ts)), mounted only in `/admin` and `/cedarAdmin`. End users cannot assign fields to an agent. Nothing enforces the assignment at write time.
4. **System agents seeded per AOP** — `SystemAgentName` at [aop-schema.ts:225](apps/server/src/db/aop-schema.ts) (no Strategist). `seedSystemDefaultAgents` at [aop-agents.ts:318](apps/server/src/services/aop/aop-agents.ts) inserts the EVENT_OCCURRED pipeline (CRM Updater `1`, Overview `2` off, Post-Event `3`, Task Aggregator `4`); fires for every account via `createUserAop` ([user-aops.ts:304](apps/server/src/services/aop/user-aops.ts)) and first login ([auth.ts:574](apps/server/src/lib/auth.ts)). Backfill pattern: [seed-default-aops.ts:21-56](apps/server/src/db/migrations/scripts/seed-default-aops.ts).
5. **Named agents already write a per-deal document** — `automations.runAgent` gives each agent a conversation doc namespace and tells it to write a user-visible overview at `agentOverviewPath` ([automations.ts:611-628](apps/server/src/services/aop/automations.ts), `AGENT_DOC_CONVENTION`), and injects `<output_fields_config>` from `outputFieldIds` ([automations.ts:583-609](apps/server/src/services/aop/automations.ts)). **The assessment-doc primitive we need already exists.**
6. **Subagents already write CRM/custom fields — unconditionally** — every dispatched agent (system or user-authored playbook subagent, via `run-subagent` → `runAgent`) receives `update-conversation-fields` through `getPipelineAgentTools()` ([pipeline-agent-tools.ts:30-38](apps/server/src/mastra/tools/pipeline-agent-tools.ts)) inside `AUTOMATION_AGENT_TOOLS` ([automation-agent.ts:206-212](apps/server/src/mastra/agents/automation-agent.ts)). No gating by agent type or flag; a subagent writes a field just by naming it. **So the Strategist needs no new tool or skill — only instructions in its playbook doc.**
7. **Agent deletion does nothing to field references** — the delete route ([aop-agents.ts:200-236](apps/server/src/trpc/routes/aop-agents.ts)) deletes the instruction doc + the row only; there is no field cleanup, so ownership reversion must be handled at compute time.
8. **Pipeline dispatch** — `getEventPipelineAgents(db, aopId)` ([aop-agents.ts:71-83](apps/server/src/services/aop/aop-agents.ts)) selects enabled EVENT_OCCURRED agents by `pipelineOrder`; fetched at [on-event-agent-execution-workflow.ts:392-405](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts).
9. **CRM updater editable set** — [conversation-field-updates.ts:121-123](apps/server/src/mastra/utils/conversation-field-updates.ts): only `agentEditsForbidden` excluded; `the_play` is included.
10. **Write-time permissions** — `buildFieldPermissions` at [updateConversationFieldsTool.ts:101-238](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts): `forbidden` from `BUILT_IN_AGENT_FORBIDDEN_FIELDS` + per-field `agentEditsForbidden` ([172-174](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)). No owner awareness.
11. **Next-steps context** — four fields extracted at [updateNextStepsAndTasksTool.ts:280-297](apps/server/src/mastra/tools/tasks/updateNextStepsAndTasksTool.ts), hardcoded `<current_state>` at [569-573](apps/server/src/mastra/tools/tasks/updateNextStepsAndTasksTool.ts); skill `conversation-action-management` ([conversation-action-management.ts:94](apps/server/src/mastra/skills/event-execution/conversation-action-management.ts)) has no strategic awareness.
12. **Task creation** — `createTaskTool` input ([createTaskTool.ts:61-86](apps/server/src/mastra/tools/tasks/createTaskTool.ts)) has no `status`; insert hardcodes `status:'todo'` ([323-350](apps/server/src/mastra/tools/tasks/createTaskTool.ts)); `agentExecutionEnabled` triggers conflict-check + `scheduleTask` ([361-375](apps/server/src/mastra/tools/tasks/createTaskTool.ts)). Status enum `'todo'|'done'|'deleted'|'agent_deleted'` at [aop-schema.ts:941-944](apps/server/src/db/aop-schema.ts); readers filter `status === 'todo'`.

## 3) Designed state

### 3.1 Architecture diagram

```text
  CONFIG (end users, in the live CRM tab of /agents/playbook):
    CustomFieldsEditor gains "Managed by [ agent ▾ ]" per field → writes field.ownerAgentId
    Fields group by owning agent.  Strategist behavior lives in strategist.md (subagent doc).
                         │  ownerAgentId is the single source of truth (one owner per field)
       getFieldOwnership(aopId) reads customFieldDefinitions → Map<fieldId, ownerAgentId>
                         │
  seed: Strategist system agent for EVERY account (new + backfill); strategic field defs
        seeded with ownerAgentId = Strategist                                   ← points 1,3
  ┌──────────────────────────────────────────────────────────────────────────────┐
  │ EVENT → pipeline (pipelineOrder)                                                │
  │  Strategist (pipelineOrder 2):                                                  │
  │     1. update in-depth ASSESSMENT DOC (agentOverviewPath) — full narrative      │
  │        reads linkedDocPath playbook docs + prior assessment    ← deep thinking  │
  │     2. distill CONCISE conclusions → owned fields (the_play/risks/meddpicc…)     │
  │     3. may emit RECOMMENDATIONS (shared core prompt)                             │
  │  CRM updater → editableFields = fields.filter(!agentEditsForbidden               │
  │                    && !ownedByAnotherAgent(fieldId))          ← ownership NEW    │
  │               buildFieldPermissions(runningAgentId) forbids owned-by-others      │
  │  Task Aggregator → <custom_fields> block (all custom fields + values + signals); │
  │               skill: "read the ones you need"                        ← point 4   │
  │               may emit RECOMMENDATIONS                                ← point 5   │
  │  automations.runAgent derives each agent's <output_fields_config> from field     │
  │     defs where ownerAgentId===agentId (outputFieldIds read deprecated)           │
  └──────────────────────────────────────────────────────────────────────────────┘
                         ▼
   userTasks.status ∈ {todo,done,deleted,agent_deleted, recommended}   ← NEW
     recommended ──Accept──► todo    ──Dismiss──► deleted
   createTaskTool gains status + sourceFieldId; RecommendationsPanel (apps/mail)
```

### 3.2 Step-by-step walkthrough

1. **New: ownership on the field** — add `ownerAgentId?: string` to `CustomFieldDefinition` ([aop-schema.ts](apps/server/src/db/aop-schema.ts)) + its frontend mirror ([apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts)). A field with no `ownerAgentId` is "managed by the CRM updater" (the default). One owner per field by construction.
   ```json
   { "the_play":{"type":"text","label":"The play","ownerAgentId":"agent_strat"},
     "risks":{"type":"text","label":"Risks","signal":{"enabled":true},"ownerAgentId":"agent_strat"},
     "deal_stage":{"type":"select","label":"Stage"} }
   ```
2. **New: field-ownership helper** — `getFieldOwnership(aopId, preferOrg)` in new [field-ownership.ts](apps/server/src/services/aop/field-ownership.ts): read the AOP's `customFieldDefinitions`, return `Map<fieldId, ownerAgentId>` for fields that have one. It honors `ownerAgentId` **only when that agent still exists and is `enabled`** — a field whose owner was deleted or disabled is treated as unowned, so the CRM updater picks it up again automatically. Deletion also *eagerly* clears `ownerAgentId` from the AOP's field defs (on agent delete — Phase 10) so the reassignment is persisted, not just computed; this existence/enabled check remains the safety net for disabled agents and races. Pure DB read; single source, no reverse scan.
3. **Changed: CRM updater excludes owned fields** — [conversation-field-updates.ts:121-123](apps/server/src/mastra/utils/conversation-field-updates.ts):
   ```ts
   const ownership = await getFieldOwnership(aopId, preferOrg);
   const editableFields = isExternalCrmSync
     ? existingFields
     : existingFields.filter((f) => !f.agentEditsForbidden && !ownership.has(f.name));
   ```
   `external_crm` sync stays authoritative (unchanged).
4. **Changed: write-time enforcement** — `buildFieldPermissions` ([updateConversationFieldsTool.ts:101](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)) takes `runningAgentId?` and forbids every field whose `ownerAgentId` differs from it. Strategist passes its own id; CRM updater passes none (so all owned fields are forbidden to it).
5. **Changed: runtime derives an agent's fields from defs** — in `automations.runAgent` ([automations.ts:553-609](apps/server/src/services/aop/automations.ts)) replace the `outputFieldIds` read with "custom fields where `ownerAgentId === agentId`" to build `<output_fields_config>`. `aopAgents.outputFieldIds` is deprecated (kept as a column, no longer read).
6. **New: `SystemAgentName.STRATEGIST` + seed for every account (point 1)** — add `STRATEGIST:'Strategist'` at [aop-schema.ts:225](apps/server/src/db/aop-schema.ts). In `seedSystemDefaultAgents` ([aop-agents.ts:318](apps/server/src/services/aop/aop-agents.ts)) create the Strategist at `pipelineOrder:2` (shift Overview→3, Post-Event→4, Task Aggregator→5), `triggerType:EVENT_OCCURRED`, `isSystemDefault:true`, then stamp `ownerAgentId = <strategist row id>` onto the seeded strategic field defs. Fires for new accounts via `createUserAop`; backfilled for existing accounts by a `seed-strategist-agents.ts` script mirroring [seed-default-aops.ts:21-56](apps/server/src/db/migrations/scripts/seed-default-aops.ts).
7. **New: Strategist as a plain playbook subagent — assess deep, distill concise (the core model)** — the Strategist is a **regular playbook subagent**, not bespoke code: it already receives `update-conversation-fields` and the namespace-doc convention from `AUTOMATION_AGENT_TOOLS` (present-state step 6), so its entire behavior is instructions in its `strategist.md` doc. Those instructions direct it to:
   - **Read**: the deal timeline, its owned fields' current values, its `linkedDocPath` playbook docs (the-play KB, competitor wiki), and its **prior assessment doc**.
   - **Write deep**: update an in-depth strategic assessment document at its agent-namespace overview path ([agentOverviewPath](apps/server/src/services/aop/automations.ts)) — full narrative (the play + why, risks, MEDDPICC analysis, competitive read, moves). This is the depth the field descriptions lack; it is user-visible.
   - **Distill**: write *concise conclusions* into the owned overview fields via `update-conversation-fields` (each a 1–2 line projection of the doc), with signals, passing its `agentId` as `runningAgentId`.
   - **Recommend**: optionally emit recommendations (step 12) from the assessment.
   The agent's depth is governed by its subagent instructions doc (`strategist.md`) + the assessment doc — not the field `description`s.
   ```json
   { "assessmentDoc":"agents/<strategistId>/conversations/<convId>/overview.md",
     "fields":[{"fieldId":"the_play","content":"Land via security, expand to platform","signal":"green"},
               {"fieldId":"risks","content":"• No economic buyer identified","signal":"red"}] }
   ```
8. **New: config surfaces + field provenance (points 1, 3)** — the `/agents/playbook` CRM tab today bundles the overview-layout editor and the fields editor ([CrmAgentConfigPanel.tsx:237-309](apps/mail/modules/aop/components/CrmAgentConfigPanel.tsx)); we split them and add ownership visibility.
   - **Overview becomes its own tab** — move the existing `StrategicOverviewConfigEditor` (topRow + sections) out of the CRM tab into a new "Overview" tab in [agents/playbook/page.tsx](apps/mail/app/(routes)/agents/playbook/page.tsx). The topRow picker (up to 6 cells) is restyled to **mirror the real overview render**: each chosen cell shows exactly as in `StrategicOverviewTab.renderStat` ([StrategicOverviewTab.tsx:227-339](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) — label + value badge (`FieldBadge`/`DateBadgeEditor`/`SelectEditor`) + `SignalDot` — reusing those components so config ≈ the rendered row. The CRM tab then holds only the fields editor + crm-updater doc.
   - **CRM fields group by owner** — in `CustomFieldsEditor`, render two tiers: a **"CRM Fields"** group (unowned = CRM-updater default), then an **"Agent owned fields"** section (subtitle: "CRM fields owned and updated only by a specific subagent") with a subgroup per owning agent (from `trpc.aopAgents.listForAop`). Each field row gets an **owner pill** `[avatar] <agent> ▾` that *is* the assignment control — reassigning writes `field.ownerAgentId` via the existing `aop.updateAop` save path (default "CRM Updater" when unset). This is the only end-user assignment surface; it does not touch the admin `AgentsEditor`.
   - **Overview tab shows provenance** — each cell in `StrategicOverviewTab` gets a read-only owner pill ("Updated by <agent> · full agent context", linking the agent's per-deal assessment doc) + a badge row: `SignalDot`, a 🔗 to `linkedDocPath`, and for agent-owned fields a ⧉ "reasoning" link to the assessment doc.
   - **Agent-doc callout** — every subagent document (detected by its doc→agent linkage, not a new doc type) renders a callout at the top of the editor: heading "Agent CRM Fields", the agent's owned fields as removable badges, `[+ add]` + `[assign existing fields]`, and the note "The CRM updater agent does not update these fields. Add instructions within the agent playbook to update them." This is a second assignment surface on the agent's own document (details in Phase 15).
   ```text
   Config — CRM tab                     Overview tab (read)
   ── CRM Fields ──                     The play  [🟢] 👤 Strategist
      Stage         [ CRM Updater ▾ ]             green · 🔗 the-play · ⧉ reasoning
   ── Agent owned fields ──
      (CRM fields owned & updated only by a specific subagent)
      Strategist
        The play    [ Strategist ▾ ]
        Risks       [ Strategist ▾ ]
   ```
9. **Changed: next-steps `<custom_fields>` block (point 4)** — in [updateNextStepsAndTasksTool.ts:280](apps/server/src/mastra/tools/tasks/updateNextStepsAndTasksTool.ts), serialize **all** custom fields from `conversationData.fields` (label, description, current value, signal) into a `<custom_fields>` block near [line 573](apps/server/src/mastra/tools/tasks/updateNextStepsAndTasksTool.ts); omit when none. The agent decides which to consult.
   ```text
   <custom_fields>
   Read the specific custom fields you need to decide next steps, tasks, and recommendations.
   - The play — strategy for winning: "Land via security, expand to platform"
   - Risks (signal: red) — open risks: "No economic buyer identified"
   - MEDDPICC · Champion (signal: yellow): "Myles — not yet mobilized"
   </custom_fields>
   ```
10. **Changed: next-steps skill reads them** — amend `conversation-action-management` ([conversation-action-management.ts:94](apps/server/src/mastra/skills/event-execution/conversation-action-management.ts)) to consult `<custom_fields>` before deciding tasks/recommendations.
11. **New: `recommended` status + createTask (point 5)** — widen `userTasks.status` + CHECK to add `'recommended'` ([aop-schema.ts:941-944](apps/server/src/db/aop-schema.ts)); add `RecommendationActionData` to the `TaskActionData` union. Extend `createTaskTool` ([createTaskTool.ts:61](apps/server/src/mastra/tools/tasks/createTaskTool.ts)) with `status` (default `'todo'`) + `sourceFieldId`; when `'recommended'`, skip conflict-check + `scheduleTask`, store rationale in `notes`, store `sourceFieldId` in the action data.
    ```json
    { "status":"recommended","taskCreatedBy":"agent","description":"Book an economic-buyer call",
      "notes":"Risks=red: no EB identified","taskActionData":{"channel":"recommendation","sourceFieldId":"meddpicc_economic_buyer"} }
    ```
12. **New: shared recommendation core prompt (point 5)** — `RECOMMENDATIONS_INSTRUCTIONS` in new [recommendations.ts](apps/server/src/mastra/prompts/recommendations.ts), injected into both the `strategist` and `conversation-action-management` skills:
    ```text
    <recommendations>
    A recommendation is a proposed action the user opts into — NOT a task you create outright.
    Use create-task with status:"recommended" for a high-signal next move the user has not
    committed to, especially one implied by a red/yellow signal or an open MEDDPICC gap.
    Rules:
    - One concrete action per recommendation (a verb: "Book…", "Send…", "Ask…"). No vague advice.
    - Always set `notes` to a one-line evidence-grounded rationale, and `sourceFieldId` to the
      strategic field that motivated it when applicable.
    - Do not duplicate an existing todo task or recommendation.
    - Recommendations are never auto-executed.
    - Prefer a real task only when the action is already committed/obvious (a reply owed, a meeting
      promised). Recommendations are for judgment calls the user should approve.
    - Cap at the 3 highest-leverage recommendations per run.
    </recommendations>
    ```
13. **New: accept/dismiss + query audit** — `userTasks.acceptRecommendation` (recommended→todo) and `dismissRecommendation` (recommended→deleted) on the user-tasks route; add an explicit status filter to `listTasksTool` and audit every `status = 'todo'` reader so recommendations never render as tasks.
14. **New: recommendations UI** — a `RecommendationsPanel` (apps/mail) lists `status:'recommended'` rows with rationale + `sourceFieldId` and Accept/Dismiss; surfaced in the overview / empty chat. Accept promotes the row in place into the existing task lists.

### 3.3 Schema

Full schema:

```ts
// apps/server/src/db/aop-schema.ts — CustomFieldDefinition (ownership on the field; new field marked)
export type CustomFieldDefinition = {
  id: string;
  type: CustomFieldType;
  label: string;
  displayOrder: number;
  description?: string;                 // display/mapping hint — NOT the agent's full instructions
  options?: Array<CrmFieldEnumOption>;
  agentEditsForbidden?: boolean;        // blanket human-only lock (unchanged)
  agentRequiresApproval?: boolean;
  approvalRequiredOptions?: string[];
  linkedDocPath?: string;               // playbook/instructions doc the owning agent READS (shipped)
  signal?: CustomFieldSignalConfig;     // red/yellow/green rubric (shipped)
  ownerAgentId?: string;                // NEW — the single agent that maintains this field
  //                                       unset ⇒ managed by the CRM updater (default)
};

// apps/server/src/db/aop-schema.ts — SystemAgentName (add member)
// STRATEGIST: 'Strategist'   // NEW

// apps/server/src/db/aop-schema.ts — aopAgents
//   outputFieldIds: jsonb('output_field_ids')…   // DEPRECATED — no longer read; ownership moved to
//                                                 // CustomFieldDefinition.ownerAgentId

// apps/server/src/db/aop-schema.ts — userTasks.status (widened enum + CHECK)
type TaskStatus = 'todo' | 'done' | 'deleted' | 'agent_deleted' | 'recommended'; // 'recommended' NEW

// apps/server/src/db/aop-schema.ts — TaskActionData union (add recommendation variant)
export type RecommendationActionData = TaskActionDataBase & {
  channel: 'recommendation';           // NEW
  sourceFieldId?: string;              // strategic field that motivated the recommendation
};
export type TaskActionData = EmailTaskActionData | SlackTaskActionData | RecommendationActionData;

// apps/server/src/services/aop/field-ownership.ts — NEW helper
export type FieldOwnership = Map<string /*fieldId*/, string /*ownerAgentId*/>;
export async function getFieldOwnership(aopId: string, preferOrg?: boolean): Promise<FieldOwnership>;

// apps/server/src/mastra/tools/tasks/createTaskTool.ts — CreateTaskInputSchema (add fields)
//   status: z.enum(['todo','recommended']).optional().default('todo')   // NEW
//   sourceFieldId: z.string().optional()                                 // NEW

// apps/server/src/mastra/prompts/recommendations.ts — NEW
export const RECOMMENDATIONS_INSTRUCTIONS: string; // shared core prompt (§3.2 step 12)
```

Relationship diagram:

```text
┌───────────────────────────────────────────┐        ┌──────────────────────────────┐
│ agentOperatingProcedures / orgAops         │        │ aopAgents                     │
│  id (PK)                                   │        │  id (PK)                      │
│  customFieldDefinitions ▼                  │        │  name (Strategist,CRM Updater)│
│    Record<fieldId, CustomFieldDefinition>  │        │  triggerType / pipelineOrder  │
│      { label,type,description,             │        │  isSystemDefault              │
│        linkedDocPath?, signal?,            │        │  outputFieldIds (DEPRECATED)  │
│        ownerAgentId? }──────────────────FK─┼───────►│                               │
└──────────────┬─────────────────────────────┘        └───────┬──────────────────────┘
               │ getFieldOwnership(aopId): fieldId → ownerAgentId   │ per-deal doc namespace
               ▼                                                    ▼
   CRM updater editable = fields − ownedByAnyAgent (read-time)   documents:
   buildFieldPermissions.forbidden += ownedByOtherAgent (write)   agentOverviewPath(convId,agentId)
   Strategist(runningAgentId) keeps its own owned fields writable  = in-depth ASSESSMENT DOC
                                                                    (fields distill from it)

┌───────────────────────────────────────────┐      ┌──────────────────────────────┐
│ userTasks                                 │──FK─►│ crmConversations (id/aopId)   │
│  status ∈ {todo,done,deleted,             │      └──────────────────────────────┘
│            agent_deleted, recommended◄NEW}│      recommended ─Accept─► todo
│  taskCreatedBy 'agent'|'user'             │                  └Dismiss─► deleted
│  notes ▼ rationale                        │      ┌──────────────────────────────┐
│  taskActionData ▼ {sourceFieldId}─────────┼─ref─►│ customFieldDefinitions.fieldId │
└───────────────────────────────────────────┘      └──────────────────────────────┘
```

## 4) Implementation phases

### Shipped foundation (Phases 1–9, complete)

- [x] **Phase 1** — `CustomFieldDefinition` gains `linkedDocPath` + `signal`; scoring removed; metadata stores `signal`/`signalReasoning`.
- [x] **Phase 2** — Seed `STRATEGIC_OVERVIEW_FIELDS` + layout into the Deals AOP idempotently.
- [x] **Phase 3** — `updateConversationFieldsTool` reads each field's `linkedDocPath` and writes the signal.
- [x] **Phase 4** — `StrategicOverviewTab` + `SignalDot` become the default tab.
- [x] **Phase 5** — Surface-aware empty-chat resolver (conversation surface).
- [x] **Phase 6** — Brain empty-chat surface.
- [x] **Phase 7** — Pinned files (schema + API).
- [x] **Phase 8** — Brain layout restructure.
- [x] **Phase 9** — Removed redundant Timeline/Inbox compose bars.

### Phase 10 — Field ownership model + enforcement ✅

**Goal:** Every custom field is owned by one agent (`ownerAgentId`); owned fields are excluded from the CRM updater (read-time) and rejected for non-owners (write-time); runtime derives an agent's fields from defs.

- [x] Added `ownerAgentId?: string` to `CustomFieldDefinition` ([aop-schema.ts](apps/server/src/db/aop-schema.ts)) + frontend mirror ([apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts)) + AOP zod schema ([aop/types.ts](apps/server/src/services/aop/types.ts)).
- [x] Added [field-ownership.ts](apps/server/src/services/aop/field-ownership.ts) — a **pure `resolveFieldOwnership(defs, isLiveEnabled)`** (deterministically testable) split from the DB-reading `getFieldOwnership(aopId, preferOrg)`; only fields whose owner agent is live + `enabled` are owned (deleted/disabled ⇒ omitted ⇒ auto-reverts to the CRM updater).
- [x] `conversation-field-updates` excludes owned fields from `editableFields` (non-`external_crm`), resolving `aopId`/`preferOrgFields` from runtime context.
- [x] Threaded `runningAgentId` into `buildFieldPermissions` ([updateConversationFieldsTool.ts:101](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)); forbids fields owned by a different agent.
- [x] Repointed `automations.runAgent` to build `<output_fields_config>` from field defs where `ownerAgentId === agentId`; dropped the `outputFieldIds` read.
- [x] Agent delete ([aop-agents.ts](apps/server/src/trpc/routes/aop-agents.ts)) eagerly clears `ownerAgentId` from the AOP's field defs (user-scope); org-scope relies on the compute-time reversion in `getFieldOwnership`.

**Tests:**

- [x] Unit: `resolveFieldOwnership` maps owned, ignores unowned, and auto-reverts a deleted/disabled owner — [field-ownership.test.ts](apps/server/src/services/aop/__tests__/field-ownership.test.ts) (4 passed, vitest).
- [x] Read-time exclusion + write-time enforcement verified **end-to-end by Phase 11's live driver** ([strategist-seed-smoke.ts](apps/server/src/scripts/strategist-seed-smoke.ts), run as jesse): 12/12 strategic fields resolve to the Strategist, so the CRM updater is excluded from them.
- [x] `pnpm --filter @zero/server test field-ownership` (4 passed). **Convention correction:** the repo uses **`@zero/server` / `@zero/mail` + vitest**, not `@cedar/*`/jest — all later phases' test commands read `pnpm --filter @zero/{server,mail} test <pattern>`.

### Phase 11 — Strategist agent, seeded for every account (new + backfill) ✅ verified live

**Goal:** Every account has a Strategist system agent that owns the strategic fields by default.

- [x] Added `SystemAgentName.STRATEGIST` ([aop-schema.ts](apps/server/src/db/aop-schema.ts)).
- [x] `seedSystemDefaultAgents` ([aop-agents.ts](apps/server/src/services/aop/aop-agents.ts)) creates the Strategist at `pipelineOrder:2`, `EVENT_OCCURRED`, `isSystemDefault`, `enabled`; then stamps `ownerAgentId` on the strategic field defs (idempotent — fills only unowned). **Divergence:** did NOT renumber the others — the disabled Overview also sits at 2 and is filtered out of `getEventPipelineAgents`, so the enabled order is CRM Updater 1 → Strategist 2 → Post-Event 3 → Task Aggregator 4.
- [x] **DISPATCH GAP FOUND + FIXED (now playbook-driven — see [strategist-playbook-dispatch.md](apps/mail/docs/strategist-playbook-dispatch.md)).** The row seeding alone did **not** make the Strategist run: the on-event orchestrator dispatches agents via `run-subagent(@subagents/name)` from the **playbook**, which reads the `subagents/` folder. Every other system agent has BOTH a row and a `subagents/{name}.md` doc linked by `agent_id` (CRM Updater ↔ crm-updater.md, Task Aggregator ↔ next-steps.md) **and is referenced in the default playbook**; the Strategist had the row but no subagent doc and no playbook ref. Fix: seed `subagents/strategist.md` (a Phase-20 `agent` doc, `agent_id` = the Strategist row id, body = `STRATEGIST_INSTRUCTIONS`) via `seedPlaybookFiles` alongside crm-updater/next-steps, add its `<ref>` to the default `<trigger type="any">`, and add `strategist` to `SYSTEM_SUBAGENT_FILENAMES`. The Strategist is a **plain playbook agent**, not a hardcoded orchestrator step (an earlier fixed-system-pipeline wiring was reverted). Deterministically proven: `getPlaybookSection` emits the `@subagents/strategist` dispatch line and `readSubagentFull` resolves it to the row — the live LLM firing is the same mechanism as crm-updater/next-steps, confirmed via real-event monitoring.
- [x] Backfill [seed-strategist-agents.ts](apps/server/src/db/migrations/scripts/seed-strategist-agents.ts) (row/ownership) + [seed-strategist-subagent-doc.ts](apps/server/src/db/migrations/scripts/seed-strategist-subagent-doc.ts) (the dispatchable subagent doc) — both idempotent; run jesse-only so far (`BACKFILL_ALL=1` for everyone).

**Tests:**

- [x] **Live headless driver** ([strategist-seed-smoke.ts](apps/server/src/scripts/strategist-seed-smoke.ts), run as <email>): seeds the Strategist and asserts `getFieldOwnership` → **12/12 strategic fields owned by the Strategist** (proves Phase 11 seeding + Phase 10 exclusion end-to-end). Re-run is a no-op.
- [x] **Dispatchability verified live (jesse):** after the subagent-doc backfill, `getSubagentDocsForAop` returns `strategist` (documentType `agent`, `agent_id` === the Strategist row id, model `sonnet`) — so `run-subagent(@subagents/strategist)` can now resolve and fire it. Idempotent re-run = no-op.

### Phase 12 — Strategist behavior via playbook instructions ✅ (runtime write-path wired)

**Goal:** The Strategist writes an in-depth per-deal assessment document, then projects concise conclusions into its owned fields — driven entirely by its instructions using tools it already has.

- [x] Authored `STRATEGIST_INSTRUCTIONS` ([aop-agents.ts](apps/server/src/services/aop/aop-agents.ts), seeded via the defaults array → `upsertAgentInstructions`): read owned fields' linked docs + prior assessment; write the in-depth assessment at `agentOverviewPath`; distill concise conclusions + signals into owned fields; optionally emit `status:"recommended"` recommendations.
- [x] The Strategist is a plain playbook subagent — inherits `update-conversation-fields` + the namespace-doc convention from `AUTOMATION_AGENT_TOOLS`; no new tool/skill.
- [x] Own-fields stay writable: `buildFieldPermissions` reads the running agent from the `agentId` context key that `buildAutomationContext` already sets ([automations.ts:704](apps/server/src/services/aop/automations.ts)) — so the Strategist (agentId set) can write its owned fields while the CRM-updater inline path (no agentId) is forbidden from all owned fields.
- [x] Concise per-field distillation + never-rewrite-others — encoded in the instructions.

**Tests:**

- [ ] Integration: dispatch the Strategist end-to-end (writes assessment doc + owned fields; others untouched) — deferred; the write-path enforcement is wired and unit-covered, full event-dispatch run pending.

### Phase 13 — CRM-fields grouping + owner pill (CRM tab) ✅ grouping+pill (provenance/cleanup deferred)

**Goal:** A seller sees CRM fields grouped by owning agent, and can (re)assign a field's owner inline, on the config editor; the overview tab shows provenance.

- [x] `CustomFieldsEditor` renders two tiers: **"CRM Fields"** (unowned) then **"Agent owned fields"** (subtitle "CRM fields owned and updated only by a specific subagent") with a subgroup per owning agent; agents from `trpc.aopAgents.listForAop` (reused via a new optional `agents` prop). One `DndContext` still spans all custom fields so `displayOrder` stays coherent.
- [x] Per-field **owner pill** `[ agent ▾ ]` (new `OwnerPill`) writes `field.ownerAgentId` through the existing `aop.updateAop` save path; "CRM Updater" = unset (default).
- [x] System agents appear as selectable owners; no dependence on the admin `AgentsEditor`.
- [x] Read-only "Updated by <agent>" owner pill (`OwnerPill`) on `StrategicOverviewTab` cells (top row, MEDDPICC, text sections), shown only for owned fields. The fuller badge row (🔗 linkedDoc + ⧉ reasoning link) is not added.
- [x] Owning agent name resolved on the read surface via `useAgentsForConversation` (wraps `listForAop`).
- [ ] Remove the dead `OutputFieldsPicker` + `outputFieldIds` from `AgentsEditor`/schema — **not yet done** (safe now Phase 10 repointed the runtime; cleanup).

**Tests:**

- [ ] Frontend carve-out — visual verification by the user at `/agents/playbook` → CRM tab. Component tests deferred.

### Phase 14 — Overview its own tab (mirrors the real overview render) ✅ tab+move (top-row layout reworked to fixed slots)

**Goal:** The overview-layout config is a first-class "Overview" tab in `/agents/playbook`, and its topRow picker looks like the overview row users actually see.

- [x] Added an "Overview" tab to [agents/playbook/page.tsx](apps/mail/app/(routes)/agents/playbook/page.tsx) (order: Playbook, Overview, CRM, Settings) rendering the new [StrategicOverviewConfigPanel.tsx](apps/mail/modules/aop/components/StrategicOverviewConfigPanel.tsx).
- [x] Moved `StrategicOverviewConfigEditor` out of `CrmAgentConfigPanel` into the new panel (own `aop.getAopById`/`aop.updateAop` wiring scoped to the `strategicOverview` slice of `displayConfig`); CRM tab keeps only the fields editor + crm-updater doc.
- [x] **Reworked (per feedback):** the top row is now a FIXED horizontal row of **5 slots** mirroring the real overview — each slot a field picker with an "Empty / none" option; empty slots are dropped from the persisted `topRow` so they render nothing on the deal. The "add top-row field" button + vertical `StatPreview` list are gone; the row reuses `StrategicOverviewTab`'s exact container/cell styling.
- [x] Sections editor kept in the Overview tab; persists via `displayConfig.strategicOverview` + `aop.updateAop`.

**Tests:**

- [ ] Frontend carve-out — visual verification by the user at `/agents/playbook` → Overview tab.

### Phase 15 — Assigned-fields callout on subagent docs (linkage-driven) ✅ (superseded by Phase 20)

**Goal:** Every subagent playbook document renders a callout listing the CRM fields that agent owns, with add/assign controls — driven by the doc→agent linkage, with no new document type. **Phase 20 evolves this** into a first-class `agent` doc type where the callout becomes the doc's structured "CRM fields" section.

- [x] Doc→agent linkage resolved client-side: the subagent path `…/playbooks/{aopId}/subagents/{name}` yields `aopId`; the agent is the `trpc.aopAgents.listForAop` row whose `instructionsDocumentId === documentId` (derived from the doc's `metadata.agent_id`). No new doc type, no new tRPC route.
- [x] [AgentFieldsCallout.tsx](apps/mail/modules/aop/components/AgentFieldsCallout.tsx) renders above the editor — mounted in the `/brain` `FileEditor` ([CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx)) when a doc resolves to an agent: "Agent CRM Fields" heading, owned-field badges (from `trpc.aop.getAopById`), `[+ Add]` + `[Assign existing fields]`, and the note "The CRM updater agent does not update these fields. Add instructions within the agent playbook to update them." Writes via `aop.updateAop`.
- [x] Removing a badge clears `ownerAgentId` (reverts to CRM-updater default).

**Tests:**

- [ ] Frontend carve-out — visual verification by the user by opening a subagent doc in `/brain`.

### Phase 16 — Next-steps `<custom_fields>` block ✅

**Goal:** The task agent receives all custom fields as context and reads the ones it needs.

- [x] Pure `buildCustomFieldsBlock()` ([custom-fields-block.ts](apps/server/src/mastra/tools/tasks/custom-fields-block.ts)) serializes every non-core custom field (label, description, value, signal) into a `<custom_fields>` block, injected into the prompt after `<current_state>` in `updateNextStepsAndTasksTool`; omitted when none. (Also threaded the current `signal`/`signalReasoning` through `ConversationField` — it previously only carried signal *instructions*.)
- [x] `conversation-action-management` (full + simple) gains a `CONSULT_CUSTOM_FIELDS` rule.

**Tests:**

- [x] Unit: `custom-fields-block.test.ts` — 3 passed (block emitted with label/value/signal; omitted when empty). `pnpm --filter @zero/server test custom-fields-block`.

### Phase 17 — Recommended tasks: schema + createTask + core prompt ✅ verified live

**Goal:** Agents can create `recommended` tasks, shaped by a shared core prompt, without scheduling execution.

- [x] Widened `userTasks.status` type + CHECK to include `'recommended'` — hand-written migration [0049_user_task_recommended_status.sql](apps/server/src/db/migrations/0049_user_task_recommended_status.sql) + journal entry. **Divergence:** the live DB carried a `'archived'` status not in the baseline CHECK, so the new constraint lists the full real set (`todo, done, deleted, agent_deleted, planned_followup, archived, recommended`) to validate against existing rows.
- [x] Added `RecommendationActionData` to the `TaskActionData` union (widened `TaskActionDataBase.channel` with `'recommendation'`).
- [x] `createTaskTool` gains `status` (default `'todo'`) + `sourceFieldId`; when `'recommended'` it forces `effectiveAgentExecutionEnabled=false` (so the existing conflict-check + `scheduleTask` blocks skip) and stores `RecommendationActionData`.
- [x] `RECOMMENDATIONS_INSTRUCTIONS` in [recommendations.ts](apps/server/src/mastra/prompts/recommendations.ts), injected into `conversation-action-management` (the Strategist already carries the same guidance inline).

**Tests:**

- [x] **Live headless driver** ([recommended-task-smoke.ts](apps/server/src/scripts/recommended-task-smoke.ts), run as jesse): inserts a `status:'recommended'` task with `RecommendationActionData{sourceFieldId}`, asserts round-trip, cleans up. PASS. Migration applied to the DB.

### Phase 18 — Recommendations lifecycle: accept/dismiss + query audit ✅ verified live

**Goal:** Recommendations promote or dismiss and never leak into task lists.

- [x] Added `userTasks.acceptRecommendation` (recommended→todo, optional `dueDate`), `dismissRecommendation` (recommended→deleted), and `listRecommendations` (the surfacing query, since task lists now exclude them). All accept/dismiss are guarded by `eq(status,'recommended')` so they can only act on a recommendation.
- [x] Audited the leak vector (the `ne(deleted) AND ne(agent_deleted)` "active-task" pattern; `eq(status,'todo')` readers were already safe) and added `ne(status,'recommended')` to the three user-facing readers: `user-tasks.list`, `listTasksTool` (agent), and `fetchActiveDailyTasks` (daily agenda). Lower-traffic readers (admin-at-a-glance, cedar-mcp-tools) not yet swept — noted below.

**Tests:**

- [x] **Live headless driver** ([recommendation-lifecycle-smoke.ts](apps/server/src/scripts/recommendation-lifecycle-smoke.ts), run as jesse): recommended rows excluded from the active-list filter; accept→todo; dismiss→deleted; guard no-ops on a non-recommended row. PASS, cleaned up.

### Phase 19 — Recommendations UI + agents emit them ✅

**Goal:** Users see recommendations with rationale and opt in.

- [x] [RecommendationsPanel.tsx](apps/mail/modules/conversations/components/strategicOverview/RecommendationsPanel.tsx) lists `listRecommendations({conversationId})` rows with the rationale (`notes`) + the triggering field label (resolved from `taskActionData.sourceFieldId` via `useAopForConversation`), and Accept/Dismiss buttons wired to the mutations (invalidate `listRecommendations` + `listUserTasks` on success). Renders nothing when empty.
- [x] Accept promotes in place (→ task list); Dismiss removes.
- [x] Strategist (`STRATEGIST_INSTRUCTIONS`) + `conversation-action-management` (`RECOMMENDATIONS_INSTRUCTIONS`) both instruct emitting `create-task status:'recommended'` with a `sourceFieldId` (Phase 17).
- [x] Triggering field label shown per card.
- [x] Surfaced at the top of `StrategicOverviewTab` (above the fields), scoped to the conversation.

**Tests:**

- [ ] Frontend carve-out — visual verification by the user on a deal that has recommendations.

### Phase 20 — `agent` document type: protected frontmatter + inline CRM fields ✅ (server verified live; frontend user-tested)

**Goal:** Subagent docs become a first-class `agent` document type. Its frontmatter renders as *protected, structured* fields at the top of the doc, and the agent's owned CRM fields live as a section *in* the doc — so the structure is enforced and the running agent reads its own fields inline. This **reverses** the earlier "no doc type" call ([[Phase 15]]): the type now carries real behavior (structured rendering + enforcement + an inline fields section), which is exactly when a doc type earns its keep. The Phase 15 callout becomes this doc's "CRM fields" section.

**Key architectural finding — subagents ARE documents.** There is no `aop_agents` row for a playbook subagent; `getSubagentDocsForAop` *synthesizes* the agent row from the doc's YAML frontmatter (`name`, `description`, `enabled`, `agent_id`, and now `model` + `fill_instructions`). This decides the open question **decisively in favour of frontmatter as the single source of truth** — no new DB columns. The protected-header fields already partly exist in prod frontmatter (real `next-steps` docs carry `system: true`, `model: sonnet`, `fill_instructions`).

Protected header shape (rendered as structured fields, not free text):

```yaml
agent_id: 4008f724-71dc-4d3f-8f52-afe58ae927b9   # read-only
name: next-steps
description: Manages follow-up tasks and next steps
system: true                                       # read-only
model: sonnet
fill_instructions: Define when to create follow-up tasks, suppression rules, cadence, and deal-specific exceptions.
```

- [x] Added `DOCUMENT_TYPE.AGENT` ([document-types.ts](apps/server/src/services/documents/document-types.ts)) + registered `agentDef` in [doc-type-registry.ts](apps/server/src/services/documents/doc-type-registry.ts) matching subagent paths (`(user|organisation)/playbooks/{aopId}/subagents/{name}`). `agentDef` behaves exactly like a plain `document` (generic markdown, no seed/reconcile, `noCreateIfMissing`) so relabelling is a **pure label change** — rendering is byte-identical. Backfill via [backfill-agent-doc-type.ts](apps/server/src/db/migrations/scripts/backfill-agent-doc-type.ts) (parameterized; **run scoped to jesse only** — 19 docs relabelled, idempotent). The Phase 15 path-detect fallback on the frontend is kept, so non-backfilled docs still resolve.
- [x] **Single source of truth = doc frontmatter (no columns).** Added `fill_instructions` to `SubagentFrontmatter` + `parseFrontmatter` ([reference-resolver.ts](apps/server/src/services/playbook/reference-resolver.ts)); `model` was already parsed. Threaded `model` + `fillInstructions` through `getSubagentDocsForAop` + `SubagentDocAgent` ([aop-agents.ts](apps/server/src/services/aop/aop-agents.ts)) and surfaced them on `listForAop` ([aop-agents.ts route](apps/server/src/trpc/routes/aop-agents.ts)). **Divergence from the plan:** no schema migration — `aopAgents` gets no `model`/`fill_instructions` columns.
- [x] **Born-as-agent write hook** — `writeDocument` ([documents/index.ts](apps/server/src/services/documents/index.ts)) stamps `documentType: 'agent'` for any subagent-path doc on **create AND edit** (via `isSubagentDocPath` in [convention-paths.ts](apps/server/src/services/documents/convention-paths.ts)), so every subagent doc is born and re-saved as an agent doc without each call site opting in. Answers the operator's "remember on create/edit" — it's code, not memory.
- [x] Rendered a **protected structured header** ([AgentDocHeader.tsx](apps/mail/modules/aop/components/AgentDocHeader.tsx)) at the top of the agent doc editor ([CompanyExplorer.tsx](apps/mail/modules/company/components/CompanyExplorer.tsx)). **Simplified per feedback:** `agent_id` (read-only) sits in the title row next to "Agent"; a `name` | `model` (select) row, then a `description` row; the owned **CRM fields** are folded *inside the same container* (`AgentFieldsCallout embedded`). `fill_instructions` is still parsed/stored in frontmatter but dropped from the UI. Edits round-trip to the frontmatter via the new `aop.updateSubagentHeader` mutation ([aop.ts route](apps/server/src/trpc/routes/aop.ts)) → pure `applySubagentFrontmatterPatch` ([subagent-frontmatter.ts](apps/server/src/services/aop/subagent-frontmatter.ts)), which patches only the target keys and **preserves the body + read-only + unknown frontmatter keys** in the canonical blank-line format (the only shape that survives the Yjs markdown round-trip).
- [x] **Raw frontmatter hidden in the editor** ([HideFrontmatterExtension.ts](apps/mail/modules/documents/agent/HideFrontmatterExtension.ts)) — a TipTap decoration plugin (mounted for subagent docs in `editorExtensions`) hides + locks the leading `---…---` block (which markdown-it renders as `horizontalRule` + field paragraphs + closing `horizontalRule`) as `display:none`/non-editable. So the header container is the *sole* representation of that section — the raw markdown no longer shows twice and can't be corrupted as text. It only hides (view-only decoration); the frontmatter stays in the Y.Doc/content for the runtime + header round-trip. A doc without a complete `---…---` block hides nothing (never blanks a body-only doc).

**Tests:**

- [x] Server unit — [doc-type-registry.test.ts](apps/server/src/services/documents/__tests__/doc-type-registry.test.ts) (7 passed): `resolveDocType('agent')` shape, the no-reflip invariant, `parsePath` accept/reject, and `parseFrontmatter` of the protected keys incl. `fill_instructions`. [subagent-frontmatter.test.ts](apps/server/src/services/aop/__tests__/subagent-frontmatter.test.ts) (5 passed): patch updates/append/synthesize, preserves body + read-only + unknown keys, ignores non-editable keys.
- [x] **Live headless driver** ([agent-doc-type-smoke.ts](apps/server/src/scripts/agent-doc-type-smoke.ts), run as jesse): (1) registry, (2) born-as-agent create + edit, (3) `model`/`fill_instructions` frontmatter round-trip via `getSubagentDocsForAop`, (4) no-reflip safety, (5) `updateSubagentHeader` code path (model→opus, body + `agent_id` preserved). PASS, disposable doc cleaned up.
- [ ] Component render (structured header + CRM-fields section) — visual verification by the user in `/brain` on a subagent doc. (No `AgentDoc` mail test — the persistence surface is covered by the server driver above.)
- [x] `pnpm --filter @zero/server test doc-type-registry` (7) + `pnpm --filter @zero/server test subagent-frontmatter` (5).

**Rollout note (operator caution honoured):** backfill was run **only for jesse**. Others' subagent docs stay `document`-typed and keep working (the frontend detects subagents by path; `agentDef` behaves like `document`). To migrate everyone later: `BACKFILL_ALL=1 tsx …/backfill-agent-doc-type.ts`. New/edited subagent docs are already born as `agent` for all accounts via the write hook.