Skip to main content

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: 033 is the highest used decision number at time of writing (032 is 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โ€‹

PropertyValue
Columnspecialists.prompt_template_key
Typevarchar(64), nullable
EntitySpecialist.promptTemplateKey: string | null (api/src/specialists/specialist.entity.ts)
Allowed valuesthe PROMPT_TEMPLATE_KEYS union โ€” ecommerce_ops, finance_ops, ir_reporting, kyc_ops (api/src/specialists/prompt-templates.ts)
DefaultNULL (means "no explicit template โ€” fall back to the conversation role chain")
Mapping1: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:

  1. Conversation create (API). ConversationsService.resolveConversationRole() reads the Specialist's promptTemplateKey; if set it becomes the conversation role, otherwise the caller-requested role, otherwise ecommerce_ops. The resolved role is stamped on the conversation.
  2. Agent dispatch (API โ†’ Agent). AgentClient sends specialist.prompt_template_key = specialistPromptTemplateKey ?? role on POST /v1/chat.
  3. Agent resolution. _resolve_role() (agent/routers/chat.py) takes specialist.prompt_template_key or role; if it is in _KNOWN_TEMPLATES it 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 canonical prompt_template_key for 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 promptTemplateKey so 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):

  1. ALTER TABLE specialists ADD COLUMN IF NOT EXISTS prompt_template_key varchar(64) โ€” nullable, so old pods keep working (NULL โ†’ existing fallback behaviour).
  2. Backfill: existing Specialists whose title matches %kyc% / %aml% (or whose specialty_domains JSON mentions KYC/AML, when that column is present) are set to kyc_ops. The backfill is written to tolerate both DB topologies โ€” it probes information_schema.columns for specialty_domains because that column is TypeORM-synchronised in dev/test but absent on fresh-migrated CI/prod schemas.
  3. 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_KEYS union, the agent _KNOWN_TEMPLATES set, and the agent/prompts/*.txt files 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) and prompt_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