0029 — Role-Prompt-Tool Router

Implementation Status — as of 2026-05-20

2026-05-27 status update — the SECURITY core shipped via a rescoped path. The role→tool authority model + dispatch-time enforcement (text + voice) + the read-only catalog audit viewer are LIVE on main. The rescoped plan is in docs/planning/role-router-rescope.md; the session delta + learnings are in docs/planning/role-router-handoff.md. The phase numbering below is the ORIGINAL as-designed sequence; the Status column reflects what actually merged. Read the rescope memo before acting on any "Not started" row — several phases were proven obsolete or no-ops by investigation, and the Phase 6 "delete the prompt files" step is NOT warranted as written (see the Phase 6 entry).

Phase Status
Phase 1 — L3 tool-role matrix + dispatcher + meta-role bridge Shipped (PR #1536 — src/lib/tools/role-matrix.ts)
Phase 2a — L1 router + intent-hints classifier (shadow logging) ObsoletecomposeCapabilities + PersonContext already provide per-identity capability routing; no separate shadow classifier needed
Phase 2b — personContext schema + refresh + status observability Obsolete (same — PersonContext already exists)
Phase 3 — L0 + L2 prompt directory + assembly + L3 dispatch wired ⚠️ Split. The L3 dispatch gate SHIPPED (PR #1546 text, #1549 voice) WITHOUT the L0/L2 prompt-directory reorg — the gate reads runtime trust tier from resolveIdentity, not a prompt-assembled role. The prompt-directory reorg (prompts/roles/<role>/<feature>) was NOT built and is now an optional, separate maintainability effort (eval-regression risk, zero security payoff) — see Phase 6
Phase 4 — Remove inline RBAC from tool handlers Verified no-op — no redundant inline caller.role !== RBAC existed to remove; turnover.ts cross-ORG scope guard is legitimate and stays
Phase 5 — Voice integration + flag deletion Shipped (PR #1549 — voice dispatch gate; no per-property flag was needed since the gate enforces directly)
Phase 6 — Delete the old intent classifier files NOT warranted as written (see corrected entry below)
Phase 7 (new) — read-only "Allowed roles" catalog viewer Shipped (PR #1553 — /admin/dev/tools column + filter, projected server-side)

Phase 3 gate (ADR-0022) is now met. As of 2026-05-20, ADR-0022 (channel-adapter-architecture.md) is Accepted — Phases 1+2 of that ADR shipped in PR #818 and the four follow-on adapter PRs, and the MessageEnvelope / ChannelAdapter / processEnvelope scaffolding is live in production. The single remaining ADR-0022 open item that THIS router depends on is the real resolveParticipant implementation (today: stubResolveParticipant returns the wire address verbatim). Phase 3 of this router can begin once resolveParticipant returns Person.id — the same predicate ADR-0022 §5 already commits to. See docs/planning/messaging-canonical-flow-plan.md for the cross-ADR sequencing.

Prerequisite spine work — closed. PR #1134 + #1139 + #1145 + #1146 + PR-C #1148 closed the writer-side personId invariant for Tenant, Prospect, Vendor, User. This router's "Person.id present?" first-step branch is therefore deterministic for every entity-bearing inbound today.


Reading guide (skim path)

This doc is ~1.3k lines. If you're skimming, read these three places:

  1. §"The architecture in one diagram" (~30 lines, one mermaid) — the whole router in one picture.
  2. §"Tier 2 — College diagrams + assembled prompt anatomy" — 5 worked examples, each with a routing diagram + a stacked-block prompt anatomy. Diagrams + 1-line captions; no prose to read.
  3. §"Three-tier context loading: eager, lazy, speculative" — the table showing what gets loaded into Clara's system prompt up-front vs fetched via tools vs pre-fetched in parallel.

Everything else (Concretely-four-layers, Phased delivery, Consequences, Forks, etc.) is reference detail for implementers. The skim path above is the strategy.

Context

PropFlow's agent loop today routes by intent: the first message of every conversation determines whether Clara runs the MAINTENANCE or LEASING workflow for the remainder of the thread (agents/clara/lib/agent/conversation-manager.tsclara-maintenance.ts / clara-leasing.ts split). The model picks tools from a single combined catalog. The system prompt is one of two static files.

This worked when PropFlow was a single-role product (tenants asking about repairs). It breaks at the seams as the role graph fans out:

  1. First-message intent routing is a heuristic. A vendor texting "we're 10 min out" hits a maintenance/leasing classifier that has no concept of a third option. A tenant asking about their lease renewal might land in MAINTENANCE because the classifier saw "lease" before "renewal." The classifier is doing identity work via prose proxies — it should be doing it via the spine.
  2. Multi-role callers have no resolution. A vendor-who-is-also-a-tenant (in-house handyman who lives on-site, or a contractor running a parallel application) sends one message; the agent picks the mode of whichever role was last discussed in this thread. Cross-thread context is invisible.
  3. Tool-RBAC lives inline at every handler. create_work_order checks if (caller.role === 'tenant' && wo.unit !== caller.unit) refuse() inside the handler. New handlers re-invent the same checks; nothing fails CI when a new tool ships without one. The audit answer to "what tools can a vendor call?" is a grep, not a query.
  4. Unknown callers get the full company prompt. A wrong-number SMS lands in the LEASING flow with all 12 leasing tools registered — Clara can technically call save_prospect on a typo'd phone before deciding "is this even someone interested in renting." Token waste + minor over-disclosure.
  5. Cross-channel context is transcript-shaped or empty. Conversations are scoped per-channel + per-property. A tenant who called yesterday and texts today gets a blank-slate Clara unless the agent is told to merge histories. Today: the agent isn't told.
  6. Voice is forked. ElevenLabs hosts the full prompt at agent-config time (one global agent). The /api/voice/personalization payload injects per-call context. There's no L2 prompt switching on voice — the whole prompt is the one Clara loaded at startup. This is the right shape for single-agent operation but doesn't compose with a "role-aware prompt directory" pattern.
  7. Phase 5a's spine closure made (1)-(6) addressable. Pre-Phase-5a, the spine had holes — phone → personId was not deterministic across Tenant/Prospect/Vendor. After PRs #1139/#1145/#1146/PR-C, every identity-bearing write produces a Person with stamped personId. The routing primitive that this router needs exists today. The router proposed here consumes that primitive; without the spine work it would not be viable.

Decision

Adopt a four-layer prompt/tool router keyed on Person.id → roles. Replace first-message intent routing with deterministic identity-driven routing for SMS/email/Telegram. Voice stays single-agent with richer personalization.

The architecture in one diagram

Boundary with ADR-0022 (LOAD-BEARING, gates Phase 3). resolveParticipant is owned by ADR-0022 (channel adapter). The router consumes participantId from the inbound MessageEnvelope — it does NOT re-resolve. Phase 3 cannot start until ADR-0022 is Accepted (status, not just Proposed) — a transitional resolveParticipant call site at the router's edge would calcify into a parallel identity-resolution path otherwise. Phase 1 and Phase 2 can ship independently of ADR-0022. The mermaid below shows the post-ADR-0022 logical shape. See ADR-0022 §5 for the canonical contract.

Status as of 2026-05-20: ADR-0022 is Accepted — the scaffolding gate is met. Phase 3 is not yet end-to-end startable, however: it requires both the ADR status gate (now met) AND resolveParticipant returning a real Person.id. The wire is live everywhere (every adapter calls ctx.resolveParticipant(channel, wireAddress) inside parseInbound), but it's currently bound to stubResolveParticipant, which returns the wire address verbatim instead of resolving to Person.id. Until the swap to a real implementation lands, the router would receive wire-address strings rather than Person.ids — so Phase 3 scaffolding work can begin, but functional Phase 3 routing is gated on Item 1 of the canonical-flow plan. See docs/planning/messaging-canonical-flow-plan.md for the swap plan.

Invariant from ADR-0022. participantId post-resolveParticipant is always a non-merged-loser Person.id. Soft-merge redirects (Person.mergedIntoPersonId) are followed inside resolveParticipant, not inside this router. The router treats participantId as terminal.

Invariant from ADR-0022 — admin origin. The dashboard "Ask Clara" panel (PM acting as themselves OR PM impersonating a tenant) emits envelopes with origin: 'admin' and carries impersonatedBy: User.id in context when impersonation is active. The router consumes both: origin informs which L2 path (PM gets pm/ask-clara.ts); impersonatedBy stamps the agent trace. PM impersonation is in scope — it flows through the same router as any other inbound, observable via Pipeline Lab + the agent-traces UI.

flowchart TD
    inbound["`Inbound MessageEnvelope
    (channel adapter — ADR-0022;
    participantId pre-resolved)`"]
    inbound --> roles{"Person.id present?"}
    roles -- no --> intent{"`Intent-hints regex fires?
    (unknown caller with
    prospect-shaped message)`"}
    intent -- no --> L0other["`MetaRole: other
    L0 + minimal L2 (light-triage)`"]
    intent -- yes --> mint["`Mint skeleton Person
    + 'prospect' PersonRole
    (source: 'router_mint',
    confidence: 'inferred')`"]
    mint --> rolesLookup
    roles -- yes --> rolesLookup["`getPersonRoles(personId, propertyId)
    → ActiveRole[] (1..N roles)`"]
    rolesLookup --> L1["`L1 — role-feature router
    (deterministic, no LLM)
    returns { roles[], confidence }`"]
    L1 --> L2["`L2 — load ALL role bundles
    (multi-role = union of features)`"]
    L0other --> assemble
    L2 --> assemble["`assemblePrompt =
    L0 + L2 union + L3 tool catalogs +
    personContext snapshot (volatile)`"]
    assemble --> loop["`Agent loop
    (claude-sonnet-4-6 + union of role tools)`"]
    loop --> tooldispatch["`dispatchTool(toolName, callerContext)
    consults tool-role-matrix
    (each tool has ONE policy)`"]
    tooldispatch --> handler["`Tool handler
    (no inline RBAC — already gated)`"]
    handler --> reply["`Reply via ChannelAdapter
    (ADR-0022)`"]
    reply --> snapshot["`At interaction end:
    async personContext refresh
    (idempotency: personId, interactionId)`"]
    snapshot --> store["Person.context (DDB)"]
    classDef active stroke:#16a34a,stroke-width:3px
    class inbound active

L1 returns the active role set. A caller with multiple verified roles (Carlos = tenant + vendor; PM-who's-also-a-tenant) gets ALL their role bundles + ALL their role tools loaded. Clara reads the message and picks tools naturally. The L1 router's job is "return the SET of active roles." Multi-role expands context; it does not narrow it.

The role universe — visual model with example flows

The diagram above shows the abstract logic (decision tree). This section gives the concrete role graph that the router operates on, plus five example flows showing how a single inbound message picks one path through the universe. Two tiers of detail:

  1. Tier 1 — kindergarten. One small diagram per example showing the gist (channel → caller → role → reply). No L2/L3 detail.
  2. Tier 2 — college. Full diagrams with L2 prompt bundles, L3 tool matrix, structured data inside each node. Use these when you need to understand exactly what's loaded into Clara's working memory.

What L2 files actually are (the confusing layer)

L2 files are prompt strings, not handlers. Each file at src/lib/agent/prompts/roles/<role>/<feature>.ts exports a Clara system-prompt fragment + metadata. The text is what gets stuffed into the model's system message at agent-loop start. Concretely:

// src/lib/agent/prompts/roles/tenant/maintenance.ts
export const PROMPT = `
You are handling a maintenance request from a tenant.

Priorities (in order):
1. Safety triage — gas smell, flooding, no heat → escalate immediately.
2. Self-serve troubleshooting — try the playbook steps first.
3. If unresolved, call create_work_order with the right vendor trade.
4. Always confirm tenant unit + access preferences before dispatching.
`;

export const META: L2FeatureMeta = {
  primaryTools: ['create_work_order', 'get_troubleshooting_steps'],
  voiceApplicable: false,    // SMS-only feature today; voice version is separate
  tokenEstimate: 280,
};

There's no executable code in an L2 file — it's text + a tiny metadata object. The handlers (the TypeScript functions that run when Clara calls create_work_order) live separately at src/lib/tools/ and are gated by the L3 tool-role matrix at dispatch time. L2 is the what Clara knows; L3 is the what Clara can do.

Within a single role, ALL the role's L2 files load together as one big system prompt. Tenant role today = maintenance.ts + renewal.ts + general-inquiry.ts + rating.ts concatenated. L1 picks which one gets the primary emphasis ("you are most likely handling X this turn"); the others stick around as appendix ("…but you may also pivot to Y").

Color legend

Same three colors across every diagram:

The agent loop node calls out which specific yellow tools Clara actually used this turn — that's runtime behavior, not a structural distinction.

All diagrams use the elk mermaid renderer for cleaner layered layouts.


Tier 1 — Kindergarten (5-node view per example)

One small diagram per example showing the spine of what happened: inbound → Person → Role → Tools → Reply. Pastel palette (matches the Property Verifications doc style); no fancy renderer, no init blocks.

Color & border legend (applies to all diagrams below):

Color (fill) Meaning
🔵 light blue Inbound channel (SMS / Phone)
🟣 light purple Identity (Person, Role rows)
🟪 light indigo Process step (adapter, resolveParticipant)
🟡 light yellow Decision/classifier (intent-hints, L1 router branches)
🩷 light pink L2 prompts (a role's feature files; all features in a loaded role bundle render solid)
🟠 light orange L3 tools / agent loop
🟢 light green Outbound reply
🔴 light red (dashed) Ruled-out role OR warning

Active path = nodes on the green-bordered path that the example actually traversed this turn. Yellow-pink dashed appendix L2 prompts are loaded into Clara's context but not the picked-feature focus.

K-1 — Tenant texts maintenance
graph TD
    in["SMS inbound
──────────────
from: +1-206-555-1001
'my toilet is overflowing!'"]:::inbound who["Person
──────────────
id: p_marcus
Marcus Johnson"]:::identity role["Role: tenant
──────────────
unit: 204
lease active"]:::identity tools["Tools available
──────────────
tenant catalog · 5 tools"]:::tools out["SMS reply
──────────────
'Help is on the way Marcus —
Mike Torres ETA 30 min.'"]:::output in --> who --> role --> tools --> out classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef identity fill:#7986CB,stroke:#5C6BC0,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef active stroke:#16a34a,stroke-width:3px class in,role,tools,who active

Tenant tools loaded (5)

Tool Scope
create_work_order own-unit
get_unit_appliances own-unit
get_troubleshooting_steps
request_photo
escalate_to_human
K-2 — Vendor calls about a work order
graph TD
    in["Phone call inbound
──────────────
from: +1-303-555-1500
'I'm 10 min out from WO-7'"]:::inbound who["Person
──────────────
id: p_mike
Mike Torres"]:::identity role["Role: vendor
──────────────
Mike Torres (in-house handyman)
activeWOs: WO-7"]:::identity tools["Tools available
──────────────
vendor catalog · 4 tools"]:::tools out["Voice reply
──────────────
'Got it Mike, see you at 3pm.
Tenant has been notified.'"]:::output in --> who --> role --> tools --> out classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef identity fill:#7986CB,stroke:#5C6BC0,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef active stroke:#16a34a,stroke-width:3px class in,role,tools,who active

Vendor tools loaded (4)

Tool Scope
update_work_order assigned-wo
add_eta_note assigned-wo
get_dispatch_details assigned-wo
handoff_to_other_vendor assigned-wo
K-3 — Prospect texts asking about availability
graph TD
    in["SMS inbound
──────────────
from: +1-415-555-9876
(unknown number)
'do you have 2BR units
next month?'"]:::inbound who["Person (new)
──────────────
'2BR' + 'next month'
→ skeleton minted
id: p_new_4155…"]:::identity role["Role: prospect
──────────────
stage: inquiry
moveIn: pending"]:::identity tools["Tools available
──────────────
prospect catalog · 4 tools"]:::tools out["SMS reply
──────────────
'We have 2BRs starting June 1.
Want to tour Tuesday at 3pm?'"]:::output in --> who --> role --> tools --> out classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef identity fill:#7986CB,stroke:#5C6BC0,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef active stroke:#16a34a,stroke-width:3px class in,role,tools,who active

Prospect tools loaded (4)

Tool Scope
save_prospect self
get_available_units
schedule_tour self
get_pricing_details
K-4 — Multi-role caller (vendor + tenant, BOTH active)
graph TD
    in["SMS inbound
──────────────
from: +1-303-555-2020
'check on WO-42'"]:::inbound who["Person (multi-role)
──────────────
id: p_carlos
Carlos Reyes
2 verified roles"]:::identity role["Roles: tenant + vendor
──────────────
BOTH active (union load)
tenant: unit 110
vendor: Carlos Plumbing"]:::identity tools["Tools available
──────────────
tenant catalog (5)
+ vendor catalog (4)
= 9 tools total"]:::tools out["SMS reply
──────────────
'WO-42 — tenant in unit 312
reported the leak Tuesday.
What's the latest?'"]:::output in --> who --> role --> tools --> out classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef identity fill:#7986CB,stroke:#5C6BC0,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef active stroke:#16a34a,stroke-width:3px class in,role,tools,who active

All 9 tools loaded — Carlos can act on his vendor authority (update WO-42 since his vendor record is assigned to it) AND on his tenant authority (create a WO for his own unit 110). The matrix scope checks gate each call at dispatch time; Clara picks the right tool based on what Carlos asks. No prompt-swap needed when he pivots between vendor questions and tenant questions in the same thread.

Tool Catalog Scope
create_work_order tenant own-unit-occupant (unit 110)
get_unit_appliances tenant own-unit-occupant
get_troubleshooting_steps tenant
request_photo tenant own-unit-occupant
escalate_to_human tenant (universal)
update_work_order vendor assigned-wo (e.g., WO-42)
add_eta_note vendor assigned-wo
get_dispatch_details vendor assigned-wo
handoff_to_other_vendor vendor assigned-wo
K-5 — Other (random caller / wrong number / advertiser / unclassified)
graph TD
    in["SMS inbound
──────────────
from: +1-310-555-7777
(no match)
'yo what's up Trevor'"]:::inbound who["Person
──────────────
none
resolveParticipant → null
no prospect signals fire"]:::warning role["MetaRole: other
──────────────
description: 'random caller'
spineCategory: wrong_number"]:::warning tools["Tools available
──────────────
other catalog · 1 tool
(escalate_to_human only)"]:::warning out["SMS reply
──────────────
'Looks like you have the wrong
number — I'm Clara, a property
management AI.'"]:::output in --> who --> role --> tools --> out classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef active stroke:#16a34a,stroke-width:3px class in,role,tools,who active

other is a positively-labeled MetaRole, not "absence of role." The description field captures runtime detail (random_caller / advertiser / spam / wrong_property / unclassified) without polluting the MetaRole enum with sub-categories. Future promotion to prospect happens when the intent-hints classifier fires on a subsequent message; otherwise the other label persists across the conversation.

The detailed Tier-2 diagrams below trace each example through every layer. Skip them if the kindergarten view answered your question.


Tier 2 — College diagrams + assembled prompt anatomy (per example)

Each example expands the kindergarten view with two diagrams:

  1. Routing topology (mermaid graph TD) — channel → adapter → Person → roles → L2 dirs → L3 catalogs → agent loop → reply. Five subgraphs group the phases; edges between subgraphs carry verbs that name what's flowing. Tool-matrix tables follow.
  2. Assembled prompt anatomy (mermaid block-beta) — what Clara's literal system prompt + tools array stack up to. Vertical color-coded blocks in cache-layout order. The cache breakpoint sits as a dashed separator: everything above is the long-cached prefix; everything below is volatile (recomputed per interaction).

Block legend (applies to all 5 anatomies below):

Example 1 (full) — Tenant texts maintenance
graph TD
    subgraph CHANNELS [Inbound channels]
        sms_in["SMS
──────
from: +1-206-555-1001
'my toilet is overflowing!'"]:::inbound phone_in["PHONE channel
(not used)"]:::dim email_in["EMAIL channel
(not used)"]:::dim telegram_in["TELEGRAM channel
(not used)"]:::dim end subgraph ADAPTER [Channel adapter & envelope] adapter["TwilioSMSAdapter
──────
.parseInbound"]:::process envelope["MessageEnvelope
──────
channel: sms
participantId: p_marcus"]:::process resolve["resolveParticipant
──────
findPersonByClaim"]:::process end sms_in --> adapter adapter --> envelope --> resolve person["Person
──────
id: p_marcus
Marcus Johnson"]:::identity resolve --> person subgraph ROLES [Person role fan-out] tenant_role["tenant role
──────
tr_marcus_204
unit 204
lease active"]:::identity prospect_role["prospect role
(none for this Person)"]:::dim vendor_role["vendor role
(none for this Person)"]:::dim pm_role["pm role
(none for this Person)"]:::dim end person --> tenant_role person --> prospect_role person --> vendor_role person --> pm_role l1["L1 router
──────
single role → confidence: 'fact'
roles: [tenant]"]:::identity tenant_role --> l1 subgraph L2 [L2 prompt directory] tenant_dir["tenant/
──────
4 features loaded
(maintenance · renewal ·
general-inquiry · rating)"]:::prompt others_dim["vendor / prospect / pm / other
(NOT loaded)"]:::dim end l1 --> tenant_dir subgraph L3 [L3 tool catalogs] tenant_tools["tenant catalog (LOADED)
──────
• create_work_order
• get_unit_appliances
• get_troubleshooting_steps
• request_photo
• escalate_to_human"]:::tools catalogs_dim["vendor / prospect / pm / other catalogs
(NOT loaded)"]:::dim end tenant_dir --> tenant_tools loop["Agent loop
──────
claude-sonnet-4-6
called: create_work_order(WO-812),
get_troubleshooting_steps"]:::tools tenant_tools --> loop reply["SMS reply
──────
'Help is on the way Marcus —
Mike Torres ETA 30 min.'"]:::output loop --> reply classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef process fill:#7986CB,stroke:#5C6BC0,color:#000 classDef identity fill:#BA68C8,stroke:#8E24AA,color:#000 classDef decision fill:#FFD54F,stroke:#F9A825,color:#000 classDef prompt fill:#F06292,stroke:#D81B60,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef ruled fill:#E57373,stroke:#EF5350,color:#000,stroke-dasharray:5 3 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef inbound_dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef active stroke:#16a34a,stroke-width:3px class sms_in,adapter,envelope,resolve,person,tenant_role,l1,tenant_dir,tenant_tools,loop,reply active

L3 tool matrix — tenant scope

Tool Scope Called this turn?
create_work_order own-unit
get_troubleshooting_steps
get_unit_appliances own-unit
request_photo own-unit
escalate_to_human
accept_dispatch vendor — REFUSED

Assembled prompt anatomy — Example 1 (Marcus, tenant)

block-beta
  columns 1
  L0["L0 — Clara identity + safety (~140 tokens)
'You are Clara, a property management AI assistant…'
SAFETY: gas / fire / flood / electrical → 911 nudge + escalate"] L2a["L2 tenant/maintenance.ts (~300 tokens)
Safety triage · self-serve troubleshooting · create_work_order · photo requests"] L2b["L2 tenant/renewal.ts (~250 tokens)
Renewal terms · accept/decline · multi-term offers · MTM premium handling"] L2c["L2 tenant/general-inquiry.ts (~200 tokens)
Lease questions · balance lookups · quiet hours · move-in/out · referrals"] L2d["L2 tenant/rating.ts (~150 tokens)
Parse post-WO rating replies ('5 thanks') into 1-5 stars"] L3f["L3 property-facility tools (~100 tokens)
get_property_amenities · get_property_hours · get_pet_policy · get_parking_rules · get_pool_status"] L3t["L3 tenant tool catalog (~400 tokens)
create_work_order (own-unit-occupant) · get_unit_appliances · get_troubleshooting_steps · request_photo · escalate_to_human"] cache["── CACHE BREAKPOINT ──
everything above is the long-cached prefix"] pc["personContext (~300 tokens, volatile)
summary: 'Marcus is primary tenant of unit 204 since 2024. Texted about gym hours last week. No open WOs.'
active: { activeWorkOrders: [], activeTours: [], activeRenewalWorkflowId: null, lastChannel: 'sms', lastInteractionAt: '2026-05-12T09:14Z' }"] L1b["L1 breadcrumb (~50 tokens)
{ roles: ['tenant'], confidence: 'fact' }"] style L0 fill:#64B5F6,stroke:#1976D2,color:#000 style L2a fill:#F06292,stroke:#D81B60,color:#000 style L2b fill:#F06292,stroke:#D81B60,color:#000 style L2c fill:#F06292,stroke:#D81B60,color:#000 style L2d fill:#F06292,stroke:#D81B60,color:#000 style L3f fill:#FFA726,stroke:#F57C00,color:#000 style L3t fill:#FFA726,stroke:#F57C00,color:#000 style cache fill:#FFFFFF,stroke:#000000,color:#000,stroke-dasharray:5 3 style pc fill:#FFD54F,stroke:#F9A825,color:#000 style L1b fill:#BA68C8,stroke:#8E24AA,color:#000

Total assembled prompt: ~1.9k tokens system + tools above the cache breakpoint; ~350 tokens volatile below. When Marcus has open WOs, personContext.active.activeWorkOrders carries them as { id, displayId, status, brief } entries — Clara answers "yes, WO-7 is in progress, Mike is dispatched" eagerly without a tool call. For deeper detail (vendor notes, photo URLs) she calls get_work_order_detail lazily.

Example 2 (full) — Vendor calls about a work order
graph TD
    subgraph CHANNELS [Inbound channels]
        sms_in["SMS channel
(not used)"]:::dim phone_in["Phone call
──────
from: +1-303-555-1500
'I'm 10 min out from WO-7'"]:::inbound email_in["EMAIL channel
(not used)"]:::dim telegram_in["TELEGRAM channel
(not used)"]:::dim end subgraph ADAPTER [Channel adapter & envelope] adapter["ElevenLabsVoiceAdapter
──────
.parseInbound"]:::process envelope["MessageEnvelope
──────
channel: phone
participantId: p_mike"]:::process resolve["resolveParticipant
──────
findPersonByClaim"]:::process end phone_in --> adapter adapter --> envelope --> resolve person["Person
──────
id: p_mike
Mike Torres"]:::identity resolve --> person subgraph ROLES [Person role fan-out] tenant_role["tenant role
(none for this Person)"]:::dim prospect_role["prospect role
(none for this Person)"]:::dim vendor_role["vendor role
──────
vr_mike
in-house handyman (no LLC)
activeWOs: WO-7"]:::identity pm_role["pm role
(none for this Person)"]:::dim end person --> tenant_role person --> prospect_role person --> vendor_role person --> pm_role l1["L1 router
──────
regex 'WO-7' hit
feature: work-order-update"]:::identity vendor_role --> l1 subgraph L2 [L2 prompt directory] vendor_dir["vendor/
──────
4 features loaded
(work-order-update · respond-to-dispatch ·
schedule-confirmation · handoff)"]:::prompt others_dim["tenant / prospect / pm / other
(NOT loaded)"]:::dim end l1 --> vendor_dir subgraph L3 [L3 tool catalogs] vendor_tools["vendor catalog (LOADED)
──────
• update_work_order
• add_eta_note
• get_dispatch_details
• handoff_to_other_vendor"]:::tools catalogs_dim["tenant / prospect / pm / other catalogs
(NOT loaded)"]:::dim end vendor_dir --> vendor_tools loop["Agent loop
──────
claude-sonnet-4-6
called: update_work_order(WO-7, eta:'3pm')"]:::tools vendor_tools --> loop reply["Voice reply
──────
'Got it Mike, see you at 3pm.
Tenant has been notified.'"]:::output loop --> reply classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef process fill:#7986CB,stroke:#5C6BC0,color:#000 classDef identity fill:#BA68C8,stroke:#8E24AA,color:#000 classDef decision fill:#FFD54F,stroke:#F9A825,color:#000 classDef prompt fill:#F06292,stroke:#D81B60,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef ruled fill:#E57373,stroke:#EF5350,color:#000,stroke-dasharray:5 3 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef inbound_dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef active stroke:#16a34a,stroke-width:3px class phone_in,adapter,envelope,resolve,person,vendor_role,l1,vendor_dir,vendor_tools,loop,reply active

L3 tool matrix — vendor scope

Tool Scope Called this turn?
update_work_order assigned-wo
add_eta_note assigned-wo
get_dispatch_details assigned-wo
handoff_to_other_vendor assigned-wo
create_work_order tenant — REFUSED

Assembled prompt anatomy — Example 2 (Mike, vendor)

block-beta
  columns 1
  L0["L0 — Clara identity + safety (~140 tokens)"]
  L2a["L2 vendor/respond-to-dispatch.ts (~250 tokens)
Acknowledge dispatch · confirm window · escalate scheduling conflicts"] L2b["L2 vendor/work-order-update.ts (~300 tokens)
ETA updates · status changes · note attachment · photo capture"] L2c["L2 vendor/schedule-confirmation.ts (~200 tokens)
Slot picks · reschedule · tenant notification triggers"] L2d["L2 vendor/handoff.ts (~150 tokens)
Vendor → vendor reassignment · sub-contractor flow"] L3f["L3 property-facility tools (~100 tokens)
get_property_amenities · get_property_hours · get_pet_policy · get_parking_rules"] L3t["L3 vendor tool catalog (~350 tokens)
update_work_order (assigned-wo) · add_eta_note · get_dispatch_details · handoff_to_other_vendor · escalate_to_human"] cache["── CACHE BREAKPOINT ──"] pc["personContext (~250 tokens, volatile)
summary: 'Mike is in-house handyman. Currently handling WO-7 (running toilet, unit 204). Average response: 38min.'
active: { activeWorkOrders: [{ id: 'wo_7', displayId: 'WO-7', status: 'in-progress', brief: 'running toilet, unit 204' }], lastChannel: 'voice', lastInteractionAt: '2026-05-19T08:55Z' }"] L1b["L1 breadcrumb
{ roles: ['vendor'], confidence: 'fact' }"] style L0 fill:#64B5F6,stroke:#1976D2,color:#000 style L2a fill:#F06292,stroke:#D81B60,color:#000 style L2b fill:#F06292,stroke:#D81B60,color:#000 style L2c fill:#F06292,stroke:#D81B60,color:#000 style L2d fill:#F06292,stroke:#D81B60,color:#000 style L3f fill:#FFA726,stroke:#F57C00,color:#000 style L3t fill:#FFA726,stroke:#F57C00,color:#000 style cache fill:#FFFFFF,stroke:#000000,color:#000,stroke-dasharray:5 3 style pc fill:#FFD54F,stroke:#F9A825,color:#000 style L1b fill:#BA68C8,stroke:#8E24AA,color:#000
Example 3 (full) — Prospect texts asking about availability
graph TD
    subgraph CHANNELS [Inbound channels]
        sms_in["SMS
──────
from: +1-415-555-9876
'do you have 2BR units
next month?'"]:::inbound phone_in["PHONE channel
(not used)"]:::dim email_in["EMAIL channel
(not used)"]:::dim telegram_in["TELEGRAM channel
(not used)"]:::dim end subgraph ADAPTER [Channel adapter & envelope] adapter["TwilioSMSAdapter
──────
.parseInbound"]:::process envelope["MessageEnvelope
──────
channel: sms
participantId: p_new_4155…"]:::process resolve["resolveParticipant
──────
findPersonByClaim"]:::process end sms_in --> adapter adapter --> envelope --> resolve person["Person
──────
id: p_new_4155…
(skeleton minted)"]:::identity resolve --> person subgraph ROLES [Person role fan-out] tenant_role["tenant role
(none for this Person)"]:::dim prospect_role["prospect role
──────
pr_new
stage: inquiry
propertyId: pf_yale"]:::identity vendor_role["vendor role
(none for this Person)"]:::dim pm_role["pm role
(none for this Person)"]:::dim end person --> tenant_role person --> prospect_role person --> vendor_role person --> pm_role intent["Intent-hints classifier
──────
regex hits prospect signals
→ promote to prospect"]:::decision mint["Mint skeleton
──────
savePerson + saveProspect"]:::decision no_person --> intent --> mint --> person l1["L1 router
──────
roles: [prospect]
confidence: 'inferred'
(intent-hints promoted)"]:::identity prospect_role --> l1 subgraph L2 [L2 prompt directory] prospect_dir["prospect/
──────
4 features loaded
(discovery · tour-schedule ·
application · disqualification)"]:::prompt others_dim["tenant / vendor / pm / other
(NOT loaded)"]:::dim end l1 --> prospect_dir subgraph L3 [L3 tool catalogs] prospect_tools["prospect catalog (LOADED)
──────
• save_prospect
• get_available_units
• schedule_tour
• get_pricing_details"]:::tools catalogs_dim["tenant / vendor / pm / other catalogs
(NOT loaded)"]:::dim end prospect_dir --> prospect_tools loop["Agent loop
──────
claude-sonnet-4-6
called: save_prospect(bedrooms:2,
moveIn:'2026-06-01')"]:::tools prospect_tools --> loop reply["SMS reply
──────
'We have 2BRs starting June 1.
Want to tour Tuesday at 3pm?'"]:::output loop --> reply classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef process fill:#7986CB,stroke:#5C6BC0,color:#000 classDef identity fill:#BA68C8,stroke:#8E24AA,color:#000 classDef decision fill:#FFD54F,stroke:#F9A825,color:#000 classDef prompt fill:#F06292,stroke:#D81B60,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef ruled fill:#E57373,stroke:#EF5350,color:#000,stroke-dasharray:5 3 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef inbound_dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef active stroke:#16a34a,stroke-width:3px class sms_in,adapter,envelope,resolve,person,prospect_role,intent,mint,l1,prospect_dir,prospect_tools,loop,reply active

L3 tool matrix — prospect scope

Tool Scope Called this turn?
save_prospect self
get_available_units
schedule_tour self
get_pricing_details
create_work_order tenant — REFUSED

Assembled prompt anatomy — Example 3 (Prospect, intent-hints minted)

block-beta
  columns 1
  L0["L0 — Clara identity + safety (~140 tokens)"]
  L2a["L2 prospect/discovery.ts (~300 tokens)
Light qualification: bedrooms · move-in date · price range · fair-housing-safe questions"] L2b["L2 prospect/tour-schedule.ts (~250 tokens)
Slot proposals · confirmation flow · reschedule · cancellation"] L2c["L2 prospect/application.ts (~200 tokens)
Application link delivery · screening criteria nudges"] L2d["L2 prospect/disqualification.ts (~150 tokens)
Fair-housing-safe wind-down (income / pet policy / unit not available)"] L3f["L3 property-facility tools (~100 tokens)
get_property_amenities · get_property_hours · get_pet_policy · get_parking_rules · get_pool_status"] L3t["L3 prospect tool catalog (~300 tokens)
save_prospect (self) · get_available_units · schedule_tour (self) · get_pricing_details · escalate_to_human"] cache["── CACHE BREAKPOINT ──"] pc["personContext (~150 tokens, fresh — first interaction)
summary: 'This is a fresh contact. No prior context available.'
active: { activeWorkOrders: [], activeTours: [], activeRenewalWorkflowId: null, lastChannel: null, lastInteractionAt: null }"] L1b["L1 breadcrumb
{ roles: ['prospect'], confidence: 'inferred', inferredIntent: { kind: 'availability' } }"] style L0 fill:#64B5F6,stroke:#1976D2,color:#000 style L2a fill:#F06292,stroke:#D81B60,color:#000 style L2b fill:#F06292,stroke:#D81B60,color:#000 style L2c fill:#F06292,stroke:#D81B60,color:#000 style L2d fill:#F06292,stroke:#D81B60,color:#000 style L3f fill:#FFA726,stroke:#F57C00,color:#000 style L3t fill:#FFA726,stroke:#F57C00,color:#000 style cache fill:#FFFFFF,stroke:#000000,color:#000,stroke-dasharray:5 3 style pc fill:#FFD54F,stroke:#F9A825,color:#000 style L1b fill:#BA68C8,stroke:#8E24AA,color:#000

Note confidence: 'inferred' — the prospect role was minted by the intent-hints classifier (source: 'router_mint'), not by AppFolio sync or magic-link signup. If this person turns out to NOT be a prospect (e.g., abandons the thread, never schedules a tour), the prospect role row stays on the spine but Person.category may flip to unclassified for cleanup. Ops visibility via the confidence: 'inferred' label on every trace.

Example 4 (full) — Multi-role caller (vendor + tenant, BOTH active via union load)
graph TD
    subgraph CHANNELS [Inbound channels]
        sms_in["SMS
──────
from: +1-303-555-2020
'check on WO-42'"]:::inbound phone_in["PHONE channel
(not used)"]:::dim email_in["EMAIL channel
(not used)"]:::dim telegram_in["TELEGRAM channel
(not used)"]:::dim end subgraph ADAPTER [Channel adapter & envelope] adapter["TwilioSMSAdapter
──────
.parseInbound"]:::process envelope["MessageEnvelope
──────
channel: sms
participantId: p_carlos"]:::process resolve["resolveParticipant
──────
findPersonByClaim"]:::process end sms_in --> adapter adapter --> envelope --> resolve person["Person
──────
id: p_carlos
Carlos Reyes (tenant + vendor)"]:::identity resolve --> person subgraph ROLES [Person role fan-out — BOTH roles active] tenant_role["tenant role
──────
tr_carlos_110
unit 110
lease active"]:::identity prospect_role["prospect role
(none for this Person)"]:::dim vendor_role["vendor role
──────
vr_carlos
Carlos Plumbing
assigned WO-42"]:::identity pm_role["pm role
(none for this Person)"]:::dim end person --> tenant_role person --> prospect_role person --> vendor_role person --> pm_role l1["L1 router
──────
returns { roles: [tenant, vendor],
confidence: 'fact' }
both bundles + both catalogs
load (union)"]:::identity tenant_role --> l1 vendor_role --> l1 subgraph L2 [L2 prompt directory — union of bundles] tenant_dir["tenant/
──────
4 features
(all loaded)"]:::prompt vendor_dir["vendor/
──────
4 features
(all loaded)"]:::prompt others_dim["prospect / pm / other
(NOT loaded)"]:::dim end l1 --> tenant_dir l1 --> vendor_dir subgraph L3 [L3 tool catalogs — union of catalogs] tenant_tools["tenant catalog (LOADED)
──────
create_work_order ·
get_unit_appliances ·
get_troubleshooting_steps ·
request_photo ·
escalate_to_human"]:::tools vendor_tools["vendor catalog (LOADED)
──────
update_work_order ·
add_eta_note ·
get_dispatch_details ·
handoff_to_other_vendor"]:::tools catalogs_dim["prospect / pm / other catalogs
(NOT loaded)"]:::dim end tenant_dir --> tenant_tools vendor_dir --> vendor_tools loop["Agent loop
──────
claude-sonnet-4-6
9 tools available
called: get_dispatch_details(WO-42)"]:::tools tenant_tools --> loop vendor_tools --> loop reply["SMS reply
──────
'WO-42 — tenant in unit 312
reported the leak Tuesday.
What's the latest?'"]:::output loop --> reply classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef process fill:#7986CB,stroke:#5C6BC0,color:#000 classDef identity fill:#BA68C8,stroke:#8E24AA,color:#000 classDef decision fill:#FFD54F,stroke:#F9A825,color:#000 classDef prompt fill:#F06292,stroke:#D81B60,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef active stroke:#16a34a,stroke-width:3px class sms_in,adapter,envelope,resolve,person,tenant_role,vendor_role,l1,tenant_dir,vendor_dir,tenant_tools,vendor_tools,loop,reply active

Both roles load. Carlos has tenant + vendor PersonRole rows in the spine, both with verified provenance (source: 'pms_sync'). L1 returns { roles: ['tenant', 'vendor'], confidence: 'fact' }. The L2 union (tenant 4 features + vendor 4 features = 8 features) and L3 union (5 tenant tools + 4 vendor tools = 9 tools) both load. Clara picks get_dispatch_details(WO-42) from the vendor catalog because the message clearly references the vendor assignment, but create_work_order(unit: 110) for Carlos's own unit would also work in the same thread if he pivoted — no prompt-swap needed.

L3 tool matrix — both scopes available

Tool Catalog Scope Called this turn?
update_work_order vendor assigned-wo
add_eta_note vendor assigned-wo
get_dispatch_details vendor assigned-wo
handoff_to_other_vendor vendor assigned-wo
create_work_order tenant own-unit-occupant (unit 110)
get_unit_appliances tenant own-unit-occupant
get_troubleshooting_steps tenant
request_photo tenant own-unit-occupant
escalate_to_human universal

Assembled prompt anatomy — Example 4 (Carlos, tenant + vendor union)

block-beta
  columns 1
  L0["L0 — Clara identity + safety (~140 tokens)"]
  L2t_a["L2 tenant/maintenance.ts (~300 tokens)"]
  L2t_b["L2 tenant/renewal.ts (~250 tokens)"]
  L2t_c["L2 tenant/general-inquiry.ts (~200 tokens)"]
  L2t_d["L2 tenant/rating.ts (~150 tokens)"]
  L2v_a["L2 vendor/respond-to-dispatch.ts (~250 tokens)"]
  L2v_b["L2 vendor/work-order-update.ts (~300 tokens)"]
  L2v_c["L2 vendor/schedule-confirmation.ts (~200 tokens)"]
  L2v_d["L2 vendor/handoff.ts (~150 tokens)"]
  L3f["L3 property-facility tools (~100 tokens)"]
  L3tools["L3 union: tenant catalog (5 tools) + vendor catalog (4 tools) = 9 tools (~750 tokens)
create_work_order · get_unit_appliances · get_troubleshooting_steps · request_photo · update_work_order · add_eta_note · get_dispatch_details · handoff_to_other_vendor · escalate_to_human"] cache["── CACHE BREAKPOINT ──"] pc["personContext (~350 tokens, volatile)
summary: 'Carlos rents unit 110 AND runs Carlos Plumbing (vendor for this property). Currently assigned WO-42 (unit 312 leak). Last week: pivoted from vendor work to a question about HIS lease renewal.'
active: { activeWorkOrders: [{ id: 'wo_42', displayId: 'WO-42', status: 'in-progress', brief: 'unit 312 leak (Carlos assigned as vendor)' }], activeRenewalWorkflowId: 'rw_carlos_110_2026', lastChannel: 'sms' }"] L1b["L1 breadcrumb
{ roles: ['tenant', 'vendor'], confidence: 'fact' }"] style L0 fill:#64B5F6,stroke:#1976D2,color:#000 style L2t_a fill:#F06292,stroke:#D81B60,color:#000 style L2t_b fill:#F06292,stroke:#D81B60,color:#000 style L2t_c fill:#F06292,stroke:#D81B60,color:#000 style L2t_d fill:#F06292,stroke:#D81B60,color:#000 style L2v_a fill:#F06292,stroke:#D81B60,color:#000 style L2v_b fill:#F06292,stroke:#D81B60,color:#000 style L2v_c fill:#F06292,stroke:#D81B60,color:#000 style L2v_d fill:#F06292,stroke:#D81B60,color:#000 style L3f fill:#FFA726,stroke:#F57C00,color:#000 style L3tools fill:#FFA726,stroke:#F57C00,color:#000 style cache fill:#FFFFFF,stroke:#000000,color:#000,stroke-dasharray:5 3 style pc fill:#FFD54F,stroke:#F9A825,color:#000 style L1b fill:#BA68C8,stroke:#8E24AA,color:#000

Carlos's prompt is larger (~3.0k tokens cached prefix vs ~1.9k for single-role Marcus) — the cost of multi-role union load. Acceptable; cached across his subsequent interactions in the same role bundle. Clara can answer "what's the status of WO-42?" with vendor tools and "the disposal in my unit is jammed" with tenant tools in the same conversation, no prompt re-assembly.

Example 5 (full) — Other (random caller / unclassified)
graph TD
    subgraph CHANNELS [Inbound channels]
        sms_in["SMS
──────
from: +1-310-555-7777
'yo what's up Trevor'"]:::inbound phone_in["PHONE channel
(not used)"]:::dim email_in["EMAIL channel
(not used)"]:::dim telegram_in["TELEGRAM channel
(not used)"]:::dim end subgraph ADAPTER [Channel adapter & envelope] adapter["TwilioSMSAdapter
──────
.parseInbound"]:::process envelope["MessageEnvelope
──────
channel: sms
participantId: null"]:::process resolve["resolveParticipant
──────
findPersonByClaim"]:::process end sms_in --> adapter adapter --> envelope --> resolve resolve --> personDecision{"Person
resolved?"} personDecision -- "yes (Ex 1, 2, 4)" --> dimPersonPath["(not this example —
fans to role graph)"]:::dim personDecision -- "no" --> no_person["no Person
──────
resolveParticipant → null"]:::warning no_person --> intentDecision{"Intent-hints
classifier fires?"} intentDecision -- "yes (Ex 3)" --> dimIntentPath["(not this example —
mint skeleton +
promote to prospect)"]:::dim intentDecision -- "no" --> l1["L1 router
──────
MetaRole: other
description: 'random caller'
confidence: fact"]:::identity subgraph L2 [L2 prompt directory] other_dir["other/
──────
light-triage (only feature)"]:::prompt roles_dim["tenant / vendor / prospect / pm
(NOT loaded)"]:::dim end l1 --> other_dir subgraph L3 [L3 tool catalogs] other_tools["other catalog
──────
• escalate_to_human (only)"]:::tools catalogs_dim["tenant / vendor / prospect / pm catalogs
(NOT loaded)"]:::dim end other_dir --> other_tools loop["Agent loop
──────
claude-sonnet-4-6
called: (none — refusal reply)"]:::tools other_tools --> loop reply["SMS reply
──────
'Looks like you have the
wrong number — I'm Clara,
a property management AI.'"]:::output loop --> reply classDef inbound fill:#64B5F6,stroke:#1976D2,color:#000 classDef process fill:#7986CB,stroke:#5C6BC0,color:#000 classDef identity fill:#BA68C8,stroke:#8E24AA,color:#000 classDef decision fill:#FFD54F,stroke:#F9A825,color:#000 classDef prompt fill:#F06292,stroke:#D81B60,color:#000 classDef tools fill:#FFA726,stroke:#F57C00,color:#000 classDef output fill:#81C784,stroke:#388E3C,color:#000 classDef ruled fill:#E57373,stroke:#EF5350,color:#000,stroke-dasharray:5 3 classDef warning fill:#E57373,stroke:#EF5350,color:#000 classDef dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef inbound_dim fill:#9E9E9E,stroke:#BDBDBD,color:#757575 classDef active stroke:#16a34a,stroke-width:3px class sms_in,adapter,envelope,resolve,personDecision,no_person,intentDecision,l1,other_dir,other_tools,loop,reply active

other is a positively-labeled MetaRole, not "absence of role." The L2 directory has one feature (light-triage); the L3 catalog has one tool (escalate_to_human). The description field on the L1 output captures the runtime sub-category (random_caller / advertiser / spam / wrong_property / unclassified) for ops visibility without expanding the MetaRole enum.

The two decision diamonds make the if-branches explicit: Person resolved? → if yes, fans to the role graph (Examples 1, 2, 4); if no, ask Intent-hints fires? → if yes, mint skeleton + promote to prospect (Example 3); if no, label as other (this example). Each example takes one path through these two decisions; the dimmed nodes show the road not taken.

Assembled prompt anatomy — Example 5 (Other, random caller)

block-beta
  columns 1
  L0["L0 — Clara identity + safety (~140 tokens)
SAFETY: gas / fire / flood → 911 + escalate (fires even on the unknown-caller path)"] L2o["L2 other/light-triage.ts (~200 tokens)
Brief refusal · kind close · escalate if user signals distress · sympathetic phrasing for estate-of inquiries (deceased)"] L3f["L3 property-facility tools (~100 tokens)
get_property_amenities · get_property_hours · get_pet_policy · get_parking_rules
(loaded even for 'other' — someone asking 'do you have a pool?' still gets a clean answer)"] L3t["L3 other tool catalog (~50 tokens)
escalate_to_human (the only role-side tool)"] cache["── CACHE BREAKPOINT ──"] pc["personContext (~80 tokens, minimal — no prior context)
summary: 'No prior interactions. Possibly a random caller — first message has no prospect/tenant/vendor signals.'
active: { all empty / null }"] L1b["L1 breadcrumb
{ roles: ['other'], confidence: 'fact', otherClassification: { description: 'random caller', spineCategory: 'wrong_number' } }"] style L0 fill:#64B5F6,stroke:#1976D2,color:#000 style L2o fill:#F06292,stroke:#D81B60,color:#000 style L3f fill:#FFA726,stroke:#F57C00,color:#000 style L3t fill:#FFA726,stroke:#F57C00,color:#000 style cache fill:#FFFFFF,stroke:#000000,color:#000,stroke-dasharray:5 3 style pc fill:#FFD54F,stroke:#F9A825,color:#000 style L1b fill:#BA68C8,stroke:#8E24AA,color:#000

Property facility tools are loaded even for other callers — someone who's not in our system but happens to ask "what time does the pool open?" can get a clean factual answer rather than being told to call a human for a public-facing detail. The escalate_to_human tool stays available; the L2 prose is brief by design.

The total assembled prompt for an other caller is ~500 tokens — much smaller than the tenant/vendor/prospect assemblies. This is also how we minimize over-disclosure: a wrong-number caller doesn't see internal tool descriptions for create_work_order, save_prospect, etc.


Concretely — four layers

L0 — Global Clara identity + unconditional safety (every interaction)

~140 tokens. Two parts:

  1. Identity (~80 tokens): brand voice, communication norms, refusal posture. No tools, no role context.
  2. Safety short-circuit (~60 tokens): if the caller signals an active emergency — gas smell, fire, active flooding, electrical sparking, threat to physical safety — Clara immediately responds with a 911 nudge AND fires escalate_to_human (the only universally-available tool). Safety runs on every path, including the unknown-caller path — a wrong-number caller reporting a gas leak gets the safety response, not a "wrong number" reply. (Finding 2.2.)

This is what an unknown caller (wrong number, prospect-not-yet-resolved) sees. Lives at src/lib/agent/prompts/L0-clara-identity.ts. See the per-example anatomy diagrams above for how L0 looks as the top block in each assembled prompt.

L1 — Role-feature router (deterministic, returns the SET of active roles)

Given participantId + propertyId + channel + message, L1 returns:

interface L1Result {
  /** ALL active roles for this Person. Single-role callers get a length-1
   *  array; multi-role callers get every active role (1..N entries).
   *  The agent loop loads every role's L2 bundle + L3 tool catalog. */
  roles: MetaRole[];

  /** Observational only — labels traces + drives shadow validation.
   *  Does NOT fork router behavior. */
  confidence: Confidence;

  /** When MetaRole is 'other', captures the runtime sub-category for ops
   *  visibility. Free-form to avoid polluting MetaRole with sub-types. */
  otherClassification?: OtherClassification;

  /** When intent-hints classifier promoted unknown → prospect, surface the
   *  inferred intent (tour / pricing / etc.) so the L2 prospect bundle can
   *  optionally prioritize the relevant feature in its response. */
  inferredIntent?: { kind: 'tour' | 'pricing' | 'availability' | 'general'; };
}

type Confidence =
  | 'fact'        // at least one PersonRole row has verified provenance
                  //   (PersonRoleSource ∈ {phase6a-backfill, auth-hook,
                  //    manual_pm, pms_sync})
  | 'inferred';   // all roles for this Person came from router_mint
                  //   (intent-hints classifier deduced the role)

type MetaRole = 'tenant' | 'vendor' | 'prospect' | 'pm' | 'other';

interface OtherClassification {
  /** Free-form description for ops visibility. NOT a controlled enum
   *  to avoid polluting the role taxonomy. */
  description: string;
  /** Optional spine pointer: maps to Person.category when known. */
  spineCategory?: 'random_caller' | 'wrong_number' | 'advertiser' | 'spam'
                 | 'wrong_property' | 'unclassified';
}

The L1 logic is pure TypeScript — no LLM in the hot path. Intent-hints classification for unknown callers is a separate pre-L1 step (see "Intent-hints classifier" below); L1 itself only runs for callers with a resolved Person.id.

Routes:

other is the graceful-degrade lane. When primary identity resolution fails (no Person, no intent-hints promotion), Clara still routes to L0 + other/light-triage.ts and responds politely — kind acknowledgement, brief close, or escalate if the caller signals distress. Clara never refuses, never 500s, never goes silent. The graceful-degrade is intentional: a real tenant texting "hi Clara" before their spine-stamp lands should get a friendly Clara, not an error message.

Every other-routed interaction must be loudly logged (see "Status observability" below). The risk of a graceful-degrade lane is comfort: we get used to seeing polite responses and miss the fact that a known tenant got routed to other because their personId wasn't stamped. The loudness check makes that mistake visible.

This is the only fallback in the router. There's NO secondary-source fallback in correctness paths (the WO filter, the role membership lookup, the tool-RBAC matrix) — those must use the primary source or fail loudly. Mixing the two kinds of fallback would obscure the architecture.

Multi-role caller example. Carlos = tenant (PMS sync) + vendor_contact (PMS sync). L1 returns { roles: ['tenant', 'vendor'], confidence: 'fact' }. The L2 assembler loads both tenant/ and vendor/ bundles (8 features total); the L3 assembler loads both tenant + vendor catalogs (9 tools total). Clara reads the message ("check on WO-42") and picks get_dispatch_details(WO-42) from the vendor catalog. If on the same thread Carlos pivots to "actually, the disposal in my own unit is jammed," Clara picks create_work_order(unit: 110) from the tenant catalog. Both tool catalogs are already loaded; no prompt re-assembly needed.

Loading shape. The router's job is "load the right capabilities for who's talking to us." Multi-role callers are common (PM-who-rents-a-unit; vendor-who-also-rents) and they expect Clara to handle whichever direction the conversation goes. Loading every active role's bundle costs ~2k extra tokens at the multi-role boundary vs the 5k system prompt total — acceptable. The model picks tools based on message content; matrix scope checks gate each call at dispatch time.

Equal-weight features within a role. Within a role bundle (e.g., tenant has 4 features: maintenance, renewal, general-inquiry, rating), all 4 features load with equal weight in the assembled prompt. Clara reads the message and decides where to focus naturally; there's no "PRIMARY emphasis" header.

Deceased Persons (Finding 1.4). When Person.deceased === true (forward-compat field declared in ADR-0020:50; not yet in current src/lib/data/types.ts), L1 returns { roles: ['other'], confidence: 'fact', otherClassification: { description: 'deceased — estate-of inquiry', spineCategory: 'unclassified' } }. The other L2's light-triage.ts includes a sympathetic phrasing for estate inquiries and routes to escalate_to_human. A future tenant/posthumous.ts L2 prompt is a Phase 7+ candidate if volume warrants.

L1 lives at src/lib/agent/router/role-feature-router.ts.

L1 — the personRoleTypesToMetaRoles bridge (Findings 1.1 + 3.1)

ADR-0020 names 9 PersonRoleType values (tenant | prospect | pm | org_admin | leasing_agent | maintenance | viewer | platform_admin | vendor_contact). The L2 directory uses 5 meta-roles (tenant | vendor | prospect | pm | other). The translation happens at L1's exit point via a bridge function. Plural in / plural out — under the union-load model, the bridge maps a SET of PersonRoleTypes to a SET of MetaRoles:

// src/lib/agent/router/meta-role-bridge.ts
export type MetaRole = 'tenant' | 'vendor' | 'prospect' | 'pm' | 'other';

export function personRoleTypesToMetaRoles(roles: PersonRoleType[]): MetaRole[] {
  const meta = new Set<MetaRole>();
  if (roles.includes('tenant')) meta.add('tenant');
  if (roles.includes('vendor_contact')) meta.add('vendor');
  if (roles.includes('prospect')) meta.add('prospect');
  if (roles.some(r =>
    (['pm','org_admin','leasing_agent','maintenance','viewer','platform_admin'] as const).includes(r)
  )) meta.add('pm');
  return meta.size > 0 ? [...meta] : ['other'];
}

Why hybrid (5 L2 dirs + 9-value matrix), not pure-5 or pure-9. Prompt prose is largely shared across the PM staff variants (pm, org_admin, leasing_agent, maintenance, viewer, platform_admin all need PM-audience phrasing) — 9 directories would duplicate text. But tool authority IS different per role (viewer should never see mutation tools; leasing_agent shouldn't see vendor-dispatch tools). So the matrix keys on the full 9-value enum, the L2 dir on the 5 meta-roles. The bridge is the gate into the existing composeCapabilities input shape — Phase 3 elevates agents/clara/lib/agent/capabilities/ into the formal L2 directory by routing through this bridge.

Set-returning vs single-returning. v2 of the ADR had personRoleTypeToMetaRole(roles): MetaRole (singular) because v2 picked ONE meta-role per turn. Under v3's union-load, the bridge returns the SET so the L2 + L3 assemblers can load all of them. Without the set-returning shape, Carlos would still get only one role's bundle.

Intent-hints classifier (separate, pre-L1, opt-in Haiku)

ADR v1 implied L1 itself ran a Haiku classifier on unknown callers; that conflicts with "L1 is LLM-free." Split out as a distinct pre-L1 step (Finding 3.4):

// Pre-L1: runs ONLY when (resolveParticipant → null) AND no spam/wrong_number Person exists yet
//         AND inbound message-shape regex hints fire ("bedroom", "tour", "available", "moving in")
// Cost: one Haiku call (~$0.0001) on the rare unknown-with-prospect-intent path
// Output: { promoteTo: 'prospect' | null, intent: 'tour' | 'pricing' | ... }

If positive: the classifier mints a skeleton Person + a prospect PersonRole row with source: 'router_mint' (a new PersonRoleSource value — see "Spine integration" below), then L1 runs normally with the freshly-minted role. If negative: unknown light-triage path.

L1 itself remains LLM-free — its hot path runs only for callers with a resolved participantId. The intent-hints classifier is a separate boundary-case helper, off the hot path for the 95%+ of interactions where the Person is already known.

L2 — Feature prompts (union of bundles across all active roles)

~150-400 tokens each, organized as src/lib/agent/prompts/roles/<role>/<feature>.ts. All features for ALL the caller's active roles load together with equal weight. A single-role tenant gets 4 features (maintenance, renewal, general-inquiry, rating). A multi-role caller like Carlos (tenant + vendor) gets 8 features. Clara reads the message and decides where to focus naturally.

src/lib/agent/prompts/roles/
├── tenant/
│   ├── maintenance.ts          (today's clara-maintenance.ts + capabilities/maintenance.ts, elevated)
│   ├── renewal.ts              (today's renewal voice/SMS prompt + capabilities/renewal.ts, elevated)
│   ├── general-inquiry.ts      (today's capabilities/resident-services.ts: lease questions, balance lookups)
│   └── rating.ts               (post-WO rating reply parsing)
├── vendor/
│   ├── respond-to-dispatch.ts
│   ├── schedule-confirmation.ts
│   ├── work-order-update.ts
│   └── handoff.ts              (vendor → vendor reassignment)
├── prospect/
│   ├── discovery.ts            (today's capabilities/leasing.ts, light qualification)
│   ├── tour-schedule.ts        (today's tour scheduling slice)
│   ├── application.ts          (rental application flow)
│   └── disqualification.ts     (fair-housing-safe wind-down)
├── pm/
│   ├── ask-clara.ts            (absorbs today's clara-pm.ts applyPmPersona prefix)
│   └── escalation-response.ts  (PM replying to escalation thread)
└── other/
    └── light-triage.ts         (random caller / advertiser / spam / wrong-property / unclassified;
                                 includes sympathetic phrasing for deceased-Person estate inquiries)

Reserved namespace for Phase 7+ roles. applicant/, cosigner/, owner/, agent/ directories are reserved. L1 currently treats applicant signals as prospect and owner-as-PM-equivalent until those dirs are populated.

L2 token-ceiling drift guard (Finding 3.3). A test at src/__tests__/role-router-l2-ceiling.drift.test.ts asserts:

Phase 3 token-aware refinement (using tiktoken or Anthropic's tokenizer) is a follow-up.

Token-cost drift guard for property facility prose. L2 prompts must NOT contain facility-fact prose (pool, gym, parking, pet policy, hours, amenities). Facility facts come from L3 tools (see "Property facility info" below). Drift guard at src/__tests__/role-router-l2-facility-prose.drift.test.ts greps prompts/roles/**/*.ts for pool|parking|pet|amenity|hours|gym and fails CI if a match is found.

Voice-applicability metadata + voicePromptHint (Findings 4.4 + 6.9). Each L2 file exports:

export const META: L2FeatureMeta = {
  primaryTools: ['create_work_order', 'get_troubleshooting_steps'],
  voiceApplicable: false,           // false = SMS-only; true = voice prompt should handle it
  voicePromptHint?: string,         // when voiceApplicable, the voice `[CONTEXT]` block's "how-to-handle" guidance
  tokenEstimate: number,
};

Voice's single ElevenLabs prompt reads the union of all voicePromptHint values when assembling its meta-instructions. This keeps the voice prompt aware of every L2 feature without forcing N voice-agent variants.

L3 — Tool-role matrix + dispatcher

dispatchTool(toolName, input, callerContext) consults a registry (toolName → ToolPolicy) and refuses BEFORE the handler runs. Replaces inline if (caller.role !== 'tenant') refuse() scattered through handlers. Lives at src/lib/agent/router/tool-dispatch.ts.

Matrix keys on full PersonRoleType enum, not on MetaRole. This gives per-staff-variant tool authority precision (a viewer doesn't see mutation tools even though they share the pm/ L2 prompt).

Each tool lives in exactly one catalog — distinct tool names per scope. If two roles need similar functionality with different scopes, they're DIFFERENT tools: create_work_order (tenant; scope=own-unit-occupant) and pm_create_work_order (PM; scope=org-wide). NEVER the same tool name in two catalogs. Reason: makes the matrix self-documenting (auditing "what can a PM do?" is a clean query) and eliminates dispatch ambiguity. Multi-role callers get access to BOTH tools because BOTH catalogs are loaded under union-load — they don't need a single tool with caller-determined scope.

// src/lib/agent/router/tool-role-matrix.ts
export const TOOL_ROLE_MATRIX: Record<ToolName, ToolPolicy> = {
  // Tenant tools
  'create_work_order':       { roles: ['tenant'], scope: 'own-unit-occupant' },
  'get_unit_appliances':     { roles: ['tenant'], scope: 'own-unit-occupant' },
  'request_photo':           { roles: ['tenant'], scope: 'own-unit-occupant' },
  // Vendor tools
  'accept_dispatch':         { roles: ['vendor_contact'], scope: 'assigned-wo' },
  'update_work_order':       { roles: ['vendor_contact'], scope: 'assigned-wo' },
  // Prospect tools
  'save_prospect':           { roles: ['prospect'], scope: 'self' },
  'schedule_tour':           { roles: ['prospect'], scope: 'self' },
  // Property facility info — universal read-only (Finding 2.1)
  'get_property_amenities':  { roles: '*' as const, scope: 'inbound-property' },
  'get_property_hours':      { roles: '*' as const, scope: 'inbound-property' },
  'get_pet_policy':          { roles: '*' as const, scope: 'inbound-property' },
  'get_parking_rules':       { roles: '*' as const, scope: 'inbound-property' },
  // Universal escalation
  'escalate_to_human':       { roles: '*' as const, scope: 'self' },
  // PM tools — role keyed on staff variant
  'pm_close_work_order':     { roles: ['pm', 'org_admin'], scope: 'org-wide' },
  'pm_send_renewal':         { roles: ['pm', 'org_admin', 'leasing_agent'], scope: 'org-wide' },
};

export type ToolPolicy = {
  roles: readonly PersonRoleType[] | '*';
  scope:
    | 'own-unit-occupant'             // any TenantOccupancy (primary OR roommate OR guarantor)
    | 'own-unit-primary'              // primary signers only
    | 'own-unit-financially-responsible' // primary + guarantor
    | 'assigned-wo'                   // WO must be assigned to caller's vendor
    | 'inbound-property'              // facility-info: same-property as inbound
    | 'self'                          // caller is the subject (saves their own prospect record)
    | 'org-wide';                     // PM actions across the org
};

scope triggers a secondary check at dispatch time against the caller's PersonRole rows + inbound's property. PersonRole.scope IS threaded into the dispatch decision (Finding 6.1 + bot turn-3): the existing composeCapabilities keys only on ResolvedIdentity.type, which is binary. Phase 3's bridge expansion threads PersonRole.scope into composeTools so Phase 4's L3 scope checks have a richer key than today's roommate/primary collapse.

Dispatch refusal shape (Finding 6.5). Refused tools are filtered at catalog registration time (the model never sees them — they're not in the tool list passed to Anthropic). A secondary dispatch-time refusal at the handler boundary is defense-in-depth in case a tool slips into the catalog without a matrix entry; in that case it returns a tool_result with is_error: true and a refusal message that Clara handles gracefully.

personContext — the cross-interaction memory

A Person.context field (DDB row, scoped to the Person partition) holds an AI-maintained snapshot of "what's going on with this person right now":

interface PersonContext {
  /** Last-refreshed timestamp; null until first interaction completes */
  refreshedAt: string | null;
  /** ~200-token summary written by a Haiku call at end-of-interaction.
   *  On first-interaction (`refreshedAt === null`): always include the block
   *  with `"This is a fresh contact. No prior context available."` so the
   *  model's framing is consistent across interaction counts (Finding 6.8). */
  summary: string;
  /** Structured pointers for next interaction; cheap to assemble, no LLM */
  active: {
    /** Open WOs where this person is tenant or vendor. Pointer + brief,
     *  NOT the full WO row — Clara can answer "yes, WO-7 is in progress,
     *  Mike is dispatched" eagerly from this without a tool call. For full
     *  detail (vendor notes, dispatch history, photo URLs) Clara calls
     *  `get_work_order_detail` lazily. */
    activeWorkOrders: Array<{
      id: string;
      displayId: string;          // 'WO-7'
      status: string;             // 'in-progress' / 'dispatched' / 'scheduled'
      brief: string;              // 'running toilet — Mike dispatched 9:30am'
    }>;
    activeTours: Array<{ id: string; status: string; scheduledAt: string }>;
    activeRenewalWorkflowId: string | null;  // (renamed from activeRenewalSagaId per ADR-0025 Phase 5; Finding 6.7)
    lastChannel: 'sms' | 'email' | 'voice' | 'telegram' | null;
    lastInteractionAt: string;
    lastInteractionId: string;           // agent_trace ID; used as idempotency key (Finding 6.4)
  };
  /** Token budget guard — if `summary` > 800 tokens, the refresh truncates */
  tokenEstimate: number;
}

Refresh trigger. End-of-interaction event. Fire-and-forget; eventually consistent.

Idempotency key (Finding 6.4). (personId, lastInteractionId), NOT (personId, lastInteractionAt). Two interactions completing within the same wall-second produce identical timestamps but distinct interaction IDs (agent trace IDs); the trace ID-keyed dedup is structural, no race.

Reset-path defense (Finding 6.10). At assemblePrompt injection time, the assembler validates each active.* pointer (one DDB read per pointer-type, ~3 reads/interaction) and filters out ghost pointers (WO/tour/saga that was deleted). Cheap defense-in-depth; reset scripts shouldn't be the sole guard.

Voice/SMS context-injection parity (Finding 6.9). The /api/voice/personalization endpoint's [CONTEXT] block ordering must mirror the SMS assemblePrompt ordering of active.* pointers. Cross-channel continuity ("she remembers what we discussed on voice yesterday") depends on the model seeing the same context shape across channels.

Three-tier context loading: eager, lazy, speculative

Clara's context comes in three latency tiers. The choice of tier per piece of information is driven by traffic frequency (eager = high traffic; lazy = rare) and information size (small + always-relevant → eager; large + sometimes-relevant → lazy).

Tier Loaded Latency Used for
Eager At agent-loop start, inside the assembled system prompt. Lives in personContext.summary + personContext.active.*. 0ms (already in the cached prefix) High-traffic identity-anchored facts: open WO IDs + brief, active tour, active renewal, last channel, last-interaction summary. "What are my open WOs?" — Clara answers from eager context, no tool call.
Lazy At dispatch-time when Clara calls a tool. ~500ms per tool call Rare or deep-detail info: full WO description, vendor notes, lease document URL, applicant credit report. "What did Mike write in the notes on WO-7?" — Clara calls get_work_order_detail(WO-7) lazily.
Speculative Fired in parallel with Clara's first response turn, by an end-of-interaction hint OR by the L1 router. The fetch completes in the background; Clara's second turn (if any) sees the result. 0ms blocking "Likely-needed" deeper context that's not in the eager snapshot but the conversation shape suggests Clara will reach for soon. Tenant mentions "the toilet thing" → speculative fetch of WO-7 detail fires; ready by Clara's follow-up.

The eager tier is the default for personContext. Persisting the snapshot is what makes this tier work — the refresh worker writes personContext.active.* at end-of-interaction so the NEXT inbound has it ready in the prompt at agent-loop start, no live-DDB rebuild. If the persisted snapshot is missing (first interaction) the eager block carries a placeholder ("No prior context available") and Clara behaves correctly with that framing.

Speculative is Phase 3+ — the foundation is the eager + lazy split (Phase 2b ships eager; the lazy path already exists via today's tools). Speculative fetch wires when the agent-trace data shows specific "Clara needed X but had to wait for a tool call" patterns that a pre-fetch would unblock.

Status observability — context-build gaps + graceful-degrade signals

Two related observability surfaces, both on /admin/dev/status.

Card 1 — "Context-build gaps." Every interaction where the eager-context build hits an edge case logs a structured event:

logWarn('agent/context-build', {
  reason: 'personId_missing'              // conversation.personId is null post-resolveParticipant
        | 'persisted_context_missing'     // first-interaction OR refresh worker hasn't landed
        | 'ghost_pointer_filtered'        // active.* pointed at a deleted WO/tour/saga
        | 'tenant_or_prospect_query_failed',
  channel: 'sms' | 'voice' | 'email' | 'telegram',
  inbound: { phoneLast4: '1001', propertyId: 'pf_yale' },
  detail: { conversationId, personId: null, ... }     // full structured payload for drill-down
});

7-day rolling count + list of most-recent 20 events with phone-last-4 + property + reason. Alarm threshold: >5/day fires feed-side review (eager-context is supposed to be the silent default; persistent gaps signal a spine-stamp issue worth investigating).

Card 2 — "Graceful-degrade signals" (every other-routed interaction). Every time L1 returns roles: ['other'], a structured event fires:

logWarn('agent/graceful-degrade', {
  reason: 'no_person_resolved_no_intent_hints'   // wrong number / random caller / advertiser
        | 'person_resolved_no_roles'             // Person exists in spine but has no PersonRole rows for this property's org
        | 'person_category_spam'                 // Person.category = 'spam' (silent drop, also logged for visibility)
        | 'person_category_wrong_number'         // Person.category = 'wrong_number'
        | 'deceased_person',
  channel: 'sms' | 'voice' | 'email' | 'telegram',
  inbound: { phoneLast4: '1001', propertyId: 'pf_yale', firstMessageSnippet: 'hi clara, just checking in' },
  otherClassification: { description: 'random caller', spineCategory: 'wrong_number' },
  detail: { conversationId, suspectedKnownPerson?: 'maybe tenant of unit 204 — phone resembles +1206555…' }
});

7-day rolling count + per-description breakdown (random_caller / wrong_number / advertiser / spam / unclassified). Drill-down shows the first message snippet so an operator can eyeball whether a "random caller" was actually a known tenant whose spine-stamp didn't land. Alarm threshold: any single phone number appearing in other more than 2× in 7 days fires a gear-side review (high probability of a spine-stamp gap, not a true random caller).

Why two cards instead of one. "Context-build gap" = the eager prompt assembly hit an edge case (could happen even for a properly-resolved Person). "Graceful-degrade signal" = the whole identity resolution lane bailed and Clara fell through to other. Different debugging vocabulary; different operator response. A tenant whose Person was resolved but personContext failed to build shows up in Card 1, not Card 2. A wrong-number caller shows up only in Card 2.

Implementation phase: both cards ship in Phase 2b. The structured-event hooks live in conversation-manager.ts (context-build) and role-feature-router.ts (graceful-degrade signal at the other exit point). Both cards reuse the existing /admin/dev/status shape (per npm run status + scripts/status-cli.ts).

assemblePrompt — ordering matters for prompt caching (Finding 3.2)

function assemblePrompt(opts: {
  L0: string;                          // identity + safety block
  L1: { roles: MetaRole[]; confidence: Confidence };
  L2_bundles: string[];                // union of every active role's feature prompts (equal weight)
  L3_toolsForCaller: ToolSpec[];       // union of every active role's catalog
  propertyFacilityTools: ToolSpec[];   // universal facility lookups
  personContext: PersonContext;
}): { system: string; tools: ToolSpec[] };

Order (most-stable-first):

  1. L0 (most stable; brand identity + safety, ~140 tokens)
  2. L2 feature prompts (union of active role bundles, equal weight; ~600-1.2k tokens depending on role count)
  3. Property facility tool descriptions (L3 — stable per property; ~100 tokens)
  4. Role-relevant tool descriptions (L3 union; ~400-800 tokens depending on role count)
  5. CACHE BREAKPOINT (mark last tool with cache_control: ephemeral)
  6. personContext.summary (per-interaction-volatile; ~200 tokens)
  7. personContext.active.* (per-interaction-volatile; ~100 tokens)
  8. L1 routing breadcrumb (per-interaction; ~50 tokens — { roles, confidence } summary)

This keeps L0 + L2 + L3 in the long-cached prefix; only the last ~350 tokens are recomputed per interaction. Phase 3 acceptance criterion: production cache-hit rate ≥80% on the 7-day rolling window (measurable from day 1 via the existing cache_read_input_tokens instrumentation in agents/clara/lib/agent/conversation-manager.ts — grep: cache_read_input_tokens).

Per-role hit-rate observability (Finding 6.3). Alarm fires when any role's 7-day rolling cache-hit rate drops below 50% — surface a feed-side prompt-stability review for that role.

Property facility info — L3 tools, not L2 prose (Finding 2.1)

Pool hours, gym hours, parking rules, pet policy, amenities — none of these are per-role; they're per-property facts. They live as L3 tools (catalog at src/lib/tools/property-info.ts), callable by any role:

get_property_amenities(propertyId): { pool: boolean; gym: boolean; ... }
get_property_hours(propertyId): { officeHours: ...; poolHours: ...; ... }
get_pet_policy(propertyId): { allowed: bool; deposit: number; ... }
get_parking_rules(propertyId): { spots: number; cost: ...; ... }
get_pool_status(propertyId): { open: bool; reason?: string; ... }

L0 includes a one-line hint: "Property facts (pool, hours, parking, pet, amenities) are available via tools — call them when asked." Drift guard ensures L2 prose stays facility-free. Same fact, same code path for tenant + prospect + vendor questions.

Spine integration — PersonRoleSource extends to 'router_mint' (Finding 1.7)

// src/lib/data/types.ts (PersonRoleSource at line ~4495)
export type PersonRoleSource =
  | 'phase6a-backfill'
  | 'auth-hook'
  | 'manual_pm'
  | 'pms_sync'
  | 'router_mint';        // NEW — added in Phase 2 (intent-hints classifier promotion)

The intent-hints classifier writes PersonRole rows with source: 'router_mint'. Spine drift guards (per the spine-stamp-pattern) recognize this source as a valid PersonRole writer.

Voice carve-out

ElevenLabs hosts the agent prompt at agent-config time. The L0+L1+L2 layering does NOT apply at runtime for voice — the prompt is loaded at agent-config sync. Instead:

  1. Voice gets a single unified prompt assembled at sync time from L0 + a meta-instruction set that absorbs the union of voicePromptHint from every voiceApplicable: true L2 file. Lives at agents/clara/lib/agent/clara-voice.ts.
  2. The /api/voice/personalization endpoint becomes the role-router for voice. At call-start, it resolves phone → personId → roles → personContext and pushes the relevant slice into the personalization payload as a structured [CONTEXT] block:
    [CONTEXT]
    Caller: Marcus Johnson (Person ID: p_abc123)
    Active role: tenant
    Active feature: maintenance
    Property: JP Yale Station / Unit 204
    Open work orders: WO-7 (running toilet, in-progress, Mike Torres dispatched 9:30am)
    Recent: Texted yesterday — Clara provided plunger steps. No reply.
    Tool scope: tenant tools available (create_work_order, get_unit_appliances, ...)
    [/CONTEXT]
    
  3. Tool-RBAC on voice goes through the same dispatchTool registry. The voice tool catalog gets a derived projection that filters by voiceApplicable: true matrix entries.

[CONTEXT] ordering mirrors SMS assemblePrompt (Finding 6.9) so cross-channel continuity delivers equivalent context emphasis. Voice doesn't have prompt caching to optimize for (ElevenLabs hosts the prompt), but the parity simplifies operator debugging.

Eval coverage (Finding 4.4). Per-L2-feature voice evals on a weekly rotation (~$0.50/feature, bounded cost) plus a schema-drift guard on every PR (free). The schema-drift guard catches missing entries; the rotation eval catches quality drift.

Locale degradation (Finding 6.11)

Under v3's union-load model, L1's locale dependency shrinks dramatically — the SET decision is locale-neutral (Person.id → spine PersonRole rows, no message-shape inspection). The only locale-bound piece left is the intent-hints classifier's English regex (bedroom, tour, available, moving in) that promotes unknown → prospect.

Cost model + observability (Finding 6.13)

Per-interaction inference cost at 80% cache-hit rate, assuming 5k system+tools tokens and 3 agent-loop turns averaging 500 output tokens:

Component Per-turn (input) Per-turn (output) Per turn Per interaction (3 turns)
Sonnet 4.6 input (cached) 4000 × $0.0000003 $0.0012
Sonnet 4.6 input (uncached) 1000 × $0.000003 $0.003
Sonnet 4.6 output 500 × $0.000015 $0.0075
Per-turn total $0.012 $0.036
Haiku refresh (end-of-interaction) $0.0002
Per-interaction total ~$0.036
Daily volume Daily cost Monthly cost
1,000 interactions $36 $1,080
10,000 interactions $360 $10,800
100,000 interactions $3,600 $108,000

Per-property refresh budget (Finding 4.3): reframed from a static $200/property/month to per-1000-interactions ≤ $0.50 expected. Alarm fires when 7-day-rolling exceeds 1.5× this baseline.

Per-1000-interaction cache observability (Finding 6.3): cache-hit rate per role surfaced on the agent-trace dashboard; drift guard fires when any role's 7-day rolling hit rate drops below 50%.

Multi-tenant / cross-org isolation (Finding 1.2)

vendor_contact PersonRoles are vendor-scoped (scope: { vendorId }), not org-scoped. Cross-org vendor work (one vendor servicing multiple PMCs) is handled via a parallel resolver alongside the property-scoped role walk:

async function resolveActiveRoles(participantId: string, propertyId: string) {
  const orgScoped = await getPersonRolesForOrg(participantId, propertyId);
  const property = await getProperty(propertyId);
  const vendorContacts = await getVendorContactsForPerson(participantId);
  const relevantVendorContacts = vendorContacts.filter(vc =>
    isVendorServicingProperty(vc.vendorId, propertyId)
  );
  return [...orgScoped, ...relevantVendorContacts];
}

Phase 1 implementation. Phase 4+ may add an OrganizationLink entity to make vendor↔org grants explicit; deferred until multi-PMC vendor patterns warrant the new entity.

In scope — PM impersonation routing

Out of scope for this ADR

Phased delivery

Each phase is independently revertable. Gates between phases require ≥1 week production-bake of the prior phase.

Phase 1 — L3 tool-role matrix + dispatcher + meta-role bridge (additive scaffolding). Build TOOL_ROLE_MATRIX covering all 22 current tools + the 5 new property-facility tools; build dispatchTool wrapper; build the personRoleTypesToMetaRoles bridge function (plural in / plural out). Add 'router_mint' to PersonRoleSource. Existing handlers keep their inline RBAC checks. Drift guards: matrix coverage; L2-facility-prose absence (against prompts/roles/ pre-emptively); distinct-tool-name-per-scope drift guard (no two matrix entries with overlapping (toolName, roles) and different scope). 1 PR, ~3 days.

Phase 2a — L1 router + intent-hints classifier + shadow logging. Build role-feature-router.ts (the deterministic L1, returning roles: MetaRole[]) + intent-hints-classifier.ts (the separate Haiku pre-L1 step); wire both into conversation-manager.ts behind a shadow=true flag that logs but doesn't act on the L1 verdict. NO new DDB field, NO new Lambda, NO new SQS queue at this phase. Output is structured_log:role_router_shadow_decision events on agent_traces. Validation: set-equality. For each historical interaction, did L1 return the correct SET of active roles against the spine's PersonRole rows? Acceptance: ≥95% match over 30 days of historical data. The harder-to-judge case — "did intent-hints correctly promote no-Person inbounds to prospect?" — is a separate smaller eval surface (~50 cases human-judged). Trivially revertable. 1 PR, ~4 days.

Phase 2b — personContext schema + refresh infrastructure + status observability. Add Person.context DDB field (additive, default null); build personContextRepository; build async refresh hook + Haiku adapter using (personId, interactionId) as the idempotency key; provision the propflow-person-context-refresh Lambda + propflow-person-context-refresh.fifo SQS queue + DLQ + alarm. Update CLAUDE.md's Lambda table. Wire two structured-event hooks: agent/context-build in conversation-manager.ts (Card 1: "Context-build gaps") and agent/graceful-degrade in role-feature-router.ts at the other exit point (Card 2: "Graceful-degrade signals"). Both cards land on /admin/dev/status. 1 PR, ~3 days, gated on Phase 2a passing.

Phase 3 — L0 + L2 prompt directory + assembly + L3 dispatch wired. GATED on ADR-0022 Accepted (Finding 3.7). Mint L0 file (identity + safety block); elevate agents/clara/lib/agent/capabilities/{maintenance,resident-services,renewal,leasing,escalate-pm}.ts to src/lib/agent/prompts/roles/<role>/<feature>.ts (Finding 3.1 — this is NOT a from-scratch extraction; it's a rename + restructure of the existing capability stack); delete applyPmPersona from agents/clara/lib/agent/clara-pm.ts and move its content to prompts/roles/pm/ask-clara.ts (Finding 1.6); every L2 file exports voiceApplicable: boolean + optional voicePromptHint; build assemblePrompt(L0, L1, L2-union, L3-union, personContext) per the ordering above; wire dispatchTool() to consult TOOL_ROLE_MATRIX BEFORE the handler runs — handler-side inline RBAC stays as defense-in-depth; wire assemblePrompt behind a per-property feature flag at Property.flags.roleRouterEnabled: boolean in DDB (this flag is a cutover device, deleted in Phase 5 — Finding 3.6); thread PersonRole.scope through composeTools for L3 scope checks. Phase 3 acceptance criteria: (a) cache-hit rate ≥80% on test property appfolio-45 over 7 days; (b) L2 char-ceiling drift guard passing; (c) eval surface — fair-housing eval × prospect features at PR-time + nightly cross-feature integration evals (Finding 6.6); (d) multi-role smoke test (Carlos-equivalent test fixture): both bundles + both catalogs loaded; both tool paths exercised in one conversation. 2 PRs, ~7 days.

Phase 4 — Remove inline RBAC from tool handlers. Once Phase 1+3 are live for ≥2 weeks, refactor each handler to drop its now-redundant inline role check. Drift guard re-fires asserting no handler contains a caller.role !== literal. 1 PR per handler-cohort (5 cohorts × ~1 day each).

Phase 5 — Voice integration + flag deletion. Update /api/voice/personalization to assemble the structured [CONTEXT] block (mirroring SMS assemblePrompt order); update clara-voice.ts prompt to consume voicePromptHint union from all voiceApplicable: true L2 files; drift-guard the voice tool catalog projection; weekly voice-eval rotation per role × feature. Delete Property.flags.roleRouterEnabled from DDB + source — the flag was a cutover device. Add drift guard src/__tests__/role-router-flag-deleted.drift.test.ts failing CI if the flag is reintroduced. ~3 days, gated on Phase 3 stability.

Phase 6 — Delete the old intent classifier files entirely. ❌ CORRECTED 2026-05-27 — DO NOT DO THIS AS WRITTEN.

The original step said: once Phases 1-5 are live, delete clara-maintenance.ts, clara-leasing.ts, AND clara-pm.ts (incl. applyPmPersona), with a drift guard pinning them out.

Why it's wrong now. That deletion presupposed Phase 3's prompt-directory reorg — relocating the prompt CONTENT into prompts/roles/<role>/<feature> so the old files would be genuinely dead. That reorg was NOT built (the security core shipped via the runtime-trust-tier dispatch gate instead, which needs no prompt relocation). The three files are therefore LIVE, not deprecated — they are ~893 lines of in-use prompt content:

Deleting them today breaks Clara on every channel. Verified by grep before this correction — all three have live importers on main.

What's actually left (optional, separate effort). If we ever want the prompt-directory reorg for maintainability, that is its own initiative: relocate the content into prompts/roles/<role>/<feature>, repoint the importers, run the full eval suite to confirm no behavioral regression, THEN the originals become deletable. It carries eval-regression risk and zero security payoff (the security model is already enforced by the dispatch gate). Pursue it only if a maintainability pain materializes — not as a "finish the migration" obligation. There is no drift guard pinning these files out, and none should be added until the reorg actually lands.

Total wall clock: ~6 weeks if every phase stabilizes on first try; ~10 weeks with realistic churn. Each phase is shipping value; nothing waits on a big-bang cutover.

Forks + alternatives considered

Fork A — Handler-side vs model-side role escalation for "inkling of prospect" cases

Picked A1: handler-side escalation (deterministic). The intent-hints classifier runs on unknown-caller inkling-of-prospect cases only (~5% of inbound traffic). Reasons: deterministic, lower latency (150ms Haiku only on the 5% path), $-cheap ($15/day cheaper than the model-side alternative at scale), grep-friendly audit log, compositional with the existing first-message classifier pattern.

Re-evaluation trigger: new role with very fuzzy intent boundary (e.g., "researcher inquiry") where regex hints don't suffice.

Fork B — Async vs sync personContext refresh

Picked B2: async fire-and-forget at end-of-interaction. Hot-path-safe (sync would add ~400ms to Clara's reply latency), cost-amortized (one Haiku call per interaction-end, ~60% skipped via the skip-rules), eventually consistent is fine (active.* pointers stay authoritative; summary is advisory).

Re-evaluation trigger: refresh per-1000-interaction cost exceeds $0.75 (1.5× baseline).

Fork C — Single-agent vs N-agent voice

Picked C1: single agent + rich personalization. Operational weight (N agents = N×every-piece — sync workflow, drift guard, snapshot retention), prompt drift compounds, no customer demand for per-role voice tone, compositional with SMS's L1 + personContext shape.

Re-evaluation trigger: customer interview names per-role voice differentiation as a buying criterion.

Consequences

What becomes easier

What becomes harder

Hard gates (Accepted because all 4 hold)

  1. feed — Phase 2 shadow set-equality ≥95% between L1's returned roles: MetaRole[] set and the spine's PersonRole rows over 30 days of historical data. Intent-hints promotion eval is a separate ~50-case sample.
  2. feedpersonContext refresh budget per-1000-interactions ≤$0.50 expected; alarm fires at 1.5× baseline.
  3. gear — drift guard at every handoff. TOOL_ROLE_MATRIX coverage; inline-RBAC-elimination; voice-personalization-schema; L2-facility-prose-absence; L2 char-ceiling; flag-deleted-after-Phase-5; distinct-tool-name-per-scope (no two matrix entries with overlapping (toolName, roles) and different scope). Seven CI tests, each lands in the phase that introduces its target.
  4. sane — no customer-facing copy ships per-role until Phase 3 is live for ≥1 property × ≥2 weeks with no regressions.

Known shortcomings (full mitigations woven into the sections above)

This list summarizes; mitigations live in the relevant sections.

  1. personContext refresh hot-path-adjacency. Mitigated by async + skip-on-trivial + interactionId-keyed dedup; per-1000-interaction cost-tracked.
  2. Voice asymmetry. Mitigated by single-agent + voicePromptHint + weekly rotation eval.
  3. L1 router centrality. Mitigated by deterministic logic + comprehensive tests + Phase 2 set-equality shadow validation.
  4. Tool-role matrix as public contract. Mitigated by matrix-coverage + distinct-tool-name-per-scope drift guards.
  5. Channel adapter (ADR-0022) co-Proposed dependency. Mitigated by hard phase-gate: Phase 3 cannot start until ADR-0022 is Accepted.
  6. Co-tenant / roommate scope. Mitigated by typed scope enum (own-unit-occupant vs own-unit-primary vs own-unit-financially-responsible) at L3 dispatch.
  7. Locale degradation. Documented; locale-aware regex is Phase 7+. The role-set decision is locale-neutral (Person.id → spine PersonRole rows); only the intent-hints classifier's English regex is locale-bound.
  8. PromptCache invalidation cascade. Mitigated by ordering (L0+L2+L3 in long-cached prefix; volatile bits at end) + per-role hit-rate observability.

Open questions — all resolved

  1. L1 ambiguity calibration. Confidence is categorical ('fact' | 'inferred'). Multi-role callers get union-load; other-labeled callers carry a description field. No calibration knob.
  2. Phase 2 shadow validation data source. Sample from agent_traces (cheap, post-classifier-filtered) as the primary source. Validation is set-equality (did L1 return the right set of roles?) — structural, not judgment.
  3. personContext write path. Direct DDB writes from conversation-manager.ts in Phase 2b–3. After ADR-0025's Temporal migration Phase 5 completes, revisit whether to fold into the Temporal activity layer for durability + replay. Tracked but not blocking.

References