Tavus persona provisioning automation + drift audit β design
- Date: 2026-07-27
- Issue: #4575 β no CI/CD automation for
provision-tavus-personas.ts; tool/prompt drift on live personas goes undetected - Status: approved (design review 2026-07-27)
Context β what changed since the issue was filedβ
Issue #4575 (2026-07-17) predates the A2 refactor b6435e6b4 (2026-07-22), which demoted
api/scripts/provision-tavus-personas.ts to first-time PAL create + Face (replica) assignment
only. Ongoing prompt/tools sync onto existing personas moved to TavusAdapterService.sync()
(runs on every config publish, per-(org, specialist) PAL, content-hash gated, marker-upserts
VIDEO_CALL_TOOLS_PROMPT and binds the 5 video-call tools via reconcilePalTools).
Remaining gaps this design closes:
- First-time provisioning of new catalog rows is still manual β the exact #4574 failure mode ("11 new specialists, someone must remember to shell in").
- No drift detection for:
- catalog personas (
specialists.tavus_persona_id) β nothing self-heals these; the 372-persona incident in the issue lived here; - OSA PALs (
org_specialist_assignments.tavus_persona_id) that never receive another config publish.
- catalog personas (
- The script's
--dry-runtoday only lists unprovisioned rows β it cannot detect drift on existing personas.
Explicitly out of scope: the one-time backfill of the 372 stale prod personas (the first
audit run produces the real number; remediation is decided after this lands), gitflow-deploy.yml
integration (decision: manual dispatch only), dev/Railway environments.
Decisions (design review, 2026-07-27)β
| Question | Decision |
|---|---|
| Scope | Provision workflow + nightly drift audit |
| 372 backfill | Audit-first; decide remediation from its output |
| Provision trigger | Manual workflow_dispatch only (external API touching live fleet stays human-gated) |
| Alerting | Slack via existing secrets.SLACK_WEBHOOK; audit run also fails red |
| Environments | staging + prod (ECS); dev stays manual via railway run |
| Audit coverage | Catalog personas (full expected set) + OSA PALs (light check) |
| Drift definition | Tool-name set + VIDEO_CALL_TOOLS_PROMPT_MARKER presence. NOT deep schema equality β personas legitimately carry manual console edits and the adapter deliberately preserves them |
| Unprovisioned rows | Audit also reports catalog rows WHERE tavus_persona_id IS NULL |
| Result plumbing | Audit script prints machine-readable summary; GHA reads it from CloudWatch and posts Slack (approach A) |
Approaches rejected: (B) container posts to Slack itself β webhook URL leaks into
DescribeTasks output or needs a new secrets-store entry, and Slack formatting gets baked into
an API-image script; (C) single shared "tavus-ops" task-def with command overrides β breaks the
established one-family-per-script convention (seed-catalog, backfill-thumbnails) and merges
log groups.
Componentsβ
1. New script β api/scripts/audit-tavus-personas.tsβ
Read-only everywhere: no DB writes, no Tavus writes (GET-only; reuses the GET-retry etiquette of
the existing scripts / TavusClient policy: 3Γ retry with backoff, writes never retried β n/a
here).
Inputs (env): TAVUS_API_KEY, DATABASE_URL. No GITHUB_PAT needed (no manifest access).
Checks, in order:
- Catalog fleet. For every
specialistsrowWHERE is_catalog = TRUE AND tavus_persona_id IS NOT NULL: GET the persona; drifted when- any expected tool name is missing from
layers.llm.tools[].function.name, where the expected set = union of activetavus_function_definitionsrows (latest active version per name, same query asbuildPersonaTools) +getVideoCallToolDefinitions()names; or system_promptdoes not containVIDEO_CALL_TOOLS_PROMPT_MARKER. A 404 persona (catalogβTavus divergence) counts as drifted with reasonmissing_persona.
- any expected tool name is missing from
- OSA PALs (light). For every
org_specialist_assignmentsrow with a non-nulltavus_persona_id: same check but the expected tool set is onlygetVideoCallToolDefinitions()names (that is allreconcilePalToolsguarantees; the DB library tools are catalog-persona-only). - Unprovisioned.
SELECT count(*), array_agg(slug) FROM specialists WHERE is_catalog = TRUE AND tavus_persona_id IS NULL.
Output: per-finding human-readable log lines, then a single final line
AUDIT_SUMMARY {"env":"prod","checked_catalog":N,"checked_osa":N,"catalog_drifted":N,"osa_drifted":N,"unprovisioned":N,"drifted_slugs":[...],"unprovisioned_slugs":[...]}
Slug arrays are truncated to 25 entries each ("...and N more" handled on the Slack side from
the counts) to keep the line CloudWatch- and Slack-safe.
Exit codes: 0 clean, 2 findings (drift and/or unprovisioned), 1 operational error
(env missing, DB/Tavus unreachable, pre-flight exception). The workflow maps 2 β Slack + red,
1 β red without Slack drift report.
2. Terraform β infra/terraform/modules/stack/β
Following the backfill_thumbnails block verbatim (same API image, local.api_environment,
local.api_secrets β which carry TAVUS_API_KEY and DATABASE_URL; GITHUB_PAT did NOT
pre-exist and is newly registered in modules/secrets external_secrets by this work, so the
operator must inject the real value out-of-band before the first non-dry provision run β
terraform only creates a REPLACE_ME placeholder):
aws_ecs_task_definition.provision_tavusβ family${var.name_prefix}-provision-tavus, command["node", "dist/scripts/provision-tavus-personas.js"], own log group/ecs/${var.name_prefix}-provision-tavus.aws_ecs_task_definition.audit_tavusβ family${var.name_prefix}-audit-tavus, command["node", "dist/scripts/audit-tavus-personas.js"], own log group/ecs/${var.name_prefix}-audit-tavus.- Outputs:
provision_tavus_task_definition_family,audit_tavus_task_definition_family(mirrorsseed_catalog_task_definition_family). modules/iamapp_deploypolicy: one added statement βlogs:GetLogEvents,logs:FilterLogEventsscoped to the two new log-group ARNs (:*stream suffix). The role today has nologs:*at all; scope stays minimal.
Both scripts must be emitted into dist/scripts/ by the API build (verify
provision-tavus-personas.ts compiles into the image the same way seed-specialist-catalog.ts
and backfill-specialist-tavus-thumbnails.ts do; the audit script follows the identical
pattern).
Note: provision-tavus-personas.ts writes CSVs to api/scripts/output/ β ephemeral in Fargate.
Acceptable: the same data is in the task log; the CSVs are a local-operator convenience. No
change.
3. Workflow β .github/workflows/provision-tavus-personas.ymlβ
workflow_dispatch only. Inputs:
environment: choicestaging | prod, defaultstagingdry_run: boolean, defaulttruelimit: string, optional (empty = no limit)
Structure mirrors seed-specialists-catalog.yml exactly: concurrency group
provision-tavus-${{ inputs.environment }} (no cancel-in-progress), permissions: id-token: write / contents: read, environment: for OIDC trust, resolve
vars.AWS_APP_DEPLOY_ROLE_ARN_{PROD,STAGING}, reuse the API service's network configuration,
aws ecs run-task, poll describe-tasks until STOPPED (not the capped tasks-stopped
waiter β aws-cli#1295), fail on nonzero container exit code, surface failures[] when no task
is placed.
dry_run/limit are passed via --overrides containerOverrides.command (append --dry-run
/ --limit N to the base command) β zero script changes; the flags already exist.
Timeout: 30 min (372-persona pre-flight is ~400 sequential GETs; seed's 15 min is too tight).
4. Workflow β .github/workflows/audit-tavus-personas.ymlβ
Triggers:
schedule: nightly0 5 * * *(05:00 UTC β before the 06:00specialists-catalog-sync, so the audit sees yesterday's steady state) β prodworkflow_dispatch:environmentchoicestaging | prodfor on-demand runs
Same OIDC/run-task/poll skeleton as Β§3 against the audit-tavus family. Then:
- exit
0β green, no notification. - exit
2β fetch theAUDIT_SUMMARYline viaaws logs filter-log-eventson the audit log group (filter patternAUDIT_SUMMARY, newest event), parse withjq, post Slack Block Kit message tosecrets.SLACK_WEBHOOK: env, drifted counts per fleet, unprovisioned count, first slugs, link to the run and to the provision workflow ("run this to fix unprovisioned rows"). Then exit 1 so the run shows red in Actions. - exit
1(or missing summary line) β red run; Slack message with "audit failed to run β check/ecs/humanwork-{env}-audit-tavuslogs" (no drift details). SLACK_WEBHOOKunset β::warning::and skip the post (pattern from prod-patrol), still red.
Data flowβ
Error handlingβ
| Failure | Behavior |
|---|---|
| Tavus GET fails after retries (audit) | exit 1 β operational error, not drift; red run, "failed to run" Slack |
| Persona 404 (audit) | counted as drift, reason missing_persona |
| DB unreachable | exit 1 |
| run-task places no task | surface failures[], fail (existing pattern) |
| Provision pre-flight name assertion fails | script throws β container exit 1 β workflow red (existing behavior, unchanged) |
| logs:FilterLogEvents denied (terraform not yet applied) | audit workflow degrades to "findings detected, could not read summary" Slack + red |
Testing / verificationβ
- Audit script: unit tests colocated per repo convention β fingerprint comparison (missing tool name, missing marker, 404 persona, clean persona), expected-set assembly per fleet (catalog = DBβͺcode, OSA = code only), summary-line shape, exit-code mapping. Tavus/pg mocked.
- Workflows:
actionlint(runs in PR checks); dry-run dispatch of the provision workflow against staging withdry_run=trueas the smoke test after terraform apply. - Terraform:
terraform-plan.ymlon the PR shows exactly two task-defs, two log groups, one IAM statement, two outputs. - End-to-end: first manual dispatch of the audit against prod produces the real drift/unprovisioned numbers for the #4575 remediation decision.
Rolloutβ
- Inject real
GITHUB_PATintohumanwork-{staging,prod}/app/GITHUB_PATin Secrets Manager (terraform seedsREPLACE_ME; provision fails loudly with a GitHub 401 until injected). - Merge (dev) β terraform plan/apply for staging + prod stacks (task-defs are inert until a run-task references them).
- Dispatch audit on staging β verify Slack message shape.
- Dispatch provision on staging with
dry_run=trueβ verify[DRY]output in/ecs/humanwork-staging-provision-tavus(the workflow smoke test from Testing above). - Dispatch audit on prod β real numbers for the 372-persona remediation call (tracked in #4575 / #4574 thread, separate from this design).
- Nightly schedule takes over.