Skip to main content

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 active tavus_function_definitions names (DB) + getVideoCallToolDefinitions() names (code); OSA PALs (org_specialist_assignments.tavus_persona_id) are only guaranteed the code-defined names (TavusAdapterService.reconcilePalTools binds exactly those).
  • Drift = missing expected tool name OR missing VIDEO_CALL_TOOLS_PROMPT_MARKER in system_prompt. NOT deep schema equality (personas legitimately carry manual console edits).
  • api/tsconfig.json has no include β†’ scripts/ compiles to dist/scripts/ in the API image (proven by the existing seed-catalog / backfill-thumbnails task-defs).
  • Jest config (api/package.json): rootDir: "src", testRegex: ".*(\.spec|\.e2e-spec)\.ts$" β€” specs must live under api/src/.
  • The app_deploy IAM role has no logs:* today; the audit workflow needs logs:FilterLogEvents/GetLogEvents on the audit log group.
  • secrets.SLACK_WEBHOOK already exists org-side (prod-patrol uses it).
  • Exit-code contract for the audit container: 0 clean, 2 findings (drift and/or unprovisioned), 1 operational error.
  • local.api_secrets carries TAVUS_API_KEY + DATABASE_URL, but GITHUB_PAT did NOT pre-exist β€” Task 3's commit registers it in modules/secrets external_secrets (terraform creates a REPLACE_ME placeholder; 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 the backfill_thumbnails task-def block, ~line 789; add two args to the existing module "iam" call)

  • Modify: infra/terraform/modules/stack/outputs.tf (append after seed_catalog_task_definition_family, ~line 101)

  • Modify: infra/terraform/modules/iam/variables.tf (append)

  • Modify: infra/terraform/modules/iam/main.tf (add statement to data.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_deploy in modules/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, &timestamp)[-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)
  1. Inject real GITHUB_PAT into humanwork-{staging,prod}/app/GITHUB_PAT in Secrets Manager (terraform creates it as REPLACE_ME).
  2. Terraform apply for staging + prod (task-defs inert until first run-task).
  3. Dispatch Audit Tavus Personas on staging β€” verify Slack message shape + red run on findings.
  4. Dispatch Provision Tavus Personas on staging with dry_run=true β€” verify [DRY] output in /ecs/humanwork-staging-provision-tavus.
  5. Dispatch audit on prod β€” its numbers decide the 372-persona backfill remediation (comment results on #4575).
  6. 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/buildAuditSummary signatures match between Task 1 code and Task 2 imports; container names (provision-tavus, audit-tavus) match between Task 3 task-defs and Task 4/5 containerOverrides.
  • No placeholders: every file's full content is inline.