ADR-034 โ Specialist Prompt-Template Scoping (prompt_template_key)
Date: 2026-06-29 Status: Accepted (field shipped in PR #1211, merged 2026-06-01, closes #1203) Issue: #1283 (docs-gap follow-up to the #1265 sweep) Authors: degen11 (PM agent) Related ADRs: ADR-020 (isolation matrix โ this is the Tools/Workflows row, Specialist-scoped), ADR-030 (Specialist identity fields), ADR-033 (L0 persona / layered prompt assembly)
Numbering note:
033is the highest used decision number at time of writing (032is duplicated for two unrelated decisions). This ADR lands at the next free number, 034. It is a retroactive ADR โ it documents a decision already implemented and merged โ written to close the data-model documentation gap flagged in #1283.
Contextโ
A Specialist's draft quality depends on the agent loading the right business-scenario prompt template (agent/prompts/<key>.txt). Before #1211 the agent service resolved the template from a single hardcoded constant:
_DEFAULT_ROLE = "ecommerce_ops" # agent โ pre-#1211
Every Specialist whose conversation did not carry an explicit, recognised role fell through to e-commerce operations. The visible failure (#1203) was cross-org template bleed: KYC / AML clients received e-commerce-flavoured drafts โ the agent answered identity-verification questions in the register of a Shopify support rep. This is a professionalism and an isolation defect: the template that shapes a reply is per-Specialist data, and a global default silently crosses that boundary.
The data model had no field to express "this Specialist operates in the KYC domain", so the routing decision had nowhere to live except a constant in the agent.
Decisionโ
Add a nullable prompt_template_key column to the specialists entity. It is the authoritative, per-Specialist selector for the agent's business-scenario template, resolved through a single explicit fallback chain (never a silent global default).
Field definitionโ
| Property | Value |
|---|---|
| Column | specialists.prompt_template_key |
| Type | varchar(64), nullable |
| Entity | Specialist.promptTemplateKey: string | null (api/src/specialists/specialist.entity.ts) |
| Allowed values | the PROMPT_TEMPLATE_KEYS union โ ecommerce_ops, finance_ops, ir_reporting, kyc_ops (api/src/specialists/prompt-templates.ts) |
| Default | NULL (means "no explicit template โ fall back to the conversation role chain") |
| Mapping | 1:1 to agent/prompts/<key>.txt; the API allow-list and the agent's _KNOWN_TEMPLATES set must stay in lockstep |
The allowed-values list is intentionally a closed union validated on both sides. The API exposes isPromptTemplateKey() (used by organizations.service.ts when reading catalog/clone rows) and the agent revalidates against _KNOWN_TEMPLATES in agent/routers/chat.py โ a key the agent doesn't recognise is treated as bad data, not trusted blindly.
Isolation classification (ADR-020)โ
prompt_template_key is Specialist persona metadata, not customer PII. It sits on the Tools / Workflows row of the ADR-020 matrix โ Specialist-scoped, carried on the org-scoped specialists row. It is not a row-1 / row-5 cross-tenant resource, so it does not require the org_id AND specialist_id query filter that conversation/PII tables do; isolation comes from the field living on the Specialist instance itself. (ADR-030 lists it among the Specialist identity fields that propagate to channel surfaces.)
Resolution / fallback chainโ
The key is resolved at two points; both fail safe and observable, never silently to a global e-commerce default:
- Conversation create (API).
ConversationsService.resolveConversationRole()reads the Specialist'spromptTemplateKey; if set it becomes the conversationrole, otherwise the caller-requested role, otherwiseecommerce_ops. The resolved role is stamped on the conversation. - Agent dispatch (API โ Agent).
AgentClientsendsspecialist.prompt_template_key = specialistPromptTemplateKey ?? roleonPOST /v1/chat. - Agent resolution.
_resolve_role()(agent/routers/chat.py) takesspecialist.prompt_template_key or role; if it is in_KNOWN_TEMPLATESit is used, otherwise the agent logs a warning (Unknown prompt_template_key=โฆ, falling back to ecommerce_ops/No prompt_template_key or role โฆ) and uses_FALLBACK_ROLE = "ecommerce_ops".
The single remaining hard fallback is now explicit and logged, so legacy callers and bad data surface in the logs instead of producing a wrong-domain draft unnoticed.
Provisioning / where the value comes fromโ
- Catalog archetypes (
is_catalog = TRUE) carry the canonicalprompt_template_keyfor their domain. - Live clones copy it verbatim when an archetype is materialised for an org (
organizations.service.tsโpromptTemplateKey: archetype.promptTemplateKey). - AM override: the create/update Specialist DTOs accept
promptTemplateKeyso an AM can set or change a live Specialist's template without re-cloning.
Migration story (expand-only)โ
Migration 1780285344732-AddSpecialistsPromptTemplateKey follows the expand-contract rule (it is pure expand โ no contract step needed):
ALTER TABLE specialists ADD COLUMN IF NOT EXISTS prompt_template_key varchar(64)โ nullable, so old pods keep working (NULL โ existing fallback behaviour).- Backfill: existing Specialists whose
titlematches%kyc%/%aml%(or whosespecialty_domainsJSON mentions KYC/AML, when that column is present) are set tokyc_ops. The backfill is written to tolerate both DB topologies โ it probesinformation_schema.columnsforspecialty_domainsbecause that column is TypeORM-synchronised in dev/test but absent on fresh-migrated CI/prod schemas. down()drops the column. SQLite test datasources short-circuit both directions.
No dual-write / read-switch phase is required: NULL is a valid, meaningful value (it means "use the role chain"), so adding the column is non-breaking on its own.
Consequencesโ
- Positive: wrong-domain drafts (#1203) are eliminated โ a KYC Specialist routes to
kyc_ops, not e-commerce. The template selector is now first-class, per-Specialist, isolation-respecting data instead of an agent constant. The fallback path is logged, so future bad data is observable rather than silent. - Coupling to maintain: the API
PROMPT_TEMPLATE_KEYSunion, the agent_KNOWN_TEMPLATESset, and theagent/prompts/*.txtfiles are three copies of one list. Adding a new business scenario means touching all three; a key present in one but not the others either fails validation (API) or falls back with a warning (agent). Keep them in lockstep. - Scope limit: this ADR covers only the business-scenario role template selector. It does not govern the persona/system-prompt layering described in ADR-033 L0 โ
system_prompt(persona) andprompt_template_key(role template) are independent inputs to prompt assembly and must not be collapsed. - Non-goals: per-conversation template overrides beyond the existing role field; a UI for editing the key (it is set via catalog seed + AM DTO today); expanding the closed union into free-form template names (the closed list is the validation boundary that makes the bleed fix enforceable).
Referencesโ
- PR #1211 โ
feat: Specialist prompt_template_key โ fix KYC e-commerce bleed (#1203) - Issue #1203 โ KYC e-commerce template bleed
- Code:
api/src/specialists/specialist.entity.ts,api/src/specialists/prompt-templates.ts,api/src/conversations/conversations.service.ts(resolveConversationRole),api/src/common/agent.client.ts,agent/routers/chat.py(_resolve_role),agent/evals/test_prompt_template_routing.py - Migration:
api/migrations/1780285344732-AddSpecialistsPromptTemplateKey.ts - Cross-references:
docs/features/expert-queue.mdยง Specialist Prompt Template Key,docs/features/agentic-tasks.md