Actionary treats security as a set of architectural properties — invariants the substrate enforces so a missed check in a repository method, a forgotten WHERE clause, or a stolen bearer token cannot leak data.

Every control below runs in production. Every one has a spec doc, a code path, and — where the failure mode warrants it — a CI gate that fails the build the moment someone drifts.

Tenant isolation is a database property

Every multi-tenant table carries a Postgres row-level security policy keyed on the request-scoped GUC app.current_tenant_id. Every request-path connection acquires the crm_app role — NOSUPERUSER NOBYPASSRLS — and sets the GUC before the first query fires.

A repository method that forgets its tenant clause returns zero rows. A raw psql session under crm_app returns zero rows. A rogue INSERT ... SELECT inside a migration returns zero rows. Fail-closed at the substrate.

The connection role has no data-plane authority. dbmasteruser on production Lightsail Postgres proves membership at handshake time; every request-path transaction then opens with SET LOCAL ROLE crm_app. Postgres uses crm_app for every privilege check for the rest of the transaction. Three BYPASSRLS roles — crm_platform_admin for staff runtime, crm_migrate for the migration container, crm_schema_owner for Schema Admin DDL — are acquired only inside the specific callsite that needs cross-tenant reach, and never granted to each other. A staff runtime request cannot DDL. Full enumeration in the tenant isolation spec.

The is_root flag is absent. Everywhere.

The users table has no such column. JWT payloads carry no such claim. /auth/context responses expose no such field. Bearer-token rows omit it. The SPA has no code path that reads it.

Staff identity lives in a physically separate staff_users table with its own login endpoint, its own cookie scope, and a three-times-stricter idle window (30 minutes vs 4 hours for tenant sessions). session_kind — one of staff_platform, staff_grant, tenant — is set at session creation. Bearer JWTs are stripped of session_kind at the request boundary, so a captured external token cannot claim staff elevation regardless of what the payload says.

A staff_platform session presented against any tenant-resolved route returns 403 grant_required. The only path from staff to tenant data is a customer-issued grant, audit-trailed end to end in auth_session_events. SOC 2 CC6.1, made structural.

One master key. HKDF-derived subsystems.

PLATFORM_MASTER_KEY is the single credential, held outside the database it protects. Six subsystem keys derive from it via HKDF-SHA256 with distinct info labels: mfa, sso-config, llm-provider-keys, integration-tokens, mcp-oauth-session, jwt-signing.

A leak in one cipher context cannot decrypt another. Adding a subsystem is one HKDF label, not one new env var. Rotation is one endpoint — set PLATFORM_MASTER_KEY_OLD, POST to /api/v1/platform/operations/consolidate-encryption-keys, drop the old master. Every encrypted column re-encrypts idempotently, composite-PK aware, in a single pass.

Application code never touches secrets. Credentials resolve through SDK default chains (boto3, OAuth providers), env vars, or IAM roles. The source has zero credential strings.

Thirteen OAuth 2.1 controls for machine identity

Standards-compliant OAuth 2.1 authorization server, full discovery metadata, audit-friendly grant types only. Mandatory PKCE S256 on every flow — plain is structurally rejected at the authorize endpoint. Audience-bound tokens per RFC 8707. Short-lived access tokens, opaque revocable refresh tokens with reuse detection, single-use authorization codes that delete on consumption.

Client secrets hashed at rest with constant-time comparison, revealed once at creation. Redirect URIs whitelisted, non-loopback HTTP refused. Dynamic Client Registration is opt-in per tenant (RFC 7591), off by default, rate-limited, with idle-client auto-revoke. Hard tenant isolation on every token, refresh row, client, and consent record — cross-tenant reuse is structurally impossible. Full audit trail on every authorize, mint, refresh, revoke, registration, and secret-rotation event.

Five-layer XSS defence. Five-layer agent safety perimeter.

XSS defence layers: closed-allowlist server-side sanitisation at the write boundary via shared/sanitize.sanitize_user_html (fourteen tags); matching client-side DOMPurify with a force-rel="noopener noreferrer" hook on every anchor; CSP blocking inline scripts as a third backstop; session cookies genuinely unreachable from JavaScript (__Host- prefixed, HttpOnly, browser-refused from document.cookie); and CSRF as a server-bound token held in the SPA’s module-scope memory rather than any DOM-readable surface.

Every agent turn passes through the five-layer agent safety perimeter: typed tool_result framing (1), a system-prompt directive naming common injection patterns (2), untrusted-content markers on text-shaped paths (3), a tenant tool policy — allow_destructive_tools + require_confirm_destructive (4), and a tenant content policy — blocked_phrases, profanity filter, response-token cap, tool_allow_list (5). The content-policy filter runs after every turn before the assistant text is persisted.

The MCP surface carries ten defensive-design constraints: no tool accepts a credential parameter, zero authentication tools, every tool stateless and idempotent, concrete parameter types over dict[str, Any], schema constraints in place of prose warnings, no implementation leakage in descriptions, a single canonical server, and a live per-tool p50/p95 anomaly surface for operator triage.

Meeting audio deletes on transcription unless the tenant opts to retain

Three retention modes ship at /settings/uploads: delete after transcription (the platform default — audio is gone the moment the transcript lands, matching the data-minimisation posture enterprise buyers ask for), retain for N days (bounded 1–3650, for coaching or dispute evidence), and keep indefinitely (explicit legal-hold shape for regulated industries).

A dedicated meeting_sweeper worker inside core-api wakes every minute regardless of user activity, purges audio whose window has elapsed, and reports its heartbeat on /platform/operations alongside the workflow and billing workers. Every policy change writes one audit_log row with before / after JSON. The retention promise is a monitored control.

The transcript is the artefact of record. The audio has done its job the moment the words are extracted.

Every uncaught failure is a forensic row

Router code carries no silent catches. The except Exception: return api_error(...) pattern is structurally absent across every router file — exceptions bubble unconditionally to one global FastAPI handler that captures full traceback, request id, exception type, tenant, and actor into server_errors. The browser tier does the same via window.onerror, window.onunhandledrejection, and React componentDidCatch into frontend_errors. Both surfaces triage from the SPA with per-transition audit_log rows. No third-party log shipper. No sampling. No ingestion lag.

Error envelopes never leak implementation detail. Detailed exception messages stay on the server; the client sees a generic server_error.

The takeaway

Enterprise-grade security is a property the substrate has or does not have. Actionary makes the boundary load-bearing — in the row-level security predicate, in the host-only cookie scope, in the HKDF-derived subsystem key, in the sweeper’s heartbeat. Contracts live in the spec, in the code, and in a CI gate. A regression cannot ship. Start at security, tenant isolation, or SOC 2.