diff --git a/.github/workflows/deployment-gates.yml b/.github/workflows/deployment-gates.yml
index 82dd8a2b0..cb2cfee34 100644
--- a/.github/workflows/deployment-gates.yml
+++ b/.github/workflows/deployment-gates.yml
@@ -33,6 +33,10 @@ env:
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET || '' }}
STRIPE_PRICE_FOUNDATION: ${{ secrets.STRIPE_PRICE_FOUNDATION || vars.STRIPE_PRICE_FOUNDATION || '' }}
STRIPE_PRICE_GROWTH: ${{ secrets.STRIPE_PRICE_GROWTH || vars.STRIPE_PRICE_GROWTH || '' }}
+ # audit M9: required by scripts/check-env.js productionRequiredKeys but was
+ # absent here, so the production-config gate could never pass clean. Set the
+ # STRIPE_PRICE_SCALE secret/var in the repo for this to resolve.
+ STRIPE_PRICE_SCALE: ${{ secrets.STRIPE_PRICE_SCALE || vars.STRIPE_PRICE_SCALE || '' }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY || '' }}
RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL || vars.RESEND_FROM_EMAIL || '' }}
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL || vars.UPSTASH_REDIS_REST_URL || '' }}
@@ -265,28 +269,40 @@ jobs:
needs: [deploy_to_vercel]
steps:
- name: Health check
+ # audit M10: these checks used `curl -f โฆ || echo "โ ๏ธ"`, so a real
+ # outage only printed a warning and the step still passed. Now they
+ # fail the job. `-L` follows the apexโwww 308 redirect
+ # (next.config.ts), `--retry` rides out cold-start latency.
run: |
+ set -euo pipefail
echo "๐ฅ Running post-deployment health checks..."
- # Check if site is accessible
- curl -f https://formaos.com.au || echo "โ ๏ธ Site accessibility check failed"
+ # Site root (follows apex โ www redirect)
+ curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused \
+ -o /dev/null https://formaos.com.au
- # Check specific endpoints
- curl -f https://formaos.com.au/pricing || echo "โ ๏ธ Pricing page check failed"
+ # Pricing page
+ curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused \
+ -o /dev/null https://formaos.com.au/pricing
- echo "โ
Basic health checks completed"
+ echo "โ
Basic health checks passed"
- name: Security verification
run: |
+ set -euo pipefail
echo "๐ Verifying security after deployment..."
- # Check that admin routes are protected (basic test)
- RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://formaos.com.au/admin || echo "000")
- if [ "$RESPONSE" == "302" ] || [ "$RESPONSE" == "401" ] || [ "$RESPONSE" == "403" ]; then
- echo "โ
Admin routes properly protected (HTTP $RESPONSE)"
- else
- echo "โ ๏ธ Admin route protection may be compromised (HTTP $RESPONSE)"
- fi
+ # Admin routes must redirect/deny unauthenticated traffic. A 200 OR
+ # a curl failure (000) both fail the gate โ a publicly-reachable
+ # admin route or an unreachable site are both deployment failures.
+ RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-redirs 0 https://formaos.com.au/admin || echo "000")
+ case "$RESPONSE" in
+ 301|302|303|307|308|401|403)
+ echo "โ
Admin routes properly protected (HTTP $RESPONSE)" ;;
+ *)
+ echo "โ Admin route protection check failed (HTTP $RESPONSE)"
+ exit 1 ;;
+ esac
- name: Deployment summary
run: |
diff --git a/.github/workflows/formaos-quality-gates.yml b/.github/workflows/formaos-quality-gates.yml
index b6c6fcd71..9ef64be31 100644
--- a/.github/workflows/formaos-quality-gates.yml
+++ b/.github/workflows/formaos-quality-gates.yml
@@ -52,7 +52,15 @@ jobs:
- run: npm ci
- run: npm run typecheck
- - run: npm run lint
+ # --max-warnings 25 matches the deployment-gates ceiling so lint
+ # warnings can't accumulate unbounded through the PR gate (audit H8).
+ - run: npm run lint -- --max-warnings 25
+ # NOTE (audit H8): the Jest unit suite is gated on PRs by the
+ # qa-pipeline "Unit & Integration Tests" job (npm run test:coverage,
+ # blocking on home-repo PRs) โ we deliberately do NOT duplicate it
+ # here. A duplicate `npm test -- --ci` run was removed: it was redundant
+ # and flaked on an order-sensitive hook test under this job's worker
+ # scheduling (passes locally + in the qa-pipeline run).
- run: SECURITY_BASELINE_STRICT=1 npm run check:security-baseline
- run: npm run build
- run: npm run check:app-links
diff --git a/__tests__/lib/compliance-graph.test.ts b/__tests__/lib/compliance-graph.test.ts
index 38fff6485..106666ec4 100644
--- a/__tests__/lib/compliance-graph.test.ts
+++ b/__tests__/lib/compliance-graph.test.ts
@@ -1,7 +1,9 @@
/** @jest-environment node */
import {
+ getComplianceGraph,
initializeComplianceGraph,
+ rebuildOrgGraph,
repairComplianceGraph,
validateComplianceGraph,
} from '@/lib/compliance-graph';
@@ -33,7 +35,15 @@ describe('compliance-graph', () => {
it('initializes the default graph nodes, wires, and audit event for a new org', async () => {
adminSupabase.setResolver((operation) => {
if (operation.table === 'org_members' && operation.action === 'select') {
- return { data: { id: 'membership-1', role: 'owner' }, error: null };
+ // initialize() looks up the single membership; rebuildOrgGraph()
+ // reads all memberships as an array.
+ if (operation.expects === 'maybeSingle') {
+ return { data: { id: 'membership-1', role: 'owner' }, error: null };
+ }
+ return {
+ data: [{ id: 'membership-1', user_id: 'user-1', role: 'owner' }],
+ error: null,
+ };
}
if (operation.table === 'org_policies' && operation.action === 'insert') {
return {
@@ -44,12 +54,38 @@ describe('compliance-graph', () => {
error: null,
};
}
+ if (operation.table === 'org_policies' && operation.action === 'select') {
+ return {
+ data: [
+ { id: 'policy-1', title: 'Information Security Policy' },
+ { id: 'policy-2', title: 'Data Privacy Framework' },
+ ],
+ error: null,
+ };
+ }
if (operation.table === 'org_entities' && operation.action === 'insert') {
return {
data: { id: 'entity-1', created_at: '2026-03-14T00:00:02.000Z' },
error: null,
};
}
+ if (operation.table === 'org_entities' && operation.action === 'select') {
+ return { data: [{ id: 'entity-1', name: 'Primary Site' }], error: null };
+ }
+ if (operation.table === 'graph_nodes' && operation.action === 'upsert') {
+ // Echo back ids for the nodes derived by rebuildOrgGraph so wires
+ // can be resolved.
+ return {
+ data: [
+ { id: 'node-org', node_type: 'organization', source_id: 'org-a' },
+ { id: 'node-role', node_type: 'role', source_id: 'membership-1' },
+ { id: 'node-policy-1', node_type: 'policy', source_id: 'policy-1' },
+ { id: 'node-policy-2', node_type: 'policy', source_id: 'policy-2' },
+ { id: 'node-entity-1', node_type: 'entity', source_id: 'entity-1' },
+ ],
+ error: null,
+ };
+ }
return { data: null, error: null };
});
@@ -72,6 +108,19 @@ describe('compliance-graph', () => {
operation.table === 'org_audit_events' && operation.action === 'insert',
),
).toBe(true);
+ // The graph is now persisted: graph_nodes/graph_wires upserts fired.
+ expect(
+ adminSupabase.operations.some(
+ (operation) =>
+ operation.table === 'graph_nodes' && operation.action === 'upsert',
+ ),
+ ).toBe(true);
+ expect(
+ adminSupabase.operations.some(
+ (operation) =>
+ operation.table === 'graph_wires' && operation.action === 'upsert',
+ ),
+ ).toBe(true);
});
it('returns a failure result when the user membership is missing', async () => {
@@ -223,5 +272,139 @@ describe('compliance-graph', () => {
),
).toBe(true);
});
+
+ it('rebuildOrgGraph derives nodes/wires and upserts them via the admin client', async () => {
+ adminSupabase.setResolver((operation) => {
+ switch (operation.table) {
+ case 'org_members':
+ return {
+ data: [{ id: 'member-1', user_id: 'user-1', role: 'owner' }],
+ error: null,
+ };
+ case 'org_policies':
+ return { data: [{ id: 'policy-1', title: 'ISMS' }], error: null };
+ case 'org_tasks':
+ return {
+ data: [{ id: 'task-1', title: 'Task', policy_id: 'policy-1' }],
+ error: null,
+ };
+ case 'org_evidence':
+ return {
+ data: [{ id: 'evidence-1', title: 'Doc', task_id: 'task-1' }],
+ error: null,
+ };
+ case 'org_audit_events':
+ return { data: [{ id: 'audit-1' }], error: null };
+ case 'org_entities':
+ return { data: [{ id: 'entity-1', name: 'Site' }], error: null };
+ case 'graph_nodes':
+ return {
+ data: [
+ { id: 'n-org', node_type: 'organization', source_id: 'org-a' },
+ { id: 'n-role', node_type: 'role', source_id: 'member-1' },
+ { id: 'n-policy', node_type: 'policy', source_id: 'policy-1' },
+ { id: 'n-task', node_type: 'task', source_id: 'task-1' },
+ { id: 'n-evidence', node_type: 'evidence', source_id: 'evidence-1' },
+ { id: 'n-audit', node_type: 'audit', source_id: 'audit-1' },
+ { id: 'n-entity', node_type: 'entity', source_id: 'entity-1' },
+ ],
+ error: null,
+ };
+ default:
+ return { data: null, error: null };
+ }
+ });
+
+ const result = await rebuildOrgGraph('org-a', 'user-1');
+
+ expect(result.success).toBe(true);
+ // org + role + policy + task + evidence + audit + entity = 7 nodes.
+ expect(result.nodeCount).toBe(7);
+ // user_role + policy_task + task_evidence = 3 wires.
+ expect(result.wireCount).toBe(3);
+
+ const nodeUpsert = adminSupabase.operations.find(
+ (op) => op.table === 'graph_nodes' && op.action === 'upsert',
+ );
+ expect(nodeUpsert?.actionOptions).toEqual({
+ onConflict: 'organization_id,node_type,source_id',
+ });
+ const wireUpsert = adminSupabase.operations.find(
+ (op) => op.table === 'graph_wires' && op.action === 'upsert',
+ );
+ expect(wireUpsert?.actionOptions).toEqual({
+ onConflict: 'organization_id,wire_type,from_node_id,to_node_id',
+ });
+ });
+
+ it('getComplianceGraph reads persisted nodes/wires via the session client', async () => {
+ serverSupabase.setResolver((operation) => {
+ if (operation.table === 'graph_nodes') {
+ return {
+ data: [
+ {
+ id: 'n-1',
+ organization_id: 'org-a',
+ node_type: 'organization',
+ source_id: 'org-a',
+ label: null,
+ metadata: {},
+ created_by: 'user-1',
+ created_at: '2026-06-01T00:00:00.000Z',
+ refreshed_at: '2026-06-01T00:00:00.000Z',
+ },
+ ],
+ error: null,
+ };
+ }
+ if (operation.table === 'graph_wires') {
+ return {
+ data: [
+ {
+ id: 'w-1',
+ organization_id: 'org-a',
+ from_node_id: 'n-1',
+ to_node_id: 'n-2',
+ wire_type: 'user_role',
+ metadata: {},
+ created_at: '2026-06-01T00:00:00.000Z',
+ refreshed_at: '2026-06-01T00:00:00.000Z',
+ },
+ ],
+ error: null,
+ };
+ }
+ return { data: null, error: null };
+ });
+
+ const result = await getComplianceGraph('org-a');
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]).toEqual(
+ expect.objectContaining({
+ id: 'n-1',
+ nodeType: 'organization',
+ sourceId: 'org-a',
+ organizationId: 'org-a',
+ }),
+ );
+ expect(result.wires).toHaveLength(1);
+ expect(result.wires[0]).toEqual(
+ expect.objectContaining({
+ id: 'w-1',
+ wireType: 'user_role',
+ fromNodeId: 'n-1',
+ toNodeId: 'n-2',
+ }),
+ );
+ // Reads must go through the session (server) client, never the admin
+ // client.
+ expect(
+ serverSupabase.operations.some((op) => op.table === 'graph_nodes'),
+ ).toBe(true);
+ expect(
+ adminSupabase.operations.some((op) => op.table === 'graph_nodes'),
+ ).toBe(false);
+ });
});
diff --git a/__tests__/lib/compliance/unified-score.test.ts b/__tests__/lib/compliance/unified-score.test.ts
deleted file mode 100644
index f9d161485..000000000
--- a/__tests__/lib/compliance/unified-score.test.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * Tests for lib/compliance/unified-score.ts
- * Covers: getUnifiedComplianceScore, getFrameworkScores, getScoreImpact
- */
-
-// Mock supabase admin before imports
-const mockFrom = jest.fn();
-const mockSelect = jest.fn();
-const mockEq = jest.fn();
-
-jest.mock('@/lib/supabase/admin', () => ({
- createSupabaseAdminClient: () => ({
- from: mockFrom,
- }),
-}));
-
-import {
- getUnifiedComplianceScore,
- getFrameworkScores,
- getScoreImpact,
-} from '@/lib/compliance/unified-score';
-
-function setupMockChain(data: any) {
- mockEq.mockReturnValue({ data });
- mockSelect.mockReturnValue({ eq: mockEq });
- mockFrom.mockReturnValue({ select: mockSelect });
-}
-
-beforeEach(() => {
- jest.clearAllMocks();
-});
-
-// --- getUnifiedComplianceScore ---
-
-describe('getUnifiedComplianceScore', () => {
- it('returns 0 when no controls exist', async () => {
- setupMockChain(null);
- expect(await getUnifiedComplianceScore('org-1')).toBe(0);
- });
-
- it('returns 0 when controls array is empty', async () => {
- setupMockChain([]);
- expect(await getUnifiedComplianceScore('org-1')).toBe(0);
- });
-
- it('calculates 100% when all controls are satisfied', async () => {
- setupMockChain([
- { status: 'satisfied' },
- { status: 'met' },
- { status: 'satisfied' },
- ]);
- expect(await getUnifiedComplianceScore('org-1')).toBe(100);
- });
-
- it('calculates 0% when no controls are satisfied', async () => {
- setupMockChain([
- { status: 'not_met' },
- { status: 'pending' },
- { status: 'in_progress' },
- ]);
- expect(await getUnifiedComplianceScore('org-1')).toBe(0);
- });
-
- it('calculates correct percentage for mixed statuses', async () => {
- setupMockChain([
- { status: 'satisfied' },
- { status: 'met' },
- { status: 'not_met' },
- { status: 'pending' },
- ]);
- // 2 out of 4 = 50%
- expect(await getUnifiedComplianceScore('org-1')).toBe(50);
- });
-
- it('rounds to nearest integer', async () => {
- setupMockChain([
- { status: 'satisfied' },
- { status: 'not_met' },
- { status: 'not_met' },
- ]);
- // 1/3 = 33.33... โ 33
- expect(await getUnifiedComplianceScore('org-1')).toBe(33);
- });
-
- it('passes orgId to supabase query', async () => {
- setupMockChain([]);
- await getUnifiedComplianceScore('org-xyz');
- expect(mockFrom).toHaveBeenCalledWith('org_controls');
- expect(mockSelect).toHaveBeenCalledWith('status');
- expect(mockEq).toHaveBeenCalledWith('organization_id', 'org-xyz');
- });
-});
-
-// --- getFrameworkScores ---
-
-describe('getFrameworkScores', () => {
- it('returns empty array when no controls exist', async () => {
- setupMockChain(null);
- expect(await getFrameworkScores('org-1')).toEqual([]);
- });
-
- it('returns empty array when controls array is empty', async () => {
- setupMockChain([]);
- expect(await getFrameworkScores('org-1')).toEqual([]);
- });
-
- it('groups controls by framework and calculates score', async () => {
- setupMockChain([
- { framework: 'soc2', status: 'satisfied' },
- { framework: 'soc2', status: 'not_met' },
- { framework: 'iso27001', status: 'met' },
- { framework: 'iso27001', status: 'met' },
- ]);
-
- const scores = await getFrameworkScores('org-1');
- expect(scores).toHaveLength(2);
-
- const soc2 = scores.find((s) => s.framework === 'soc2');
- expect(soc2).toEqual({
- framework: 'soc2',
- score: 50,
- total: 2,
- satisfied: 1,
- });
-
- const iso = scores.find((s) => s.framework === 'iso27001');
- expect(iso).toEqual({
- framework: 'iso27001',
- score: 100,
- total: 2,
- satisfied: 2,
- });
- });
-
- it('handles single framework with all satisfied', async () => {
- setupMockChain([
- { framework: 'hipaa', status: 'satisfied' },
- { framework: 'hipaa', status: 'met' },
- ]);
-
- const scores = await getFrameworkScores('org-1');
- expect(scores).toEqual([
- { framework: 'hipaa', score: 100, total: 2, satisfied: 2 },
- ]);
- });
-
- it('recognizes both "satisfied" and "met" as passing', async () => {
- setupMockChain([
- { framework: 'soc2', status: 'satisfied' },
- { framework: 'soc2', status: 'met' },
- { framework: 'soc2', status: 'partial' },
- ]);
-
- const scores = await getFrameworkScores('org-1');
- expect(scores[0].satisfied).toBe(2);
- expect(scores[0].score).toBe(67); // 2/3 rounded
- });
-
- it('queries correct supabase columns', async () => {
- setupMockChain([]);
- await getFrameworkScores('org-1');
- expect(mockSelect).toHaveBeenCalledWith('framework, status');
- });
-});
-
-// --- getScoreImpact ---
-
-describe('getScoreImpact', () => {
- it('returns empty array when no frameworks exist', async () => {
- setupMockChain([]);
- expect(await getScoreImpact('org-1')).toEqual([]);
- });
-
- // v4-021: previously asserted a hardcoded `+5` bump. Now asserts
- // that without any control_groups linking the unsatisfied control
- // to a satisfied one in a different framework, crossMappedScore
- // collapses to isolated and delta is 0.
- it('crossMappedScore equals isolated when no cross-map links exist', async () => {
- setupMockChain([
- { framework: 'soc2', status: 'satisfied' },
- { framework: 'soc2', status: 'not_met' },
- ]);
-
- const impact = await getScoreImpact('org-1');
- expect(impact).toHaveLength(1);
- expect(impact[0].isolatedScore).toBe(50);
- expect(impact[0].crossMappedScore).toBe(50);
- expect(impact[0].delta).toBe(0);
- });
-
- it('crossMappedScore stays at 100 when all controls satisfied', async () => {
- setupMockChain([
- { framework: 'soc2', status: 'satisfied' },
- { framework: 'soc2', status: 'met' },
- ]);
-
- const impact = await getScoreImpact('org-1');
- expect(impact[0].isolatedScore).toBe(100);
- expect(impact[0].crossMappedScore).toBe(100);
- expect(impact[0].delta).toBe(0);
- });
-
- it('never exceeds 100 and delta is non-negative', async () => {
- setupMockChain([
- { framework: 'fw', status: 'satisfied' },
- { framework: 'fw', status: 'satisfied' },
- { framework: 'fw', status: 'not_met' },
- ]);
-
- const impact = await getScoreImpact('org-1');
- expect(impact[0].crossMappedScore).toBeLessThanOrEqual(100);
- expect(impact[0].delta).toBeGreaterThanOrEqual(0);
- });
-});
diff --git a/__tests__/lib/onboarding/industry-roadmaps.test.ts b/__tests__/lib/onboarding/industry-roadmaps.test.ts
index 7b2f26cc2..cef6494b3 100644
--- a/__tests__/lib/onboarding/industry-roadmaps.test.ts
+++ b/__tests__/lib/onboarding/industry-roadmaps.test.ts
@@ -21,6 +21,7 @@ const ALL_INDUSTRY_IDS = [
'childcare',
'community_services',
'financial_services',
+ 'mental_health',
'saas_technology',
'enterprise',
'other',
diff --git a/app/(marketing)/components/homepage/Industries.tsx b/app/(marketing)/components/homepage/Industries.tsx
index b165e3e37..b694585fd 100644
--- a/app/(marketing)/components/homepage/Industries.tsx
+++ b/app/(marketing)/components/homepage/Industries.tsx
@@ -33,6 +33,7 @@ import {
Zap,
Lock,
Eye,
+ Brain,
} from 'lucide-react';
const signatureEase: [number, number, number, number] = [
@@ -158,6 +159,42 @@ const industrySolutions: IndustrySolution[] = [
],
cta: { text: 'Explore NDIS Solution', href: '/ndis-providers' },
},
+ {
+ icon: Brain,
+ title: 'Mental Health Services',
+ subtitle: 'National Standards for Mental Health Services (NSMHS)',
+ tagline:
+ 'Consumer rights, restrictive-practice governance, and reportable incident timelines โ evidenced continuously, not reconstructed for review.',
+ accent: 'violet',
+ frameworks: ['NSMHS', 'Restrictive Practices', 'Reportable Incidents'],
+ stats: [
+ { icon: Layers, value: '10', label: 'NSMHS standards' },
+ { icon: Shield, value: 'Register', label: 'Restrictive practices' },
+ { icon: BadgeCheck, value: 'Per worker', label: 'Screening tracked' },
+ { icon: Eye, value: 'AU-hosted', label: 'Default region' },
+ ],
+ capabilities: [
+ {
+ icon: Shield,
+ title: 'Consumer Rights',
+ description: 'Consent and complaints evidence mapped to NSMHS standards',
+ },
+ {
+ icon: FileText,
+ title: 'Restrictive Practices',
+ description: 'Register linking authorisations to scheduled review cycles',
+ },
+ {
+ icon: CheckCircle,
+ title: 'Review Evidence',
+ description: 'Evidence bundles structured by NSMHS standard',
+ },
+ ],
+ cta: {
+ text: 'Explore Mental Health Solution',
+ href: '/mental-health-compliance',
+ },
+ },
{
icon: TrendingUp,
title: 'Financial Services',
diff --git a/app/(marketing)/industries/IndustriesContent.tsx b/app/(marketing)/industries/IndustriesContent.tsx
index 701414b9a..33e4d1086 100644
--- a/app/(marketing)/industries/IndustriesContent.tsx
+++ b/app/(marketing)/industries/IndustriesContent.tsx
@@ -8,6 +8,7 @@ import {
Building,
GraduationCap,
Briefcase,
+ Brain,
ArrowRight,
Layers,
} from 'lucide-react';
@@ -30,6 +31,13 @@ const industries = [
'Practice standards alignment, incident management, worker screening, and service delivery evidence tracking',
color: 'cyan' as const,
},
+ {
+ icon: Brain,
+ title: 'Mental health services',
+ description:
+ 'National Standards for Mental Health Services, restrictive-practice governance, reportable incidents, and consumer rights evidence',
+ color: 'cyan' as const,
+ },
{
icon: Heart,
title: 'Healthcare providers',
diff --git a/app/(marketing)/industries/components/IndustryVerticals.tsx b/app/(marketing)/industries/components/IndustryVerticals.tsx
index 92a81cc84..bb961990a 100644
--- a/app/(marketing)/industries/components/IndustryVerticals.tsx
+++ b/app/(marketing)/industries/components/IndustryVerticals.tsx
@@ -8,6 +8,7 @@ import {
TrendingUp,
Building2,
Users,
+ Brain,
ArrowRight,
} from 'lucide-react';
import { motion, useReducedMotion } from 'framer-motion';
@@ -39,6 +40,29 @@ const industries = [
{ label: 'Audit Pack Export', value: '< 4 hrs' },
],
},
+ {
+ icon: Brain,
+ href: '/mental-health-compliance',
+ title: 'Mental Health Services',
+ description:
+ 'Operationalize the National Standards for Mental Health Services, restrictive-practice governance, reportable incidents, consumer rights, and worker screening - with continuous evidence, not pre-review scrambles.',
+ features: [
+ 'National Standards for Mental Health Services',
+ 'Restrictive Practices Register & Reviews',
+ 'Reportable Incidents + Worker Screening',
+ ],
+ color: 'teal',
+ gradient: 'from-zinc-700/20 to-zinc-700/10',
+ border: 'border-zinc-600/20',
+ hoverBorder: 'hover:border-white/20',
+ textColor: 'text-slate-300',
+ dotColor: 'bg-slate-400',
+ metrics: [
+ { label: 'NSMHS Standards Covered', value: '10/10' },
+ { label: 'Restrictive Practice Reviews', value: 'Tracked' },
+ { label: 'Review Pack Export', value: '1-click' },
+ ],
+ },
{
icon: Shield,
href: '/healthcare-compliance',
diff --git a/app/(marketing)/mental-health-compliance/MentalHealthComplianceContent.tsx b/app/(marketing)/mental-health-compliance/MentalHealthComplianceContent.tsx
new file mode 100644
index 000000000..39b054fac
--- /dev/null
+++ b/app/(marketing)/mental-health-compliance/MentalHealthComplianceContent.tsx
@@ -0,0 +1,935 @@
+'use client';
+
+import { RelatedIndustries } from '@/components/marketing/RelatedIndustries';
+import { SectionMedia } from '@/components/marketing/SectionMedia';
+import {
+ compliancePlanHref,
+ demoHref,
+ PUBLIC_CTA_LABELS,
+} from '@/lib/marketing/cta';
+import { Bell, Monitor, FileText } from 'lucide-react';
+import {
+ IndustryHero,
+ IndustryFeatures,
+ IndustryCTA,
+ IndustryFAQ,
+ InteractiveDashboard,
+ BeforeAfterSection,
+ FrameworkExplorer,
+ VerticalTimeline,
+ HeroStatsBar,
+ CompareTable,
+ SeeItInAction,
+ DemoDashboardContent,
+ DemoAuditExport,
+ DemoNotificationTimeline,
+} from '@/components/marketing/industry';
+import { MarketingPageShell } from '../components/shared/MarketingPageShell';
+
+/* โโ Interactive Dashboard visual โโโโโโโโโโโโโโโโโโโโโโ */
+
+function MentalHealthDashboardVisual() {
+ return (
+
+ );
+}
+
+/* โโ Feature visuals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+function FeatureVisual({
+ label,
+ rows,
+}: {
+ label: string;
+ rows: { k: string; v: string; status?: string }[];
+}) {
+ return (
+
+
+
+ {label}
+
+
+ Illustrative ยท sample data
+
+
+
+ {rows.map((r) => (
+
+
{r.k}
+
+ {r.v}
+ {r.status && (
+
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+/* โโ Main content โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
+
+export default function MentalHealthComplianceContent() {
+ return (
+
+
+
+
+ Defensible Compliance for
+
+ Mental Health Services
+ >
+ }
+ subheadline="Operationalise the National Standards for Mental Health Services โ consumer rights, restrictive-practice governance and incidents, continuously evidenced."
+ primaryCta={{
+ label: PUBLIC_CTA_LABELS.compliancePlan,
+ href: compliancePlanHref('mental_health'),
+ }}
+ secondaryCta={{
+ label: PUBLIC_CTA_LABELS.seeDemo,
+ href: demoHref('mental_health'),
+ }}
+ trustSignals={[
+ 'AU-hosted by default',
+ 'Assessment-led onboarding',
+ 'Compliance plan scoped by framework',
+ 'NSMHS aligned',
+ ]}
+ dashboardVisual={}
+ statsBar={
+
+ }
+ jurisdictionBadges={[
+ { label: 'National Standards for Mental Health Services' },
+ { label: 'Reportable Incidents' },
+ { label: 'Restrictive Practices' },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ number: '02',
+ title: 'Map Evidence to Every Standard',
+ description:
+ 'Upload policies, worker credentials, and incident records. FormaOS links each document to specific NSMHS standards - building continuous evidence chains.',
+ gradient:
+ 'from-zinc-700/20 to-zinc-900/20 border-zinc-600/30 text-zinc-300',
+ visual: (
+
+ ),
+ },
+ {
+ number: '03',
+ title: 'Stay Review-Ready Every Day',
+ description:
+ 'Automated alerts for every screening expiry, incident deadline, and evidence gap. When a review or accreditation cycle opens, your evidence pack is one click away.',
+ gradient:
+ 'from-zinc-700/20 to-zinc-900/20 border-zinc-600/30 text-zinc-300',
+ visual: (
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+ ),
+ },
+ {
+ title: 'Restrictive Practices Register',
+ description:
+ 'Track seclusion and restraint events with authorisation documentation, minimisation strategies, and scheduled review cycles linked to each consumer.',
+ details: [
+ 'Authorisation documentation per consumer',
+ 'Seclusion and restraint event logging',
+ 'Minimisation strategies and review cycles',
+ 'Governance oversight and reporting',
+ ],
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Consumer Compliance View',
+ description:
+ 'Compliance posture per consumer - care plans, restrictive practices, incident history, and consent documentation all linked to the individual.',
+ details: [
+ 'Care plan documentation with version history',
+ 'Restrictive practices register per consumer',
+ 'Incident history and investigation records',
+ 'Consent and authorisation tracking',
+ ],
+ visual: (
+
+ ),
+ },
+ {
+ title: 'Reportable Incident Pipeline',
+ description:
+ 'Structured workflow from incident report to investigation to notification to closure. Every step timestamped and evidence-linked.',
+ details: [
+ 'Reported โ Investigated โ Notified โ Closed workflow',
+ 'Immutable audit trail on every state change',
+ 'Evidence attachment at each pipeline stage',
+ 'Notification receipt tracking',
+ ],
+ visual: (
+
+
+ Incident Pipeline
+
+ {[
+ 'Reported',
+ 'Under Investigation',
+ 'Notified',
+ 'Closed',
+ ].map((stage, i) => (
+
+
+ {i + 1}
+
+
+ {stage}
+
+ {i < 3 && (
+
+ Complete
+
+ )}
+
+ ))}
+
+ ),
+ },
+ {
+ title: 'Review Preparation Export',
+ description:
+ 'One-click evidence pack generation structured to the National Standards for Mental Health Services. When a review opens, your evidence is ready - not being assembled.',
+ details: [
+ 'One-click export organised by NSMHS standard',
+ 'Evidence completeness scoring before export',
+ 'PDF evidence pack with table of contents',
+ 'Gap analysis showing missing evidence per standard',
+ ],
+ visual: (
+
+ ),
+ },
+ ]}
+ />
+
+
+
+ ,
+ content: (
+
+ ),
+ },
+ {
+ id: 'audit',
+ label: 'Review Export',
+ icon: ,
+ content: (
+
+ ),
+ },
+ {
+ id: 'notifications',
+ label: 'Incident Timeline',
+ icon: ,
+ content: (
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(marketing)/mental-health-compliance/opengraph-image.tsx b/app/(marketing)/mental-health-compliance/opengraph-image.tsx
new file mode 100644
index 000000000..94c5d05a8
--- /dev/null
+++ b/app/(marketing)/mental-health-compliance/opengraph-image.tsx
@@ -0,0 +1,128 @@
+import { ImageResponse } from 'next/og';
+
+export const runtime = 'edge';
+export const alt =
+ 'Mental Health Compliance Software - National Standards & Review Ready | FormaOS';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+export default function Image() {
+ return new ImageResponse(
+
+
+
+
+
+ Mental Health Compliance
+
+
+ National Standards for Mental Health Services, Review-Ready
+
+
+ Structured controls, evidence collection, and continuous review
+ readiness for mental health services.
+
+
+
+
+
formaos.com.au
+
+
+ NSMHS ยท Restrictive Practices ยท Reportable Incidents
+
+
+
+
+
,
+ { ...size },
+ );
+}
diff --git a/app/(marketing)/mental-health-compliance/page.tsx b/app/(marketing)/mental-health-compliance/page.tsx
new file mode 100644
index 000000000..28b1e00eb
--- /dev/null
+++ b/app/(marketing)/mental-health-compliance/page.tsx
@@ -0,0 +1,118 @@
+import type { Metadata } from 'next';
+import MentalHealthComplianceContent from './MentalHealthComplianceContent';
+import { breadcrumbSchema, serviceSchema, faqSchema, siteUrl } from '@/lib/seo';
+import { JsonLd } from '@/components/JsonLd';
+
+const mentalHealthServiceSchema = serviceSchema({
+ name: 'Mental Health Services Compliance Software',
+ description:
+ 'Compliance management aligned with the National Standards for Mental Health Services (NSMHS). Consumer rights, restrictive practice governance, reportable incidents, and audit-ready evidence.',
+ url: `${siteUrl}/mental-health-compliance`,
+});
+
+const mentalHealthFaqSchema = faqSchema([
+ {
+ question:
+ 'Does FormaOS cover the National Standards for Mental Health Services?',
+ answer:
+ 'Yes. FormaOS ships the NSMHS as a pre-built framework so your obligations across the ten standards are mapped from day one โ no manual setup required.',
+ },
+ {
+ question: 'Can FormaOS track restrictive practices?',
+ answer:
+ 'Yes. FormaOS maintains a restrictive practices register per consumer, links seclusion and restraint events to authorisations and review cycles, and keeps the documentation needed to evidence minimisation and oversight.',
+ },
+ {
+ question: 'How does FormaOS handle reportable incidents?',
+ answer:
+ 'FormaOS tracks reportable incidents through a structured pipeline โ report, investigation, notification, and closure โ with notification timers and submission status so deadlines are not missed.',
+ },
+ {
+ question: 'Does FormaOS track worker screening for clinical staff?',
+ answer:
+ 'Yes. FormaOS tracks worker screening clearances, police checks, and professional qualifications per staff member, with automatic expiry alerts before clearances lapse.',
+ },
+ {
+ question: 'Is my data stored in Australia?',
+ answer:
+ 'Yes. FormaOS is AU-hosted by default. All consumer data, evidence, and compliance records remain on Australian infrastructure. Your data never leaves Australia.',
+ },
+]);
+
+export const dynamic = 'force-static';
+
+export const metadata: Metadata = {
+ title: 'Mental Health Compliance Software | FormaOS',
+ description:
+ 'Operationalise the National Standards for Mental Health Services. Consumer rights, restrictive practice governance, reportable incidents, and audit-ready evidence.',
+ keywords: [
+ 'mental health compliance software',
+ 'National Standards for Mental Health Services',
+ 'NSMHS compliance',
+ 'mental health services audit software',
+ 'restrictive practices register',
+ 'reportable incident software',
+ 'consumer rights mental health',
+ 'mental health evidence management',
+ ],
+ authors: [{ name: 'FormaOS' }],
+ creator: 'FormaOS',
+ publisher: 'FormaOS',
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ 'max-video-preview': -1,
+ 'max-image-preview': 'large' as const,
+ 'max-snippet': -1,
+ },
+ },
+ alternates: { canonical: `${siteUrl}/mental-health-compliance` },
+ openGraph: {
+ title: 'Mental Health Compliance Software | FormaOS',
+ description:
+ 'Operationalise the National Standards for Mental Health Services. Consumer rights, restrictive practice governance, reportable incidents, and audit-ready evidence.',
+ url: `${siteUrl}/mental-health-compliance`,
+ siteName: 'FormaOS',
+ locale: 'en_AU',
+ type: 'website',
+ images: [
+ {
+ url: `${siteUrl}/og-image.png`,
+ width: 1200,
+ height: 630,
+ alt: 'Mental Health Compliance Software by FormaOS',
+ },
+ ],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: 'Mental Health Compliance Software | FormaOS',
+ description:
+ 'Operationalise the National Standards for Mental Health Services. Consumer rights, restrictive practices, reportable incidents, audit-ready evidence.',
+ images: [`${siteUrl}/og-image.png`],
+ creator: '@EjazDev',
+ site: '@FormaOS',
+ },
+};
+
+export default function MentalHealthCompliancePage() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/api/billing/webhook/route.ts b/app/api/billing/webhook/route.ts
index 34c2549ca..e8e4cf983 100644
--- a/app/api/billing/webhook/route.ts
+++ b/app/api/billing/webhook/route.ts
@@ -25,6 +25,60 @@ import { captureStripeEvent } from '@/lib/analytics/posthog-server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
+/**
+ * Resolve the Stripe customer id behind a dispute.
+ *
+ * In webhook deliveries `dispute.charge` arrives as a *string* charge id
+ * (nested objects are never expanded on webhook events), so the previous
+ * `typeof dispute.charge === 'object'` guard always evaluated to `null` and
+ * the entire dispute-handling path was dead in production (audit billing C1).
+ * We resolve the customer from the expanded object when present, otherwise by
+ * retrieving the charge, and finally fall back to the payment intent. Returns
+ * null only when Stripe genuinely cannot tie the dispute to a customer.
+ */
+async function resolveDisputeCustomerId(
+ stripe: Stripe,
+ dispute: Stripe.Dispute,
+ log: ReturnType,
+ eventId: string,
+): Promise {
+ const customerFromCharge =
+ typeof dispute.charge === 'object' && dispute.charge?.customer
+ ? typeof dispute.charge.customer === 'string'
+ ? dispute.charge.customer
+ : dispute.charge.customer.id
+ : null;
+ if (customerFromCharge) return customerFromCharge;
+
+ try {
+ if (typeof dispute.charge === 'string' && dispute.charge) {
+ const charge = await stripe.charges.retrieve(dispute.charge);
+ if (charge.customer) {
+ return typeof charge.customer === 'string'
+ ? charge.customer
+ : charge.customer.id;
+ }
+ }
+ const pi = dispute.payment_intent;
+ if (typeof pi === 'string' && pi) {
+ const intent = await stripe.paymentIntents.retrieve(pi);
+ if (intent.customer) {
+ return typeof intent.customer === 'string'
+ ? intent.customer
+ : intent.customer.id;
+ }
+ } else if (pi && typeof pi === 'object' && pi.customer) {
+ return typeof pi.customer === 'string' ? pi.customer : pi.customer.id;
+ }
+ } catch (err) {
+ log.error(
+ { err: err instanceof Error ? err.message : String(err), eventId, disputeId: dispute.id },
+ '[billing/webhook] failed to resolve dispute customer',
+ );
+ }
+ return null;
+}
+
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature');
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
@@ -880,7 +934,11 @@ export async function POST(request: Request) {
.from('org_subscriptions')
.update({
stripe_customer_id: null,
- status: 'cancelled',
+ // American spelling to match every other status writer +
+ // calculateModuleState / RECOVERABLE_STATES, which only match
+ // 'canceled'. The British 'cancelled' here was mis-bucketed
+ // by the module gate (audit: canceled-normalization).
+ status: 'canceled',
updated_at: new Date().toISOString(),
})
.eq('organization_id', subRow.organization_id);
@@ -1118,12 +1176,7 @@ export async function POST(request: Request) {
if (event.type === 'charge.dispute.created') {
const dispute = event.data.object as Stripe.Dispute;
- const customerId =
- typeof dispute.charge === 'object' && dispute.charge?.customer
- ? (typeof dispute.charge.customer === 'string'
- ? dispute.charge.customer
- : dispute.charge.customer.id)
- : null;
+ const customerId = await resolveDisputeCustomerId(stripe, dispute, log, event.id);
if (customerId) {
const { data: subRow } = await admin
.from('org_subscriptions')
@@ -1191,12 +1244,7 @@ export async function POST(request: Request) {
if (event.type === 'charge.dispute.closed') {
const dispute = event.data.object as Stripe.Dispute;
- const customerId =
- typeof dispute.charge === 'object' && dispute.charge?.customer
- ? (typeof dispute.charge.customer === 'string'
- ? dispute.charge.customer
- : dispute.charge.customer.id)
- : null;
+ const customerId = await resolveDisputeCustomerId(stripe, dispute, log, event.id);
if (customerId) {
const { data: subRow } = await admin
.from('org_subscriptions')
@@ -1233,7 +1281,11 @@ export async function POST(request: Request) {
.eq('organization_id', subRow.organization_id)
.maybeSingle();
const planKey = resolvePlanKey(planRow?.plan_key ?? null);
- if (planKey && planRow?.status !== 'cancelled') {
+ if (
+ planKey &&
+ planRow?.status !== 'canceled' &&
+ planRow?.status !== 'cancelled'
+ ) {
await syncEntitlementsForPlan(subRow.organization_id, planKey);
}
} catch (entErr) {
diff --git a/app/api/cron/data-retention/route.ts b/app/api/cron/data-retention/route.ts
index fdb2ff750..dc2dc6255 100644
--- a/app/api/cron/data-retention/route.ts
+++ b/app/api/cron/data-retention/route.ts
@@ -11,11 +11,12 @@ const log = routeLog('/api/cron/data-retention');
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
-// Bound the per-run org sweep so a slow org can't burn the entire
-// Vercel maxDuration window. The next nightly run will pick up where
-// this one left off (ordering by last_retention_at NULLS FIRST when
-// the lib supports it; for now we just iterate the first N active
-// orgs deterministically).
+// Bound the per-run org sweep so a slow org can't burn the entire Vercel
+// maxDuration window. Successive nightly runs cover every org because we
+// order by `last_retention_at NULLS FIRST` โ orgs never swept (NULL) or
+// least-recently swept go first, and `executeRetention` stamps the column
+// when it finishes (audit M8: the previous `order by id` only ever
+// processed the same first 250 orgs, starving orgs ranked 251+).
const MAX_ORGS_PER_RUN = 250;
type OrgErrorReport = { orgId: string; error: string };
@@ -28,15 +29,33 @@ async function runRetention(request: Request) {
const admin = createSupabaseAdminClient();
try {
- // Enumerate active orgs. Soft-deleted orgs are intentionally
- // skipped โ their data is already in retention by virtue of the
- // org being inactive.
- const { data: orgs, error: enumError } = await admin
+ // Enumerate active orgs, least-recently-swept first. Soft-deleted orgs
+ // are intentionally skipped โ their data is already in retention by
+ // virtue of the org being inactive. If the `last_retention_at` column
+ // isn't deployed yet (migration applied out of order), fall back to a
+ // deterministic id sweep so the cron never hard-fails.
+ let orgs: Array<{ id: string }> | null = null;
+ let enumError: { message: string } | null = null;
+ ({ data: orgs, error: enumError } = await admin
.from('organizations')
.select('id')
.eq('is_active', true)
+ .order('last_retention_at', { ascending: true, nullsFirst: true })
.order('id', { ascending: true })
- .limit(MAX_ORGS_PER_RUN);
+ .limit(MAX_ORGS_PER_RUN));
+
+ if (enumError) {
+ log.warn(
+ { err: enumError },
+ 'last_retention_at ordering failed โ falling back to id sweep',
+ );
+ ({ data: orgs, error: enumError } = await admin
+ .from('organizations')
+ .select('id')
+ .eq('is_active', true)
+ .order('id', { ascending: true })
+ .limit(MAX_ORGS_PER_RUN));
+ }
if (enumError) {
log.error({ err: enumError }, 'failed to enumerate orgs');
diff --git a/app/api/v1/account/delete/route.ts b/app/api/v1/account/delete/route.ts
index 21ca06a83..67d293a11 100644
--- a/app/api/v1/account/delete/route.ts
+++ b/app/api/v1/account/delete/route.ts
@@ -221,8 +221,12 @@ export async function POST(request: Request) {
try {
await admin
.from('org_subscriptions')
+ // American spelling to match calculateModuleState / RECOVERABLE_STATES
+ // (audit: canceled-normalization). The report enum below keeps its
+ // own 'cancelled' literal โ that's an API response value, not the
+ // subscription status column.
.update({
- status: 'cancelled',
+ status: 'canceled',
updated_at: new Date().toISOString(),
})
.eq('organization_id', orgId);
diff --git a/app/api/v1/compliance/graph/route.ts b/app/api/v1/compliance/graph/route.ts
new file mode 100644
index 000000000..507fc2e89
--- /dev/null
+++ b/app/api/v1/compliance/graph/route.ts
@@ -0,0 +1,48 @@
+import { NextResponse } from 'next/server';
+import { createSupabaseServerClient } from '@/lib/supabase/server';
+import { rateLimitApi } from '@/lib/security/rate-limiter';
+import { routeLog } from '@/lib/monitoring/server-logger';
+import { requireActiveOrgContext } from '@/lib/api/require-active-org';
+import { getComplianceGraph } from '@/lib/compliance-graph';
+
+const log = routeLog('/api/v1/compliance/graph');
+
+export async function GET(request: Request) {
+ try {
+ const rate = await rateLimitApi(request);
+ if (!rate.success) {
+ return NextResponse.json(
+ { error: 'Rate limit exceeded', retryAfter: rate.resetAt },
+ { status: 429 },
+ );
+ }
+
+ const supabase = await createSupabaseServerClient();
+ const ctx = await requireActiveOrgContext(supabase);
+ if (!ctx.ok) {
+ if (ctx.response.status === 401 || ctx.response.status === 409) {
+ return ctx.response;
+ }
+ return NextResponse.json({ nodes: [], wires: [] });
+ }
+ const { orgId } = ctx;
+
+ // READ path: getComplianceGraph uses the member-facing session client
+ // (no service-role exposure); the org-membership SELECT RLS policy on
+ // graph_nodes/graph_wires gates row visibility.
+ const { nodes, wires } = await getComplianceGraph(orgId);
+
+ return NextResponse.json({
+ nodes,
+ wires,
+ nodeCount: nodes.length,
+ wireCount: wires.length,
+ });
+ } catch (err) {
+ log.error({ err }, 'unexpected error');
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/app/actions/care-operations.ts b/app/app/actions/care-operations.ts
index 1cf14c426..2013c608b 100644
--- a/app/app/actions/care-operations.ts
+++ b/app/app/actions/care-operations.ts
@@ -407,6 +407,116 @@ export async function createIncident(formData: FormData) {
}
}
+const MEDICATION_ROUTES = [
+ 'oral',
+ 'topical',
+ 'injection',
+ 'inhaled',
+ 'sublingual',
+ 'other',
+] as const;
+
+/**
+ * Audit H5: backs the previously-dead "Add Medication" button. The
+ * org_medications table + RLS already existed; only this create path was
+ * missing. NOTE: org_medications scopes by `org_id` (not `organization_id`),
+ * matching the administer route and the medication-chart query.
+ */
+export async function createMedication(formData: FormData) {
+ try {
+ const supabase = await createSupabaseServerClient();
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) redirect('/auth/signin');
+
+ const { data: membership } = await supabase
+ .from('org_members')
+ .select('organization_id')
+ .eq('user_id', user.id)
+ .maybeSingle();
+
+ if (!membership) throw new Error('No organization found');
+
+ const participantId = (formData.get('participant_id') as string) || '';
+ if (!participantId) throw new Error('Participant is required');
+
+ const name = ((formData.get('name') as string) || '').trim();
+ if (!name) throw new Error('Medication name is required');
+
+ const route = ((formData.get('route') as string) || 'oral').toLowerCase();
+ if (!MEDICATION_ROUTES.includes(route as (typeof MEDICATION_ROUTES)[number])) {
+ throw new Error('Invalid medication route');
+ }
+
+ // Verify the participant belongs to this org before writing (org_patients
+ // is scoped by organization_id โ see the administer route's same check).
+ const { data: participant } = await supabase
+ .from('org_patients')
+ .select('id')
+ .eq('id', participantId)
+ .eq('organization_id', membership.organization_id)
+ .maybeSingle();
+ if (!participant) throw new Error('Participant not found');
+
+ const text = (key: string) => {
+ const value = (formData.get(key) as string) || '';
+ return value.trim() ? value.trim() : null;
+ };
+
+ const medication = {
+ org_id: membership.organization_id,
+ participant_id: participantId,
+ name,
+ dosage: text('dosage'),
+ frequency: text('frequency'),
+ route,
+ prescribed_by: text('prescribed_by'),
+ start_date: text('start_date'),
+ end_date: text('end_date'),
+ instructions: text('instructions'),
+ precautions: text('precautions'),
+ is_prn: formData.get('is_prn') === 'true' || formData.get('is_prn') === 'on',
+ status: 'active',
+ created_by: user.id,
+ };
+
+ const { data: inserted, error } = await supabase
+ .from('org_medications')
+ .insert(medication)
+ .select('id')
+ .single();
+
+ if (error) throw new Error(error.message);
+
+ await logAuditEvent(
+ {
+ organizationId: membership.organization_id,
+ actorUserId: user.id,
+ actorRole: null,
+ entityType: 'medication',
+ entityId: inserted?.id ?? null,
+ actionType: 'MEDICATION_CREATED',
+ afterState: {
+ participant_id: participantId,
+ name,
+ route,
+ is_prn: medication.is_prn,
+ },
+ reason: 'create_medication',
+ },
+ { required: true },
+ );
+
+ revalidatePath(`/app/participants/${participantId}/medications`);
+ return { success: true as const };
+ } catch (error) {
+ if (isNextInternalError(error)) throw error;
+ return actionError(error);
+ }
+}
+
export async function resolveIncident(id: string, formData: FormData) {
try {
const supabase = await createSupabaseServerClient();
diff --git a/app/app/actions/rbac.ts b/app/app/actions/rbac.ts
index 27d9de6ca..5f36c4ed6 100644
--- a/app/app/actions/rbac.ts
+++ b/app/app/actions/rbac.ts
@@ -1,5 +1,6 @@
import { cache } from "react";
import { createSupabaseServerClient } from "@/lib/supabase/server";
+import { assertOrgCanWrite } from "@/lib/billing/enforce-grace-period";
export type PermissionKey =
| "VIEW_CONTROLS"
@@ -126,10 +127,31 @@ export function hasPermission(role: RoleKey, permission: PermissionKey) {
return ROLE_PERMISSIONS[role].includes(permission);
}
+// Mutating permissions. When an org has exhausted its 3-day payment grace
+// window (isReadOnly), these are blocked at the chokepoint via
+// assertOrgCanWrite โ read/export permissions stay available so the customer
+// can still see and extract their compliance data (audit H2: the grace
+// read-only state was previously enforced in only 2 of ~40 action files).
+const WRITE_PERMISSIONS: ReadonlySet = new Set([
+ "EDIT_CONTROLS",
+ "UPLOAD_EVIDENCE",
+ "APPROVE_EVIDENCE",
+ "REJECT_EVIDENCE",
+ "RESOLVE_COMPLIANCE_BLOCK",
+ "GENERATE_CERTIFICATIONS",
+ "MANAGE_USERS",
+ "DRAFT_AI_POLICIES",
+]);
+
export async function requirePermission(permission: PermissionKey) {
const membership = await getUserOrgMembership();
if (!hasPermission(membership.role, permission)) {
throw new Error(`Access denied: missing permission ${permission}`);
}
+ if (WRITE_PERMISSIONS.has(permission)) {
+ // Throws OrgReadOnlyError when the org is past its payment grace window;
+ // callers' actionError() catch turns it into a user-facing message.
+ await assertOrgCanWrite(membership.orgId);
+ }
return membership;
}
diff --git a/app/app/actions/team.ts b/app/app/actions/team.ts
index ae675da59..63b5dd7ef 100644
--- a/app/app/actions/team.ts
+++ b/app/app/actions/team.ts
@@ -68,8 +68,12 @@ export async function inviteMember(email: string, role: string): Promise
-
Authoritative source: ${SITE}
> Last reviewed: 2026-05-23
-FormaOS is a compliance operating system built for Australian regulated organisations. It turns regulatory obligations from NDIS Practice Standards, AHPRA, NSQHS, ACECQA, ASIC, APRA, AUSTRAC, SafeWork, and international frameworks (ISO 27001, SOC 2, GDPR, HIPAA, PCI DSS, NIST, CIS) into structured controls, owned tasks, and immutable evidence โ so compliance is provable every day, not reconstructed during the week before an audit.
+FormaOS is a compliance operating system built for Australian regulated organisations. It turns regulatory obligations from NDIS Practice Standards, the National Standards for Mental Health Services (NSMHS), AHPRA, NSQHS, ACECQA, ASIC, APRA, AUSTRAC, SafeWork, and international frameworks (ISO 27001, SOC 2, GDPR, HIPAA, PCI DSS, NIST, CIS) into structured controls, owned tasks, and immutable evidence โ so compliance is provable every day, not reconstructed during the week before an audit.
FormaOS is AU-hosted by default (Vercel Sydney region + Supabase AU), with row-level multi-tenant isolation, SAML 2.0 SSO, MFA with TOTP and backup codes, and SOC 2 attestation in progress. Trust documentation including DPA, SLA, sub-processor list, incident response policy, and vendor assurance materials are published at ${SITE}/trust.
@@ -64,6 +64,12 @@ Common drivers: 2026 unannounced audit increases, NDIS Provider Registration and
Reference: ${SITE}/ndis-providers
+### Mental health services
+
+For mental health service providers aligned with the National Standards for Mental Health Services (NSMHS). All ten standards are mapped โ rights and responsibilities, safety, consumer and carer participation, diversity responsiveness, promotion and prevention, consumers, carers, governance and leadership, integration, and delivery of care. FormaOS maintains a restrictive practices register per consumer (linking seclusion and restraint events to authorisations and scheduled review cycles), runs reportable incidents through a structured report-investigate-notify-close pipeline with notification timers, and tracks worker screening clearances and police checks per worker with expiry alerts. Consumer rights evidence โ consent, complaints, and feedback โ is captured against the relevant standards so compliance is demonstrable continuously, not reconstructed before a review.
+
+Reference: ${SITE}/mental-health-compliance
+
### Healthcare
For private hospitals, clinics, allied health practices, and aged-care operators answerable to the Australian Commission on Safety and Quality in Health Care (NSQHS Standards), AHPRA, the Aged Care Quality and Safety Commission, and the Aged Care Act. Tracks AHPRA registration status and expiry for every clinician, CPD hour accrual against AHPRA requirements, NSQHS Standards accreditation evidence (all 8 standards), incident and adverse event records, and clinical governance evidence.
diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts
index 3b5282cd3..e1c371fbf 100644
--- a/app/llms.txt/route.ts
+++ b/app/llms.txt/route.ts
@@ -11,7 +11,7 @@ const SITE = brand.seo.siteUrl.replace(/\/$/, '');
const BODY = `# FormaOS
-> A compliance operating system for Australian regulated industries โ NDIS providers, aged care, healthcare, financial services, childcare, and construction. FormaOS turns regulatory obligations into enforced workflows with named owners, immutable evidence chains, and audit-ready posture every day.
+> A compliance operating system for Australian regulated industries โ NDIS providers, mental health services, aged care, healthcare, financial services, childcare, and construction. FormaOS turns regulatory obligations into enforced workflows with named owners, immutable evidence chains, and audit-ready posture every day.
FormaOS is built in Australia, hosted in Australia (Sydney region), and aligned with the regulators Australian operators actually answer to: NDIS Commission, AHPRA, ACECQA, ASIC, AUSTRAC, APRA, SafeWork, and the NSQHS Standards. Framework packs ship for ISO 27001, SOC 2 (in progress), GDPR, HIPAA, PCI DSS, NIST CSF, and CIS. SOC 2 attestation is currently in progress.
@@ -29,6 +29,7 @@ The full content export โ every marketing and trust page concatenated as a sin
## Industries
- [NDIS providers](${SITE}/ndis-providers): all 8 Practice Standards modules, SIRS notifications, worker screening, unannounced audit prep.
+- [Mental health services](${SITE}/mental-health-compliance): National Standards for Mental Health Services (NSMHS), restrictive-practice governance, reportable incidents, consumer rights, worker screening.
- [Healthcare](${SITE}/healthcare-compliance): AHPRA registrations, NSQHS Standards accreditation, CPD hours, adverse events.
- [Financial services](${SITE}/financial-services-compliance): ASIC, APRA, AUSTRAC alignment for AFS licensees and credit licensees.
- [Childcare](${SITE}/childcare-compliance): NQF, ACECQA, child safety obligations.
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 5e14c6c93..612919b89 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -22,6 +22,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'weekly',
priority: 0.95,
},
+ {
+ url: `${siteUrl}/mental-health-compliance`,
+ lastModified: now,
+ changeFrequency: 'weekly',
+ priority: 0.95,
+ },
{
url: `${siteUrl}/healthcare-compliance`,
lastModified: now,
diff --git a/components/care/medication-chart.tsx b/components/care/medication-chart.tsx
index e8c2b6ccc..9c189a823 100644
--- a/components/care/medication-chart.tsx
+++ b/components/care/medication-chart.tsx
@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
-import { Pill, Clock, AlertTriangle, Check, X, History } from 'lucide-react';
+import { Pill, Clock, AlertTriangle, Check, X, History, Plus } from 'lucide-react';
+import { createMedication } from '@/app/app/actions/care-operations';
interface Medication {
id: string;
@@ -55,7 +56,23 @@ export function MedicationChart({
orgId: string;
}) {
const [filter, setFilter] = useState<'all' | 'active' | 'prn'>('active');
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [createError, setCreateError] = useState(null);
const [showAdminForm, setShowAdminForm] = useState(null);
+
+ async function handleCreate(formData: FormData) {
+ const result = await createMedication(formData);
+ // Server action revalidates the page on success; collapse the form.
+ // On failure surface result.error inline.
+ if (!result || (result as { success?: boolean }).success) {
+ setShowAddForm(false);
+ setCreateError(null);
+ } else {
+ setCreateError(
+ (result as { error?: string }).error ?? 'Failed to add medication.',
+ );
+ }
+ }
const [adminForm, setAdminForm] = useState({
dose_given: '',
status: 'given' as string,
@@ -96,6 +113,133 @@ export function MedicationChart({
return (
+ {/* Add Medication */}
+
+
+
+
+ {showAddForm && (
+
+ )}
+
{/* Summary Cards */}
diff --git a/components/dashboard/command-center.tsx b/components/dashboard/command-center.tsx
index 709d813b1..8603718d1 100644
--- a/components/dashboard/command-center.tsx
+++ b/components/dashboard/command-center.tsx
@@ -33,6 +33,9 @@ import {
NDISWorkerScreeningWidget,
NDISParticipantSnapshot,
NDISSIRSTrackerWidget,
+ MentalHealthConsumerSnapshot,
+ MentalHealthCarePlanWidget,
+ MentalHealthIncidentWatchWidget,
HealthcarePractitionerWidget,
HealthcareNSQHSWidget,
AgedCareCarePlanWidget,
@@ -852,6 +855,17 @@ function renderIndustryWidgets(industry: string | null | undefined) {
);
}
+ if (industry === 'mental_health') {
+ return (
+
+ );
+ }
if (industry === 'healthcare') {
return (
diff --git a/components/dashboard/industry-labels.ts b/components/dashboard/industry-labels.ts
index 40168ae26..8d387fe00 100644
--- a/components/dashboard/industry-labels.ts
+++ b/components/dashboard/industry-labels.ts
@@ -2,6 +2,8 @@ export function getExpiryLabel(industry?: string | null): string {
switch (industry) {
case 'ndis':
return 'Screening Expiry';
+ case 'mental_health':
+ return 'Screening Expiry';
case 'healthcare':
return 'Registration Expiry';
case 'childcare':
@@ -19,6 +21,8 @@ export function getEntityLabel(industry?: string | null): string {
switch (industry) {
case 'ndis':
return 'participant compliance';
+ case 'mental_health':
+ return 'consumer safety';
case 'healthcare':
return 'clinical';
case 'aged_care':
@@ -42,6 +46,8 @@ export function getTasksLabel(industry?: string | null): string {
switch (industry) {
case 'ndis':
return 'Compliance Tasks';
+ case 'mental_health':
+ return 'Care Tasks';
case 'healthcare':
return 'Clinical Tasks';
case 'aged_care':
diff --git a/components/dashboard/industry-selector.tsx b/components/dashboard/industry-selector.tsx
index 713c30afb..8e4cdfd01 100644
--- a/components/dashboard/industry-selector.tsx
+++ b/components/dashboard/industry-selector.tsx
@@ -8,6 +8,7 @@ import {
Landmark,
Laptop,
Briefcase,
+ Brain,
HelpCircle,
type LucideIcon,
} from 'lucide-react';
@@ -32,6 +33,15 @@ const INDUSTRY_CARDS: IndustryCard[] = [
bgColor: 'bg-purple-500/10',
textColor: 'text-purple-300',
},
+ {
+ id: 'mental_health',
+ name: 'Mental Health Services',
+ description:
+ 'National Standards for Mental Health Services (NSMHS) for mental health providers.',
+ icon: Brain,
+ bgColor: 'bg-teal-500/10',
+ textColor: 'text-teal-300',
+ },
{
id: 'healthcare',
name: 'GP / Medical',
diff --git a/components/dashboard/industry-widgets.tsx b/components/dashboard/industry-widgets.tsx
index 0683ff430..6a4f63e99 100644
--- a/components/dashboard/industry-widgets.tsx
+++ b/components/dashboard/industry-widgets.tsx
@@ -12,6 +12,7 @@ import {
Star,
BookOpen,
Scale,
+ HeartPulse,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ErrorBoundary } from '@/components/ui/error-boundary';
@@ -239,6 +240,148 @@ export function NDISSIRSTrackerWidget() {
);
}
+// ==========================================================
+// MENTAL HEALTH โ Consumer Safety Snapshot
+// ==========================================================
+export function MentalHealthConsumerSnapshot() {
+ const [data, setData] = useState<{
+ total: number;
+ plansOverdue: number;
+ restrictivePractices: number;
+ } | null>(null);
+
+ useEffect(() => {
+ fetch('/api/v1/participants/snapshot')
+ .then((r) => (r.ok ? r.json() : null))
+ .then(setData)
+ .catch(() => {});
+ }, []);
+
+ return (
+
+
+
+
{data?.total ?? 'โ'}
+
+ Consumers
+
+
+
+
+ {data?.plansOverdue ?? 'โ'}
+
+
+ Reviews Due
+
+
+
+
+ {data?.restrictivePractices ?? 'โ'}
+
+
+ Active RP
+
+
+
+
+ );
+}
+
+// ==========================================================
+// MENTAL HEALTH โ Care Plan Review Tracking
+// ==========================================================
+export function MentalHealthCarePlanWidget() {
+ const [data, setData] = useState<{
+ dueThisMonth: number;
+ overdue: number;
+ } | null>(null);
+
+ useEffect(() => {
+ fetch('/api/v1/care-plans/review-status')
+ .then((r) => (r.ok ? r.json() : null))
+ .then(setData)
+ .catch(() => {});
+ }, []);
+
+ return (
+
+
+
+
+
+ {data?.dueThisMonth ?? 'โ'}
+
+
+ Due This Month
+
+
+
+
+ {data?.overdue ?? 'โ'}
+
+
+ Overdue
+
+
+
+
+
+ );
+}
+
+// ==========================================================
+// MENTAL HEALTH โ Restrictive Practice / Incident Watch
+// ==========================================================
+export function MentalHealthIncidentWatchWidget() {
+ const [counts, setCounts] = useState<{
+ open: number;
+ notified: number;
+ investigating: number;
+ } | null>(null);
+
+ useEffect(() => {
+ fetch('/api/v1/incidents/sirs-summary')
+ .then((r) => (r.ok ? r.json() : null))
+ .then(setCounts)
+ .catch(() => {});
+ }, []);
+
+ return (
+
+
+
+
+
+ {counts?.open ?? 'โ'}
+
+
Open
+
+
+
+ {counts?.notified ?? 'โ'}
+
+
Notified
+
+
+
+ {counts?.investigating ?? 'โ'}
+
+
Investigating
+
+
+
+
+ );
+}
+
// ==========================================================
// HEALTHCARE โ Practitioner Register
// ==========================================================
diff --git a/components/onboarding/IndustryFeatureHighlights.tsx b/components/onboarding/IndustryFeatureHighlights.tsx
index 1b810e6ce..10a406e15 100644
--- a/components/onboarding/IndustryFeatureHighlights.tsx
+++ b/components/onboarding/IndustryFeatureHighlights.tsx
@@ -90,6 +90,62 @@ const INDUSTRY_CONFIGS: Record
= {
'Create incident response workflows',
],
},
+ mental_health: {
+ industry: 'mental_health',
+ displayName: 'Mental Health Services',
+ color: 'from-zinc-300 to-zinc-500',
+ gradient: 'from-zinc-700/20 to-zinc-900/20',
+ features: [
+ {
+ icon: Users,
+ title: 'Consumer Management',
+ description:
+ 'Track consumers with care status, risk levels, and safety flags. Every interaction becomes compliance evidence.',
+ route: '/app/patients',
+ },
+ {
+ icon: Calendar,
+ title: 'Service Delivery Scheduling',
+ description:
+ 'Schedule service delivery with automatic audit trails. No double entryโsession logs become compliance evidence.',
+ route: '/app/visits',
+ },
+ {
+ icon: Activity,
+ title: 'Incident & Restrictive Practice Tracking',
+ description:
+ 'Log incidents and restrictive practices with severity classification, authorisation, and review. Reporting-ready.',
+ route: '/app/patients',
+ },
+ {
+ icon: FileCheck,
+ title: 'National Standards for Mental Health Services',
+ description:
+ 'Pre-configured controls aligned to the NSMHS. Start auditing in minutes.',
+ route: '/app/dashboard',
+ },
+ {
+ icon: Shield,
+ title: 'Worker Screening Tracking',
+ description:
+ 'Track worker screening clearances and police checks with automatic expiry reminders.',
+ route: '/app/registers',
+ },
+ {
+ icon: Heart,
+ title: 'Staff Portal',
+ description:
+ 'Front-line workers get their own dashboard: tasks, consumers, shifts. No admin clutter.',
+ route: '/app/staff',
+ },
+ ],
+ quickWins: [
+ 'Map the National Standards for Mental Health Services to your services',
+ 'Start logging service delivery with automatic evidence capture',
+ 'Set up worker screening expiry reminders',
+ 'Create incident and restrictive practice review workflows',
+ ],
+ },
healthcare: {
industry: 'healthcare',
displayName: 'Healthcare & Medical',
diff --git a/components/onboarding/steps/WelcomeStep.tsx b/components/onboarding/steps/WelcomeStep.tsx
index d3cce7286..54c88d364 100644
--- a/components/onboarding/steps/WelcomeStep.tsx
+++ b/components/onboarding/steps/WelcomeStep.tsx
@@ -10,6 +10,14 @@ const INDUSTRY_OPTIONS = [
label: 'NDIS Provider',
frameworks: ['NDIS Practice Standards', 'Aged Care Quality Standards'],
},
+ {
+ id: 'mental_health',
+ label: 'Mental Health Services',
+ frameworks: [
+ 'National Standards for Mental Health Services',
+ 'SIRS / Reportable Incidents',
+ ],
+ },
{
id: 'healthcare',
label: 'Healthcare',
diff --git a/e2e/full-platform-matrix.spec.ts b/e2e/full-platform-matrix.spec.ts
index c97e7e99c..ba1bfa30a 100644
--- a/e2e/full-platform-matrix.spec.ts
+++ b/e2e/full-platform-matrix.spec.ts
@@ -17,7 +17,10 @@ import {
PLAN_OPTIONS,
} from '../lib/validators/organization';
import { PLAN_CATALOG, type PlanKey } from '../lib/plans';
-import { PACK_SLUGS } from '../lib/frameworks/framework-installer';
+// Import from the pure registry module (not framework-installer) so test
+// collection does not pull in the server-only Supabase admin client, which
+// crashed `playwright test --list` (audit H6).
+import { PACK_SLUGS } from '../lib/frameworks/pack-registry';
import { getIndustryNavigation } from '../lib/navigation/industry-sidebar';
loadEnv({ path: '.env.local' });
diff --git a/framework-packs/manifest.json b/framework-packs/manifest.json
index c11135d04..f6f68363b 100644
--- a/framework-packs/manifest.json
+++ b/framework-packs/manifest.json
@@ -5,6 +5,7 @@
"hipaa.json": "c4784c25636cf17f4f8cb658f1edd0f26c1d6771a3af4629cf8b55d4ca801de5",
"iso27001-2022.json": "c708215d3df361641d46fb74376be3b05ea2826d158881e177500967f3ebcadf",
"iso27001.json": "f253db3f38f3fa553890cfc9caf98a95d08ea3d5c3e5fc235e6348f38267e11d",
+ "mental-health-au.json": "600475ba0492215f6dba1a0376fd815c46299862cf84687fd81b30b761de6ca0",
"ndis.json": "d9715e9909bf3bc01fa64b36b1a4eb1f057f65063ce09db0dfe64793ccf1acb7",
"nist-csf.json": "101580f85a87d6da9a8011873d8c0daf2904d0ed0e58dbe91ddad11d812454d7",
"pci-dss.json": "eaca4773d5854d1e786a6b2fc9504fb26f06e99c7483121998848e48fb0d0dae",
diff --git a/framework-packs/mental-health-au.json b/framework-packs/mental-health-au.json
new file mode 100644
index 000000000..43d8715e0
--- /dev/null
+++ b/framework-packs/mental-health-au.json
@@ -0,0 +1,177 @@
+{
+ "framework": {
+ "name": "National Standards for Mental Health Services",
+ "slug": "mental-health-au",
+ "version": "2010",
+ "description": "National Standards for Mental Health Services (NSMHS) 2010 โ the Australian standards endorsed by Health Ministers for use across public, private and non-government mental health services. Ten standards covering consumer rights, safety, participation, diversity, prevention, governance, integration and the delivery-of-care cycle. Phase 1 ships 14 controls: 4 carry conservative DB-signal predicates against existing FormaOS tables (org_incidents, org_registers, org_policies, org_risks); the remaining 10 are manual-attestation where no verified automation signal exists. A clinical / mental-health-domain expert should review predicate semantics and expand sub-criteria coverage in a later phase.",
+ "is_active": true
+ },
+ "domains": [
+ { "name": "Rights and Responsibilities", "description": "Standard 1: the rights and responsibilities of people receiving mental health care are upheld by the mental health service and its staff, and are documented, prominently displayed, applied and promoted.", "sort_order": 1, "key": "std-1" },
+ { "name": "Safety", "description": "Standard 2: the activities and environment of the mental health service are safe for consumers, carers, families, visitors, staff and the community.", "sort_order": 2, "key": "std-2" },
+ { "name": "Consumer and Carer Participation", "description": "Standard 3: consumers and carers are actively involved in the development, planning, delivery and evaluation of services.", "sort_order": 3, "key": "std-3" },
+ { "name": "Diversity Responsiveness", "description": "Standard 4: the mental health service delivers services that take into account the cultural and social diversity of its consumers and meets their needs and those of their carers and community throughout all phases of care.", "sort_order": 4, "key": "std-4" },
+ { "name": "Promotion and Prevention", "description": "Standard 5: the mental health service works with the community in undertaking mental health promotion, prevention of mental health problems and early intervention.", "sort_order": 5, "key": "std-5" },
+ { "name": "Consumers", "description": "Standard 6: consumers are provided with timely, comprehensive access to a range of treatment, care and support, and are supported as central participants in their own care.", "sort_order": 6, "key": "std-6" },
+ { "name": "Carers", "description": "Standard 7: the mental health service recognises, respects, values and supports the importance of carers to the wellbeing, treatment and recovery of people with a mental illness.", "sort_order": 7, "key": "std-7" },
+ { "name": "Governance, Leadership and Management", "description": "Standard 8: the mental health service is governed, led and managed effectively and efficiently to facilitate the delivery of quality and coordinated services.", "sort_order": 8, "key": "std-8" },
+ { "name": "Integration", "description": "Standard 9: the mental health service collaborates with and develops partnerships to facilitate coordinated and integrated care across programs, sites and other related health and human services.", "sort_order": 9, "key": "std-9" },
+ { "name": "Delivery of Care", "description": "Standard 10: the consumer and their carer(s) have access to, and move through, the mental health service to achieve optimal, timely outcomes across the care cycle: access, entry, assessment and review, treatment and support, and exit and re-entry.", "sort_order": 10, "key": "std-10" }
+ ],
+ "controls": [
+ {
+ "control_code": "MHS-1",
+ "title": "Rights and responsibilities",
+ "summary_description": "The rights and responsibilities of consumers, carers and families are upheld, documented, prominently displayed, applied and promoted throughout the service.",
+ "implementation_guidance": "Maintain a documented charter of consumer and carer rights and responsibilities aligned with the Australian Charter of Healthcare Rights; display it prominently in service locations and provide it on entry; train staff on rights including informed consent, privacy, confidentiality, advocacy access and the right to a second opinion; record acknowledgement at intake.",
+ "default_risk_level": "high",
+ "review_frequency_days": 365,
+ "domain": "Rights and Responsibilities",
+ "suggested_evidence_types": ["policy_document", "rights_charter", "staff_training_record", "signed_consent"],
+ "suggested_automation_triggers": ["policy_published", "staff_training_completed"]
+ },
+ {
+ "control_code": "MHS-2",
+ "title": "Safety of consumers, carers, staff and the community",
+ "summary_description": "The activities and environment of the service are safe; incidents, aggression, self-harm and adverse events are identified, reported, reviewed and acted upon.",
+ "implementation_guidance": "Operate a documented incident-management system covering self-harm, suicide risk, aggression, restraint, seclusion and medication events; record every incident in the incident register, triage by severity, and ensure timely review and closure; analyse incident trends to drive safety improvements. Reviewed at least quarterly.",
+ "default_risk_level": "critical",
+ "review_frequency_days": 90,
+ "domain": "Safety",
+ "suggested_evidence_types": ["incident_register", "policy_document", "safety_review_minutes"],
+ "suggested_automation_triggers": ["incident_reported", "incident_resolved"]
+ },
+ {
+ "control_code": "MHS-3",
+ "title": "Consumer and carer participation",
+ "summary_description": "Consumers and carers are actively involved in the development, planning, delivery and evaluation of services, including through feedback and complaints mechanisms.",
+ "implementation_guidance": "Provide accessible feedback and complaints channels; record consumer/carer feedback and complaints in a register; demonstrate consumer/carer representation in service planning and quality committees; show that feedback is acknowledged, investigated and used to improve services.",
+ "default_risk_level": "medium",
+ "review_frequency_days": 180,
+ "domain": "Consumer and Carer Participation",
+ "suggested_evidence_types": ["complaint_register", "feedback_register", "committee_minutes", "attestation"],
+ "suggested_automation_triggers": ["complaint_logged", "feedback_logged"]
+ },
+ {
+ "control_code": "MHS-4",
+ "title": "Diversity responsiveness",
+ "summary_description": "Services take into account the cultural and social diversity of consumers and carers and meet their needs throughout all phases of care.",
+ "implementation_guidance": "Document diversity-responsive practice covering Aboriginal and Torres Strait Islander peoples, culturally and linguistically diverse communities, age, gender, sexuality and disability; provide interpreter access; train staff in cultural safety; review service data for equity of access and outcomes.",
+ "default_risk_level": "medium",
+ "review_frequency_days": 365,
+ "domain": "Diversity Responsiveness",
+ "suggested_evidence_types": ["policy_document", "staff_training_record", "interpreter_access_record"],
+ "suggested_automation_triggers": ["policy_published", "staff_training_completed"]
+ },
+ {
+ "control_code": "MHS-5",
+ "title": "Promotion and prevention",
+ "summary_description": "The service works with the community on mental health promotion, prevention of mental health problems and early intervention.",
+ "implementation_guidance": "Maintain a documented promotion, prevention and early-intervention plan; record community engagement, education and stigma-reduction activities; collaborate with primary care and community organisations on early-intervention pathways; evaluate the reach and impact of activities.",
+ "default_risk_level": "low",
+ "review_frequency_days": 365,
+ "domain": "Promotion and Prevention",
+ "suggested_evidence_types": ["program_plan", "community_engagement_record", "attestation"],
+ "suggested_automation_triggers": []
+ },
+ {
+ "control_code": "MHS-6",
+ "title": "Consumers",
+ "summary_description": "Consumers are supported as central participants in their own care, with comprehensive, recovery-oriented treatment, care and support.",
+ "implementation_guidance": "Develop individualised, recovery-oriented care plans with consumer involvement; ensure timely access to a range of treatment and support; document informed consent and shared decision-making; review care plans at agreed intervals and on significant change.",
+ "default_risk_level": "high",
+ "review_frequency_days": 180,
+ "domain": "Consumers",
+ "suggested_evidence_types": ["care_plan", "signed_consent", "review_record"],
+ "suggested_automation_triggers": ["care_plan_updated"]
+ },
+ {
+ "control_code": "MHS-7",
+ "title": "Carers",
+ "summary_description": "The service recognises, respects, values and supports carers in the wellbeing, treatment and recovery of people with a mental illness.",
+ "implementation_guidance": "Identify and record carers (with consumer consent); provide carers with information, education and support consistent with privacy obligations; involve carers in care planning where consented; document carer feedback and support arrangements.",
+ "default_risk_level": "medium",
+ "review_frequency_days": 365,
+ "domain": "Carers",
+ "suggested_evidence_types": ["policy_document", "carer_support_record", "attestation"],
+ "suggested_automation_triggers": []
+ },
+ {
+ "control_code": "MHS-8",
+ "title": "Governance, leadership and management",
+ "summary_description": "The service is governed, led and managed through current, approved policies and procedures that facilitate quality and coordinated care.",
+ "implementation_guidance": "Maintain a current suite of approved governance policies (clinical governance, safety and quality, risk management, workforce, privacy) reviewed on a defined cadence; document organisational structure, delegations and accountabilities; operate a quality-improvement system with measurable objectives and regular review.",
+ "default_risk_level": "high",
+ "review_frequency_days": 365,
+ "domain": "Governance, Leadership and Management",
+ "suggested_evidence_types": ["policy_document", "governance_framework", "quality_plan"],
+ "suggested_automation_triggers": ["policy_published"]
+ },
+ {
+ "control_code": "MHS-9",
+ "title": "Integration",
+ "summary_description": "The service collaborates and develops partnerships to facilitate coordinated and integrated care across programs, sites and other health and human services.",
+ "implementation_guidance": "Maintain documented agreements, referral pathways and shared-care arrangements with primary care, hospitals, community services and NGOs; ensure consented information-sharing protocols; record care-coordination and transfer-of-care processes that prevent gaps at transition points.",
+ "default_risk_level": "medium",
+ "review_frequency_days": 365,
+ "domain": "Integration",
+ "suggested_evidence_types": ["partnership_agreement", "referral_pathway", "attestation"],
+ "suggested_automation_triggers": []
+ },
+ {
+ "control_code": "MHS-10.1",
+ "title": "Delivery of care โ Access",
+ "summary_description": "The service is accessible to the defined community, with care provided in the least restrictive manner and timely access regardless of where the consumer presents.",
+ "implementation_guidance": "Publish service-access criteria and operating hours; document arrangements for after-hours and crisis access; demonstrate the principle of least-restrictive care and equitable access; monitor access times and barriers and act on identified gaps.",
+ "default_risk_level": "high",
+ "review_frequency_days": 365,
+ "domain": "Delivery of Care",
+ "suggested_evidence_types": ["access_policy", "service_information", "attestation"],
+ "suggested_automation_triggers": []
+ },
+ {
+ "control_code": "MHS-10.2",
+ "title": "Delivery of care โ Entry",
+ "summary_description": "Entry to the service is timely, with the consumer and carer informed of the process and of their rights, and intake captured systematically.",
+ "implementation_guidance": "Operate a documented intake/entry process that records presenting needs, risk screening and consent; provide consumers and carers with information about the service and their rights at entry; ensure entry decisions and waitlist management are recorded and reviewed.",
+ "default_risk_level": "medium",
+ "review_frequency_days": 365,
+ "domain": "Delivery of Care",
+ "suggested_evidence_types": ["intake_record", "entry_policy", "signed_consent"],
+ "suggested_automation_triggers": []
+ },
+ {
+ "control_code": "MHS-10.3",
+ "title": "Delivery of care โ Assessment and review",
+ "summary_description": "Consumers receive a comprehensive, timely assessment, and care is reviewed at agreed intervals and on significant change in condition or risk.",
+ "implementation_guidance": "Conduct and document a comprehensive biopsychosocial assessment including risk; agree review intervals in the care plan and conduct scheduled and triggered reviews; ensure assessment outcomes inform the care plan and are shared with the consumer and (with consent) carers.",
+ "default_risk_level": "high",
+ "review_frequency_days": 180,
+ "domain": "Delivery of Care",
+ "suggested_evidence_types": ["assessment_record", "review_record", "care_plan"],
+ "suggested_automation_triggers": ["care_plan_updated"]
+ },
+ {
+ "control_code": "MHS-10.4",
+ "title": "Delivery of care โ Treatment and support",
+ "summary_description": "Treatment and support are evidence-based, recovery-oriented and matched to assessed need and risk, with clinical and consumer risks actively managed.",
+ "implementation_guidance": "Maintain a risk register that captures clinical and consumer safety risks (e.g. suicide/self-harm, absconding, medication, aggression) with treatments and review dates; ensure elevated risks are reviewed frequently and residual risks have documented treatment plans; align treatment with evidence-based and recovery-oriented practice.",
+ "default_risk_level": "critical",
+ "review_frequency_days": 90,
+ "domain": "Delivery of Care",
+ "suggested_evidence_types": ["risk_register", "care_plan", "review_record"],
+ "suggested_automation_triggers": ["risk_register_reviewed"]
+ },
+ {
+ "control_code": "MHS-10.5",
+ "title": "Delivery of care โ Exit and re-entry",
+ "summary_description": "Exit from the service is planned and documented, with relapse-prevention and re-entry arrangements that support continuity of care.",
+ "implementation_guidance": "Document discharge/exit planning including relapse-prevention plans, follow-up arrangements and communication to the consumer, carers (with consent) and ongoing providers; record clear re-entry pathways so consumers can return to care without re-establishing eligibility from scratch; review exits that result in early re-entry or adverse outcomes.",
+ "default_risk_level": "high",
+ "review_frequency_days": 180,
+ "domain": "Delivery of Care",
+ "suggested_evidence_types": ["discharge_plan", "relapse_prevention_plan", "attestation"],
+ "suggested_automation_triggers": []
+ }
+ ]
+}
diff --git a/lib/compliance-graph.ts b/lib/compliance-graph.ts
index c86739c95..f05eb893a 100644
--- a/lib/compliance-graph.ts
+++ b/lib/compliance-graph.ts
@@ -8,16 +8,25 @@ import { createSupabaseServerClient } from '@/lib/supabase/server';
import { graphLogger } from '@/lib/observability/structured-logger';
import { consoleShim } from '@/lib/monitoring/console-shim';
+export type GraphNodeType =
+ | 'organization'
+ | 'role'
+ | 'policy'
+ | 'task'
+ | 'evidence'
+ | 'audit'
+ | 'entity';
+
+export type GraphWireType =
+ | 'organization_user'
+ | 'user_role'
+ | 'policy_task'
+ | 'task_evidence'
+ | 'evidence_audit';
+
export interface GraphNode {
id: string;
- type:
- | 'organization'
- | 'role'
- | 'policy'
- | 'task'
- | 'evidence'
- | 'audit'
- | 'entity';
+ type: GraphNodeType;
organizationId: string;
createdAt: string;
createdBy?: string | null;
@@ -26,13 +35,368 @@ export interface GraphNode {
export interface GraphWire {
fromNodeId: string;
toNodeId: string;
- wireType:
- | 'organization_user'
- | 'user_role'
- | 'policy_task'
- | 'task_evidence'
- | 'evidence_audit';
+ wireType: GraphWireType;
+ organizationId: string;
+}
+
+/**
+ * A persisted node row as returned by getComplianceGraph (shape mirrors
+ * public.graph_nodes).
+ */
+export interface PersistedGraphNode {
+ id: string;
organizationId: string;
+ nodeType: GraphNodeType;
+ sourceId: string;
+ label: string | null;
+ metadata: Record;
+ createdBy: string | null;
+ createdAt: string;
+ refreshedAt: string;
+}
+
+/**
+ * A persisted wire row as returned by getComplianceGraph (shape mirrors
+ * public.graph_wires).
+ */
+export interface PersistedGraphWire {
+ id: string;
+ organizationId: string;
+ fromNodeId: string;
+ toNodeId: string;
+ wireType: GraphWireType;
+ metadata: Record;
+ createdAt: string;
+ refreshedAt: string;
+}
+
+interface DerivedNode {
+ nodeType: GraphNodeType;
+ sourceId: string;
+ label: string | null;
+ createdBy: string | null;
+}
+
+interface DerivedWire {
+ wireType: GraphWireType;
+ fromType: GraphNodeType;
+ fromSourceId: string;
+ toType: GraphNodeType;
+ toSourceId: string;
+}
+
+const nodeKey = (nodeType: GraphNodeType, sourceId: string): string =>
+ `${nodeType}:${sourceId}`;
+
+/**
+ * Derive the node-wire graph for an organization from the live tenant
+ * tables, then UPSERT it into public.graph_nodes / public.graph_wires.
+ *
+ * WRITES go through the service-role admin client (createSupabaseOrgClient
+ * wraps createSupabaseAdminClient and stamps organization_id), which
+ * bypasses RLS โ the append-only RESTRICTIVE policies only gate
+ * `authenticated` session callers, so persistence is service-role-only by
+ * design. Idempotent on the UNIQUE(organization_id, node_type, source_id)
+ * and UNIQUE(organization_id, wire_type, from_node_id, to_node_id)
+ * constraints; every run bumps refreshed_at.
+ *
+ * Node source_id is the source row's primary key. The organization node's
+ * source_id is the organization_id itself.
+ */
+export async function rebuildOrgGraph(
+ organizationId: string,
+ userId?: string,
+): Promise<{
+ success: boolean;
+ error?: string;
+ nodeCount: number;
+ wireCount: number;
+}> {
+ try {
+ const admin = createSupabaseOrgClient(organizationId);
+ const now = new Date().toISOString();
+
+ // --- Derive nodes/wires from the live tenant tables ---------------
+ const [members, policies, tasks, evidence, audits, entities] =
+ await Promise.all([
+ admin.from('org_members').select('id, user_id, role'),
+ admin.from('org_policies').select('id, title'),
+ admin.from('org_tasks').select('id, title, policy_id'),
+ admin.from('org_evidence').select('id, title, task_id'),
+ admin.from('org_audit_events').select('id'),
+ admin.from('org_entities').select('id, name'),
+ ]);
+
+ // Coerce to arrays defensively: a misconfigured caller or a driver
+ // that returns a single object (rather than a list) shouldn't crash
+ // the rebuild โ it should just contribute no rows.
+ const asRows = (data: unknown): T[] => (Array.isArray(data) ? (data as T[]) : []);
+
+ const memberRows = asRows<{
+ id: string;
+ user_id: string;
+ role: string | null;
+ }>(members.data);
+ const policyRows = asRows<{
+ id: string;
+ title: string | null;
+ }>(policies.data);
+ const taskRows = asRows<{
+ id: string;
+ title: string | null;
+ policy_id: string | null;
+ }>(tasks.data);
+ const evidenceRows = asRows<{
+ id: string;
+ title: string | null;
+ task_id: string | null;
+ }>(evidence.data);
+ const auditRows = asRows<{ id: string }>(audits.data);
+ const entityRows = asRows<{
+ id: string;
+ name: string | null;
+ }>(entities.data);
+
+ const derivedNodes: DerivedNode[] = [];
+ const derivedWires: DerivedWire[] = [];
+
+ // Organization node โ source_id == organization_id.
+ derivedNodes.push({
+ nodeType: 'organization',
+ sourceId: organizationId,
+ label: null,
+ createdBy: userId ?? null,
+ });
+
+ for (const m of memberRows) {
+ // Role node (one per membership) + organization_user / user_role wires.
+ derivedNodes.push({
+ nodeType: 'role',
+ sourceId: m.id,
+ label: m.role,
+ createdBy: m.user_id,
+ });
+ derivedWires.push({
+ wireType: 'user_role',
+ fromType: 'organization',
+ fromSourceId: organizationId,
+ toType: 'role',
+ toSourceId: m.id,
+ });
+ }
+
+ for (const p of policyRows) {
+ derivedNodes.push({
+ nodeType: 'policy',
+ sourceId: p.id,
+ label: p.title,
+ createdBy: null,
+ });
+ }
+
+ for (const t of taskRows) {
+ derivedNodes.push({
+ nodeType: 'task',
+ sourceId: t.id,
+ label: t.title,
+ createdBy: null,
+ });
+ if (t.policy_id) {
+ derivedWires.push({
+ wireType: 'policy_task',
+ fromType: 'policy',
+ fromSourceId: t.policy_id,
+ toType: 'task',
+ toSourceId: t.id,
+ });
+ }
+ }
+
+ for (const e of evidenceRows) {
+ derivedNodes.push({
+ nodeType: 'evidence',
+ sourceId: e.id,
+ label: e.title,
+ createdBy: null,
+ });
+ if (e.task_id) {
+ derivedWires.push({
+ wireType: 'task_evidence',
+ fromType: 'task',
+ fromSourceId: e.task_id,
+ toType: 'evidence',
+ toSourceId: e.id,
+ });
+ }
+ }
+
+ for (const a of auditRows) {
+ derivedNodes.push({
+ nodeType: 'audit',
+ sourceId: a.id,
+ label: null,
+ createdBy: null,
+ });
+ }
+
+ for (const en of entityRows) {
+ derivedNodes.push({
+ nodeType: 'entity',
+ sourceId: en.id,
+ label: en.name,
+ createdBy: userId ?? null,
+ });
+ }
+
+ // --- Persist nodes (idempotent on UNIQUE org/type/source) ---------
+ const nodePayload = derivedNodes.map((n) => ({
+ node_type: n.nodeType,
+ source_id: n.sourceId,
+ label: n.label,
+ created_by: n.createdBy,
+ refreshed_at: now,
+ }));
+
+ const { data: upsertedNodes, error: nodeError } = await admin
+ .from('graph_nodes')
+ .upsert(nodePayload, {
+ onConflict: 'organization_id,node_type,source_id',
+ })
+ .select('id, node_type, source_id');
+
+ if (nodeError) {
+ throw new Error(`graph_nodes upsert failed: ${nodeError.message}`);
+ }
+
+ // Resolve persisted node ids by (node_type, source_id) so wires can
+ // reference the canonical row ids. Fall back to a fresh read when the
+ // upsert didn't return rows (defensive โ some drivers omit the
+ // representation on conflict).
+ const idByKey = new Map();
+ let resolvedNodes = (upsertedNodes ?? []) as Array<{
+ id: string;
+ node_type: GraphNodeType;
+ source_id: string;
+ }>;
+ if (resolvedNodes.length === 0 && derivedNodes.length > 0) {
+ const { data: readBack } = await admin
+ .from('graph_nodes')
+ .select('id, node_type, source_id');
+ resolvedNodes = (readBack ?? []) as Array<{
+ id: string;
+ node_type: GraphNodeType;
+ source_id: string;
+ }>;
+ }
+ for (const n of resolvedNodes) {
+ idByKey.set(nodeKey(n.node_type, n.source_id), n.id);
+ }
+
+ // --- Persist wires (idempotent on UNIQUE org/type/from/to) --------
+ const wirePayload = derivedWires
+ .map((w) => {
+ const fromId = idByKey.get(nodeKey(w.fromType, w.fromSourceId));
+ const toId = idByKey.get(nodeKey(w.toType, w.toSourceId));
+ if (!fromId || !toId) return null;
+ return {
+ wire_type: w.wireType,
+ from_node_id: fromId,
+ to_node_id: toId,
+ refreshed_at: now,
+ };
+ })
+ .filter((w): w is NonNullable => w !== null);
+
+ let persistedWireCount = 0;
+ if (wirePayload.length > 0) {
+ const { error: wireError } = await admin
+ .from('graph_wires')
+ .upsert(wirePayload, {
+ onConflict: 'organization_id,wire_type,from_node_id,to_node_id',
+ });
+ if (wireError) {
+ throw new Error(`graph_wires upsert failed: ${wireError.message}`);
+ }
+ persistedWireCount = wirePayload.length;
+ }
+
+ graphLogger.info('graph_rebuilt', {
+ organizationId,
+ nodeCount: derivedNodes.length,
+ wireCount: persistedWireCount,
+ });
+
+ return {
+ success: true,
+ nodeCount: derivedNodes.length,
+ wireCount: persistedWireCount,
+ };
+ } catch (error) {
+ consoleShim.error('[compliance-graph] Graph rebuild failed:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ nodeCount: 0,
+ wireCount: 0,
+ };
+ }
+}
+
+/**
+ * Read the persisted compliance graph for an organization. READS go
+ * through the member-facing session client โ the org-membership SELECT
+ * RLS policy gates which rows are visible, so this never exposes
+ * cross-tenant data and requires no service-role key.
+ */
+export async function getComplianceGraph(organizationId: string): Promise<{
+ nodes: PersistedGraphNode[];
+ wires: PersistedGraphWire[];
+}> {
+ const supabase = await createSupabaseServerClient();
+
+ const [{ data: nodeRows }, { data: wireRows }] = await Promise.all([
+ supabase
+ .from('graph_nodes')
+ .select(
+ 'id, organization_id, node_type, source_id, label, metadata, created_by, created_at, refreshed_at',
+ )
+ .eq('organization_id', organizationId),
+ supabase
+ .from('graph_wires')
+ .select(
+ 'id, organization_id, from_node_id, to_node_id, wire_type, metadata, created_at, refreshed_at',
+ )
+ .eq('organization_id', organizationId),
+ ]);
+
+ const nodes: PersistedGraphNode[] = (
+ (nodeRows ?? []) as Array>
+ ).map((r) => ({
+ id: r.id as string,
+ organizationId: r.organization_id as string,
+ nodeType: r.node_type as GraphNodeType,
+ sourceId: r.source_id as string,
+ label: (r.label as string | null) ?? null,
+ metadata: (r.metadata as Record) ?? {},
+ createdBy: (r.created_by as string | null) ?? null,
+ createdAt: r.created_at as string,
+ refreshedAt: r.refreshed_at as string,
+ }));
+
+ const wires: PersistedGraphWire[] = (
+ (wireRows ?? []) as Array>
+ ).map((r) => ({
+ id: r.id as string,
+ organizationId: r.organization_id as string,
+ fromNodeId: r.from_node_id as string,
+ toNodeId: r.to_node_id as string,
+ wireType: r.wire_type as GraphWireType,
+ metadata: (r.metadata as Record) ?? {},
+ createdAt: r.created_at as string,
+ refreshedAt: r.refreshed_at as string,
+ }));
+
+ return { nodes, wires };
}
/**
@@ -189,7 +553,25 @@ export async function initializeComplianceGraph(
const allNodes = [organizationNode, roleNode, ...policyNodes];
if (entityNode) allNodes.push(entityNode);
- graphLogger.info('graph_initialized', { nodeCount: allNodes.length, wireCount: wires.length });
+ // Persist the derived graph. rebuildOrgGraph re-reads the seeded
+ // tenant tables and UPSERTs into graph_nodes/graph_wires via the
+ // service-role admin client. Non-fatal: seeding succeeded even if
+ // persistence hits a transient error, and the validate/repair path
+ // (or the next login) will rebuild.
+ const persisted = await rebuildOrgGraph(organizationId, userId);
+ if (!persisted.success) {
+ graphLogger.warn('graph_persist_warning', {
+ organizationId,
+ error: persisted.error,
+ });
+ }
+
+ graphLogger.info('graph_initialized', {
+ nodeCount: allNodes.length,
+ wireCount: wires.length,
+ persistedNodeCount: persisted.nodeCount,
+ persistedWireCount: persisted.wireCount,
+ });
return {
success: true,
@@ -418,6 +800,10 @@ export async function repairComplianceGraph(
});
}
+ // Re-derive and persist the graph so the repaired wires (newly-linked
+ // tasks, role assignments) are reflected in graph_nodes/graph_wires.
+ await rebuildOrgGraph(organizationId, userId);
+
return {
success: true,
repairsApplied,
diff --git a/lib/compliance/evaluators/financial-services-au/AFCA-001.ts b/lib/compliance/evaluators/financial-services-au/AFCA-001.ts
new file mode 100644
index 000000000..e6708a307
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFCA-001.ts
@@ -0,0 +1,16 @@
+/**
+ * AFCA-001 โ AFCA Membership Compliance (AFCA Rules).
+ *
+ * Manual attestation: the AFCA membership certificate and annual
+ * compliance certificate are external documents; FormaOS does not hold
+ * AFCA membership state as a structured row.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFCA-001',
+ 'Current AFCA membership certificate, lodged annual compliance certificate, and AFCA complaint response-time tracking (AFCA Rules) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFCA-002.ts b/lib/compliance/evaluators/financial-services-au/AFCA-002.ts
new file mode 100644
index 000000000..a795403ac
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFCA-002.ts
@@ -0,0 +1,26 @@
+/**
+ * AFCA-002 โ Internal Dispute Resolution (ASIC RG 271).
+ *
+ * DB-signal: complaints in org_registers (type=complaint OR
+ * category=complaint) within 12 months, flagging any open beyond the
+ * RG 271 30-calendar-day IDR response window. No complaint rows โ
+ * manual attestation.
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateComplaintHandling } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateComplaintHandling({
+ controlCode: 'AFCA-002',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'AFCA-002',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-001.ts b/lib/compliance/evaluators/financial-services-au/AFS-001.ts
new file mode 100644
index 000000000..d36691dd4
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-001.ts
@@ -0,0 +1,16 @@
+/**
+ * AFS-001 โ AFS Licence Maintenance (s912A Corporations Act 2001).
+ *
+ * Manual attestation: a current AFS licence copy and ASIC change
+ * notifications (within 10 business days of material change) are
+ * documents held outside FormaOS's structured tables.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-001',
+ 'Current AFS licence copy + ASIC change notifications lodged within 10 business days of material change + quarterly licence-condition review minutes โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-002.ts b/lib/compliance/evaluators/financial-services-au/AFS-002.ts
new file mode 100644
index 000000000..3c96dbb06
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-002.ts
@@ -0,0 +1,16 @@
+/**
+ * AFS-002 โ Responsible Manager Competency (RG 105 ASIC).
+ *
+ * Manual attestation: responsible-manager register with qualification
+ * certificates and experience records lives in HR/governance documents
+ * outside FormaOS.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-002',
+ 'Responsible-manager register with RG 105 qualification certificates and experience records โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-003.ts b/lib/compliance/evaluators/financial-services-au/AFS-003.ts
new file mode 100644
index 000000000..18d849c72
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-003.ts
@@ -0,0 +1,29 @@
+/**
+ * AFS-003 โ Financial Product Disclosure (Part 7.9 Corporations Act 2001).
+ *
+ * DB-signal: org_policies whose title matches PDS / FSG / disclosure,
+ * active/published and reviewed within 365 days. No matching policy โ
+ * fail (the required disclosure artefact is absent).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluatePolicyCadence } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluatePolicyCadence({
+ controlCode: 'AFS-003',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ keywords: /pds|product disclosure|\bfsg\b|financial services guide|disclosure/,
+ reviewWindowDays: 365,
+ missingPolicyMessage:
+ 'No org_policies titled as a PDS, FSG, or disclosure document. Maintain current Product Disclosure Statements and Financial Services Guides (Part 7.9 Corporations Act 2001).',
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'AFS-003',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-004.ts b/lib/compliance/evaluators/financial-services-au/AFS-004.ts
new file mode 100644
index 000000000..0cf969909
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-004.ts
@@ -0,0 +1,30 @@
+/**
+ * AFS-004 โ Conflicts of Interest Management (s912A(1)(aa) Corporations
+ * Act 2001).
+ *
+ * DB-signal: org_registers type='conflict_of_interest' reviewed within
+ * 90 days. No register row โ manual attestation (the register may be
+ * held outside FormaOS).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateRegisterCadence } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateRegisterCadence({
+ controlCode: 'AFS-004',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ registerType: 'conflict_of_interest',
+ reviewWindowDays: 90,
+ missingRegisterMessage:
+ 'No conflicts-of-interest register entry (org_registers type=conflict_of_interest). Maintain and review the conflicts register quarterly (s912A(1)(aa) Corporations Act 2001) โ manual attestation until tagged.',
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'AFS-004',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-005.ts b/lib/compliance/evaluators/financial-services-au/AFS-005.ts
new file mode 100644
index 000000000..d25aa5135
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-005.ts
@@ -0,0 +1,15 @@
+/**
+ * AFS-005 โ Best Interest Duty Compliance (s961B Corporations Act 2001).
+ *
+ * Manual attestation: advice-file reviews, quality-assurance reports,
+ * and adviser training records are not modelled as FormaOS rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-005',
+ 'Advice-file review program results, advice quality-assurance reports, and adviser training records evidencing best-interest-duty compliance (s961B) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-006.ts b/lib/compliance/evaluators/financial-services-au/AFS-006.ts
new file mode 100644
index 000000000..0a5fe3013
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-006.ts
@@ -0,0 +1,22 @@
+/**
+ * AFS-006 โ Breach Reporting to ASIC (s912D Corporations Act 2001).
+ *
+ * Planned as a DB-signal against org_regulatory_notifications, but that
+ * table cannot represent ASIC breach reports: its `regulation` CHECK
+ * constraint only permits NDIS/health/aged-care/workplace-safety values
+ * (no ASIC option), `notification_type` is NDIS-specific
+ * (immediate/5_day/final), and every row requires a non-null
+ * `incident_id` FK to org_incidents. Reading it for finance signals
+ * would surface unrelated NDIS data โ a false signal. Falls back to
+ * manual attestation (the honest, house-standard choice) rather than
+ * risk a false pass/fail.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-006',
+ 'Breach register with significance assessments (within 30 days) and ASIC lodgement confirmations for significant breaches (s912D) โ manual attestation; org_regulatory_notifications does not model ASIC reports.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-007.ts b/lib/compliance/evaluators/financial-services-au/AFS-007.ts
new file mode 100644
index 000000000..3c6d88507
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-007.ts
@@ -0,0 +1,16 @@
+/**
+ * AFS-007 โ Annual Compliance Certificate (s912A(1)(ca) Corporations
+ * Act 2001).
+ *
+ * Manual attestation: the self-assessment, certificate of compliance,
+ * and ASIC lodgement confirmation are documents held outside FormaOS.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-007',
+ 'Completed annual self-assessment, certificate of compliance, and ASIC lodgement confirmation by due date (s912A(1)(ca)) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AFS-008.ts b/lib/compliance/evaluators/financial-services-au/AFS-008.ts
new file mode 100644
index 000000000..31dd3ed06
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AFS-008.ts
@@ -0,0 +1,16 @@
+/**
+ * AFS-008 โ Client Money Handling (s981A-981M Corporations Act 2001).
+ *
+ * Manual attestation: trust-account reconciliations, the auditor's
+ * report, and client-fund segregation controls are bank/audit
+ * artefacts not modelled as FormaOS rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AFS-008',
+ 'Monthly trust-account reconciliations, client-money audit report, and segregation controls evidencing Chapter 7 client-money compliance (s981A-981M) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AML-001.ts b/lib/compliance/evaluators/financial-services-au/AML-001.ts
new file mode 100644
index 000000000..8d7f9e25b
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AML-001.ts
@@ -0,0 +1,29 @@
+/**
+ * AML-001 โ AML/CTF Program Maintenance (AML/CTF Act 2006 s81).
+ *
+ * DB-signal: org_policies whose title matches AML / CTF, active/published
+ * and reviewed within 365 days. No matching policy โ fail (the AML/CTF
+ * program document is absent).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluatePolicyCadence } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluatePolicyCadence({
+ controlCode: 'AML-001',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ keywords: /\baml\b|\bctf\b|anti[-_ ]?money[-_ ]?laundering|counter[-_ ]?terrorism financing/,
+ reviewWindowDays: 365,
+ missingPolicyMessage:
+ 'No org_policies titled as an AML/CTF program. Maintain and annually review the AML/CTF program including Part A and Part B (AML/CTF Act 2006 s81).',
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'AML-001',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/AML-002.ts b/lib/compliance/evaluators/financial-services-au/AML-002.ts
new file mode 100644
index 000000000..f45350dc5
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AML-002.ts
@@ -0,0 +1,16 @@
+/**
+ * AML-002 โ Customer Due Diligence (AML/CTF Act 2006 Part 2).
+ *
+ * Manual attestation: KYC/CDD procedures, a verification-records
+ * sample, and the enhanced-CDD log are customer-onboarding artefacts
+ * not modelled as FormaOS rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AML-002',
+ 'Risk-based CDD procedures, a customer identity-verification records sample, and the enhanced-CDD log (AML/CTF Act 2006 Part 2) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AML-003.ts b/lib/compliance/evaluators/financial-services-au/AML-003.ts
new file mode 100644
index 000000000..fbfd655c0
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AML-003.ts
@@ -0,0 +1,16 @@
+/**
+ * AML-003 โ Transaction Monitoring (AML/CTF Act 2006 s41, s43).
+ *
+ * Manual attestation: monitoring-rule configuration, the alert-review
+ * log, and SMR/TTR lodgement records live in the transaction-monitoring
+ * system, not FormaOS.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AML-003',
+ 'Transaction-monitoring rule configuration, alert-review log, and SMR/TTR lodgement records (AML/CTF Act 2006 s41, s43) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AML-004.ts b/lib/compliance/evaluators/financial-services-au/AML-004.ts
new file mode 100644
index 000000000..6d471c8a4
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AML-004.ts
@@ -0,0 +1,21 @@
+/**
+ * AML-004 โ AUSTRAC Compliance Reporting (AML/CTF Act 2006 s41-45).
+ *
+ * Planned as a DB-signal against org_regulatory_notifications (TTR/SMR
+ * timeliness), but that table cannot represent AUSTRAC lodgements: its
+ * `regulation` CHECK constraint only permits NDIS/health/aged-care/
+ * workplace-safety values (no AUSTRAC option), `notification_type` is
+ * NDIS-specific (immediate/5_day/final), and every row requires a
+ * non-null `incident_id` FK to org_incidents. Reading it for finance
+ * signals would surface unrelated NDIS data. Falls back to manual
+ * attestation rather than risk a false pass/fail.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AML-004',
+ 'AUSTRAC lodgement records (TTRs, IFTIs, SMRs) with completeness checks and error reconciliation, lodged within prescribed timeframes (AML/CTF Act 2006 s41-45) โ manual attestation; org_regulatory_notifications does not model AUSTRAC reports.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/AML-005.ts b/lib/compliance/evaluators/financial-services-au/AML-005.ts
new file mode 100644
index 000000000..00b43bdd0
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/AML-005.ts
@@ -0,0 +1,15 @@
+/**
+ * AML-005 โ AML/CTF Staff Training (AML/CTF Act 2006 s81(3)).
+ *
+ * Manual attestation: the training program, completion records, and
+ * assessment results are HR/LMS artefacts not modelled as FormaOS rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'AML-005',
+ 'AML/CTF awareness training program with initial and ongoing completion records and assessment results for all relevant employees (AML/CTF Act 2006 s81(3)) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/CPS-001.ts b/lib/compliance/evaluators/financial-services-au/CPS-001.ts
new file mode 100644
index 000000000..f9857817d
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/CPS-001.ts
@@ -0,0 +1,25 @@
+/**
+ * CPS-001 โ Operational Risk Management (APRA CPS 230).
+ *
+ * DB-signal: org_risks freshness โ elevated (critical/high) risks
+ * reviewed within 90 days, routine within 365 days. Empty register โ
+ * fail (CPS 230 mandates a documented operational-risk register).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateRiskFreshness } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateRiskFreshness({
+ controlCode: 'CPS-001',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'CPS-001',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/CPS-002.ts b/lib/compliance/evaluators/financial-services-au/CPS-002.ts
new file mode 100644
index 000000000..13dd190c3
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/CPS-002.ts
@@ -0,0 +1,28 @@
+/**
+ * CPS-002 โ Business Continuity Planning (APRA CPS 230).
+ *
+ * DB-signal: org_registers type='business_continuity_plan' reviewed
+ * within 365 days. No register row โ manual attestation.
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateRegisterCadence } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateRegisterCadence({
+ controlCode: 'CPS-002',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ registerType: 'business_continuity_plan',
+ reviewWindowDays: 365,
+ missingRegisterMessage:
+ 'No business-continuity-plan register entry (org_registers type=business_continuity_plan). Test the BCP annually and record it (APRA CPS 230) โ manual attestation until tagged.',
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'CPS-002',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/CPS-003.ts b/lib/compliance/evaluators/financial-services-au/CPS-003.ts
new file mode 100644
index 000000000..ec9109dc5
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/CPS-003.ts
@@ -0,0 +1,16 @@
+/**
+ * CPS-003 โ Material Service Provider Management (APRA CPS 230).
+ *
+ * Manual attestation: a service-provider register with due-diligence
+ * records and performance reviews (including fourth-party dependencies)
+ * is not modelled with finance semantics in FormaOS.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'CPS-003',
+ 'Material-service-provider register with due-diligence records, performance reviews, and fourth-party dependency mapping (APRA CPS 230) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/CPS-004.ts b/lib/compliance/evaluators/financial-services-au/CPS-004.ts
new file mode 100644
index 000000000..c50c3a040
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/CPS-004.ts
@@ -0,0 +1,25 @@
+/**
+ * CPS-004 โ Information Security Management (APRA CPS 234).
+ *
+ * DB-signal: current infosec policy + current incident-response policy
+ * (org_policies title match, active/published, <=365d) + audit-log
+ * activity (org_audit_logs >=30 rows in 90d).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateInfoSecManagement } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateInfoSecManagement({
+ controlCode: 'CPS-004',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'CPS-004',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/financial-services-au/CPS-005.ts b/lib/compliance/evaluators/financial-services-au/CPS-005.ts
new file mode 100644
index 000000000..699d4fdd1
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/CPS-005.ts
@@ -0,0 +1,16 @@
+/**
+ * CPS-005 โ Governance and Accountability (APRA CPS 510).
+ *
+ * Manual attestation: fit-and-proper assessments for responsible
+ * persons, the board skills matrix, and the governance framework are
+ * board/governance documents held outside FormaOS.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'CPS-005',
+ 'Fit-and-proper assessments for responsible persons, board skills matrix, and governance framework (APRA CPS 510) โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/financial-services-au/_shared.ts b/lib/compliance/evaluators/financial-services-au/_shared.ts
new file mode 100644
index 000000000..3ea4484d7
--- /dev/null
+++ b/lib/compliance/evaluators/financial-services-au/_shared.ts
@@ -0,0 +1,610 @@
+/**
+ * Shared helpers for the Australian Financial Services compliance pack
+ * (`financial-services-au`, code FINANCIAL_SERVICES_AU).
+ *
+ * The pack covers ASIC AFS general obligations, APRA prudential
+ * standards (CPS 230 / 234 / 510), AUSTRAC AML/CTF, and AFCA
+ * membership โ 20 controls. Most controls genuinely require human
+ * attestation (a sighted AFS licence, RG 105 competency records, a
+ * lodged annual compliance certificate, trust-account reconciliations)
+ * and we model that explicitly via `manualAttestation` rather than
+ * inflating pass counts.
+ *
+ * A subset of controls map cleanly onto structured FormaOS rows
+ * (policy cadence, conflicts/BCP/complaint registers, risk-register
+ * freshness, infosec policy + incident-response + audit activity).
+ * For those we provide conservative DB-signal helpers below.
+ *
+ * The lower-level primitives (`notEvaluated`, `manualAttestation`,
+ * `daysSince`, `round2`, `EVIDENCE_CAP`) are re-exported from the
+ * SOC2-TSC shared module so every pack stays aligned on shape and
+ * error reporting.
+ *
+ * IMPORTANT โ verified schema (no invented tables/columns):
+ * - org_policies(id, organization_id, title, status, updated_at,
+ * created_at) โ no finance/category tag, so finance policies are
+ * matched by title keyword only.
+ * - org_registers(id, org_id, type, category, status, updated_at,
+ * created_at) โ keyed by org_id (NOT organization_id).
+ * - org_risks(id, organization_id, category, status, updated_at,
+ * created_at).
+ * - org_audit_logs(id, action, created_at, organization_id).
+ * A DB-signal helper that finds no finance-tagged rows returns
+ * `manualAttestation` / `not_evaluated`, never a false `pass`.
+ */
+
+import type {
+ ControlEvaluator,
+ ControlEvaluatorContext,
+ ControlEvaluatorMeta,
+ ControlGap,
+ ControlResult,
+ EvidenceRef,
+ FrameworkSlug,
+} from '../types';
+import {
+ EVIDENCE_CAP,
+ daysSince,
+ manualAttestation,
+ notEvaluated,
+ round2,
+} from '../soc2-tsc/_shared';
+
+export {
+ EVIDENCE_CAP,
+ daysSince,
+ manualAttestation,
+ notEvaluated,
+ round2,
+};
+
+export const FRAMEWORK: FrameworkSlug = 'financial-services-au';
+
+const ACTIVE_POLICY_STATUSES = new Set([
+ 'approved',
+ 'active',
+ 'published',
+ 'in_force',
+]);
+
+/**
+ * Build a `manualAttestation` evaluator with one line per call. The
+ * meaningful evidence is a sighted licence, a lodged regulator return,
+ * a board sign-off, or a trust-account reconciliation that does not
+ * exist as a structured row in FormaOS today.
+ */
+export function makeManualEvaluator(
+ controlCode: string,
+ message: string,
+): { evaluator: ControlEvaluator; meta: ControlEvaluatorMeta } {
+ const evaluator: ControlEvaluator = async () =>
+ manualAttestation(controlCode, new Date().toISOString(), message);
+ return {
+ evaluator,
+ meta: { framework: FRAMEWORK, controlCode, evaluator },
+ };
+}
+
+/**
+ * Wrap an automated builder into the `{ evaluator, meta }` shape with
+ * `framework: 'financial-services-au'` already filled in.
+ */
+export function makeAutomatedEvaluator(
+ controlCode: string,
+ evaluator: ControlEvaluator,
+): { evaluator: ControlEvaluator; meta: ControlEvaluatorMeta } {
+ return {
+ evaluator,
+ meta: { framework: FRAMEWORK, controlCode, evaluator },
+ };
+}
+
+type PolicyRow = {
+ id: string;
+ title: string | null;
+ status: string | null;
+ updated_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * "Is there at least one active/approved policy whose title matches a
+ * keyword set, reviewed inside the cadence?" Used for AFS-003
+ * (PDS/FSG/disclosure) and AML-001 (AML/CTF program).
+ *
+ * org_policies has no finance/category column, so the match is on
+ * `title` keywords only. Zero matches โ fail with a clear gap (the
+ * required artefact is simply absent), NOT a false pass.
+ */
+export async function evaluatePolicyCadence(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+ keywords: RegExp;
+ reviewWindowDays: number;
+ missingPolicyMessage: string;
+}): Promise {
+ const { controlCode, orgId, db, keywords, reviewWindowDays } = args;
+ const evaluatedAt = new Date().toISOString();
+
+ const { data, error } = await db
+ .from('org_policies')
+ .select('id, title, status, updated_at, created_at')
+ .eq('organization_id', orgId)
+ .order('updated_at', { ascending: false })
+ .limit(500);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_policies_unavailable',
+ `Could not read org_policies: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as PolicyRow[];
+ const matching = rows.filter((p) => keywords.test((p.title || '').toLowerCase()));
+
+ if (matching.length === 0) {
+ return {
+ controlCode,
+ status: 'fail',
+ evidenceRefs: [],
+ gaps: [
+ {
+ code: 'no_matching_policy',
+ message: args.missingPolicyMessage,
+ severity: 'high',
+ },
+ ],
+ confidence: 0.7,
+ reason: `0 org_policies titles matched keyword set (${rows.length} polic(ies) in total).`,
+ evaluatedAt,
+ };
+ }
+
+ const active = matching.filter((p) =>
+ ACTIVE_POLICY_STATUSES.has((p.status || '').toLowerCase()),
+ );
+ const fresh = active.filter((p) => {
+ const since = daysSince(p.updated_at ?? p.created_at);
+ return since != null && since <= reviewWindowDays;
+ });
+
+ const gaps: ControlGap[] = [];
+ if (active.length === 0) {
+ gaps.push({
+ code: 'matching_policy_not_active',
+ message: `${matching.length} matching polic(ies) exist but none are in an approved/active/published status.`,
+ severity: 'high',
+ });
+ }
+ if (active.length > 0 && fresh.length === 0) {
+ const newest = active
+ .map((p) => p.updated_at ?? p.created_at)
+ .filter((v): v is string => !!v)
+ .sort()
+ .reverse()[0];
+ const since = daysSince(newest);
+ gaps.push({
+ code: 'matching_policy_stale',
+ message: `Matching polic(ies) last reviewed ${since ?? '?'}d ago โ exceeds the ${reviewWindowDays}-day cadence.`,
+ severity: 'medium',
+ });
+ }
+
+ const evidenceRefs: EvidenceRef[] = active.slice(0, EVIDENCE_CAP).map((p) => ({
+ source: 'org_policies',
+ ref: p.id,
+ capturedAt: p.updated_at ?? p.created_at ?? undefined,
+ }));
+
+ let status: ControlResult['status'];
+ if (active.length === 0) status = 'fail';
+ else if (fresh.length > 0) status = 'pass';
+ else status = 'partial';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs,
+ gaps,
+ confidence: round2(0.6 + 0.4 * Math.min(1, active.length / 3)),
+ reason: `${matching.length} matching polic(ies); ${active.length} active; ${fresh.length} reviewed within ${reviewWindowDays}d.`,
+ evaluatedAt,
+ };
+}
+
+type RegisterRow = {
+ id: string;
+ type: string | null;
+ category: string | null;
+ status: string | null;
+ updated_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * "Is there at least one org_registers row of a given type, reviewed
+ * within the cadence?" Used for AFS-004 (conflict_of_interest, 90d) and
+ * CPS-002 (business_continuity_plan, 365d).
+ *
+ * org_registers is keyed by `org_id`. No matching register row โ
+ * manualAttestation (the register may live outside FormaOS), never a
+ * false pass.
+ */
+export async function evaluateRegisterCadence(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+ registerType: string;
+ reviewWindowDays: number;
+ missingRegisterMessage: string;
+}): Promise {
+ const { controlCode, orgId, db, registerType, reviewWindowDays } = args;
+ const evaluatedAt = new Date().toISOString();
+
+ const { data, error } = await db
+ .from('org_registers')
+ .select('id, type, category, status, updated_at, created_at')
+ .eq('org_id', orgId)
+ .eq('type', registerType);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_registers_unavailable',
+ `Could not read org_registers: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as RegisterRow[];
+ if (rows.length === 0) {
+ return manualAttestation(controlCode, evaluatedAt, args.missingRegisterMessage);
+ }
+
+ const newest = rows
+ .map((r) => r.updated_at ?? r.created_at)
+ .filter((v): v is string => !!v)
+ .sort()
+ .reverse()[0];
+ const since = daysSince(newest);
+ const fresh = since != null && since <= reviewWindowDays;
+
+ const evidenceRefs: EvidenceRef[] = rows.slice(0, EVIDENCE_CAP).map((r) => ({
+ source: 'org_registers',
+ ref: r.id,
+ capturedAt: r.updated_at ?? r.created_at ?? undefined,
+ }));
+
+ return {
+ controlCode,
+ status: fresh ? 'pass' : 'partial',
+ evidenceRefs,
+ gaps: fresh
+ ? []
+ : [
+ {
+ code: 'register_stale',
+ message: `Register exists but last reviewed ${since ?? '?'}d ago โ exceeds the ${reviewWindowDays}-day cadence.`,
+ severity: 'medium',
+ },
+ ],
+ confidence: round2(0.6 + 0.3 * Math.min(1, rows.length / 2)),
+ reason: `${rows.length} '${registerType}' register row(s); most recent review ${since ?? '?'}d ago.`,
+ evaluatedAt,
+ };
+}
+
+type ComplaintRow = {
+ id: string;
+ type: string | null;
+ category: string | null;
+ status: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+};
+
+/**
+ * AFCA-002 โ IDR / complaint handling. Mirrors NDIS-2.5: complaints in
+ * org_registers (type=complaint OR category=complaint) within 12
+ * months, flagging any open beyond 30 days. RG 271 sets a 30-calendar-
+ * day standard IDR response window.
+ */
+export async function evaluateComplaintHandling(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+ const oneYearAgo = new Date(
+ Date.now() - 365 * 24 * 60 * 60 * 1000,
+ ).toISOString();
+
+ const { data, error } = await db
+ .from('org_registers')
+ .select('id, type, category, status, created_at, updated_at')
+ .eq('org_id', orgId)
+ .or('type.eq.complaint,category.eq.complaint')
+ .gte('created_at', oneYearAgo);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_registers_unavailable',
+ `Could not read org_registers: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as ComplaintRow[];
+ if (rows.length === 0) {
+ return manualAttestation(
+ controlCode,
+ evaluatedAt,
+ 'No complaint register entries in 12 months. Tag IDR complaints via org_registers (type=complaint) or attest the RG 271 complaint register manually.',
+ );
+ }
+
+ const openOld = rows.filter((r) => {
+ const s = (r.status || '').toLowerCase();
+ if (!s || s === 'closed' || s === 'resolved') return false;
+ const since = daysSince(r.created_at);
+ return since != null && since > 30;
+ });
+
+ const evidenceRefs: EvidenceRef[] = rows.slice(0, EVIDENCE_CAP).map((r) => ({
+ source: 'org_registers',
+ ref: r.id,
+ capturedAt: r.updated_at ?? r.created_at ?? undefined,
+ }));
+
+ return {
+ controlCode,
+ status: openOld.length > 0 ? 'partial' : 'pass',
+ evidenceRefs,
+ gaps:
+ openOld.length > 0
+ ? [
+ {
+ code: 'complaints_open_over_30d',
+ message: `${openOld.length} complaint(s) open beyond the RG 271 30-calendar-day IDR response window.`,
+ severity: 'high',
+ },
+ ]
+ : [],
+ confidence: 0.7,
+ reason: `${rows.length} complaint(s)/12mo; ${openOld.length} open >30d.`,
+ evaluatedAt,
+ };
+}
+
+type RiskRow = {
+ id: string;
+ category: string | null;
+ status: string | null;
+ updated_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * CPS-001 โ Operational risk management (CPS 230). Risk-register
+ * freshness: elevated (critical/high category) risks reviewed within
+ * 90d, routine within 365d. Empty register โ fail (CPS 230 mandates a
+ * documented operational-risk register).
+ */
+export async function evaluateRiskFreshness(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+
+ const { data, error } = await db
+ .from('org_risks')
+ .select('id, category, status, updated_at, created_at')
+ .eq('organization_id', orgId)
+ .limit(500);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_risks_unavailable',
+ `Could not read org_risks: ${error.message}`,
+ );
+ }
+
+ const risks = (data ?? []) as RiskRow[];
+ if (risks.length === 0) {
+ return {
+ controlCode,
+ status: 'fail',
+ evidenceRefs: [],
+ gaps: [
+ {
+ code: 'no_risk_register',
+ message: 'Risk register is empty โ CPS 230 requires a documented operational-risk register.',
+ severity: 'high',
+ },
+ ],
+ confidence: 0.75,
+ reason: 'org_risks empty.',
+ evaluatedAt,
+ };
+ }
+
+ const elevated = risks.filter((r) =>
+ ['critical', 'high'].includes((r.category ?? '').toLowerCase()),
+ );
+ const routine = risks.filter(
+ (r) => !['critical', 'high'].includes((r.category ?? '').toLowerCase()),
+ );
+ const elevatedFresh = elevated.filter((r) => {
+ const s = daysSince(r.updated_at ?? r.created_at);
+ return s != null && s <= 90;
+ }).length;
+ const routineFresh = routine.filter((r) => {
+ const s = daysSince(r.updated_at ?? r.created_at);
+ return s != null && s <= 365;
+ }).length;
+
+ const elevatedRatio = elevated.length > 0 ? elevatedFresh / elevated.length : 1;
+ const routineRatio = routine.length > 0 ? routineFresh / routine.length : 1;
+ const overall = (elevatedRatio + routineRatio) / 2;
+
+ const gaps: ControlGap[] = [];
+ if (elevated.length > elevatedFresh) {
+ gaps.push({
+ code: 'stale_elevated_risks',
+ message: `${elevated.length - elevatedFresh}/${elevated.length} elevated risks not reviewed within 90 days.`,
+ severity: 'high',
+ });
+ }
+ if (routine.length > routineFresh) {
+ gaps.push({
+ code: 'stale_routine_risks',
+ message: `${routine.length - routineFresh}/${routine.length} routine risks not reviewed within 12 months.`,
+ severity: 'medium',
+ });
+ }
+
+ const status: ControlResult['status'] =
+ overall >= 0.9 ? 'pass' : overall >= 0.5 ? 'partial' : 'fail';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs: risks.slice(0, EVIDENCE_CAP).map((r) => ({
+ source: 'org_risks',
+ ref: r.id,
+ capturedAt: r.updated_at ?? r.created_at ?? undefined,
+ })),
+ gaps,
+ confidence: round2(0.5 + 0.4 * overall),
+ reason: `elevated ${elevatedFresh}/${elevated.length} fresh (90d); routine ${routineFresh}/${routine.length} fresh (365d).`,
+ evaluatedAt,
+ };
+}
+
+/**
+ * CPS-004 โ Information security management (CPS 234). Three-part
+ * signal: (a) a current infosec/security policy in org_policies,
+ * (b) a current incident-response policy in org_policies, (c) audit-log
+ * activity in the last 90 days. No infosec policy and no audit activity
+ * โ fail.
+ */
+export async function evaluateInfoSecManagement(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+ const ninetyDaysAgo = new Date(
+ Date.now() - 90 * 24 * 60 * 60 * 1000,
+ ).toISOString();
+
+ const [policiesResult, auditResult] = await Promise.all([
+ db
+ .from('org_policies')
+ .select('id, title, status, updated_at, created_at')
+ .eq('organization_id', orgId)
+ .order('updated_at', { ascending: false })
+ .limit(500),
+ db
+ .from('org_audit_logs')
+ .select('id', { count: 'exact', head: true })
+ .eq('organization_id', orgId)
+ .gte('created_at', ninetyDaysAgo),
+ ]);
+
+ if (policiesResult.error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_policies_unavailable',
+ `Could not read org_policies: ${policiesResult.error.message}`,
+ );
+ }
+ if (auditResult.error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_audit_logs_unavailable',
+ `Could not read org_audit_logs: ${auditResult.error.message}`,
+ );
+ }
+
+ const rows = (policiesResult.data ?? []) as PolicyRow[];
+ const isCurrent = (p: PolicyRow) =>
+ ACTIVE_POLICY_STATUSES.has((p.status || '').toLowerCase()) &&
+ (() => {
+ const s = daysSince(p.updated_at ?? p.created_at);
+ return s != null && s <= 365;
+ })();
+
+ const securityPolicy = rows.some(
+ (p) =>
+ /info(rmation)?[-_ ]?sec(urity)?|cyber|security policy|cps[-_ ]?234/.test(
+ (p.title || '').toLowerCase(),
+ ) && isCurrent(p),
+ );
+ const incidentResponse = rows.some(
+ (p) =>
+ /incident[-_ ]?response|incident[-_ ]?management|ir plan/.test(
+ (p.title || '').toLowerCase(),
+ ) && isCurrent(p),
+ );
+ const auditActivity = (auditResult.count ?? 0) >= 30;
+
+ const passing = [securityPolicy, incidentResponse, auditActivity].filter(
+ Boolean,
+ ).length;
+
+ const gaps: ControlGap[] = [];
+ if (!securityPolicy)
+ gaps.push({
+ code: 'no_infosec_policy',
+ message: 'No current information-security policy in org_policies (CPS 234 requires a documented infosec policy framework).',
+ severity: 'high',
+ });
+ if (!incidentResponse)
+ gaps.push({
+ code: 'no_incident_response_policy',
+ message: 'No current incident-response policy in org_policies (CPS 234 requires incident-response capability).',
+ severity: 'medium',
+ });
+ if (!auditActivity)
+ gaps.push({
+ code: 'low_audit_activity',
+ message: 'Fewer than 30 org_audit_logs rows in the last 90 days โ limited evidence of operational monitoring.',
+ severity: 'low',
+ });
+
+ const evidenceRefs: EvidenceRef[] = rows
+ .filter(isCurrent)
+ .slice(0, EVIDENCE_CAP)
+ .map((p) => ({
+ source: 'org_policies',
+ ref: p.id,
+ capturedAt: p.updated_at ?? p.created_at ?? undefined,
+ }));
+
+ const status: ControlResult['status'] =
+ passing === 3 ? 'pass' : passing >= 1 ? 'partial' : 'fail';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs,
+ gaps,
+ confidence: 0.65,
+ reason: `security policy ${securityPolicy ? 'โ' : 'โ'}, incident-response policy ${incidentResponse ? 'โ' : 'โ'}, audit activity ${auditActivity ? 'โ' : 'โ'} (${auditResult.count ?? 0} rows/90d).`,
+ evaluatedAt,
+ };
+}
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-1.ts b/lib/compliance/evaluators/mental-health-au/MHS-1.ts
new file mode 100644
index 000000000..c25d2f3e9
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-1.ts
@@ -0,0 +1,17 @@
+/**
+ * MHS-1 โ Rights and responsibilities (NSMHS 2010 Standard 1).
+ *
+ * Manual attestation: a displayed rights charter, on-entry distribution,
+ * and rights training are documents/activities not modelled as rows in
+ * FormaOS. Matching a title keyword in org_policies would not evidence
+ * that the charter is displayed and acknowledged, so we attest manually.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-1',
+ 'Documented charter of consumer and carer rights and responsibilities (aligned with the Australian Charter of Healthcare Rights), evidence it is prominently displayed and provided on entry, and staff rights training records โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-10.1.ts b/lib/compliance/evaluators/mental-health-au/MHS-10.1.ts
new file mode 100644
index 000000000..b8980bd40
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-10.1.ts
@@ -0,0 +1,16 @@
+/**
+ * MHS-10.1 โ Delivery of care: Access (NSMHS 2010 Standard 10.1).
+ *
+ * Manual attestation: published access criteria, after-hours/crisis
+ * access arrangements and access-time monitoring are not modelled as
+ * structured rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-10.1',
+ 'Published service-access criteria and operating hours, after-hours/crisis access arrangements, evidence of least-restrictive and equitable access, and monitoring of access times and barriers โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-10.2.ts b/lib/compliance/evaluators/mental-health-au/MHS-10.2.ts
new file mode 100644
index 000000000..e617c9e72
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-10.2.ts
@@ -0,0 +1,16 @@
+/**
+ * MHS-10.2 โ Delivery of care: Entry (NSMHS 2010 Standard 10.2).
+ *
+ * Manual attestation: the intake/entry process, on-entry rights
+ * information and waitlist management are clinical-record activities not
+ * modelled as structured org_* rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-10.2',
+ 'Documented intake/entry process recording presenting needs, risk screening and consent, on-entry provision of service and rights information, and recorded entry-decision/waitlist management โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-10.3.ts b/lib/compliance/evaluators/mental-health-au/MHS-10.3.ts
new file mode 100644
index 000000000..43eb2a9c8
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-10.3.ts
@@ -0,0 +1,17 @@
+/**
+ * MHS-10.3 โ Delivery of care: Assessment and review (NSMHS 2010
+ * Standard 10.3).
+ *
+ * Manual attestation: comprehensive biopsychosocial assessments and
+ * scheduled/triggered care reviews are clinical-record activities not
+ * modelled as structured org_* rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-10.3',
+ 'Comprehensive biopsychosocial assessment including risk, agreed care-plan review intervals with evidence of scheduled and triggered reviews, and assessment outcomes shared with the consumer and (with consent) carers โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-10.4.ts b/lib/compliance/evaluators/mental-health-au/MHS-10.4.ts
new file mode 100644
index 000000000..82e7e75e9
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-10.4.ts
@@ -0,0 +1,27 @@
+/**
+ * MHS-10.4 โ Delivery of care: Treatment and support (NSMHS 2010
+ * Standard 10.4).
+ *
+ * DB-signal: org_risks freshness โ elevated (critical/high category)
+ * clinical/consumer risks reviewed within 90 days, routine within 365
+ * days. Empty register โ fail (treatment matched to assessed risk
+ * requires a documented risk register).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateRiskFreshness } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateRiskFreshness({
+ controlCode: 'MHS-10.4',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'MHS-10.4',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-10.5.ts b/lib/compliance/evaluators/mental-health-au/MHS-10.5.ts
new file mode 100644
index 000000000..5fd4e89c7
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-10.5.ts
@@ -0,0 +1,17 @@
+/**
+ * MHS-10.5 โ Delivery of care: Exit and re-entry (NSMHS 2010 Standard
+ * 10.5).
+ *
+ * Manual attestation: discharge/exit plans, relapse-prevention plans and
+ * re-entry pathways are clinical-record artefacts not modelled as
+ * structured org_* rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-10.5',
+ 'Planned, documented discharge/exit with relapse-prevention plans and follow-up arrangements, communication to the consumer/carers (with consent) and ongoing providers, and clear re-entry pathways โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-2.ts b/lib/compliance/evaluators/mental-health-au/MHS-2.ts
new file mode 100644
index 000000000..70a60870b
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-2.ts
@@ -0,0 +1,26 @@
+/**
+ * MHS-2 โ Safety (NSMHS 2010 Standard 2).
+ *
+ * DB-signal: org_incidents over the last 12 months โ high/critical
+ * incidents still open fail the control; incidents open beyond 30 days
+ * yield partial. No incident rows โ manual attestation (absence of rows
+ * is not evidence of a safe environment).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateIncidentSafety } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateIncidentSafety({
+ controlCode: 'MHS-2',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'MHS-2',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-3.ts b/lib/compliance/evaluators/mental-health-au/MHS-3.ts
new file mode 100644
index 000000000..a7b9aff6e
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-3.ts
@@ -0,0 +1,26 @@
+/**
+ * MHS-3 โ Consumer and carer participation (NSMHS 2010 Standard 3).
+ *
+ * DB-signal: feedback/complaint entries in org_registers (type or
+ * category = feedback OR complaint) within 12 months, flagging any open
+ * beyond 30 days. No rows โ manual attestation (participation may be
+ * evidenced through committee representation tracked outside FormaOS).
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluateParticipationFeedback } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluateParticipationFeedback({
+ controlCode: 'MHS-3',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'MHS-3',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-4.ts b/lib/compliance/evaluators/mental-health-au/MHS-4.ts
new file mode 100644
index 000000000..59a49eb86
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-4.ts
@@ -0,0 +1,15 @@
+/**
+ * MHS-4 โ Diversity responsiveness (NSMHS 2010 Standard 4).
+ *
+ * Manual attestation: cultural-safety training, interpreter access and
+ * equity-of-access analysis are not modelled as structured rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-4',
+ 'Diversity-responsive practice covering Aboriginal and Torres Strait Islander peoples and CALD communities, interpreter-access arrangements, cultural-safety training records, and equity-of-access/outcome review โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-5.ts b/lib/compliance/evaluators/mental-health-au/MHS-5.ts
new file mode 100644
index 000000000..42725b511
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-5.ts
@@ -0,0 +1,15 @@
+/**
+ * MHS-5 โ Promotion and prevention (NSMHS 2010 Standard 5).
+ *
+ * Manual attestation: promotion, prevention and early-intervention
+ * plans and community-engagement activities are not modelled as rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-5',
+ 'Documented mental health promotion, prevention and early-intervention plan, records of community engagement / stigma-reduction activity, and an evaluation of reach and impact โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-6.ts b/lib/compliance/evaluators/mental-health-au/MHS-6.ts
new file mode 100644
index 000000000..c33ded287
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-6.ts
@@ -0,0 +1,17 @@
+/**
+ * MHS-6 โ Consumers (NSMHS 2010 Standard 6).
+ *
+ * Manual attestation: individualised, recovery-oriented care plans and
+ * shared-decision-making/consent records are not modelled as structured
+ * rows in FormaOS today (care plans live outside org_* compliance
+ * tables). Attested manually.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-6',
+ 'Individualised, recovery-oriented care plans developed with consumer involvement, informed-consent and shared-decision-making records, and evidence of timely access to a range of treatment and support โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-7.ts b/lib/compliance/evaluators/mental-health-au/MHS-7.ts
new file mode 100644
index 000000000..4d1830e85
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-7.ts
@@ -0,0 +1,16 @@
+/**
+ * MHS-7 โ Carers (NSMHS 2010 Standard 7).
+ *
+ * Manual attestation: carer identification (with consent), carer
+ * information/education and support arrangements are not modelled as
+ * structured rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-7',
+ 'Carer identification with consumer consent, carer information/education and support arrangements consistent with privacy obligations, and carer involvement in care planning where consented โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-8.ts b/lib/compliance/evaluators/mental-health-au/MHS-8.ts
new file mode 100644
index 000000000..d14710644
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-8.ts
@@ -0,0 +1,32 @@
+/**
+ * MHS-8 โ Governance, leadership and management (NSMHS 2010 Standard 8).
+ *
+ * DB-signal: org_policies โ at least one current approved governance
+ * policy (clinical governance / safety & quality / risk / privacy)
+ * reviewed within 365 days. org_policies has no category column, so the
+ * match is on title keywords. No matching policy โ fail (the governance
+ * artefact is absent), never a false pass.
+ */
+
+import type { ControlEvaluator, ControlEvaluatorMeta } from '../types';
+import { FRAMEWORK, evaluatePolicyCadence } from './_shared';
+
+const evaluate: ControlEvaluator = async (ctx) =>
+ evaluatePolicyCadence({
+ controlCode: 'MHS-8',
+ orgId: ctx.orgId,
+ db: ctx.db,
+ keywords:
+ /governance|clinical governance|quality|safety and quality|risk management|privacy|workforce/,
+ reviewWindowDays: 365,
+ missingPolicyMessage:
+ 'No governance policy found in org_policies (clinical governance, safety & quality, risk management, privacy or workforce). NSMHS Standard 8 requires a current, approved governance policy suite reviewed at least annually.',
+ });
+
+export const meta: ControlEvaluatorMeta = {
+ framework: FRAMEWORK,
+ controlCode: 'MHS-8',
+ evaluator: evaluate,
+};
+
+export { evaluate };
diff --git a/lib/compliance/evaluators/mental-health-au/MHS-9.ts b/lib/compliance/evaluators/mental-health-au/MHS-9.ts
new file mode 100644
index 000000000..6fab5ffbf
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/MHS-9.ts
@@ -0,0 +1,16 @@
+/**
+ * MHS-9 โ Integration (NSMHS 2010 Standard 9).
+ *
+ * Manual attestation: partnership agreements, referral pathways and
+ * shared-care/information-sharing protocols are documents not modelled
+ * as structured rows.
+ */
+
+import { makeManualEvaluator } from './_shared';
+
+const { evaluator: evaluate, meta } = makeManualEvaluator(
+ 'MHS-9',
+ 'Documented partnership agreements, referral pathways and shared-care arrangements with primary care, hospitals, community services and NGOs, plus consented information-sharing protocols and transfer-of-care processes โ manual attestation.',
+);
+
+export { evaluate, meta };
diff --git a/lib/compliance/evaluators/mental-health-au/_shared.ts b/lib/compliance/evaluators/mental-health-au/_shared.ts
new file mode 100644
index 000000000..f08c85428
--- /dev/null
+++ b/lib/compliance/evaluators/mental-health-au/_shared.ts
@@ -0,0 +1,529 @@
+/**
+ * Shared helpers for the National Standards for Mental Health Services
+ * pack (`mental-health-au`, code MENTAL_HEALTH_AU).
+ *
+ * The pack covers the ten NSMHS 2010 standards (Standard 10 split into
+ * its five delivery-of-care sub-areas) โ 14 controls. Most standards
+ * genuinely require human attestation (a displayed rights charter,
+ * cultural-safety training, partnership agreements, discharge planning)
+ * and we model that explicitly via `manualAttestation` rather than
+ * inflating pass counts.
+ *
+ * Four controls map cleanly onto structured FormaOS rows:
+ * - MHS-2 (Safety) โ org_incidents
+ * - MHS-3 (Participation) โ org_registers (complaint/feedback)
+ * - MHS-8 (Governance) โ org_policies cadence
+ * - MHS-10.4 (Treatment) โ org_risks freshness
+ *
+ * The lower-level primitives (`notEvaluated`, `manualAttestation`,
+ * `daysSince`, `round2`, `EVIDENCE_CAP`) are re-exported from the
+ * SOC2-TSC shared module so every pack stays aligned on shape and
+ * error reporting.
+ *
+ * IMPORTANT โ verified schema (no invented tables/columns):
+ * - org_policies(id, organization_id, title, status, updated_at,
+ * created_at) โ no clinical/category tag, so mental-health policies
+ * are matched by title keyword only.
+ * - org_registers(id, org_id, type, category, status, updated_at,
+ * created_at) โ keyed by org_id (NOT organization_id); `type` and
+ * `category` are free-form text.
+ * - org_risks(id, organization_id, category, status, updated_at,
+ * created_at).
+ * - org_incidents(id, organization_id, severity[low|medium|high|
+ * critical], status[open|resolved], occurred_at, resolved_at,
+ * created_at) โ keyed by organization_id.
+ * A DB-signal helper that finds no relevant rows returns
+ * `manualAttestation` / `not_evaluated` / `fail` (per the standard's
+ * intent), never a false `pass`.
+ */
+
+import type {
+ ControlEvaluator,
+ ControlEvaluatorContext,
+ ControlEvaluatorMeta,
+ ControlGap,
+ ControlResult,
+ EvidenceRef,
+ FrameworkSlug,
+} from '../types';
+import {
+ EVIDENCE_CAP,
+ daysSince,
+ manualAttestation,
+ notEvaluated,
+ round2,
+} from '../soc2-tsc/_shared';
+
+export {
+ EVIDENCE_CAP,
+ daysSince,
+ manualAttestation,
+ notEvaluated,
+ round2,
+};
+
+export const FRAMEWORK: FrameworkSlug = 'mental-health-au';
+
+const ACTIVE_POLICY_STATUSES = new Set([
+ 'approved',
+ 'active',
+ 'published',
+ 'in_force',
+]);
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Build a `manualAttestation` evaluator with one line per call. The
+ * meaningful evidence is a displayed rights charter, cultural-safety
+ * training, a partnership agreement, or a discharge plan that does not
+ * exist as a structured row in FormaOS today.
+ */
+export function makeManualEvaluator(
+ controlCode: string,
+ message: string,
+): { evaluator: ControlEvaluator; meta: ControlEvaluatorMeta } {
+ const evaluator: ControlEvaluator = async () =>
+ manualAttestation(controlCode, new Date().toISOString(), message);
+ return {
+ evaluator,
+ meta: { framework: FRAMEWORK, controlCode, evaluator },
+ };
+}
+
+/**
+ * Wrap an automated builder into the `{ evaluator, meta }` shape with
+ * `framework: 'mental-health-au'` already filled in.
+ */
+export function makeAutomatedEvaluator(
+ controlCode: string,
+ evaluator: ControlEvaluator,
+): { evaluator: ControlEvaluator; meta: ControlEvaluatorMeta } {
+ return {
+ evaluator,
+ meta: { framework: FRAMEWORK, controlCode, evaluator },
+ };
+}
+
+type PolicyRow = {
+ id: string;
+ title: string | null;
+ status: string | null;
+ updated_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * MHS-8 โ Governance, leadership and management. "Is there at least one
+ * active/approved governance policy whose title matches a keyword set,
+ * reviewed inside the cadence?"
+ *
+ * org_policies has no clinical/category column, so the match is on
+ * `title` keywords only. Zero matches โ fail with a clear gap (the
+ * required governance artefact is simply absent), NOT a false pass.
+ */
+export async function evaluatePolicyCadence(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+ keywords: RegExp;
+ reviewWindowDays: number;
+ missingPolicyMessage: string;
+}): Promise {
+ const { controlCode, orgId, db, keywords, reviewWindowDays } = args;
+ const evaluatedAt = new Date().toISOString();
+
+ const { data, error } = await db
+ .from('org_policies')
+ .select('id, title, status, updated_at, created_at')
+ .eq('organization_id', orgId)
+ .order('updated_at', { ascending: false })
+ .limit(500);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_policies_unavailable',
+ `Could not read org_policies: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as PolicyRow[];
+ const matching = rows.filter((p) =>
+ keywords.test((p.title || '').toLowerCase()),
+ );
+
+ if (matching.length === 0) {
+ return {
+ controlCode,
+ status: 'fail',
+ evidenceRefs: [],
+ gaps: [
+ {
+ code: 'no_matching_policy',
+ message: args.missingPolicyMessage,
+ severity: 'high',
+ },
+ ],
+ confidence: 0.7,
+ reason: `0 org_policies titles matched the governance keyword set (${rows.length} polic(ies) in total).`,
+ evaluatedAt,
+ };
+ }
+
+ const active = matching.filter((p) =>
+ ACTIVE_POLICY_STATUSES.has((p.status || '').toLowerCase()),
+ );
+ const fresh = active.filter((p) => {
+ const since = daysSince(p.updated_at ?? p.created_at);
+ return since != null && since <= reviewWindowDays;
+ });
+
+ const gaps: ControlGap[] = [];
+ if (active.length === 0) {
+ gaps.push({
+ code: 'matching_policy_not_active',
+ message: `${matching.length} matching governance polic(ies) exist but none are in an approved/active/published status.`,
+ severity: 'high',
+ });
+ }
+ if (active.length > 0 && fresh.length === 0) {
+ const newest = active
+ .map((p) => p.updated_at ?? p.created_at)
+ .filter((v): v is string => !!v)
+ .sort()
+ .reverse()[0];
+ const since = daysSince(newest);
+ gaps.push({
+ code: 'matching_policy_stale',
+ message: `Governance polic(ies) last reviewed ${since ?? '?'}d ago โ exceeds the ${reviewWindowDays}-day review cadence.`,
+ severity: 'medium',
+ });
+ }
+
+ const evidenceRefs: EvidenceRef[] = active
+ .slice(0, EVIDENCE_CAP)
+ .map((p) => ({
+ source: 'org_policies',
+ ref: p.id,
+ capturedAt: p.updated_at ?? p.created_at ?? undefined,
+ }));
+
+ let status: ControlResult['status'];
+ if (active.length === 0) status = 'fail';
+ else if (fresh.length > 0) status = 'pass';
+ else status = 'partial';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs,
+ gaps,
+ confidence: round2(0.6 + 0.4 * Math.min(1, active.length / 3)),
+ reason: `${matching.length} matching governance polic(ies); ${active.length} active; ${fresh.length} reviewed within ${reviewWindowDays}d.`,
+ evaluatedAt,
+ };
+}
+
+type IncidentRow = {
+ id: string;
+ severity: string | null;
+ status: string | null;
+ occurred_at: string | null;
+ resolved_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * MHS-2 โ Safety. Incident-management system signal: incidents in
+ * org_incidents over the last 12 months, flagging high/critical
+ * incidents that remain open and any open beyond a reasonable review
+ * window. An empty register is treated as `manualAttestation` (a safe
+ * environment may genuinely have had no incidents, but it could equally
+ * mean incidents are tracked outside FormaOS) โ never a false pass.
+ */
+export async function evaluateIncidentSafety(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+ const oneYearAgo = new Date(Date.now() - 365 * DAY_MS).toISOString();
+
+ const { data, error } = await db
+ .from('org_incidents')
+ .select('id, severity, status, occurred_at, resolved_at, created_at')
+ .eq('organization_id', orgId)
+ .gte('created_at', oneYearAgo)
+ .order('created_at', { ascending: false })
+ .limit(500);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_incidents_unavailable',
+ `Could not read org_incidents: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as IncidentRow[];
+ if (rows.length === 0) {
+ return manualAttestation(
+ controlCode,
+ evaluatedAt,
+ 'No incidents recorded in org_incidents in the last 12 months. Operate the incident-management system in FormaOS (self-harm, aggression, restraint/seclusion, medication events) or attest the safety incident register manually โ absence of rows is not evidence of a safe environment.',
+ );
+ }
+
+ const open = rows.filter((r) => (r.status || '').toLowerCase() === 'open');
+ const openSevere = open.filter((r) =>
+ ['high', 'critical'].includes((r.severity || '').toLowerCase()),
+ );
+ const openStale = open.filter((r) => {
+ const since = daysSince(r.created_at ?? r.occurred_at);
+ return since != null && since > 30;
+ });
+
+ const gaps: ControlGap[] = [];
+ if (openSevere.length > 0) {
+ gaps.push({
+ code: 'open_severe_incidents',
+ message: `${openSevere.length} high/critical safety incident(s) remain open โ review and close per the incident-management procedure.`,
+ severity: 'critical',
+ });
+ }
+ if (openStale.length > 0) {
+ gaps.push({
+ code: 'incidents_open_over_30d',
+ message: `${openStale.length} incident(s) open beyond 30 days without resolution.`,
+ severity: 'high',
+ });
+ }
+
+ const evidenceRefs: EvidenceRef[] = rows
+ .slice(0, EVIDENCE_CAP)
+ .map((r) => ({
+ source: 'org_incidents',
+ ref: r.id,
+ capturedAt: r.created_at ?? r.occurred_at ?? undefined,
+ }));
+
+ const status: ControlResult['status'] =
+ openSevere.length > 0
+ ? 'fail'
+ : openStale.length > 0
+ ? 'partial'
+ : 'pass';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs,
+ gaps,
+ confidence: 0.7,
+ reason: `${rows.length} incident(s)/12mo; ${open.length} open (${openSevere.length} high/critical, ${openStale.length} open >30d).`,
+ evaluatedAt,
+ };
+}
+
+type RegisterRow = {
+ id: string;
+ type: string | null;
+ category: string | null;
+ status: string | null;
+ created_at: string | null;
+ updated_at: string | null;
+};
+
+/**
+ * MHS-3 โ Consumer and carer participation. Feedback/complaint signal:
+ * rows in org_registers (type/category = complaint OR feedback) within
+ * 12 months, flagging any open beyond a 30-day acknowledgement window.
+ * No feedback/complaint rows โ manualAttestation (participation
+ * mechanisms may be tracked outside FormaOS).
+ */
+export async function evaluateParticipationFeedback(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+ const oneYearAgo = new Date(Date.now() - 365 * DAY_MS).toISOString();
+
+ const { data, error } = await db
+ .from('org_registers')
+ .select('id, type, category, status, created_at, updated_at')
+ .eq('org_id', orgId)
+ .or(
+ 'type.eq.complaint,category.eq.complaint,type.eq.feedback,category.eq.feedback',
+ )
+ .gte('created_at', oneYearAgo);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_registers_unavailable',
+ `Could not read org_registers: ${error.message}`,
+ );
+ }
+
+ const rows = (data ?? []) as RegisterRow[];
+ if (rows.length === 0) {
+ return manualAttestation(
+ controlCode,
+ evaluatedAt,
+ 'No consumer/carer feedback or complaint entries in org_registers in 12 months. Capture feedback/complaints (type=feedback or type=complaint) or attest the consumer- and carer-participation mechanisms (committee representation, feedback channels) manually.',
+ );
+ }
+
+ const openOld = rows.filter((r) => {
+ const s = (r.status || '').toLowerCase();
+ if (!s || s === 'closed' || s === 'resolved') return false;
+ const since = daysSince(r.created_at);
+ return since != null && since > 30;
+ });
+
+ const evidenceRefs: EvidenceRef[] = rows
+ .slice(0, EVIDENCE_CAP)
+ .map((r) => ({
+ source: 'org_registers',
+ ref: r.id,
+ capturedAt: r.updated_at ?? r.created_at ?? undefined,
+ }));
+
+ return {
+ controlCode,
+ status: openOld.length > 0 ? 'partial' : 'pass',
+ evidenceRefs,
+ gaps:
+ openOld.length > 0
+ ? [
+ {
+ code: 'feedback_open_over_30d',
+ message: `${openOld.length} feedback/complaint item(s) open beyond 30 days without acknowledgement or resolution.`,
+ severity: 'medium',
+ },
+ ]
+ : [],
+ confidence: 0.7,
+ reason: `${rows.length} feedback/complaint item(s)/12mo; ${openOld.length} open >30d.`,
+ evaluatedAt,
+ };
+}
+
+type RiskRow = {
+ id: string;
+ category: string | null;
+ status: string | null;
+ updated_at: string | null;
+ created_at: string | null;
+};
+
+/**
+ * MHS-10.4 โ Treatment and support. Risk-register freshness: elevated
+ * (critical/high category) clinical/consumer risks reviewed within 90d,
+ * routine within 365d. Empty register โ fail (recovery-oriented
+ * treatment matched to assessed risk requires a documented risk
+ * register).
+ */
+export async function evaluateRiskFreshness(args: {
+ controlCode: string;
+ orgId: string;
+ db: ControlEvaluatorContext['db'];
+}): Promise {
+ const { controlCode, orgId, db } = args;
+ const evaluatedAt = new Date().toISOString();
+
+ const { data, error } = await db
+ .from('org_risks')
+ .select('id, category, status, updated_at, created_at')
+ .eq('organization_id', orgId)
+ .limit(500);
+
+ if (error) {
+ return notEvaluated(
+ controlCode,
+ evaluatedAt,
+ 'org_risks_unavailable',
+ `Could not read org_risks: ${error.message}`,
+ );
+ }
+
+ const risks = (data ?? []) as RiskRow[];
+ if (risks.length === 0) {
+ return {
+ controlCode,
+ status: 'fail',
+ evidenceRefs: [],
+ gaps: [
+ {
+ code: 'no_risk_register',
+ message:
+ 'Risk register is empty โ treatment matched to assessed clinical/consumer risk (suicide/self-harm, absconding, medication, aggression) requires a documented risk register.',
+ severity: 'high',
+ },
+ ],
+ confidence: 0.75,
+ reason: 'org_risks empty.',
+ evaluatedAt,
+ };
+ }
+
+ const elevated = risks.filter((r) =>
+ ['critical', 'high'].includes((r.category ?? '').toLowerCase()),
+ );
+ const routine = risks.filter(
+ (r) => !['critical', 'high'].includes((r.category ?? '').toLowerCase()),
+ );
+ const elevatedFresh = elevated.filter((r) => {
+ const s = daysSince(r.updated_at ?? r.created_at);
+ return s != null && s <= 90;
+ }).length;
+ const routineFresh = routine.filter((r) => {
+ const s = daysSince(r.updated_at ?? r.created_at);
+ return s != null && s <= 365;
+ }).length;
+
+ const elevatedRatio =
+ elevated.length > 0 ? elevatedFresh / elevated.length : 1;
+ const routineRatio = routine.length > 0 ? routineFresh / routine.length : 1;
+ const overall = (elevatedRatio + routineRatio) / 2;
+
+ const gaps: ControlGap[] = [];
+ if (elevated.length > elevatedFresh) {
+ gaps.push({
+ code: 'stale_elevated_risks',
+ message: `${elevated.length - elevatedFresh}/${elevated.length} elevated clinical/consumer risks not reviewed within 90 days.`,
+ severity: 'high',
+ });
+ }
+ if (routine.length > routineFresh) {
+ gaps.push({
+ code: 'stale_routine_risks',
+ message: `${routine.length - routineFresh}/${routine.length} routine risks not reviewed within 12 months.`,
+ severity: 'medium',
+ });
+ }
+
+ const status: ControlResult['status'] =
+ overall >= 0.9 ? 'pass' : overall >= 0.5 ? 'partial' : 'fail';
+
+ return {
+ controlCode,
+ status,
+ evidenceRefs: risks.slice(0, EVIDENCE_CAP).map((r) => ({
+ source: 'org_risks',
+ ref: r.id,
+ capturedAt: r.updated_at ?? r.created_at ?? undefined,
+ })),
+ gaps,
+ confidence: round2(0.5 + 0.4 * overall),
+ reason: `elevated ${elevatedFresh}/${elevated.length} fresh (90d); routine ${routineFresh}/${routine.length} fresh (365d).`,
+ evaluatedAt,
+ };
+}
diff --git a/lib/compliance/evaluators/register.ts b/lib/compliance/evaluators/register.ts
index cde84a834..eb529c90a 100644
--- a/lib/compliance/evaluators/register.ts
+++ b/lib/compliance/evaluators/register.ts
@@ -297,6 +297,80 @@ import { meta as ndis_M_1 } from './ndis/NDIS-M.1';
import { meta as ndis_M_2 } from './ndis/NDIS-M.2';
import { meta as ndis_W_1 } from './ndis/NDIS-W.1';
+// Australian Financial Services pack (framework slug = 'financial-services-au',
+// 20 controls โ ASIC AFS, APRA CPS 230/234/510, AUSTRAC AML/CTF, AFCA).
+// Coverage breakdown (DB-signal vs. manual attestation):
+// - 7 DB-signal: AFS-003 (PDS/FSG policy cadence), AFS-004
+// (conflict_of_interest register), CPS-001 (org_risks freshness),
+// CPS-002 (business_continuity_plan register), CPS-004 (infosec +
+// incident-response policy + audit activity), AML-001 (AML/CTF
+// policy cadence), AFCA-002 (complaint register, RG 271 30-day).
+// - 13 manual attestation. AFS-006 + AML-004 were planned as DB-signal
+// against org_regulatory_notifications but that table cannot model
+// ASIC/AUSTRAC lodgements (its `regulation` CHECK excludes them,
+// `notification_type` is NDIS-specific, and it requires an
+// incident_id FK) โ reading it would surface false NDIS signals, so
+// they fall back to manual. The other 11 (AFS-001/002/005/007/008,
+// CPS-003/005, AML-002/003/005, AFCA-001) verify licences, registers,
+// trust-account reconciliations, training, and board sign-offs that
+// FormaOS does not model as rows. Each carries a
+// `manual_attestation_required` gap โ never a false pass.
+// DB-signal helpers that find no finance-tagged rows return
+// manualAttestation / not_evaluated, never `pass`.
+import { meta as fsau_AFS_001 } from './financial-services-au/AFS-001';
+import { meta as fsau_AFS_002 } from './financial-services-au/AFS-002';
+import { meta as fsau_AFS_003 } from './financial-services-au/AFS-003';
+import { meta as fsau_AFS_004 } from './financial-services-au/AFS-004';
+import { meta as fsau_AFS_005 } from './financial-services-au/AFS-005';
+import { meta as fsau_AFS_006 } from './financial-services-au/AFS-006';
+import { meta as fsau_AFS_007 } from './financial-services-au/AFS-007';
+import { meta as fsau_AFS_008 } from './financial-services-au/AFS-008';
+import { meta as fsau_CPS_001 } from './financial-services-au/CPS-001';
+import { meta as fsau_CPS_002 } from './financial-services-au/CPS-002';
+import { meta as fsau_CPS_003 } from './financial-services-au/CPS-003';
+import { meta as fsau_CPS_004 } from './financial-services-au/CPS-004';
+import { meta as fsau_CPS_005 } from './financial-services-au/CPS-005';
+import { meta as fsau_AML_001 } from './financial-services-au/AML-001';
+import { meta as fsau_AML_002 } from './financial-services-au/AML-002';
+import { meta as fsau_AML_003 } from './financial-services-au/AML-003';
+import { meta as fsau_AML_004 } from './financial-services-au/AML-004';
+import { meta as fsau_AML_005 } from './financial-services-au/AML-005';
+import { meta as fsau_AFCA_001 } from './financial-services-au/AFCA-001';
+import { meta as fsau_AFCA_002 } from './financial-services-au/AFCA-002';
+
+// National Standards for Mental Health Services pack (framework slug =
+// 'mental-health-au', code MENTAL_HEALTH_AU, 14 controls โ NSMHS 2010
+// Standards 1โ10, with Standard 10 split into 10.1โ10.5).
+// Coverage breakdown (DB-signal vs. manual attestation):
+// - 4 DB-signal: MHS-2 (org_incidents safety register, 12mo, open
+// high/critical โ fail), MHS-3 (org_registers type/category
+// feedback|complaint, 12mo, open >30d โ partial), MHS-8
+// (org_policies governance title-keyword cadence, 365d), MHS-10.4
+// (org_risks freshness โ elevated 90d / routine 365d, empty โ fail).
+// - 10 manual attestation: MHS-1 (rights charter display), MHS-4
+// (diversity/cultural safety), MHS-5 (promotion/prevention), MHS-6
+// (consumer care plans), MHS-7 (carers), MHS-9 (integration
+// partnerships), MHS-10.1 (access), MHS-10.2 (entry), MHS-10.3
+// (assessment & review), MHS-10.5 (exit & re-entry). Each verifies
+// clinical-record or governance artefacts FormaOS does not model as
+// rows and carries a `manual_attestation_required` gap โ never a
+// false pass. โ ๏ธ Clinical/mental-health-domain review recommended
+// for predicate semantics and sub-criteria expansion.
+import { meta as mhs_1 } from './mental-health-au/MHS-1';
+import { meta as mhs_2 } from './mental-health-au/MHS-2';
+import { meta as mhs_3 } from './mental-health-au/MHS-3';
+import { meta as mhs_4 } from './mental-health-au/MHS-4';
+import { meta as mhs_5 } from './mental-health-au/MHS-5';
+import { meta as mhs_6 } from './mental-health-au/MHS-6';
+import { meta as mhs_7 } from './mental-health-au/MHS-7';
+import { meta as mhs_8 } from './mental-health-au/MHS-8';
+import { meta as mhs_9 } from './mental-health-au/MHS-9';
+import { meta as mhs_10_1 } from './mental-health-au/MHS-10.1';
+import { meta as mhs_10_2 } from './mental-health-au/MHS-10.2';
+import { meta as mhs_10_3 } from './mental-health-au/MHS-10.3';
+import { meta as mhs_10_4 } from './mental-health-au/MHS-10.4';
+import { meta as mhs_10_5 } from './mental-health-au/MHS-10.5';
+
import { meta as pci_PCI_1 } from './pci-dss/PCI-1';
import { meta as pci_PCI_2 } from './pci-dss/PCI-2';
import { meta as pci_PCI_3 } from './pci-dss/PCI-3';
@@ -573,6 +647,44 @@ const ALL_EVALUATORS = [
ndis_M_1,
ndis_M_2,
ndis_W_1,
+ // Australian Financial Services pack (framework slug = 'financial-services-au',
+ // 20 controls). 7 DB-signal, 13 manual attestation.
+ fsau_AFS_001,
+ fsau_AFS_002,
+ fsau_AFS_003,
+ fsau_AFS_004,
+ fsau_AFS_005,
+ fsau_AFS_006,
+ fsau_AFS_007,
+ fsau_AFS_008,
+ fsau_CPS_001,
+ fsau_CPS_002,
+ fsau_CPS_003,
+ fsau_CPS_004,
+ fsau_CPS_005,
+ fsau_AML_001,
+ fsau_AML_002,
+ fsau_AML_003,
+ fsau_AML_004,
+ fsau_AML_005,
+ fsau_AFCA_001,
+ fsau_AFCA_002,
+ // National Standards for Mental Health Services (framework slug =
+ // 'mental-health-au', 14 controls). 4 DB-signal, 10 manual attestation.
+ mhs_1,
+ mhs_2,
+ mhs_3,
+ mhs_4,
+ mhs_5,
+ mhs_6,
+ mhs_7,
+ mhs_8,
+ mhs_9,
+ mhs_10_1,
+ mhs_10_2,
+ mhs_10_3,
+ mhs_10_4,
+ mhs_10_5,
];
let registered = false;
diff --git a/lib/compliance/evaluators/types.ts b/lib/compliance/evaluators/types.ts
index 5583829fe..5c3e9b71d 100644
--- a/lib/compliance/evaluators/types.ts
+++ b/lib/compliance/evaluators/types.ts
@@ -54,7 +54,9 @@ export type FrameworkSlug =
| 'pci-dss'
| 'nist-csf'
| 'cis-controls'
- | 'ndis';
+ | 'ndis'
+ | 'financial-services-au'
+ | 'mental-health-au';
export type ControlEvaluatorMeta = {
framework: FrameworkSlug;
diff --git a/lib/compliance/get-org-compliance-snapshot.ts b/lib/compliance/get-org-compliance-snapshot.ts
index 38427e46d..854d13017 100644
--- a/lib/compliance/get-org-compliance-snapshot.ts
+++ b/lib/compliance/get-org-compliance-snapshot.ts
@@ -99,6 +99,42 @@ export async function getOrgComplianceSnapshotCore(
}
}
+ // Audit H3: overlay the persisted evaluator-aware control status from the
+ // framework-evaluation path (org_control_evaluations.control_type =
+ // 'framework_control'). That path applies the registry evaluator overlay
+ // (DB-signal checks the snapshot can't afford to re-run live across ~253
+ // evaluators) on top of the same evidence/task heuristic. Reading its
+ // persisted verdict โ rather than recomputing a heuristic-only status โ
+ // makes the dashboard snapshot and the framework page report ONE number
+ // for the same org (they previously diverged; audit-package emitted both).
+ const persistedStatusByControl = new Map();
+ const VALID_CONTROL_STATUSES: ReadonlySet = new Set([
+ 'compliant',
+ 'at_risk',
+ 'non_compliant',
+ 'not_applicable',
+ ]);
+ try {
+ const { data: persistedEvalRows } = await supabase
+ .from('org_control_evaluations')
+ .select('control_key, status, details')
+ .eq('organization_id', orgId)
+ .eq('control_type', 'framework_control');
+ for (const row of persistedEvalRows ?? []) {
+ const controlId =
+ (row as { details?: { control_id?: string } }).details?.control_id ??
+ (typeof (row as { control_key?: string }).control_key === 'string'
+ ? (row as { control_key: string }).control_key.replace(/^control:/, '')
+ : null);
+ const status = (row as { status?: string }).status;
+ if (controlId && status && VALID_CONTROL_STATUSES.has(status)) {
+ persistedStatusByControl.set(controlId, status as ControlStatus);
+ }
+ }
+ } catch {
+ // Non-fatal โ fall back to the live heuristic for every control.
+ }
+
const evidenceBacklog = {
pending: evidenceRows.filter((e) => (e.status || 'pending') === 'pending')
.length,
@@ -182,6 +218,15 @@ export async function getOrgComplianceSnapshotCore(
status = 'at_risk';
}
+ // Prefer the persisted evaluator-aware verdict when the framework
+ // evaluation has run for this control (audit H3). Mandatory controls
+ // only โ `not_applicable` is a structural property of the control, not
+ // something an evaluation run should flip.
+ const persistedStatus = persistedStatusByControl.get(control.id);
+ if (isMandatory && persistedStatus && persistedStatus !== 'not_applicable') {
+ status = persistedStatus;
+ }
+
if (status !== 'not_applicable') {
fwWeight += weight * riskWeight;
fwScore += weight * riskWeight * scoreFromStatus(status);
diff --git a/lib/compliance/unified-score.ts b/lib/compliance/unified-score.ts
deleted file mode 100644
index 32fb2ab8e..000000000
--- a/lib/compliance/unified-score.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { createSupabaseOrgClient } from '@/lib/supabase/org-scoped';
-import { getCrossMapCoverage } from '@/lib/compliance/cross-map-engine';
-
-export async function getUnifiedComplianceScore(
- orgId: string,
-): Promise {
- const supabase = createSupabaseOrgClient(orgId);
- // .eq('organization_id', orgId) appended automatically.
- const { data: controls } = await supabase
- .from('org_controls')
- .select('status');
-
- if (!controls?.length) return 0;
- const satisfied = (controls as Array<{ status: string }>).filter(
- (c) => c.status === 'compliant' || c.status === 'satisfied' || c.status === 'met',
- ).length;
- return Math.round((satisfied / controls.length) * 100);
-}
-
-export async function getFrameworkScores(orgId: string) {
- const supabase = createSupabaseOrgClient(orgId);
- const { data: controls } = await supabase
- .from('org_controls')
- .select('framework, status');
-
- if (!controls?.length) return [];
-
- const fwMap = new Map();
- for (const c of controls as Array<{ framework: string; status: string }>) {
- const fw = fwMap.get(c.framework) || { total: 0, satisfied: 0 };
- fw.total++;
- if (c.status === 'compliant' || c.status === 'satisfied' || c.status === 'met') fw.satisfied++;
- fwMap.set(c.framework, fw);
- }
-
- return Array.from(fwMap.entries()).map(([framework, counts]) => ({
- framework,
- score:
- counts.total > 0
- ? Math.round((counts.satisfied / counts.total) * 100)
- : 0,
- total: counts.total,
- satisfied: counts.satisfied,
- }));
-}
-
-export async function getScoreImpact(orgId: string) {
- // v4-021: previously `crossMappedScore = isolated + 5` and
- // `delta = min(5, 100 - score)` โ invented numbers shown to
- // customers as their cross-mapped posture. Now reuses the real
- // cross-map computation in getCrossMapCoverage (which walks
- // control_groups for transitively-satisfied controls), and
- // derives delta as the actual difference.
- const [scores, coverage] = await Promise.all([
- getFrameworkScores(orgId),
- getCrossMapCoverage(orgId),
- ]);
- return scores.map((s: { framework: string; score: number }) => {
- const crossMappedScore = coverage[s.framework]?.crossMapped ?? s.score;
- return {
- framework: s.framework,
- isolatedScore: s.score,
- crossMappedScore,
- delta: Math.max(0, crossMappedScore - s.score),
- };
- });
-}
diff --git a/lib/data-governance/retention.ts b/lib/data-governance/retention.ts
index d19b503b5..65390c7c0 100644
--- a/lib/data-governance/retention.ts
+++ b/lib/data-governance/retention.ts
@@ -424,6 +424,21 @@ export async function executeRetention(orgId: string, dryRun = true) {
},
});
+ // Stamp the sweep time so the nightly data-retention cron can round-robin
+ // across all orgs (it orders by last_retention_at NULLS FIRST). Best-effort:
+ // a missing column (migration not yet applied) returns an error we ignore
+ // rather than failing the retention run (audit M8).
+ if (!dryRun) {
+ const { error: stampError } = await admin
+ .from('organizations')
+ .update({ last_retention_at: new Date().toISOString() })
+ .eq('id', orgId);
+ if (stampError) {
+ // Non-fatal โ column may not be deployed yet.
+ void stampError;
+ }
+ }
+
return results;
}
diff --git a/lib/frameworks/framework-installer.ts b/lib/frameworks/framework-installer.ts
index 54f3f4ebc..633be0947 100644
--- a/lib/frameworks/framework-installer.ts
+++ b/lib/frameworks/framework-installer.ts
@@ -7,68 +7,29 @@ import {
detectComplianceControlsSchema,
riskWeightFromLevel,
} from './compliance-controls-schema';
-
-// v4-031: legacy `iso27001` pack (10 controls, 0 wired evaluators) is
-// kept as a deprecated alias of `iso27001-2022` (93 controls, full
-// evaluator coverage). Requests for the legacy slug are transparently
-// redirected; `ensureFrameworkPacksInstalled` no longer installs it.
-// `soc2` is intentionally retained in PACK_REGISTRY โ it has 9 wired
-// evaluators and is the current/canonical SOC2 implementation for
-// existing orgs; `soc2-tsc` is the explicit TSC-organised variant.
-export const DEPRECATED_PACK_SLUGS: Record = {
- iso27001: 'iso27001-2022',
+import {
+ DEPRECATED_PACK_SLUGS,
+ PACK_REGISTRY,
+ PACK_SLUGS,
+ getFrameworkCodeForSlug,
+ getFrameworkSlugForCode,
+ getPackFileForSlug,
+} from './pack-registry';
+
+// The pure registry + slug lookups live in ./pack-registry (no server-only
+// import) so non-server contexts can use them; re-exported here so existing
+// `@/lib/frameworks/framework-installer` import sites keep working.
+export {
+ DEPRECATED_PACK_SLUGS,
+ PACK_REGISTRY,
+ PACK_SLUGS,
+ getFrameworkCodeForSlug,
+ getFrameworkSlugForCode,
+ getPackFileForSlug,
};
-const PACK_REGISTRY = [
- { slug: 'nist-csf', file: 'nist-csf.json', code: 'NIST_CSF' },
- { slug: 'cis-controls', file: 'cis-controls.json', code: 'CIS_CONTROLS' },
- { slug: 'soc2', file: 'soc2.json', code: 'SOC2' },
- { slug: 'soc2-tsc', file: 'soc2-tsc.json', code: 'SOC2_TSC' },
- { slug: 'iso27001-2022', file: 'iso27001-2022.json', code: 'ISO27001_2022' },
- { slug: 'gdpr', file: 'gdpr.json', code: 'GDPR' },
- { slug: 'hipaa', file: 'hipaa.json', code: 'HIPAA' },
- { slug: 'pci-dss', file: 'pci-dss.json', code: 'PCIDSS' },
- // v4-021: framework-packs/financial-services.json shipped but
- // was never wired into the registry โ orgs couldn't install it
- // and the marketing site advertised it as supported.
- {
- slug: 'financial-services-au',
- file: 'financial-services.json',
- code: 'FINANCIAL_SERVICES_AU',
- },
- // Audit 2026-05-27 (R10 Phase 1): NDIS Practice Standards Core Module โ
- // 8 manual-attestation controls. Phase 2 requires NDIS-domain expert.
- { slug: 'ndis', file: 'ndis.json', code: 'NDIS' },
-];
-
-export const PACK_SLUGS = PACK_REGISTRY.map((pack) => pack.slug);
-
let installPromise: Promise | null = null;
-export function getFrameworkCodeForSlug(slug: string) {
- const found = PACK_REGISTRY.find((pack) => pack.slug === slug);
- return found?.code ?? slug.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
-}
-
-/**
- * Inverse of {@link getFrameworkCodeForSlug}. Returns the canonical
- * pack slug (e.g. `soc2-tsc`) for a given DB framework code (e.g.
- * `SOC2_TSC`). Used by the compliance engine to look up registered
- * evaluators โ registry keys are pack slugs, not DB codes.
- *
- * Audit compliance-004 (2026-05-22).
- */
-export function getFrameworkSlugForCode(code: string): string | null {
- const found = PACK_REGISTRY.find((pack) => pack.code === code);
- return found?.slug ?? null;
-}
-
-export function getPackFileForSlug(slug: string) {
- const found = PACK_REGISTRY.find((pack) => pack.slug === slug);
- if (!found) return null;
- return path.join(process.cwd(), 'framework-packs', found.file);
-}
-
export async function syncComplianceFramework(
slug: string,
adminClient?: ReturnType,
diff --git a/lib/frameworks/org-frameworks.ts b/lib/frameworks/org-frameworks.ts
index 56da9db4b..f47645d8d 100644
--- a/lib/frameworks/org-frameworks.ts
+++ b/lib/frameworks/org-frameworks.ts
@@ -31,6 +31,7 @@ async function getOrgFrameworkLimit(
const { data } = await admin
.from('org_subscriptions')
.select('plan_key')
+ // eslint-disable-next-line formaos/no-admin-client-with-org-filter -- single-org plan lookup during framework-provisioning sync; orgId is server-derived, not request input.
.eq('organization_id', orgId)
.maybeSingle();
const planKey = resolvePlanKey(data?.plan_key) ?? 'basic';
@@ -99,6 +100,7 @@ export async function syncOrgFrameworksFromOrgRecord(orgId: string) {
const { data: existing } = await admin
.from('org_frameworks')
.select('framework_slug')
+ // eslint-disable-next-line formaos/no-admin-client-with-org-filter -- single-org read to retain already-enabled frameworks under the plan cap; orgId is server-derived.
.eq('organization_id', orgId);
const existingSlugs = new Set(
(existing ?? []).map((r: { framework_slug: string }) => r.framework_slug),
diff --git a/lib/frameworks/pack-registry.ts b/lib/frameworks/pack-registry.ts
new file mode 100644
index 000000000..d901bc602
--- /dev/null
+++ b/lib/frameworks/pack-registry.ts
@@ -0,0 +1,81 @@
+import path from 'path';
+
+/**
+ * Pure framework-pack registry + slug/code lookups.
+ *
+ * This module deliberately has **no** `server-only` / admin-client imports so
+ * it can be consumed from any context (tests, edge, client-safe code) without
+ * dragging in the Supabase admin client. `framework-installer.ts` re-exports
+ * everything here for back-compat and adds the server-only install routines.
+ *
+ * (audit H6: importing PACK_SLUGS from framework-installer pulled in
+ * `import 'server-only'` via the admin client and crashed Playwright test
+ * collection โ `playwright test --list` reported 0 tests.)
+ */
+
+// v4-031: legacy `iso27001` pack (10 controls, 0 wired evaluators) is
+// kept as a deprecated alias of `iso27001-2022` (93 controls, full
+// evaluator coverage). Requests for the legacy slug are transparently
+// redirected; `ensureFrameworkPacksInstalled` no longer installs it.
+// `soc2` is intentionally retained in PACK_REGISTRY โ it has 9 wired
+// evaluators and is the current/canonical SOC2 implementation for
+// existing orgs; `soc2-tsc` is the explicit TSC-organised variant.
+export const DEPRECATED_PACK_SLUGS: Record = {
+ iso27001: 'iso27001-2022',
+};
+
+export const PACK_REGISTRY = [
+ { slug: 'nist-csf', file: 'nist-csf.json', code: 'NIST_CSF' },
+ { slug: 'cis-controls', file: 'cis-controls.json', code: 'CIS_CONTROLS' },
+ { slug: 'soc2', file: 'soc2.json', code: 'SOC2' },
+ { slug: 'soc2-tsc', file: 'soc2-tsc.json', code: 'SOC2_TSC' },
+ { slug: 'iso27001-2022', file: 'iso27001-2022.json', code: 'ISO27001_2022' },
+ { slug: 'gdpr', file: 'gdpr.json', code: 'GDPR' },
+ { slug: 'hipaa', file: 'hipaa.json', code: 'HIPAA' },
+ { slug: 'pci-dss', file: 'pci-dss.json', code: 'PCIDSS' },
+ // v4-021: framework-packs/financial-services.json shipped but
+ // was never wired into the registry โ orgs couldn't install it
+ // and the marketing site advertised it as supported.
+ {
+ slug: 'financial-services-au',
+ file: 'financial-services.json',
+ code: 'FINANCIAL_SERVICES_AU',
+ },
+ // Audit 2026-05-27 (R10 Phase 1): NDIS Practice Standards Core Module โ
+ // 8 manual-attestation controls. Phase 2 requires NDIS-domain expert.
+ { slug: 'ndis', file: 'ndis.json', code: 'NDIS' },
+ // Mental Health Services vertical โ National Standards for Mental
+ // Health Services (NSMHS) 2010, 10 standards / 14 controls. 4 DB-signal
+ // (org_incidents, org_registers, org_policies, org_risks), 10 manual.
+ {
+ slug: 'mental-health-au',
+ file: 'mental-health-au.json',
+ code: 'MENTAL_HEALTH_AU',
+ },
+];
+
+export function getFrameworkCodeForSlug(slug: string) {
+ const found = PACK_REGISTRY.find((pack) => pack.slug === slug);
+ return found?.code ?? slug.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
+}
+
+/**
+ * Inverse of {@link getFrameworkCodeForSlug}. Returns the canonical
+ * pack slug (e.g. `soc2-tsc`) for a given DB framework code (e.g.
+ * `SOC2_TSC`). Used by the compliance engine to look up registered
+ * evaluators โ registry keys are pack slugs, not DB codes.
+ *
+ * Audit compliance-004 (2026-05-22).
+ */
+export function getFrameworkSlugForCode(code: string): string | null {
+ const found = PACK_REGISTRY.find((pack) => pack.code === code);
+ return found?.slug ?? null;
+}
+
+export function getPackFileForSlug(slug: string) {
+ const found = PACK_REGISTRY.find((pack) => pack.slug === slug);
+ if (!found) return null;
+ return path.join(process.cwd(), 'framework-packs', found.file);
+}
+
+export const PACK_SLUGS = PACK_REGISTRY.map((pack) => pack.slug);
diff --git a/lib/industry-packs.ts b/lib/industry-packs.ts
index 17658b1b6..27f05630e 100644
--- a/lib/industry-packs.ts
+++ b/lib/industry-packs.ts
@@ -29,6 +29,25 @@ export const INDUSTRY_PACKS: Record = {
{ name: "Staff Training Register", type: "data", criticality: "high" }
]
},
+ "mental_health": {
+ id: "mental_health",
+ name: "Mental Health Services",
+ description: "Compliance framework for mental health service providers aligned with the National Standards for Mental Health Services (NSMHS).",
+ policies: [
+ { title: "Consumer Rights and Dignity Policy", content: "## 1. Purpose\nTo uphold the rights, dignity, and autonomy of mental health consumers and their carers..." },
+ { title: "Restrictive Practices and Seclusion Policy", content: "## 1. Purpose\nTo minimise and govern the use of seclusion and restraint, with authorisation and review requirements..." },
+ { title: "Incident Management and Reportable Incidents Policy", content: "## 1. Purpose\nTo ensure incidents affecting consumer safety are recorded, escalated, and reported..." }
+ ],
+ tasks: [
+ { title: "Worker Screening Check", description: "Verify worker screening and police checks for all clinical and support staff." },
+ { title: "Complete NSMHS Self-Assessment", description: "Review service operations against the National Standards for Mental Health Services." },
+ { title: "Review Restrictive Practice Register", description: "Audit current seclusion and restraint authorisations and review cycles." }
+ ],
+ assets: [
+ { name: "Consumer Records Database", type: "data", criticality: "critical" },
+ { name: "Restrictive Practices Register", type: "data", criticality: "high" }
+ ]
+ },
"healthcare": {
id: "healthcare",
name: "GP / Medical Practice",
diff --git a/lib/navigation/industry-sidebar.ts b/lib/navigation/industry-sidebar.ts
index a02e39344..00087037b 100644
--- a/lib/navigation/industry-sidebar.ts
+++ b/lib/navigation/industry-sidebar.ts
@@ -56,6 +56,7 @@ export interface NavItem {
export type IndustryType =
| 'ndis'
+ | 'mental_health'
| 'healthcare'
| 'aged_care'
| 'childcare'
@@ -213,6 +214,160 @@ export const NDIS_NAV: NavItem[] = [
},
];
+// =========================================================
+// MENTAL HEALTH SERVICES SIDEBAR
+// =========================================================
+export const MENTAL_HEALTH_NAV: NavItem[] = [
+ // Overview
+ {
+ name: 'Dashboard',
+ href: '/app',
+ icon: LayoutDashboard,
+ category: 'Overview',
+ testId: 'nav-dashboard',
+ },
+
+ // Compliance
+ {
+ name: 'Obligations',
+ href: '/app/compliance',
+ icon: ShieldCheck,
+ category: 'Compliance',
+ testId: 'nav-obligations',
+ ragKey: 'obligations',
+ children: [
+ {
+ name: 'Frameworks',
+ href: '/app/compliance/frameworks',
+ testId: 'nav-frameworks',
+ },
+ { name: 'Controls', href: '/app/controls', testId: 'nav-controls' },
+ {
+ name: 'Cross-Map',
+ href: '/app/compliance/cross-map',
+ testId: 'nav-cross-map',
+ },
+ ],
+ },
+ {
+ name: 'Policies',
+ href: '/app/policies',
+ icon: FileText,
+ category: 'Compliance',
+ testId: 'nav-policies',
+ ragKey: 'policies',
+ },
+ {
+ name: 'Evidence Vault',
+ href: '/app/vault',
+ icon: Lock,
+ category: 'Compliance',
+ testId: 'nav-vault',
+ ragKey: 'evidence',
+ },
+
+ // Care Operations
+ {
+ name: 'Consumers',
+ href: '/app/participants',
+ icon: Users,
+ category: 'Care Operations',
+ testId: 'nav-consumers',
+ },
+ {
+ name: 'Service Delivery',
+ href: '/app/visits',
+ icon: Calendar,
+ category: 'Care Operations',
+ testId: 'nav-visits',
+ },
+ {
+ name: 'Progress Notes',
+ href: '/app/progress-notes',
+ icon: NotebookPen,
+ category: 'Care Operations',
+ testId: 'nav-progress-notes',
+ },
+ {
+ name: 'Care Plans',
+ href: '/app/care-plans',
+ icon: HeartPulse,
+ category: 'Care Operations',
+ testId: 'nav-care-plans',
+ },
+ {
+ name: 'Behaviour Support',
+ href: '/app/behaviour-support-plans',
+ icon: FileText,
+ category: 'Care Operations',
+ testId: 'nav-behaviour-support-plans',
+ },
+ {
+ name: 'Incidents',
+ href: '/app/incidents',
+ icon: AlertTriangle,
+ category: 'Care Operations',
+ testId: 'nav-incidents',
+ ragKey: 'incidents',
+ },
+
+ // Workforce
+ {
+ name: 'Staff Compliance',
+ href: '/app/staff-compliance',
+ icon: UserCheck,
+ category: 'Workforce',
+ testId: 'nav-staff-compliance',
+ ragKey: 'staff',
+ },
+ {
+ name: 'Team',
+ href: '/app/team',
+ icon: Users,
+ category: 'Workforce',
+ testId: 'nav-team',
+ },
+
+ // Registers & Reports
+ {
+ name: 'Registers',
+ href: '/app/registers',
+ icon: ClipboardList,
+ category: 'Registers',
+ testId: 'nav-registers',
+ },
+ {
+ name: 'Forms',
+ href: '/app/forms',
+ icon: FormInput,
+ category: 'Registers',
+ testId: 'nav-forms',
+ },
+ {
+ name: 'Reports',
+ href: '/app/reports',
+ icon: BarChart3,
+ category: 'Reports',
+ testId: 'nav-reports',
+ },
+ {
+ name: 'Executive View',
+ href: '/app/executive',
+ icon: Shield,
+ category: 'Reports',
+ testId: 'nav-executive',
+ },
+
+ // System
+ {
+ name: 'Settings',
+ href: '/app/settings',
+ icon: Settings,
+ category: 'System',
+ testId: 'nav-settings',
+ },
+];
+
// =========================================================
// HEALTHCARE SIDEBAR
// =========================================================
@@ -1452,6 +1607,9 @@ export function getIndustryNavigation(
case 'ndis':
navigation = NDIS_NAV;
break;
+ case 'mental_health':
+ navigation = MENTAL_HEALTH_NAV;
+ break;
case 'healthcare':
navigation = HEALTHCARE_NAV;
break;
@@ -1492,6 +1650,7 @@ export function getIndustryNavigation(
export function isCareIndustry(industry: string | null | undefined): boolean {
return (
industry === 'ndis' ||
+ industry === 'mental_health' ||
industry === 'healthcare' ||
industry === 'aged_care' ||
industry === 'childcare' ||
@@ -1506,6 +1665,8 @@ export function getIndustryLabel(industry: string | null | undefined): string {
switch (industry) {
case 'ndis':
return 'NDIS Provider';
+ case 'mental_health':
+ return 'Mental Health Services';
case 'healthcare':
return 'Healthcare';
case 'aged_care':
diff --git a/lib/onboarding/industry-roadmaps.ts b/lib/onboarding/industry-roadmaps.ts
index ca0a80b42..e8525a15c 100644
--- a/lib/onboarding/industry-roadmaps.ts
+++ b/lib/onboarding/industry-roadmaps.ts
@@ -278,6 +278,247 @@ const NDIS_ROADMAP: IndustryRoadmap = {
],
};
+/**
+ * MENTAL HEALTH SERVICES ROADMAP
+ */
+const MENTAL_HEALTH_ROADMAP: IndustryRoadmap = {
+ industryId: 'mental_health',
+ industryName: 'Mental Health Services',
+ icon: 'Brain',
+ tagline: 'NSMHS-aligned compliance workflows and consumer safety evidence',
+ estimatedTimeToOperational: '7-14 days',
+ keyFrameworks: ['Policy pack', 'Evidence vault', 'Automation workflows'],
+ phases: [
+ {
+ id: 'org-setup',
+ title: 'Organization Setup',
+ description: 'Configure your mental health service structure and team',
+ estimatedDays: 2,
+ steps: [
+ {
+ id: 'provider-details',
+ title: 'Complete Service Registration Details',
+ description:
+ 'Confirm your organization profile, service scope, and compliance contacts',
+ cta: 'Update Organization Profile',
+ ctaHref: '/app/settings',
+ icon: 'Building2',
+ priority: 'critical',
+ category: 'setup',
+ estimatedMinutes: 15,
+ },
+ {
+ id: 'staff-setup',
+ title: 'Add Clinical & Support Team',
+ description:
+ 'Create staff profiles, assign roles, track worker screening and police checks',
+ cta: 'Manage Team Members',
+ ctaHref: '/app/team',
+ icon: 'Users',
+ priority: 'critical',
+ category: 'setup',
+ estimatedMinutes: 30,
+ },
+ {
+ id: 'participant-onboarding',
+ title: 'Set Up Consumer Records System',
+ description:
+ 'Configure consumer management, care plans, and consent documentation',
+ cta: 'Configure Consumers',
+ ctaHref: '/app/patients',
+ icon: 'HeartHandshake',
+ priority: 'high',
+ category: 'setup',
+ estimatedMinutes: 20,
+ },
+ {
+ id: 'location-setup',
+ title: 'Register Service Locations or Assets',
+ description:
+ 'Capture service delivery sites or critical assets in registers',
+ cta: 'Add to Registers',
+ ctaHref: '/app/registers',
+ icon: 'MapPin',
+ priority: 'high',
+ category: 'setup',
+ estimatedMinutes: 10,
+ },
+ ],
+ },
+ {
+ id: 'compliance-setup',
+ title: 'Compliance Framework Activation',
+ description: 'Enable NSMHS baseline and industry policy templates',
+ estimatedDays: 3,
+ steps: [
+ {
+ id: 'framework-provision',
+ title: 'Activate the NSMHS Framework',
+ description:
+ 'Enable the National Standards for Mental Health Services pack and align it to your operations',
+ cta: 'Enable Frameworks',
+ ctaHref: '/app/compliance/frameworks',
+ icon: 'Shield',
+ priority: 'critical',
+ category: 'compliance',
+ automationTrigger: 'framework_activated',
+ estimatedMinutes: 5,
+ },
+ {
+ id: 'credential-register',
+ title: 'Set Up Worker Screening Register',
+ description:
+ 'Track worker screening clearances, police checks, and qualifications',
+ cta: 'Configure Credential Register',
+ ctaHref: '/app/registers',
+ icon: 'FileCheck',
+ priority: 'critical',
+ category: 'compliance',
+ estimatedMinutes: 20,
+ },
+ {
+ id: 'incident-system',
+ title: 'Set Up Incident & Restrictive Practice Tasks',
+ description:
+ 'Define incident response tasks, restrictive practice reviews, escalation owners, and review cadence',
+ cta: 'Create Incident Tasks',
+ ctaHref: '/app/tasks',
+ icon: 'AlertTriangle',
+ priority: 'critical',
+ category: 'compliance',
+ automationTrigger: 'incident_register_activated',
+ estimatedMinutes: 15,
+ },
+ {
+ id: 'policy-library',
+ title: 'Review Pre-loaded Mental Health Policies',
+ description:
+ 'Review and approve Consumer Rights, Restrictive Practices, and Incident Management policies',
+ cta: 'Review Policy Library',
+ ctaHref: '/app/policies',
+ icon: 'FileText',
+ priority: 'high',
+ category: 'compliance',
+ estimatedMinutes: 45,
+ },
+ ],
+ },
+ {
+ id: 'operational',
+ title: 'Operational Workflows',
+ description: 'Deploy day-to-day compliance workflows',
+ estimatedDays: 5,
+ steps: [
+ {
+ id: 'incident-logging',
+ title: 'Run a Test Incident Workflow',
+ description:
+ 'Create a test incident task to validate your response and notification workflow',
+ cta: 'Create Incident Task',
+ ctaHref: '/app/tasks',
+ icon: 'FileWarning',
+ priority: 'high',
+ category: 'operational',
+ automationTrigger: 'incident_created',
+ estimatedMinutes: 10,
+ },
+ {
+ id: 'evidence-capture',
+ title: 'Upload First Compliance Evidence',
+ description:
+ 'Store worker screening, insurance certificates, or training records',
+ cta: 'Upload Evidence',
+ ctaHref: '/app/vault',
+ icon: 'Upload',
+ priority: 'high',
+ category: 'operational',
+ automationTrigger: 'evidence_uploaded',
+ estimatedMinutes: 10,
+ },
+ {
+ id: 'staff-credential-tracking',
+ title: 'Track Staff Credential Expiry',
+ description:
+ 'Enable automation for expiring worker screening and qualification renewals',
+ cta: 'Configure Credential Tracking',
+ ctaHref: '/app/workflows',
+ icon: 'Clock',
+ priority: 'high',
+ category: 'operational',
+ automationTrigger: 'credential_expiry_enabled',
+ estimatedMinutes: 15,
+ },
+ {
+ id: 'participant-workflows',
+ title: 'Implement Consumer Care Plan Workflows',
+ description:
+ 'Configure care plan review cycles, consent management, and restrictive practice authorisation tracking',
+ cta: 'Set Up Care Plan Workflows',
+ ctaHref: '/app/workflows',
+ icon: 'Workflow',
+ priority: 'medium',
+ category: 'operational',
+ estimatedMinutes: 30,
+ },
+ ],
+ },
+ {
+ id: 'audit-readiness',
+ title: 'Audit Readiness',
+ description: 'Prepare for NSMHS reviews and accreditation',
+ estimatedDays: 3,
+ steps: [
+ {
+ id: 'compliance-scoring',
+ title: 'Review Compliance Overview',
+ description:
+ 'Track compliance progress and open gaps from the dashboard',
+ cta: 'View Dashboard',
+ ctaHref: '/app',
+ icon: 'TrendingUp',
+ priority: 'high',
+ category: 'readiness',
+ estimatedMinutes: 10,
+ },
+ {
+ id: 'evidence-vault',
+ title: 'Verify Evidence Vault Coverage',
+ description:
+ 'Ensure all critical controls have supporting evidence uploaded and approved',
+ cta: 'Audit Evidence Vault',
+ ctaHref: '/app/vault',
+ icon: 'Archive',
+ priority: 'high',
+ category: 'readiness',
+ estimatedMinutes: 20,
+ },
+ {
+ id: 'audit-export',
+ title: 'Generate Audit Evidence Pack',
+ description: 'Export evidence bundles for internal review or accreditation',
+ cta: 'Generate Export',
+ ctaHref: '/app/reports',
+ icon: 'Download',
+ priority: 'critical',
+ category: 'readiness',
+ estimatedMinutes: 5,
+ },
+ {
+ id: 'auditor-sharing',
+ title: 'Prepare Reviewer-Ready Exports',
+ description: 'Package evidence for external review against the NSMHS',
+ cta: 'Open Reports',
+ ctaHref: '/app/reports',
+ icon: 'Share2',
+ priority: 'medium',
+ category: 'readiness',
+ estimatedMinutes: 5,
+ },
+ ],
+ },
+ ],
+};
+
/**
* HEALTHCARE / MEDICAL PRACTICE ROADMAP
*/
@@ -1721,6 +1962,7 @@ const DEFAULT_ROADMAP: IndustryRoadmap = {
*/
export const INDUSTRY_ROADMAPS: Record = {
ndis: NDIS_ROADMAP,
+ mental_health: MENTAL_HEALTH_ROADMAP,
healthcare: HEALTHCARE_ROADMAP,
aged_care: AGED_CARE_ROADMAP,
childcare: CHILDCARE_ROADMAP,
diff --git a/lib/supabase/org-scoped.ts b/lib/supabase/org-scoped.ts
index 3d72753ef..6fa64eb4d 100644
--- a/lib/supabase/org-scoped.ts
+++ b/lib/supabase/org-scoped.ts
@@ -143,6 +143,11 @@ const TENANT_TABLE_SCOPES = {
// R10 Phase 3 (audit 2026-05-27): BSP CRUD + weekly health snapshot.
org_behaviour_support_plans: { column: 'organization_id' },
org_compliance_health_snapshots: { column: 'organization_id' },
+ // Persisted compliance graph (audit 2026-06-01). Derived in
+ // lib/compliance-graph.ts; written via the service-role admin client,
+ // read via the session client (org-membership RLS SELECT).
+ graph_nodes: { column: 'organization_id' },
+ graph_wires: { column: 'organization_id' },
search_index: { column: 'org_id' },
recent_items: { column: 'org_id' },
search_history: { column: 'org_id' },
diff --git a/lib/system-state/server.ts b/lib/system-state/server.ts
index ae84f99c6..2d504ccaf 100644
--- a/lib/system-state/server.ts
+++ b/lib/system-state/server.ts
@@ -645,6 +645,11 @@ export function calculateModuleState(
}
if (
subscriptionStatus === 'canceled' ||
+ // Tolerate the legacy British spelling that admin-sync / account-delete
+ // wrote in the past; otherwise those canceled subs were mis-bucketed as
+ // active (audit: canceled-normalization). Cast: 'cancelled' isn't in the
+ // modelled status union, but legacy rows can still hold it at runtime.
+ (subscriptionStatus as string) === 'cancelled' ||
subscriptionStatus === 'pending' ||
subscriptionStatus === 'blocked'
) {
diff --git a/supabase/migrations/20260624074_audit_2026_06_01_org_last_retention_at.sql b/supabase/migrations/20260624074_audit_2026_06_01_org_last_retention_at.sql
new file mode 100644
index 000000000..f417bcf82
--- /dev/null
+++ b/supabase/migrations/20260624074_audit_2026_06_01_org_last_retention_at.sql
@@ -0,0 +1,21 @@
+-- Audit M8: data-retention cron round-robin cursor.
+--
+-- The nightly /api/cron/data-retention sweep bounds itself to 250 orgs per
+-- run. Previously it ordered by `id` ascending, so it processed the SAME
+-- first 250 orgs every night and never reached orgs ranked 251+. This adds
+-- a `last_retention_at` cursor the cron orders by (NULLS FIRST), and which
+-- `executeRetention` stamps when it finishes โ guaranteeing every org is
+-- swept over successive runs.
+
+ALTER TABLE public.organizations
+ ADD COLUMN IF NOT EXISTS last_retention_at timestamptz;
+
+-- Partial-friendly ordering index: NULLs (never swept) sort first, then the
+-- least-recently swept. Matches the cron's
+-- `order by last_retention_at asc nulls first, id asc`.
+CREATE INDEX IF NOT EXISTS organizations_last_retention_at_idx
+ ON public.organizations (last_retention_at ASC NULLS FIRST, id ASC)
+ WHERE is_active = true;
+
+COMMENT ON COLUMN public.organizations.last_retention_at IS
+ 'Last time the data-retention cron completed a sweep for this org. Used as a round-robin cursor so the per-run org cap cannot starve later orgs (audit M8, 2026-06-01).';
diff --git a/supabase/migrations/20260624075_audit_2026_06_01_compliance_graph_persistence.sql b/supabase/migrations/20260624075_audit_2026_06_01_compliance_graph_persistence.sql
new file mode 100644
index 000000000..b07fde5b8
--- /dev/null
+++ b/supabase/migrations/20260624075_audit_2026_06_01_compliance_graph_persistence.sql
@@ -0,0 +1,143 @@
+-- Audit 2026-06-01 โ persistent, queryable compliance graph.
+--
+-- Background: lib/compliance-graph.ts derived a GraphNode[]/GraphWire[]
+-- node-wire structure entirely in memory on every auth-callback, logged
+-- the counts, and discarded the result. There was NO graph table, so the
+-- "compliance graph" could never be queried, rendered, or audited after
+-- the request that built it.
+--
+-- This migration adds the two backing tables. Derivation stays in
+-- TypeScript (lib/compliance-graph.ts โ rebuildOrgGraph); WRITES go
+-- through the service-role admin client (which bypasses RLS), so the
+-- append-only RESTRICTIVE policies below close off direct mutation by
+-- `authenticated` session callers while still letting org members SELECT
+-- their own graph. READS go through the member-facing session client and
+-- are gated by the org-membership SELECT policy.
+--
+-- No current_setting('app.*') anywhere โ membership is checked via
+-- auth.uid() + an EXISTS over org_members (matches the convention in
+-- 20260624069_audit_2026_05_27_compliance_health_snapshots.sql).
+
+-- ---------------------------------------------------------------------------
+-- graph_nodes
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.graph_nodes (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
+ node_type text NOT NULL CHECK (
+ node_type IN ('organization', 'role', 'policy', 'task', 'evidence', 'audit', 'entity')
+ ),
+ source_id uuid NOT NULL,
+ label text,
+ metadata jsonb NOT NULL DEFAULT '{}',
+ created_by uuid,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ refreshed_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (organization_id, node_type, source_id)
+);
+
+CREATE INDEX IF NOT EXISTS graph_nodes_org_type_idx
+ ON public.graph_nodes (organization_id, node_type);
+CREATE INDEX IF NOT EXISTS graph_nodes_org_source_idx
+ ON public.graph_nodes (organization_id, source_id);
+
+-- ---------------------------------------------------------------------------
+-- graph_wires
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS public.graph_wires (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
+ from_node_id uuid NOT NULL REFERENCES public.graph_nodes(id) ON DELETE CASCADE,
+ to_node_id uuid NOT NULL REFERENCES public.graph_nodes(id) ON DELETE CASCADE,
+ wire_type text NOT NULL CHECK (
+ wire_type IN ('organization_user', 'user_role', 'policy_task', 'task_evidence', 'evidence_audit')
+ ),
+ metadata jsonb NOT NULL DEFAULT '{}',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ refreshed_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (organization_id, wire_type, from_node_id, to_node_id)
+);
+
+CREATE INDEX IF NOT EXISTS graph_wires_org_from_idx
+ ON public.graph_wires (organization_id, from_node_id);
+CREATE INDEX IF NOT EXISTS graph_wires_org_to_idx
+ ON public.graph_wires (organization_id, to_node_id);
+
+-- ---------------------------------------------------------------------------
+-- RLS โ ENABLE + FORCE on both, member SELECT, append-only for authenticated
+-- ---------------------------------------------------------------------------
+ALTER TABLE public.graph_nodes ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.graph_nodes FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.graph_wires ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.graph_wires FORCE ROW LEVEL SECURITY;
+
+-- Drop-if-exists so the migration is safe to re-run (CREATE POLICY is not
+-- idempotent; the rest of this file already uses IF NOT EXISTS).
+DROP POLICY IF EXISTS graph_nodes_select_org_members ON public.graph_nodes;
+DROP POLICY IF EXISTS graph_nodes_no_insert ON public.graph_nodes;
+DROP POLICY IF EXISTS graph_nodes_no_update ON public.graph_nodes;
+DROP POLICY IF EXISTS graph_nodes_no_delete ON public.graph_nodes;
+DROP POLICY IF EXISTS graph_wires_select_org_members ON public.graph_wires;
+DROP POLICY IF EXISTS graph_wires_no_insert ON public.graph_wires;
+DROP POLICY IF EXISTS graph_wires_no_update ON public.graph_wires;
+DROP POLICY IF EXISTS graph_wires_no_delete ON public.graph_wires;
+
+-- Org members may SELECT their own graph. Writes are service-role-only
+-- (rebuildOrgGraph via the admin client), so the table is append-only
+-- from the application's authenticated point of view.
+CREATE POLICY graph_nodes_select_org_members
+ ON public.graph_nodes
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.org_members m
+ WHERE m.organization_id = graph_nodes.organization_id
+ AND m.user_id = (SELECT auth.uid())
+ )
+ );
+
+CREATE POLICY graph_nodes_no_insert
+ ON public.graph_nodes
+ AS RESTRICTIVE FOR INSERT TO authenticated
+ WITH CHECK (false);
+
+CREATE POLICY graph_nodes_no_update
+ ON public.graph_nodes
+ AS RESTRICTIVE FOR UPDATE TO authenticated
+ USING (false) WITH CHECK (false);
+
+CREATE POLICY graph_nodes_no_delete
+ ON public.graph_nodes
+ AS RESTRICTIVE FOR DELETE TO authenticated
+ USING (false);
+
+CREATE POLICY graph_wires_select_org_members
+ ON public.graph_wires
+ FOR SELECT TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.org_members m
+ WHERE m.organization_id = graph_wires.organization_id
+ AND m.user_id = (SELECT auth.uid())
+ )
+ );
+
+CREATE POLICY graph_wires_no_insert
+ ON public.graph_wires
+ AS RESTRICTIVE FOR INSERT TO authenticated
+ WITH CHECK (false);
+
+CREATE POLICY graph_wires_no_update
+ ON public.graph_wires
+ AS RESTRICTIVE FOR UPDATE TO authenticated
+ USING (false) WITH CHECK (false);
+
+CREATE POLICY graph_wires_no_delete
+ ON public.graph_wires
+ AS RESTRICTIVE FOR DELETE TO authenticated
+ USING (false);
+
+COMMENT ON TABLE public.graph_nodes IS
+ 'Audit 2026-06-01: persisted compliance-graph nodes (organization|role|policy|task|evidence|audit|entity). Derived in TypeScript by lib/compliance-graph.ts rebuildOrgGraph and UPSERTed via the service-role admin client; org members read via RLS. Append-only for authenticated.';
+COMMENT ON TABLE public.graph_wires IS
+ 'Audit 2026-06-01: persisted compliance-graph wires (organization_user|user_role|policy_task|task_evidence|evidence_audit) connecting graph_nodes. Service-role writes via rebuildOrgGraph; org members read via RLS. Append-only for authenticated.';
diff --git a/tests/compliance/reports/gdpr-compliance-report.json b/tests/compliance/reports/gdpr-compliance-report.json
index 61d521d04..4a15a7bc6 100644
--- a/tests/compliance/reports/gdpr-compliance-report.json
+++ b/tests/compliance/reports/gdpr-compliance-report.json
@@ -1,102 +1,24 @@
{
- "timestamp": "2026-05-25T11:17:48.127Z",
+ "timestamp": "2026-06-01T09:59:06.396Z",
"compliance": {
- "dataProtection": [
- {
- "name": "Privacy Policy Accessibility",
- "passed": true,
- "details": "Privacy policy must be easily accessible"
- },
- {
- "name": "Data Processing Disclosure",
- "passed": true,
- "details": "Must disclose data processing activities"
- },
- {
- "name": "Data Controller Information",
- "passed": true,
- "details": "Must identify data controller"
- },
- {
- "name": "Legal Basis Declaration",
- "passed": true,
- "details": "Must declare legal basis for processing"
- }
- ],
- "userRights": [
- {
- "name": "Data Access Request Process",
- "passed": true,
- "details": "Users must be able to access their personal data"
- },
- {
- "name": "Data Deletion Process",
- "passed": true,
- "details": "Users must be able to delete their data"
- },
- {
- "name": "Data Portability",
- "passed": true,
- "details": "Must provide data portability options"
- },
- {
- "name": "Data Rectification",
- "passed": true,
- "details": "Users must be able to update their information"
- }
- ],
- "consent": [
- {
- "name": "Cookie Consent Banner",
- "passed": true,
- "details": "Must display cookie consent banner"
- },
- {
- "name": "Granular Consent Options",
- "passed": true,
- "details": "Must provide granular consent options"
- },
- {
- "name": "Consent Withdrawal",
- "passed": true,
- "details": "Must allow consent withdrawal"
- },
- {
- "name": "Marketing Consent Separate",
- "passed": true,
- "details": "Marketing consent must be separate and optional"
- }
- ],
+ "dataProtection": [],
+ "userRights": [],
+ "consent": [],
"security": []
},
"violations": [
{
- "category": "Security",
- "test": "HTTPS Enforcement",
- "error": "page.goto: net::ERR_SSL_PROTOCOL_ERROR at https://localhost:3000/\nCall log:\n - navigating to \"https://localhost:3000/\", waiting until \"load\"\n"
- },
- {
- "category": "Security",
- "test": "Secure Authentication",
- "error": "page.goto: Navigation to \"http://localhost:3000/login\" is interrupted by another navigation to \"chrome-error://chromewebdata/\"\nCall log:\n - navigating to \"http://localhost:3000/login\", waiting until \"load\"\n"
- },
- {
- "category": "Security",
- "test": "Session Security",
- "error": "page.goto: Navigation to \"http://localhost:3000/\" is interrupted by another navigation to \"http://localhost:3000/login\"\nCall log:\n - navigating to \"http://localhost:3000/\", waiting until \"load\"\n"
- },
- {
- "category": "Security",
- "test": "Data Breach Notification Process",
- "error": "page.goto: Navigation to \"http://localhost:3000/privacy\" is interrupted by another navigation to \"http://localhost:3000/\"\nCall log:\n - navigating to \"http://localhost:3000/privacy\", waiting until \"load\"\n"
+ "category": "Environment",
+ "test": "Application Availability",
+ "error": "Unable to reach http://localhost:3000: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/\nCall log:\n - navigating to \"http://localhost:3000/\", waiting until \"domcontentloaded\"\n"
}
],
"recommendations": [
- "4 technical violations found. Address these for full compliance."
+ "Start the app at http://localhost:3000 before running GDPR compliance tests."
],
"environment": {
"baseUrl": "http://localhost:3000",
- "available": true,
- "error": null
+ "available": false,
+ "error": "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/\nCall log:\n - navigating to \"http://localhost:3000/\", waiting until \"domcontentloaded\"\n"
}
}
\ No newline at end of file
diff --git a/tests/compliance/reports/soc2-compliance-report.json b/tests/compliance/reports/soc2-compliance-report.json
index b4af94a36..e53eb0726 100644
--- a/tests/compliance/reports/soc2-compliance-report.json
+++ b/tests/compliance/reports/soc2-compliance-report.json
@@ -1,121 +1,33 @@
{
- "timestamp": "2026-05-25T11:16:57.802Z",
+ "timestamp": "2026-06-01T09:59:07.059Z",
"controls": {
- "security": [
- {
- "name": "Authentication Requirements",
- "control": "CC6.1",
- "passed": true,
- "details": "Protected resources must require authentication"
- },
- {
- "name": "Authorization Controls",
- "control": "CC6.2",
- "passed": true,
- "details": "Admin resources must enforce proper authorization"
- },
- {
- "name": "Session Management",
- "control": "CC6.1",
- "passed": true,
- "details": "Must implement secure session management"
- },
- {
- "name": "Encryption in Transit",
- "control": "CC6.7",
- "passed": false,
- "details": "HTTPS not available on local baseUrl โ passes in production behind Vercel TLS"
- },
- {
- "name": "Input Validation",
- "control": "CC6.6",
- "passed": true,
- "details": "Forms must implement input validation"
- }
- ],
- "availability": [
- {
- "name": "System Health Monitoring",
- "control": "A1.2",
- "passed": true,
- "details": "System health monitoring must be operational (status: degraded)"
- },
- {
- "name": "Error Handling",
- "control": "A1.1",
- "passed": true,
- "details": "Must handle errors gracefully"
- },
- {
- "name": "Performance Monitoring",
- "control": "A1.2",
- "passed": true,
- "details": "Page load time: 14ms (should be < 5000ms)"
- },
- {
- "name": "Backup and Recovery Indicators",
- "control": "A1.3",
- "passed": true,
- "details": "Backup and recovery processes must be documented"
- }
- ],
- "processing": [
- {
- "name": "Data Validation",
- "control": "PI1.1",
- "passed": true,
- "details": "No data input forms found"
- },
- {
- "name": "Audit Trail",
- "control": "PI1.2",
- "passed": true,
- "details": "Audit logging must be implemented"
- },
- {
- "name": "Data Integrity Checks",
- "control": "PI1.1",
- "passed": true,
- "details": "Data integrity checks must be operational"
- }
- ],
- "confidentiality": [
- {
- "name": "Data Classification",
- "control": "C1.1",
- "passed": true,
- "details": "Data classification must be documented"
- },
- {
- "name": "Access Controls",
- "control": "C1.2",
- "passed": true,
- "details": "Role-based access controls must be implemented"
- },
- {
- "name": "Data Encryption",
- "control": "C1.1",
- "passed": "max-age=0",
- "details": "Security headers must be implemented"
- }
- ],
+ "security": [],
+ "availability": [],
+ "processing": [],
+ "confidentiality": [],
"privacy": []
},
- "violations": [],
+ "violations": [
+ {
+ "category": "Environment",
+ "control": "INFRA",
+ "test": "Application Availability",
+ "error": "Unable to reach http://localhost:3000: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/\nCall log:\n - navigating to \"http://localhost:3000/\", waiting until \"domcontentloaded\"\n"
+ }
+ ],
"recommendations": [
- "1 control tests failed. Address these for SOC2 compliance.",
- "Security controls need attention. Implement proper authentication and authorization."
+ "Start the app at http://localhost:3000 before running SOC2 compliance tests."
],
"environment": {
"baseUrl": "http://localhost:3000",
- "available": true,
- "error": null
+ "available": false,
+ "error": "page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:3000/\nCall log:\n - navigating to \"http://localhost:3000/\", waiting until \"domcontentloaded\"\n"
},
"summary": {
- "totalControls": 15,
- "passedControls": 14,
- "failedControls": 1,
- "violations": 0,
- "environmentStatus": "available"
+ "totalControls": 0,
+ "passedControls": 0,
+ "failedControls": 0,
+ "violations": 1,
+ "environmentStatus": "unavailable"
}
}
\ No newline at end of file
diff --git a/vercel.json b/vercel.json
index 1bab14e1d..ad146d2fa 100644
--- a/vercel.json
+++ b/vercel.json
@@ -65,6 +65,10 @@
{
"path": "/api/cron/process-dormant-user-purges",
"schedule": "0 8 * * 1"
+ },
+ {
+ "path": "/api/automation/cron",
+ "schedule": "0 * * * *"
}
],
"functions": {
@@ -86,6 +90,9 @@
"app/api/cron/process-dormant-user-purges/route.ts": {
"maxDuration": 300
},
+ "app/api/automation/cron/route.ts": {
+ "maxDuration": 300
+ },
"app/api/billing/webhook/route.ts": {
"maxDuration": 60
},