Skip to content

Developer guide

How to extend the compliance module. For background read overview. For architectural shape read architecture.


When to use this: A new platform action needs auditing — adding to the SOC2 starter slate, or wiring a Phase 6 policy event.

  1. Append the dotted-lowercase name to COMPLIANCE_EVENT_TYPES in packages/database-pg/src/schema/compliance.ts. Convention: <domain>.<action> (e.g., agent.created, auth.signin.success). Keep the array order stable; append rather than insert.

  2. Add a redaction allow-list entry in packages/api/src/lib/compliance/redaction.ts. The allow-list says which payload fields persist into the audit row verbatim; everything else is redacted to <REDACTED> at write time. Reviewer guidance: be conservative — fields you don’t list never reach the audit log.

  3. Identify call sites that should emit. Each call site picks a tier:

    • Inside the originating db.transaction: control-evidence (audit failure rolls back the originating action).
    • Wrapped in try { void emitAuditEvent(...) } catch { logger.warn(...) }: telemetry (audit failure does not block the action).

    See audit-event tier semantics below for the decision rule.

  4. Add an integration test. The cross-cutting tests live in packages/api/test/integration/compliance-event-writers/ (e.g., cross-cutting.integration.test.ts). Each test fires the originating mutation, asserts the outbox row landed with the right event_type, and asserts the drainer wrote it to audit_events with a valid hash chain link.

  5. Regenerate GraphQL codegen if the new type appears in the GraphQL ComplianceEventType enum (mirror in packages/database-pg/graphql/types/compliance.graphql):

    Terminal window
    pnpm --filter @thinkwork/api codegen
    pnpm --filter @thinkwork/admin codegen
  6. Verify the drift snapshot test still passes. packages/api/src/__tests__/compliance-event-type-drift.test.ts asserts the GraphQL enum values match the runtime COMPLIANCE_EVENT_TYPES slate exactly. Adding a new type without updating the GraphQL schema fails here.

PR template: see #903 (U5) for an example of wiring emitAuditEvent at multiple call sites.


The emitAuditEvent helper is tier-agnostic; the call site picks how to handle a write failure.

TierPatternFailure modeUse for
Control-evidenceawait emitAuditEvent(tx, {...}) inside an existing db.transaction(async tx => {...})Audit write failure throws; the transaction rolls back; originating mutation fails with a user-visible errorSecurity-relevant events (auth, data export, agent CRUD, MCP CRUD, governance file edits)
Telemetrytry { await emitAuditEvent(db, {...}) } catch (err) { logger.warn({err}, "audit-emit-failed") }Audit write failure logs + fires an operator alert; originating action proceedsHigh-volume informational events that must not block production traffic

Default to control-evidence for any event whose absence from the audit log would mean an auditor can’t reconstruct what happened. Drop to telemetry only when the action’s value to the user clearly outweighs the audit gap (e.g., a high-throughput read-only operation that would degrade UX if audit DB pressure causes the action to fail).

Master plan reference: R6 in docs/plans/2026-05-06-011-feat-compliance-audit-event-log-plan.md.

Helper signature + behavior: packages/api/src/lib/compliance/emit.ts.


The Python Strands runtime can’t share TypeScript code, so it emits via a REST round-trip into the graphql-http API. The path is wired so retries are idempotent.

Strands side:

  1. The container constructs a ComplianceClient at boot from THINKWORK_API_URL + API_AUTH_SECRET (resolved from Secrets Manager). Source: packages/agentcore-strands/agent-container/ (compliance_client.py).
  2. On a relevant runtime event, the client generates a fresh UUIDv7 event_id locally (deterministic-prefix timestamp + random suffix; see packages/agentcore-strands/agent-container/ helper).
  3. The client POSTs /api/compliance/events with bearer API_AUTH_SECRET, body {event_id, tenant_id, actor_id, event_type, payload, ...}.
  4. Snapshot env at coroutine entry — never re-read os.environ mid-handler. This is a load-bearing rule per feedback_completion_callback_snapshot_pattern — env vars can be re-injected mid-process by Lambda’s warm-container update path, and a re-read mid-handler can pick up stale values.

API side:

  1. The compliance-events REST handler (packages/api/src/handlers/compliance.ts) validates the bearer, validates the body schema, and INSERTs into audit_outbox.
  2. The outbox table has uq_audit_outbox_event_id (unique on event_id). A retry with the same event_id is a no-op at the DB layer — the second INSERT raises a unique-constraint violation that the handler catches and treats as success. This is the idempotency guarantee.

Drainer side:

  1. The single-writer drainer Lambda picks up the row in the next 5-second poll cycle, computes the chain hash, INSERTs into audit_events. Strands events are indistinguishable from Yoga events at this point — they share the same chain.

Strands runtime emit shipped in U6 (#911).


When to use this: A new background process is needed — e.g., a daily verifier scheduled run, an aggregation job, a Phase 6 policy evaluator.

  1. Lambda body at packages/lambda/compliance-<name>.ts:

    • Module-load env snapshot via getXxxEnv() helper (mirror packages/lambda/compliance-anchor.ts getAnchorEnv).
    • Lazy clients (S3 / Secrets Manager / pg) cached at module scope; reset on connection error.
    • For SQS-triggered Lambdas: implement the ReportBatchItemFailures partial-failure protocol; CAS-guard on the row state to make re-deliveries no-ops.
    • For scheduled Lambdas: use ReservedConcurrentExecutions=1 if the work is single-writer-sensitive (drainer, anchor).
  2. Build entry in scripts/build-lambdas.sh:

    Terminal window
    build_handler "compliance-<name>" \
    "$REPO_ROOT/packages/lambda/compliance-<name>.ts"

    If the Lambda imports @aws-sdk/lib-storage, @aws-sdk/s3-request-presigner, @aws-sdk/client-bedrock-agentcore, or other clients not in the Lambda runtime SDK, add the handler name to the conditional that flips the build to BUNDLED_AGENTCORE_ESBUILD_FLAGS — otherwise the import will fail at cold start.

  3. Terraform handler resource in terraform/modules/app/lambda-api/handlers.tf:

    • Standalone resource (NOT in the for_each pool) when the Lambda needs a per-key IAM role / env / source_code_hash. The compliance-anchor and compliance-export-runner Lambdas use this pattern — isolates blast radius from the 60+ shared handlers.
    • for_each pool when the Lambda fits the shared aws_iam_role.lambda permissions and needs no special env. The drainer used this pattern in U4.
  4. Post-deploy smoke at packages/api/src/__smoke__/compliance-<name>-smoke.ts + scripts/post-deploy-smoke-compliance-<name>.sh. Pin on dispatch status in the response payload, not on log filtering (feedback_smoke_pin_dispatch_status_in_response). CloudWatch log grep is fragile; downstream-state pinning balloons smoke runtime.

  5. GHA workflow gate in .github/workflows/deploy.yml — add a new job after terraform-apply that runs the smoke. Mirror the compliance-anchor-smoke and compliance-export-runner-smoke jobs in shape.

The U11.U2 + U11.U3 PRs (#948, #950) are a recent end-to-end example of all five steps for the export runner.


LayerLocation
Compliance schema + driftpackages/api/src/__tests__/compliance-event-type-drift.test.ts
emitAuditEvent helperpackages/api/src/lib/compliance/__tests__/emit.test.ts
Redaction allow-listpackages/api/src/lib/compliance/__tests__/redaction.test.ts
Hash chainpackages/api/src/lib/compliance/__tests__/hash-chain.test.ts
Resolver authpackages/api/src/__tests__/compliance-authz.test.ts
Cursor paginationpackages/api/src/__tests__/compliance-cursor.test.ts
Tenants + operator-check resolverspackages/api/src/__tests__/compliance-tenants-and-operator-check.test.ts
Exports mutation + querypackages/api/src/__tests__/compliance-exports.test.ts
Cross-cutting integrationpackages/api/test/integration/compliance-event-writers/
Anchor Lambda unitpackages/lambda/__tests__/compliance-anchor.test.ts
Anchor Lambda S3 spypackages/lambda/__tests__/compliance-anchor-s3-spy.test.ts
Drainer Lambda unitpackages/lambda/__tests__/compliance-outbox-drainer.test.ts
Anchor + drainer integrationpackages/lambda/__tests__/integration/compliance-anchor.integration.test.ts + compliance-drainer.integration.test.ts
Export runner unitpackages/lambda/__tests__/compliance-export-runner.test.ts
Verifier CLIpackages/audit-verifier/src/ (*.test.ts)
Admin SPAapps/admin/src/ — TanStack route components don’t have unit tests; verification happens via the U11.U5 SOC2 walkthrough rehearsal

When adding a new emit site, the integration test in packages/api/test/integration/compliance-event-writers/ is the gate that proves the cross-cutting path works (originating mutation → outbox row → drainer → audit_events row). Unit tests of the emit helper alone don’t catch wiring regressions.