Tavus Persona Provisioning Automation + Drift Audit β Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Close #4575 β make provision-tavus-personas.ts runnable from GitHub Actions (manual dispatch, staging|prod) and add a nightly read-only drift audit that Slack-alerts when live Tavus personas are missing expected tools/prompt-marker or catalog rows are unprovisioned.
Architecture: Two new ECS Fargate task-def families (terraform, cloned from the existing backfill_thumbnails pattern: same API image/env/secrets, command override) + two workflows following the seed-specialists-catalog.yml OIDCβrun-taskβpoll skeleton. The audit's pure logic (fingerprint compare, summary building) lives in api/src/tavus/persona-audit.util.ts (jest-covered β api/scripts/ is outside jest roots); the script is a thin runner that prints a machine-readable AUDIT_SUMMARY {json} line which the workflow reads back via CloudWatch filter-log-events (one new scoped IAM statement) and posts to Slack.
Tech Stack: TypeScript (NestJS api image, plain pg + fetch scripts), Terraform (infra/terraform/modules/{stack,iam}), GitHub Actions (AWS OIDC, aws ecs run-task), Slack incoming webhook.
Spec: docs/superpowers/specs/2026-07-27-tavus-persona-provisioning-automation-design.md
Key facts an implementer needs (all verified 2026-07-27):
- Expected tool sets differ per fleet: catalog personas (
specialists.tavus_persona_id) must carry the union of activetavus_function_definitionsnames (DB) +getVideoCallToolDefinitions()names (code); OSA PALs (org_specialist_assignments.tavus_persona_id) are only guaranteed the code-defined names (TavusAdapterService.reconcilePalToolsbinds exactly those). - Drift = missing expected tool name OR missing
VIDEO_CALL_TOOLS_PROMPT_MARKERinsystem_prompt. NOT deep schema equality (personas legitimately carry manual console edits). api/tsconfig.jsonhas noincludeβscripts/compiles todist/scripts/in the API image (proven by the existingseed-catalog/backfill-thumbnailstask-defs).- Jest config (
api/package.json):rootDir: "src",testRegex: ".*(\.spec|\.e2e-spec)\.ts$"β specs must live underapi/src/. - The
app_deployIAM role has nologs:*today; the audit workflow needslogs:FilterLogEvents/GetLogEventson the audit log group. secrets.SLACK_WEBHOOKalready exists org-side (prod-patrol uses it).- Exit-code contract for the audit container:
0clean,2findings (drift and/or unprovisioned),1operational error. local.api_secretscarriesTAVUS_API_KEY+DATABASE_URL, butGITHUB_PATdid NOT pre-exist β Task 3's commit registers it inmodules/secretsexternal_secrets(terraform creates aREPLACE_MEplaceholder; operator injects the real value out-of-band before the first non-dry provision run β the placeholder fails loudly with a GitHub 401).
Task 1: Audit util (pure logic) β persona-audit.util.tsβ
Files:
-
Create:
api/src/tavus/persona-audit.util.ts -
Test:
api/src/tavus/persona-audit.util.spec.ts -
Step 1: Write the failing test
// api/src/tavus/persona-audit.util.spec.ts
import {
auditPersona,
buildAuditSummary,
SUMMARY_SLUG_CAP,
type AuditFinding,
} from "./persona-audit.util";
import { VIDEO_CALL_TOOLS_PROMPT_MARKER } from "./video-call-tool-definitions";
/**
* #4575 β nightly drift audit. `auditPersona` fingerprints one live Tavus
* persona against an expected tool-NAME set + the VIDEO_CALL_TOOLS_PROMPT
* marker (deliberately NOT deep schema equality β personas carry manual
* console edits the adapter preserves). `buildAuditSummary` shapes the
* machine-readable AUDIT_SUMMARY line the workflow greps from CloudWatch.
*/
describe("auditPersona", () => {
const EXPECTED = ["recall_memory", "search_knowledge_base"];
const promptWithMarker = `You are X.\n\n${VIDEO_CALL_TOOLS_PROMPT_MARKER}\n- use tools`;
const persona = (toolNames: string[], systemPrompt: string) => ({
system_prompt: systemPrompt,
layers: {
llm: {
tools: toolNames.map((name) => ({
type: "function",
function: { name, description: "d", parameters: {} },
})),
},
},
});
it("returns no reasons for a persona with all expected tools and the marker", () => {
expect(auditPersona(persona(EXPECTED, promptWithMarker), EXPECTED)).toEqual([]);
});
it("flags a 404/missing persona as missing_persona", () => {
expect(auditPersona(null, EXPECTED)).toEqual(["missing_persona"]);
});
it("flags each missing expected tool by name", () => {
expect(
auditPersona(persona(["search_knowledge_base"], promptWithMarker), EXPECTED),
).toEqual(["missing_tool:recall_memory"]);
});
it("tolerates extra unexpected tools (name-set check, not equality)", () => {
expect(
auditPersona(persona([...EXPECTED, "legacy_tool"], promptWithMarker), EXPECTED),
).toEqual([]);
});
it("flags a missing prompt marker", () => {
expect(auditPersona(persona(EXPECTED, "You are X."), EXPECTED)).toEqual([
"missing_prompt_marker",
]);
});
it("flags everything when layers.llm.tools is absent and prompt is null", () => {
expect(auditPersona({ system_prompt: null }, EXPECTED)).toEqual([
"missing_tool:recall_memory",
"missing_tool:search_knowledge_base",
"missing_prompt_marker",
]);
});
});
describe("buildAuditSummary", () => {
const finding = (slug: string, fleet: "catalog" | "osa"): AuditFinding => ({
slug,
personaId: `p-${slug}`,
fleet,
reasons: ["missing_prompt_marker"],
});
it("counts per fleet and carries env + checked totals", () => {
const s = buildAuditSummary(
"prod",
[finding("a", "catalog"), finding("b", "catalog"), finding("c@1234", "osa")],
372,
40,
["new-1", "new-2"],
);
expect(s).toEqual({
env: "prod",
checked_catalog: 372,
checked_osa: 40,
catalog_drifted: 2,
osa_drifted: 1,
unprovisioned: 2,
drifted_slugs: ["a", "b", "c@1234"],
unprovisioned_slugs: ["new-1", "new-2"],
});
});
it("caps slug arrays at SUMMARY_SLUG_CAP but keeps true counts", () => {
const many = Array.from({ length: 30 }, (_, i) => finding(`s${i}`, "catalog"));
const unprov = Array.from({ length: 30 }, (_, i) => `u${i}`);
const s = buildAuditSummary("staging", many, 30, 0, unprov);
expect(s.catalog_drifted).toBe(30);
expect(s.unprovisioned).toBe(30);
expect(s.drifted_slugs).toHaveLength(SUMMARY_SLUG_CAP);
expect(s.unprovisioned_slugs).toHaveLength(SUMMARY_SLUG_CAP);
});
});
- Step 2: Run the test to verify it fails
Run: cd api && npx jest persona-audit.util.spec
Expected: FAIL β Cannot find module './persona-audit.util'
- Step 3: Write the implementation
// api/src/tavus/persona-audit.util.ts
import { VIDEO_CALL_TOOLS_PROMPT_MARKER } from "./video-call-tool-definitions";
/**
* #4575 β pure fingerprint logic for the nightly Tavus persona drift audit
* (scripts/audit-tavus-personas.ts). Kept under src/ so jest covers it
* (jest rootDir is src; api/scripts is outside the test roots).
*
* Drift is deliberately a tool-NAME-set + prompt-marker check, NOT deep
* schema equality: personas carry manual Tavus-console edits that the
* adapter (A2-1) explicitly preserves, and the DB tool library versions
* independently of code. The incident behind #4575 was missing tool names
* + missing VIDEO_CALL_TOOLS_PROMPT block β exactly what this detects.
*/
/** Minimal slice of a Tavus GET /personas/{id} response the audit reads. */
export interface AuditablePersona {
system_prompt?: string | null;
layers?: {
llm?: {
tools?: Array<{ type: string; function?: { name?: string } }>;
};
};
}
export type DriftReason =
| "missing_persona"
| "missing_prompt_marker"
| `missing_tool:${string}`;
export interface AuditFinding {
/** catalog: specialist slug; osa: `${slug}@${orgId8}` */
slug: string;
personaId: string;
fleet: "catalog" | "osa";
reasons: DriftReason[];
}
export interface AuditSummary {
env: string;
checked_catalog: number;
checked_osa: number;
catalog_drifted: number;
osa_drifted: number;
unprovisioned: number;
drifted_slugs: string[];
unprovisioned_slugs: string[];
}
/** Keeps the AUDIT_SUMMARY log line CloudWatch/Slack-safe. */
export const SUMMARY_SLUG_CAP = 25;
/**
* Fingerprint one persona. `null` persona = GET returned 404/400 (catalogβ
* Tavus divergence) β reported as drift, not an operational error.
* Reasons are ordered: missing tools (in `expectedToolNames` order), then
* the marker check.
*/
export function auditPersona(
persona: AuditablePersona | null,
expectedToolNames: readonly string[],
): DriftReason[] {
if (!persona) return ["missing_persona"];
const reasons: DriftReason[] = [];
const bound = new Set(
(persona.layers?.llm?.tools ?? [])
.map((t) => t.function?.name)
.filter((n): n is string => typeof n === "string" && n.length > 0),
);
for (const name of expectedToolNames) {
if (!bound.has(name)) reasons.push(`missing_tool:${name}`);
}
if (!(persona.system_prompt ?? "").includes(VIDEO_CALL_TOOLS_PROMPT_MARKER)) {
reasons.push("missing_prompt_marker");
}
return reasons;
}
export function buildAuditSummary(
env: string,
findings: readonly AuditFinding[],
checkedCatalog: number,
checkedOsa: number,
unprovisionedSlugs: readonly string[],
): AuditSummary {
return {
env,
checked_catalog: checkedCatalog,
checked_osa: checkedOsa,
catalog_drifted: findings.filter((f) => f.fleet === "catalog").length,
osa_drifted: findings.filter((f) => f.fleet === "osa").length,
unprovisioned: unprovisionedSlugs.length,
drifted_slugs: findings.slice(0, SUMMARY_SLUG_CAP).map((f) => f.slug),
unprovisioned_slugs: unprovisionedSlugs.slice(0, SUMMARY_SLUG_CAP),
};
}
- Step 4: Run the test to verify it passes
Run: cd api && npx jest persona-audit.util.spec
Expected: PASS (8 tests)
- Step 5: Commit
git add api/src/tavus/persona-audit.util.ts api/src/tavus/persona-audit.util.spec.ts
git commit -m "feat(tavus): persona drift fingerprint util for the #4575 audit"
Task 2: Audit runner script β audit-tavus-personas.tsβ
Files:
- Create:
api/scripts/audit-tavus-personas.ts
Thin I/O shell around Task 1's util β no unit tests (mirrors the untested runner convention of the other api/scripts/*); verified by typecheck + the staging dispatch in Task 5.
- Step 1: Write the script
// api/scripts/audit-tavus-personas.ts
/**
* Read-only Tavus persona drift audit (#4575). Complements
* provision-tavus-personas.ts (first-time create only, A2) by detecting
* personas whose live tool set / prompt has drifted from expectations,
* plus catalog rows that were never provisioned at all.
*
* Checks:
* 1. Catalog fleet (specialists.tavus_persona_id, is_catalog=TRUE):
* expected tools = active tavus_function_definitions names (DB)
* βͺ getVideoCallToolDefinitions() names (code) β what
* provision-tavus-personas.ts#buildPersonaTools assembles.
* 2. OSA PALs (org_specialist_assignments.tavus_persona_id): expected
* tools = code names only (all TavusAdapterService.reconcilePalTools
* guarantees). Catches OSAs that never receive another config publish.
* 3. Unprovisioned catalog rows (tavus_persona_id IS NULL).
*
* Writes NOTHING (no DB writes, GET-only Tavus). GETs retry 3x with
* backoff (TavusClient policy); a persona 404/400 is drift
* (missing_persona), a persistent 5xx is an operational error.
*
* Output: per-finding log lines + one final machine-readable line:
* AUDIT_SUMMARY {"env":...,"checked_catalog":N,...}
* (the audit workflow greps this from CloudWatch and posts Slack).
*
* Exit codes: 0 clean Β· 2 findings (drift and/or unprovisioned) Β· 1 error.
*
* Usage:
* TAVUS_API_KEY=... DATABASE_URL=... [AUDIT_ENV=prod] \
* npx ts-node api/scripts/audit-tavus-personas.ts
*/
import { Client } from "pg";
import { getVideoCallToolDefinitions } from "../src/tavus/video-call-tool-definitions";
import {
auditPersona,
buildAuditSummary,
type AuditablePersona,
type AuditFinding,
} from "../src/tavus/persona-audit.util";
const TAVUS_BASE = "https://tavusapi.com/v2";
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required environment variable: ${name}`);
return v;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* GET one persona. 404/400 β null (drift: missing_persona). Network errors
* and 5xx retry 3x (300ms/900ms β mirrors TavusClient GET policy); still
* failing β throw (operational error, exit 1).
*/
async function fetchPersona(
apiKey: string,
personaId: string,
): Promise<AuditablePersona | null> {
const delays = [0, 300, 900];
let lastErr: Error | null = null;
for (const delay of delays) {
if (delay) await sleep(delay);
try {
const res = await fetch(`${TAVUS_BASE}/personas/${personaId}`, {
headers: { "x-api-key": apiKey },
});
if (res.status === 404 || res.status === 400) return null;
if (res.status >= 500) {
lastErr = new Error(`Tavus GET /personas/${personaId} β ${res.status}`);
continue;
}
if (!res.ok) {
throw new Error(`Tavus GET /personas/${personaId} β ${res.status}`);
}
return (await res.json()) as AuditablePersona;
} catch (err) {
lastErr = err as Error;
}
}
throw lastErr ?? new Error(`Tavus GET /personas/${personaId} failed`);
}
export async function main(): Promise<number> {
const tavusApiKey = requireEnv("TAVUS_API_KEY");
const databaseUrl = requireEnv("DATABASE_URL");
const env = process.env.AUDIT_ENV ?? "unknown";
const pg = new Client({ connectionString: databaseUrl });
await pg.connect();
try {
// Expected tool-name sets per fleet (see module docblock).
const { rows: libRows } = await pg.query<{ name: string }>(
`SELECT DISTINCT name FROM tavus_function_definitions WHERE is_active = TRUE`,
);
const codeNames = getVideoCallToolDefinitions().map((d) => d.name);
const catalogExpected = [
...new Set([...libRows.map((r) => r.name), ...codeNames]),
].sort();
const osaExpected = [...new Set(codeNames)].sort();
console.log(
`Expected tools β catalog: [${catalogExpected.join(", ")}] Β· osa: [${osaExpected.join(", ")}]`,
);
const { rows: catRows } = await pg.query<{
slug: string;
tavus_persona_id: string;
}>(
`SELECT slug, tavus_persona_id FROM specialists
WHERE is_catalog = TRUE AND tavus_persona_id IS NOT NULL
ORDER BY slug`,
);
const { rows: osaRows } = await pg.query<{
slug: string;
org_id: string;
tavus_persona_id: string;
}>(
`SELECT s.slug, osa.org_id, osa.tavus_persona_id
FROM org_specialist_assignments osa
JOIN specialists s ON s.id = osa.specialist_id
WHERE osa.tavus_persona_id IS NOT NULL
ORDER BY s.slug, osa.org_id`,
);
const { rows: unprovRows } = await pg.query<{ slug: string }>(
`SELECT slug FROM specialists
WHERE is_catalog = TRUE AND tavus_persona_id IS NULL
ORDER BY slug`,
);
console.log(
`Auditing ${catRows.length} catalog persona(s), ${osaRows.length} OSA PAL(s); ` +
`${unprovRows.length} unprovisioned catalog row(s)`,
);
const findings: AuditFinding[] = [];
for (const row of catRows) {
const persona = await fetchPersona(tavusApiKey, row.tavus_persona_id);
const reasons = auditPersona(persona, catalogExpected);
if (reasons.length) {
findings.push({
slug: row.slug,
personaId: row.tavus_persona_id,
fleet: "catalog",
reasons,
});
console.log(
`DRIFT [catalog] ${row.slug} (${row.tavus_persona_id}): ${reasons.join(", ")}`,
);
}
}
for (const row of osaRows) {
const label = `${row.slug}@${row.org_id.slice(0, 8)}`;
const persona = await fetchPersona(tavusApiKey, row.tavus_persona_id);
const reasons = auditPersona(persona, osaExpected);
if (reasons.length) {
findings.push({
slug: label,
personaId: row.tavus_persona_id,
fleet: "osa",
reasons,
});
console.log(
`DRIFT [osa] ${label} (${row.tavus_persona_id}): ${reasons.join(", ")}`,
);
}
}
for (const row of unprovRows) {
console.log(`UNPROVISIONED ${row.slug}`);
}
const summary = buildAuditSummary(
env,
findings,
catRows.length,
osaRows.length,
unprovRows.map((r) => r.slug),
);
console.log(`AUDIT_SUMMARY ${JSON.stringify(summary)}`);
return findings.length > 0 || unprovRows.length > 0 ? 2 : 0;
} finally {
await pg.end();
}
}
if (require.main === module) {
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(1);
});
}
- Step 2: Typecheck
Run: cd api && npx tsc --noEmit
Expected: clean exit (0). (scripts/ is inside the tsconfig compilation scope β no include, only exclude.)
- Step 3: Verify the compiled artifact lands in dist/scripts
Run: cd api && npm run build >/dev/null 2>&1; ls dist/scripts/audit-tavus-personas.js
Expected: path prints (exists).
- Step 4: Commit
git add api/scripts/audit-tavus-personas.ts
git commit -m "feat(scripts): read-only Tavus persona drift audit runner (#4575)"
Task 3: Terraform β task-defs, log groups, outputs, IAM logs-readβ
Files:
-
Modify:
infra/terraform/modules/stack/main.tf(append after thebackfill_thumbnailstask-def block, ~line 789; add two args to the existingmodule "iam"call) -
Modify:
infra/terraform/modules/stack/outputs.tf(append afterseed_catalog_task_definition_family, ~line 101) -
Modify:
infra/terraform/modules/iam/variables.tf(append) -
Modify:
infra/terraform/modules/iam/main.tf(add statement todata.aws_iam_policy_document.app_deploy, before its closing brace ~line 288) -
Step 1: Add task-defs + log groups to
modules/stack/main.tf
Append after the backfill_thumbnails task-def block:
# ---------------------------------------------------------------------------
# Tavus persona provisioning task (run-task only, no service; #4575). Run via
# the provision-tavus-personas workflow to create personas for catalog rows
# that lack one (first-time create + Face assignment ONLY, A2 β ongoing
# prompt/tool sync is TavusAdapterService's job). Same image/env/secrets as
# the API (needs TAVUS_API_KEY + DATABASE_URL + GITHUB_PAT); command
# overridden to provision + exit. Idempotent (only touches rows WHERE
# tavus_persona_id IS NULL; loud pre-flight abort on catalogβTavus
# divergence). --dry-run/--limit are appended per-run via containerOverrides.
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "provision_tavus" {
name = "/ecs/${var.name_prefix}-provision-tavus"
retention_in_days = var.log_retention_days
tags = local.common_tags
}
resource "aws_ecs_task_definition" "provision_tavus" {
family = "${var.name_prefix}-provision-tavus"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512"
memory = "1024"
execution_role_arn = module.iam.execution_role_arn
task_role_arn = module.iam.api_task_role_arn
runtime_platform {
cpu_architecture = "X86_64"
operating_system_family = "LINUX"
}
container_definitions = jsonencode([{
name = "provision-tavus"
image = local.api_image
essential = true
command = ["node", "dist/scripts/provision-tavus-personas.js"]
environment = [for k, v in local.api_environment : { name = k, value = v }]
secrets = [for k, arn in local.api_secrets : { name = k, valueFrom = arn }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.provision_tavus.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "provision-tavus"
}
}
}])
tags = local.common_tags
}
# ---------------------------------------------------------------------------
# Tavus persona drift audit task (run-task only, no service; #4575).
# Read-only: GET-only Tavus + SELECT-only Postgres. Prints a final
# `AUDIT_SUMMARY {json}` log line that the audit-tavus-personas workflow
# reads back via logs:FilterLogEvents (granted to the app-deploy role below)
# and posts to Slack. Exit codes: 0 clean Β· 2 findings Β· 1 error.
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "audit_tavus" {
name = "/ecs/${var.name_prefix}-audit-tavus"
retention_in_days = var.log_retention_days
tags = local.common_tags
}
resource "aws_ecs_task_definition" "audit_tavus" {
family = "${var.name_prefix}-audit-tavus"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512"
memory = "1024"
execution_role_arn = module.iam.execution_role_arn
task_role_arn = module.iam.api_task_role_arn
runtime_platform {
cpu_architecture = "X86_64"
operating_system_family = "LINUX"
}
container_definitions = jsonencode([{
name = "audit-tavus"
image = local.api_image
essential = true
command = ["node", "dist/scripts/audit-tavus-personas.js"]
environment = [for k, v in local.api_environment : { name = k, value = v }]
secrets = [for k, arn in local.api_secrets : { name = k, valueFrom = arn }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.audit_tavus.name
"awslogs-region" = data.aws_region.current.name
"awslogs-stream-prefix" = "audit-tavus"
}
}
}])
tags = local.common_tags
}
- Step 2: Wire the log-group ARNs into the iam module call
In the existing module "iam" block in modules/stack/main.tf, add one argument:
# #4575 β the audit workflow reads the AUDIT_SUMMARY line back from the
# audit task's log group (and may inspect provision logs on failure).
ops_log_group_arns = [
aws_cloudwatch_log_group.provision_tavus.arn,
aws_cloudwatch_log_group.audit_tavus.arn,
]
- Step 3: Add the variable to
modules/iam/variables.tf
variable "ops_log_group_arns" {
description = "CloudWatch log-group ARNs of one-off ops tasks (Tavus provision/audit, #4575) the app-deploy role may read (GetLogEvents/FilterLogEvents) so workflows can surface task output. Empty = no grant."
type = list(string)
default = []
}
- Step 4: Add the statement to
data.aws_iam_policy_document.app_deployinmodules/iam/main.tf
Insert before the policy document's closing brace (after the PassTaskRoles statement):
# #4575 β read one-off ops task output (AUDIT_SUMMARY line) back into the
# dispatching workflow. Scoped to the named ops log groups only; the role
# otherwise has no logs:* at all β keep it that way.
dynamic "statement" {
for_each = length(var.ops_log_group_arns) > 0 ? [1] : []
content {
sid = "OpsTaskLogRead"
actions = ["logs:GetLogEvents", "logs:FilterLogEvents"]
resources = concat(
var.ops_log_group_arns,
[for a in var.ops_log_group_arns : "${a}:*"],
)
}
}
- Step 5: Add outputs to
modules/stack/outputs.tf
Append after seed_catalog_task_definition_family:
output "provision_tavus_task_definition_family" {
value = aws_ecs_task_definition.provision_tavus.family
}
output "audit_tavus_task_definition_family" {
value = aws_ecs_task_definition.audit_tavus.family
}
- Step 6: Format + validate
Run: terraform fmt -recursive infra/terraform && git diff --stat infra/terraform
Expected: fmt makes no changes beyond your edits (exit 0). Full terraform validate/plan runs in CI (terraform-plan.yml) on the PR β expected plan delta: 2 log groups, 2 task-defs, 2 outputs, 1 updated inline IAM policy.
- Step 7: Commit
git add infra/terraform/modules/stack/main.tf infra/terraform/modules/stack/outputs.tf infra/terraform/modules/iam/variables.tf infra/terraform/modules/iam/main.tf
git commit -m "infra(tavus): provision + audit ECS task-defs, scoped ops-log read for app-deploy (#4575)"
Task 4: Provision workflow β provision-tavus-personas.ymlβ
Files:
-
Create:
.github/workflows/provision-tavus-personas.yml -
Step 1: Write the workflow
# Provision Tavus personas for catalog rows that lack one (#4575) as a one-off
# ECS run-task (humanwork-<env>-provision-tavus). First-time create + Face
# assignment ONLY (A2) β ongoing prompt/tool sync onto existing personas is
# TavusAdapterService's job on config publish. Idempotent: only touches rows
# WHERE tavus_persona_id IS NULL and aborts loudly if the catalog and Tavus
# have diverged (pre-flight name assertion).
#
# Runs inside infra so it inherits the env's TAVUS_API_KEY + DATABASE_URL +
# GITHUB_PAT from the task definition's secrets β nothing sensitive is passed
# from CI. Reuses the API service's network config (private subnets + SG).
# Required: vars.AWS_APP_DEPLOY_ROLE_ARN_PROD / _STAGING, vars.AWS_REGION.
#
# The task definition is provisioned by terraform
# (infra/terraform/modules/stack/main.tf, aws_ecs_task_definition.provision_tavus);
# apply terraform for the target env before the first run.
#
# Deliberately manual-dispatch only (design decision, 2026-07-27 spec): the
# script PATCHes an external, rate-limited API β a human picks env + dry-run.
name: Provision Tavus Personas
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
type: choice
default: staging
options: [staging, prod]
dry_run:
description: "Dry run (report what would be provisioned, no API calls or DB writes)"
type: boolean
default: true
limit:
description: "Max rows to provision (empty = no limit)"
type: string
default: ""
concurrency:
group: provision-tavus-${{ inputs.environment }}
cancel-in-progress: false
permissions:
id-token: write
contents: read
jobs:
provision:
name: Provision Tavus personas (${{ inputs.environment }})
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ inputs.environment }} # OIDC sub -> environment:<env> (app-deploy role trust)
steps:
- name: Resolve role
env:
ENVIRONMENT: ${{ inputs.environment }}
ROLE_PROD: ${{ vars.AWS_APP_DEPLOY_ROLE_ARN_PROD }}
ROLE_STAGING: ${{ vars.AWS_APP_DEPLOY_ROLE_ARN_STAGING }}
run: |
set -euo pipefail
if [ "$ENVIRONMENT" = "prod" ]; then ROLE="$ROLE_PROD"; else ROLE="$ROLE_STAGING"; fi
UP=$(echo "$ENVIRONMENT" | tr '[:lower:]' '[:upper:]')
if [ -z "$ROLE" ]; then echo "::error::vars.AWS_APP_DEPLOY_ROLE_ARN_${UP} is not set"; exit 1; fi
echo "ROLE=$ROLE" >> "$GITHUB_ENV"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE }}
aws-region: ${{ vars.AWS_REGION }}
role-session-name: gha-provision-tavus-${{ inputs.environment }}-${{ github.run_id }}
- name: Run provisioning task
env:
CLUSTER: humanwork-${{ inputs.environment }}-cluster
API_SERVICE: humanwork-${{ inputs.environment }}-api
TASK_FAMILY: humanwork-${{ inputs.environment }}-provision-tavus
DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
ROW_LIMIT: ${{ inputs.limit }}
run: |
set -euo pipefail
# Reuse the API service's network config (private subnets + SG).
NETCFG=$(aws ecs describe-services --cluster "$CLUSTER" --services "$API_SERVICE" \
--query 'services[0].networkConfiguration' --output json)
# Per-run command override: append --dry-run / --limit N to the
# task definition's base command (the script's existing CLI flags β
# zero script changes). Container name must match the task def.
CMD='["node","dist/scripts/provision-tavus-personas.js"]'
if [ "$DRY_RUN" = "1" ]; then CMD=$(echo "$CMD" | jq -c '. + ["--dry-run"]'); fi
if [ -n "$ROW_LIMIT" ]; then CMD=$(echo "$CMD" | jq -c --arg n "$ROW_LIMIT" '. + ["--limit", $n]'); fi
OVERRIDES=$(jq -cn --argjson cmd "$CMD" \
'{containerOverrides: [{name: "provision-tavus", command: $cmd}]}')
echo "Launching $TASK_FAMILY (dry_run=$DRY_RUN limit=${ROW_LIMIT:-none}) ..."
RUN_OUT=$(aws ecs run-task --cluster "$CLUSTER" \
--task-definition "$TASK_FAMILY" --launch-type FARGATE \
--network-configuration "$NETCFG" \
--overrides "$OVERRIDES" --output json)
TASK_ARN=$(echo "$RUN_OUT" | jq -r '.tasks[0].taskArn // empty')
if [ -z "$TASK_ARN" ]; then
echo "::error::run-task did not place a task:"
echo "$RUN_OUT" | jq -r '.failures[]? | " - \(.arn // "?"): \(.reason // "unknown")"'
exit 1
fi
echo "Task: $TASK_ARN β waiting for it to stop..."
# Poll instead of `aws ecs wait tasks-stopped` (fixed ~10 min waiter
# ceiling, aws-cli#1295) β the GHA job timeout is the ceiling.
until [ "$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].lastStatus' --output text)" = "STOPPED" ]; do
sleep 10
done
EXIT=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].containers[0].exitCode' --output text)
REASON=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].stoppedReason' --output text)
echo "Provision task exit code: $EXIT (reason: $REASON)"
if [ "$EXIT" != "0" ]; then
echo "::error::Provision task failed (exit $EXIT). Check /ecs/humanwork-${{ inputs.environment }}-provision-tavus logs."
exit 1
fi
echo "Provisioning complete β (dry_run=$DRY_RUN)"
- Step 2: Lint
Run: actionlint .github/workflows/provision-tavus-personas.yml (if the binary is absent locally, rely on PR Checks β it runs actionlint).
Expected: no findings.
- Step 3: Commit
git add .github/workflows/provision-tavus-personas.yml
git commit -m "ci(tavus): manual-dispatch persona provisioning workflow (#4575)"
Task 5: Audit workflow β audit-tavus-personas.ymlβ
Files:
-
Create:
.github/workflows/audit-tavus-personas.yml -
Step 1: Write the workflow
# Nightly Tavus persona drift audit (#4575) as a one-off ECS run-task
# (humanwork-<env>-audit-tavus). Read-only: GET-only Tavus + SELECT-only
# Postgres. Detects catalog/OSA personas missing expected function tools or
# the VIDEO_CALL_TOOLS_PROMPT marker, and catalog rows never provisioned.
#
# Container exit codes: 0 clean Β· 2 findings Β· 1 operational error. On
# findings, the AUDIT_SUMMARY json line is read back from CloudWatch
# (logs:FilterLogEvents on the audit log group β granted to the app-deploy
# role by terraform) and posted to Slack (secrets.SLACK_WEBHOOK, same secret
# prod-patrol uses); the run then fails red for Actions visibility.
#
# Schedule runs 05:00 UTC (before the 06:00 specialists-catalog-sync, so we
# audit yesterday's steady state) against PROD; dispatch for staging/on-demand.
name: Audit Tavus Personas
on:
schedule:
- cron: "0 5 * * *" # 05:00 UTC nightly -> prod
workflow_dispatch:
inputs:
environment:
description: "Target environment"
type: choice
default: prod
options: [staging, prod]
concurrency:
group: audit-tavus-${{ inputs.environment || 'prod' }}
cancel-in-progress: false
permissions:
id-token: write
contents: read
jobs:
audit:
name: Audit Tavus personas (${{ inputs.environment || 'prod' }})
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ inputs.environment || 'prod' }} # OIDC sub -> environment:<env> (app-deploy role trust)
env:
ENVIRONMENT: ${{ inputs.environment || 'prod' }}
steps:
- name: Resolve role
env:
ROLE_PROD: ${{ vars.AWS_APP_DEPLOY_ROLE_ARN_PROD }}
ROLE_STAGING: ${{ vars.AWS_APP_DEPLOY_ROLE_ARN_STAGING }}
run: |
set -euo pipefail
if [ "$ENVIRONMENT" = "prod" ]; then ROLE="$ROLE_PROD"; else ROLE="$ROLE_STAGING"; fi
UP=$(echo "$ENVIRONMENT" | tr '[:lower:]' '[:upper:]')
if [ -z "$ROLE" ]; then echo "::error::vars.AWS_APP_DEPLOY_ROLE_ARN_${UP} is not set"; exit 1; fi
echo "ROLE=$ROLE" >> "$GITHUB_ENV"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE }}
aws-region: ${{ vars.AWS_REGION }}
role-session-name: gha-audit-tavus-${{ env.ENVIRONMENT }}-${{ github.run_id }}
- name: Run audit task
id: run_audit
env:
CLUSTER: humanwork-${{ env.ENVIRONMENT }}-cluster
API_SERVICE: humanwork-${{ env.ENVIRONMENT }}-api
TASK_FAMILY: humanwork-${{ env.ENVIRONMENT }}-audit-tavus
run: |
set -euo pipefail
NETCFG=$(aws ecs describe-services --cluster "$CLUSTER" --services "$API_SERVICE" \
--query 'services[0].networkConfiguration' --output json)
# Label the AUDIT_SUMMARY line with the env; capture launch time for
# the CloudWatch summary lookup below.
OVERRIDES=$(jq -cn --arg env "$ENVIRONMENT" \
'{containerOverrides: [{name: "audit-tavus", environment: [{name: "AUDIT_ENV", value: $env}]}]}')
START_MS=$(( ($(date +%s) - 60) * 1000 ))
echo "start_ms=$START_MS" >> "$GITHUB_OUTPUT"
echo "Launching $TASK_FAMILY ..."
RUN_OUT=$(aws ecs run-task --cluster "$CLUSTER" \
--task-definition "$TASK_FAMILY" --launch-type FARGATE \
--network-configuration "$NETCFG" \
--overrides "$OVERRIDES" --output json)
TASK_ARN=$(echo "$RUN_OUT" | jq -r '.tasks[0].taskArn // empty')
if [ -z "$TASK_ARN" ]; then
echo "::error::run-task did not place a task:"
echo "$RUN_OUT" | jq -r '.failures[]? | " - \(.arn // "?"): \(.reason // "unknown")"'
exit 1
fi
echo "Task: $TASK_ARN β waiting for it to stop..."
# Poll instead of `aws ecs wait tasks-stopped` (fixed ~10 min waiter
# ceiling, aws-cli#1295) β the GHA job timeout is the ceiling.
until [ "$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].lastStatus' --output text)" = "STOPPED" ]; do
sleep 10
done
EXIT=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].containers[0].exitCode' --output text)
REASON=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK_ARN" \
--query 'tasks[0].stoppedReason' --output text)
echo "Audit task exit code: $EXIT (reason: $REASON)"
echo "exit_code=$EXIT" >> "$GITHUB_OUTPUT"
if [ "$EXIT" != "0" ] && [ "$EXIT" != "2" ]; then
echo "::error::Audit task failed to run (exit $EXIT). Check /ecs/$TASK_FAMILY logs."
exit 1
fi
- name: Fetch audit summary
if: steps.run_audit.outputs.exit_code == '2'
id: summary
env:
LOG_GROUP: /ecs/humanwork-${{ env.ENVIRONMENT }}-audit-tavus
START_MS: ${{ steps.run_audit.outputs.start_ms }}
run: |
set -euo pipefail
# Newest AUDIT_SUMMARY event since task launch. Quoted filter = exact
# substring match. Concurrency group prevents same-env overlap, so
# the newest event is ours.
EVENT=$(aws logs filter-log-events --log-group-name "$LOG_GROUP" \
--start-time "$START_MS" --filter-pattern '"AUDIT_SUMMARY"' \
--query 'sort_by(events, ×tamp)[-1].message' --output text)
if [ -z "$EVENT" ] || [ "$EVENT" = "None" ]; then
echo "::warning::findings detected but AUDIT_SUMMARY line not found in $LOG_GROUP"
echo "found=0" >> "$GITHUB_OUTPUT"
exit 0
fi
SUMMARY="${EVENT#*AUDIT_SUMMARY }"
echo "$SUMMARY" | jq . # validate + pretty-print into the job log
echo "found=1" >> "$GITHUB_OUTPUT"
echo "json=$(echo "$SUMMARY" | jq -c .)" >> "$GITHUB_OUTPUT"
- name: Notify Slack
if: steps.run_audit.outputs.exit_code == '2'
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
FOUND: ${{ steps.summary.outputs.found }}
SUMMARY_JSON: ${{ steps.summary.outputs.json }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
PROVISION_URL: ${{ github.server_url }}/${{ github.repository }}/actions/workflows/provision-tavus-personas.yml
run: |
set -euo pipefail
if [ -z "$SLACK_WEBHOOK" ]; then
echo "::warning::SLACK_WEBHOOK unset β skipping notification"; exit 0
fi
if [ "$FOUND" = "1" ]; then
PAYLOAD=$(jq -cn --argjson s "$SUMMARY_JSON" --arg run "$RUN_URL" --arg prov "$PROVISION_URL" '
{
text: "Tavus persona audit (\($s.env)): \($s.catalog_drifted + $s.osa_drifted) drifted, \($s.unprovisioned) unprovisioned",
blocks: [
{type: "section", text: {type: "mrkdwn", text:
":rotating_light: *Tavus persona audit β \($s.env)*\nβ’ catalog drifted: *\($s.catalog_drifted)* / \($s.checked_catalog)\nβ’ OSA PALs drifted: *\($s.osa_drifted)* / \($s.checked_osa)\nβ’ unprovisioned catalog rows: *\($s.unprovisioned)*"}},
{type: "section", text: {type: "mrkdwn", text:
("drifted: " + (if ($s.drifted_slugs | length) > 0 then ($s.drifted_slugs | join(", ")) else "β" end)
+ "\nunprovisioned: " + (if ($s.unprovisioned_slugs | length) > 0 then ($s.unprovisioned_slugs | join(", ")) else "β" end)
+ (if ($s.catalog_drifted + $s.osa_drifted) > ($s.drifted_slugs | length) or $s.unprovisioned > ($s.unprovisioned_slugs | length) then "\n_(lists truncated β full detail in task logs)_" else "" end))}},
{type: "section", text: {type: "mrkdwn", text:
"<\($run)|audit run> Β· <\($prov)|provision workflow> fixes unprovisioned rows Β· drift remediation: see #4575"}}
]
}')
else
PAYLOAD=$(jq -cn --arg run "$RUN_URL" --arg env "$ENVIRONMENT" '
{text: "Tavus persona audit (\($env)): findings detected but summary unreadable β check /ecs logs",
blocks: [{type: "section", text: {type: "mrkdwn", text:
":warning: *Tavus persona audit β \($env)*: findings detected (exit 2) but AUDIT_SUMMARY could not be read from CloudWatch. <\($run)|run> Β· check `/ecs/humanwork-\($env)-audit-tavus` logs."}}]}')
fi
curl -fsS -X POST -H 'Content-Type: application/json' -d "$PAYLOAD" "$SLACK_WEBHOOK" >/dev/null
echo "Slack notified"
- name: Fail red on findings
if: steps.run_audit.outputs.exit_code == '2'
run: |
echo "::error::Tavus persona drift/unprovisioned findings β see Slack message and task logs."
exit 1
- Step 2: Lint
Run: actionlint .github/workflows/audit-tavus-personas.yml (if absent locally, PR Checks runs it).
Expected: no findings.
- Step 3: Commit
git add .github/workflows/audit-tavus-personas.yml
git commit -m "ci(tavus): nightly persona drift audit with Slack alerting (#4575)"
Task 6: Docs β fix the stale CLAUDE.md claim, note the new automationβ
Files:
-
Modify:
api/src/tavus/CLAUDE.md(the "Persona tool-use prompt block" bullet, ~line 31) -
Step 1: Update the stale bullet
The bullet currently claims scripts/provision-tavus-personas.ts "syncs it onto existing ones marker-based append-if-absent" β stale since A2 b6435e6b4 demoted the script to first-time create only. Rewrite the bullet (keep everything about the marker semantics, fix the ownership claim, add the audit):
Replace the sentence fragment
`scripts/provision-tavus-personas.ts` includes it for new catalog personas AND syncs it onto existing ones **marker-based append-if-absent** (keyed on `VIDEO_CALL_TOOLS_PROMPT_MARKER`), NOT fingerprint-rebuild
with
`scripts/provision-tavus-personas.ts` includes it at first-time persona create (A2: the script no longer touches existing personas); `TavusAdapterService.sync` marker-upserts it onto the PAL on every config publish (keyed on `VIDEO_CALL_TOOLS_PROMPT_MARKER`), NOT fingerprint-rebuild. The nightly `audit-tavus-personas` workflow (#4575) alarms on personas missing the marker or expected tool names (`persona-audit.util.ts`)
β keep the rest of the bullet (the "personas carry manual prompt tweaksβ¦" rationale) verbatim.
- Step 2: Commit
git add api/src/tavus/CLAUDE.md
git commit -m "docs(tavus): reflect A2 script demotion + #4575 audit in module docs"
Task 7: PR + rollout checklistβ
- Step 1: Push branch + open PR against dev
git push -u origin HEAD
gh pr create --base dev \
--title "feat(ci): Tavus persona provisioning workflow + nightly drift audit (#4575)" \
--body "Closes #4575. Spec: docs/superpowers/specs/2026-07-27-tavus-persona-provisioning-automation-design.md
- provision-tavus-personas.yml β manual dispatch (staging|prod, dry_run default ON, optional limit) -> ECS run-task
- audit-tavus-personas.yml β nightly 05:00 UTC vs prod + dispatch; read-only; Slack on findings; red run
- persona-audit.util.ts β name-set + prompt-marker fingerprint (catalog = DBβͺcode tools, OSA PALs = code tools only)
- terraform: 2 task-def families + log groups, scoped logs-read for app-deploy
Rollout (after merge): terraform apply staging+prod -> dispatch audit on staging (verify Slack shape) -> dispatch audit on prod (real drift numbers drive the 372-persona remediation decision, tracked in #4575)."
- Step 2: Verify CI on the PR
Expected: PR Checks green (jest incl. new spec, actionlint on both new workflows), terraform-plan.yml shows exactly: 2 aws_cloudwatch_log_group, 2 aws_ecs_task_definition, 2 new outputs, 1 changed aws_iam_role_policy (app-deploy, added OpsTaskLogRead).
- Step 3: Post-merge rollout (operator steps, in PR/issue thread)
- Inject real
GITHUB_PATintohumanwork-{staging,prod}/app/GITHUB_PATin Secrets Manager (terraform creates it asREPLACE_ME). - Terraform apply for staging + prod (task-defs inert until first run-task).
- Dispatch Audit Tavus Personas on staging β verify Slack message shape + red run on findings.
- Dispatch Provision Tavus Personas on staging with
dry_run=trueβ verify[DRY]output in/ecs/humanwork-staging-provision-tavus. - Dispatch audit on prod β its numbers decide the 372-persona backfill remediation (comment results on #4575).
- Nightly schedule takes over.
Self-review (done at plan time)β
- Spec coverage: audit script Β§1 β Tasks 1β2; terraform Β§2 β Task 3; provision workflow Β§3 β Task 4; audit workflow Β§4 β Task 5; error-handling table β encoded in script exit codes (Task 2) + workflow steps (Task 5: exit-1 vs exit-2 paths, missing-summary degradation, SLACK_WEBHOOK-unset warning); rollout Β§ β Task 7.
- Types:
AuditablePersona/AuditFinding/buildAuditSummarysignatures match between Task 1 code and Task 2 imports; container names (provision-tavus,audit-tavus) match between Task 3 task-defs and Task 4/5containerOverrides. - No placeholders: every file's full content is inline.