Skip to main content
Governance

Phase 2.1 — Multi-Tenant Isolation Remediation

Audience: internal audit, external auditors, Tier-1 customer compliance / risk functions, supervisory authorities. This chapter is intentionally stand-alone evidence — it can be extracted and circulated independently of the rest of the manual.

Scope: the platform's row-level security (RLS) and tenant-isolation surface across all tenant-scoped tables in public.*. Covers the work executed between 2026-06-10 and 2026-06-11 (manual versions 1.4.0 → 1.14.0).

Standards this record supports: ISO/IEC 27001:2022 Annex A controls A.5.34 (privacy and PII protection), A.8.16 (monitoring activities), A.8.31 (separation of development, test and production environments — applied here to tenant separation), A.8.33 (test information / data); EU DORA Article 12 (ICT-related incident reporting), Article 9 (protection and prevention).

1. Executive summary

A targeted hardening pass on the platform's RLS / tenant-isolation surface ran over 10 sequenced batches covering 32 tables in the public schema (every tenant-scoped table plus the six globally-referenced reference tables).

OutcomeCount
Distinct incidents identified8 (Incidents #1–6 from Phase 2.1 tenant-isolation sweep; Incidents #7–8 added 2026-06-11 during Phase 5.0 AI-governance inventory)
Incidents fully remediated7 (Incident #8 has compensating control shipped; full remediation tracked under Phase 5.3)
Incidents open with compensating controls1 (Incident #8 — see §3)
Items deferred for product/business decision0 (Tech-debt #8 — the only decision-pending item — closed v1.16.3 as Option A+; see §4 and chapter 14 §9). Scope note: this counter tracks items blocked on a product/business decision only. Items gated on future engineering work (Tech-debt #11–13, Findings #7/#8/#10, Phase 5.6 Client AI Connectors) are tracked in chapter 13 §6/§9 and are not counted here.
Tables now under automated regression harness32 (126 isolation checks)
Exploitation evidence observed (pre- or post-fix)None — see §5

Scope note (2026-06-11): Incidents #7 and #8 were surfaced during the Phase 5.0 AI-governance inventory rather than the Phase 2.1 tenant-isolation sweep. They are recorded here (rather than in a separate Phase 5 chapter) because both concern the integrity / provenance of evidence the platform produces, which is the same evidentiary substrate this chapter is the standing register for. A separate Phase 5 chapter will be added in Phase 5.5 covering AI-governance-specific incidents that arise during 5.1–5.4 execution; the cross-reference is intentional.

All six findings were latent integrity gaps: discovered through deliberate policy review during the hardening pass, not through customer report, monitoring alert, or third-party disclosure. The pre-fix corpus of the platform audit trail (audit_logs) was independently audited by scripts/audit-logs-integrity-check.ts after the most severe finding (Incident #5) was closed and returned 0 suspect rows across 4 forensic checks against 206 rows. No customer notification was indicated by this evidence.

A regression guardrail (the isolation harness, plus a CI lint blocking recurrence — Tech-debt #10, delivered as Phase 6) is in place to prevent re-introduction.

2. Severity rubric

Severity is mapped to the closest equivalent CVSS v3.1 base vector. Where the gap is reachable only by an already-authorised tenant member, the Privileges Required (PR) and Attack Complexity (AC) components dominate the score and the result lands in the Medium band even when the data-integrity impact is high. Where the gap is reachable by any authenticated user, the score rises to High.

BandCVSS rangeDefinition used here
High7.0 – 8.9Exploitable by any authenticated user with no prior role grant in the target tenant, OR produces fabricated audit-trail evidence that downstream regulator packs would consume without a tamper signal.
Medium4.0 – 6.9Exploitable only by an already-authorised tenant member; produces integrity / attribution drift within a single tenant.
Low0.1 – 3.9Latent path requiring a deactivated-but-not-purged auth user whose role grant survived membership deactivation; not reachable through the in-app UI today.

CVSS scores are internal estimates, not vendor-issued CVE entries. They are provided to give external readers a familiar yardstick.

3. Incident register

Incident #1 — tenant_members.is_active not enforced on tasks / evidence write paths

FieldDetail
SeverityLow (latent)
CVSS v3.1 estimate3.1 — AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N
Affected tablespublic.tasks, public.evidence
Date discovered2026-06-10 (Phase 2.1 batch 1 — deliberate policy review)
Date remediated2026-06-10
Remediating migrations20260610213858, 20260610214215
Description (plain language)The rules controlling who can edit tasks and evidence records did not check whether the user's tenant membership was still active. A user who had been removed from a tenant but whose underlying account and role grant had not been cleaned up could still modify rows that referenced them as assignee, or write evidence records, in that tenant.
Root causePolicies were written before the tenant_members.is_active flag became the canonical "still part of this tenant?" predicate and were not revisited when the is_tenant_member() helper was introduced.
RemediationBoth *_write policies rewritten to add is_tenant_member(auth.uid(), tenant_id) as the outermost predicate on USING and WITH CHECK.
Verificationtests/integration/tenant-isolation.tstasks and evidence both pass the full 4-check suite (read isolation, forged-INSERT rejection, cross-tenant UPDATE rejection, cross-tenant DELETE rejection).

Incident #2 — Same is_active-bypass class on 21 further tables

FieldDetail
SeverityLow (latent)
CVSS v3.1 estimate3.1 — AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N
Affected tablesincidents, controls, policies, risks, risk_treatments, ai_models, vendors, vendor_assessments, ai_approvals, access_request_approvals, access_requests, ai_impact_assessments, ai_risk_assessments, control_mappings, groups, group_members, group_roles, resources, security_policies, workspaces, case_share_links
Date discovered2026-06-10 (batch 2) — pattern then audited across all remaining tenant-scoped tables and confirmed as repository-wide
Date remediated2026-06-10 → 2026-06-11 (closed across batches 2–8)
Remediating migrations20260610214644, 20260610215315, 20260610215917, 20260610220655, 20260611074635, 20260611080256
Description (plain language)Same gap class as Incident #1, confirmed on 21 more governance, AI-governance, vendor, access-governance, and workspace tables. The case_share_links sub-case was distinct: its policies checked only the row's created_by, with no tenant-membership predicate at all — a deactivated owner could continue to enumerate or rotate share links scoped to a tenant they no longer belonged to.
Root causeSame as Incident #1 — a generation of policies written before is_tenant_member() existed.
RemediationAll 21 *_write policies rewritten to gate on is_tenant_member(...) alongside the existing role predicate. case_share_links additionally gained the membership predicate on every path (USING and WITH CHECK) on top of the existing owner check.
VerificationAll 21 tables covered by the isolation harness — 4/4 checks green each (except group_members, which is 3/3 due to a composite primary key and no mutable column — append-only by schema, equivalent coverage).

Incident #3 — Approval-chain approver_id not enforced by RLS (open as product decision)

FieldDetail
SeverityMedium (latent, within-tenant)
CVSS v3.1 estimate5.3 — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
Affected tablespublic.ai_approvals (column approver_id), public.access_request_approvals (column approver_id)
Date discovered2026-06-10 (batch 4 — ai_approvals); confirmed on access_request_approvals in batch 5
StatusClosed (v1.16.3, 2026-06-11). Decision recorded in chapter 14 §9: Option A+ approved by project owner — strict WITH CHECK (approver_id IS NULL OR approver_id = auth.uid()) shipped on both tables' INSERT and UPDATE policies; delegation-during-leave handled via the existing access_grants time-bounded role mechanism (ISO/IEC 27001 A.5.18; no new attribution infrastructure built). Acknowledged that the specific gap — any can_write user attributing an approval to another tenant member — is closed by this control and not by any other compensating control (audit-row truth, append-only audit, tenant isolation, and quarterly access reviews remain in force as defence-in-depth only). Sign-off: decision recorded by project owner — see manual revision history for date/version.
Description (plain language)Any role-bearing member of a tenant could write a row in either approval table attributing the approval decision to an arbitrary user within that tenant — the approver_id column was not constrained by RLS to equal auth.uid(). Both approval chains carry regulatory weight: AI model approvals back EU AI Act Article 14 human-oversight attribution, and access-request approvals back SoX-style segregation-of-duties evidence. Attribution accuracy in both is material.
Root causeRLS predicates were written to gate whether a user can record an approval in this tenant, not whose name the approval is recorded under. The distinction between "authorised to act" and "authorised to act AS this person" was not modelled.
Remediation (Option A+)Pre-check: 0 / 13 ai_approvals and 0 / 12 access_request_approvals had non-null approver_id mismatching the audit-row actor (in fact zero rows in either table currently have a non-null approver_id), so no retroactive violations. Migration drops aia_write and "ara write" (FOR ALL combined policies) and recreates them as three per-action policies each (INSERT, UPDATE, DELETE), preserving every other tenant/role gate exactly and adding (approver_id IS NULL OR approver_id = auth.uid()) to INSERT/UPDATE WITH CHECK — the IS NULL branch keeps the pending-row workflow intact (rows legitimately open with approver_id = NULL). Delegation-during-leave is the existing access_grants flow (chapter 3 runbook entry: "Delegating approval authority during leave").
Verification (live)tests/integration/sod-approver-self.ts — 4/4 PASS: INSERT with spoofed approver_id rejected on each table; UPDATE setting approver_id to another user's id rejected on each table. Postgres returned new row violates row-level security policy on all four attempts.
Compensating controls (now defence-in-depth, not primary)(1) the audit_logs trigger captures both the actor_id (true caller, from auth.uid()) and the new_data payload (including the claimed approver_id) on every write; (2) cross-tenant forgery of approver_id is blocked by Incident #6's enforce_same_tenant() trigger on access_request_approvals.request_id; (3) the access-governance review workflow surfaces decisions to a second reviewer; (4) quarterly access reviews. None of these prevent the forgery at write time; they only detect it after the fact. The new RLS predicate is now the primary control.

Incident #4 — access_requests SELECT leak to deactivated requester/subject

FieldDetail
SeverityLow (latent)
CVSS v3.1 estimate3.7 — AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N
Affected tablespublic.access_requests
Date discovered2026-06-10 (batch 5)
Date remediated2026-06-10
Remediating migrations20260610220655
Description (plain language)The read policy on access_requests exposed open request rows to the user named as the requester or subject of the request, without re-checking that the user was still an active member of the tenant. A user whose membership had been deactivated mid-flight could continue to read the state of their own request after offboarding.
RemediationRead policy rewritten to require is_tenant_member(auth.uid(), tenant_id) in addition to the requester/subject branch.
VerificationHarness access_requests 4/4 green.

Incident #5 — Cross-tenant audit-row forgery on audit_logs (headline)

FieldDetail
SeverityHigh
CVSS v3.1 estimate7.1 — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N (any authenticated user; integrity-only)
Affected tablespublic.audit_logs
Date discovered2026-06-10 (batch 6)
Date remediated2026-06-10
Remediating migrations20260610221659
Description (plain language)The rule controlling who could write to the platform's audit-trail table accepted any row from any authenticated user, regardless of which tenant the row claimed to belong to or which user it claimed to attribute the action to. In practice this meant an authenticated user from tenant A could in principle insert a forged audit-trail row tagged with tenant B's identifier and an arbitrary actor_id, undermining the evidence-pack integrity of every regulator-facing workflow that consumes audit_logs downstream.
Root causeThe original audit_insert policy was written as WITH CHECK (true) because all legitimate writes were expected to come via the tg_audit_row() SECURITY DEFINER trigger (which bypasses RLS as the postgres role). The fact that the policy also applied to direct writes from any authenticated user was not modelled.
Remediationaudit_insert policy rewritten to ((tenant_id IS NULL) OR is_tenant_member(auth.uid(), tenant_id)) AND ((actor_id IS NULL) OR actor_id = auth.uid()). The NULL branches preserve trigger-driven writes (which run as postgres, bypassing RLS) and SYSTEM rows.
Sub-finding (closed)A weaker shape of the same class — UPDATE policies on access_reviews and gdpr_requests had a USING clause but no WITH CHECK, allowing an authorised member to mutate tenant_id and move a row across tenants. Both regulator-facing evidence tables (periodic access reviews and GDPR subject-rights workflow). Closed in migration 20260611081109 (batch 9).
Independent corpus auditscripts/audit-logs-integrity-check.ts (Tech-debt #9) ran four forensic checks against the live corpus immediately after fix-out: unexpected action value, tenant referenced no longer exists, actor never a member of the referenced tenant, table never wired to the audit trigger. Result: 0 suspect rows across 206 audited rows. Customer notification not indicated.
VerificationHarness audit_logs 3/3 (append-only); access_reviews and gdpr_requests 4/4 each.

Incident #6 — Cross-tenant FK leak class (headline: user_roles.access_request_id)

FieldDetail
SeverityHigh (headline user_roles case); Medium (remaining 11 FKs)
CVSS v3.1 estimate6.4 — AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N (authenticated tenant member; integrity-only; high impact via fabricated access-governance evidence)
Affected tables12 child→parent foreign keys across 12 tables: access_request_approvals.request_id, ai_approvals.model_id, ai_impact_assessments.model_id, ai_risk_assessments.model_id, control_mappings.control_id, evidence.control_id, group_members.group_id, group_roles.group_id, resources.workspace_id, risk_treatments.risk_id, user_roles.access_request_id (headline), vendor_assessments.vendor_id
Date discovered2026-06-11 (scoping pass after batch 7; full class enumerated before batch 8)
Date remediated2026-06-11 (batch 10)
Remediating migrations20260611082547
Description (plain language)Twelve child→parent foreign key relationships across the platform's tenant-scoped tables did not validate that the child row and the parent row belonged to the same tenant. A role-bearing user in tenant A could create a child row tagged with tenant A's identifier whose foreign-key column pointed to a parent row owned by tenant B (if they happened to know the parent's identifier). The headline case — user_roles.access_request_id — meant a role grant in tenant A could have its justification (the access request that authorised it) point to an access request from tenant B. In the access-governance audit trail this is fabricated evidence: the row reads as a valid grant-with-justification but the justification is not actually for that tenant.
Root causeThe schema's foreign keys enforced referential integrity (the parent row exists) but not tenant-scoped referential integrity (the parent row belongs to the same tenant as the child). No existing trigger or helper in the schema enforced same-tenant for any FK.
RemediationGeneric SECURITY DEFINER trigger function public.enforce_same_tenant(parent_table, child_fk_column) introduced in 20260611082547. Attached BEFORE INSERT OR UPDATE OF <fk_col>, tenant_id on all 12 FKs. Triggering on tenant_id updates as well as the FK column closes the "move-the-child-to-the-other-tenant" variant. Raises with ERRCODE = 'check_violation' on violation. No-ops on NULL FK columns (only evidence.control_id is nullable in this set).
Why a trigger and not a CHECK constraintCHECK constraints in Postgres must be immutable; a "same tenant as parent" predicate requires a lookup of the parent row, which CHECK cannot perform. A trigger is the correct mechanism.
VerificationHarness covers the entire affected set; forged-INSERT regex extended to accept the trigger's cross-tenant rejection as a valid block (defence in depth on top of RLS, not a substitute).

Incident #7 — Transparency Report PDF export packaged fully-fabricated decision cases as audit-grade evidence

FieldDetail
SeverityHigh — CVSS-equivalent 7.5 (AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:N). Any authenticated user with Validator/Risk/Compliance/Admin role on the Fairness page could export an audit-styled PDF whose decision-case content is 100 % seed/mock data. The PDF carries no demo watermark, is named Regulai_Transparency_<CASE-ID>_<DATE>.pdf, and is visually indistinguishable from a future real Transparency Report. Risk: external evidence-pack misrepresentation.
Date discovered2026-06-11 (Phase 5.0 inventory)
Date remediated2026-06-11
Affected surfacesrc/components/fairness/QuickActionToolbar.tsx → "Generate Transparency PDF" action → src/lib/pdf/TransparencyPdf.tsx, fed entirely from useFairness() which is 100 % seed data (no from(), no useQuery, no server-fn calls). Static fallback at /downloads/transparency-report.pdf is also pre-built from the same seed corpus.
Root causeThe Fairness surface and its PDF export were shipped as a UI shell ahead of the bias/fairness telemetry pipeline (planned for Phase 5.3) and were never gated behind a "demo data — do not export" interlock. The export action was reachable from a production-styled UI with no visible distinction between seeded and live data.
Remediation"Generate Transparency PDF" action disabled at the UI with a persistent label: "Demo data — export disabled pending Phase 5.3". The underlying TransparencyPdf component, the static asset, and the recordPdfExport audit hook are intentionally left in place (no behavioural drift inside the build pipeline) — only the user-facing trigger is gated off. Re-enablement is conditional on Phase 5.3 connecting the surface to real fairness telemetry; the gate will be reopened in the same change that flips useFairness from seed-backed to telemetry-backed.
Why disable rather than watermarkA watermarked PDF is crop-removable and screenshot-removable; a disabled feature is not. The decision was: a disabled feature is safer than a real-looking PDF with a stamp on it that can be missed or removed downstream.
Compensating controls already in place(a) The Fairness page mock surface gains a persistent "Demo data" banner under Incident #8's remediation. (b) recordPdfExport continues to write to useFairness state for parity with the live-path implementation, so when the feature is re-enabled there is no audit-trail discontinuity.
VerificationManual: clicking "Generate Transparency PDF" on /app/fairness no longer opens the dialog or initiates a download. Visual: button is rendered in disabled state with explanatory label. Code: setOpen("pdf") path removed from the button handler; modal-render branch preserved but unreachable from the UI.

Incident #8 — AI-governance UI surfaces present seed data with no DB backing as if it were live evidence

FieldDetail
SeverityMedium — CVSS-equivalent 5.3 (AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N). Three production-styled routes (/app/fairness, /app/ai-assessments, /app/ai-workforce) render 100 % seed/mock data with no visible distinction from live records. Risk: screenshot-based misrepresentation in demos, sales material, or evidence packs. Lower severity than Incident #7 because there is no audit-formatted artefact (PDF) being produced; the risk is "screenshot looks real" rather than "PDF is real".
Date discovered2026-06-11 (Phase 5.0 inventory)
Date remediated (compensating control)2026-06-11
Affected surfaces & backing state(a) /app/fairnessuseFairness.ts (633 lines, all seed). (b) /app/ai-assessmentsuseAssessments.ts (635 lines, all seed; no rows read from ai_risk_assessments or ai_impact_assessments despite those tables existing and holding 10 + 10 real rows under tenant-scoped RLS). (c) /app/ai-workforceuseAIWorkforce.ts + agentSeeds.ts (140 + 82 lines, all seed; no backing ai_workforce* table exists in the schema).
Root causeThe AI-governance UI was shipped ahead of (and architecturally divergent from) the AI-governance schema. useAssessments is a 635-line custom domain model (RA-#### IDs, status workflow, framework tags, audit entries) that does not map onto the schema's ai_risk_assessments / ai_impact_assessments tables. The Workforce UI was shipped without ever having a backing table at all.
Remediation(a) Compensating control (shipped 2026-06-11): persistent "Demo data" banner over every seeded section on /app/ai-assessments; an additive, read-only panel was added that surfaces the 10 real ai_risk_assessments and 10 real ai_impact_assessments rows from the DB so that the live data is visible and distinguishable from the seeded UI. (b) Tracked for full remediation: the Fairness surface is gated by Phase 5.3 (real telemetry); the Assessments domain-model rebuild is tracked as a separate Phase 5.x item (Option B in the Phase 5.0 review) rather than being folded into PRE-5.1-b; the AI Workforce surface is out of scope for Phase 5 entirely (no AI traffic, no DB, no decisions of record) and will be evaluated separately.
Why a compensating control rather than full remediation nowRebuilding useAssessments against the existing schema is a Phase-5-sized batch in its own right (schema expansion: assessment_name, risk_category, risk_framework_tags, assessment_status, fria_fundamental_rights, audit-entry table). Doing it inside PRE-5.1-b would push out Phase 5.1 indefinitely. The compensating control (banner + live-DB panel) closes the "screenshot looks real" risk without taking on the schema work.
VerificationManual: every seeded section on /app/ai-assessments carries a visible "Demo data" banner; the new live-DB panel returns the 10 + 10 tenant-scoped real rows under RLS. Tenant-isolation harness coverage on ai_risk_assessments and ai_impact_assessments (4 checks each, both green) confirms the new read path is properly tenant-scoped.

Incident #9 — Chatbot edge function hardcoded to a model identifier no longer served by the Lovable AI Gateway

FieldDetail
SeverityHigh (operational) — CVSS-equivalent N/A (availability, not confidentiality/integrity). The Regur chatbot returned a generic "AI assistant request failed" (HTTP 502) for every non-attack request because MODEL_ID = "claude-sonnet-4-5" was rejected by the gateway with invalid model: claude-sonnet-4-5. Effective user-visible outage of the chatbot in both Knowledge and Training modes.
Date discovered2026-06-11 (Phase 5.2 end-to-end verification of llmGuard wrapping). The 502 was visible in the gateway response captured by the guard test but had been present prior to Phase 5.2 — the upgrade did not introduce it, only exposed it.
Date remediated2026-06-11
Affected surfacesupabase/functions/chatbot-message/index.ts (line 14: const MODEL_ID = "claude-sonnet-4-5"); call-site fields model_used in src/components/chatbot/ChatbotWidget.tsx (3 occurrences: anthropic/claude-sonnet-4-5 written into chatbot_audit_log).
Root causeModel identifier hardcoded against the Lovable AI Gateway's earlier supported model list. The gateway's supported-model set is curated and changes over time; claude-sonnet-4-5 was removed without breaking-change notification to consuming code. No monitoring alert was wired to the 502 response, so the chatbot failed silently from a UX point of view (rendered as INPUT REJECTED — POLICY VIOLATION adjacent surface or a generic toast).
RemediationMigrated to google/gemini-2.5-flash — a stable, non-preview, currently-supported model from the gateway's allow-list (verified inline against the gateway's own error message which echoed the full allow-list). Same change applied at all four call sites (1 edge function constant + 3 chatbot_audit_log writes). Comment added inline at MODEL_ID explaining why this identifier exists separately from the rest of the gateway calls.
VerificationEnd-to-end test against the deployed chatbot-message function: POST {message:"What is ISO 42001 Clause 6.1 about?", mode:"knowledge", user_role:"compliance", user_type:"internal"} returns HTTP 200, ok:true, modelId:"google/gemini-2.5-flash", well-formed Source: ISO 42001:2023 · Clause 6.1 citation, both guard.input.safe and guard.output.safe = true, latencyMs ≈ 11.4 s. Tested at 2026-06-11 11:06 UTC.
Follow-up control (Tech-debt #11)Add a CI / runtime probe that calls chatbot-message with a known-safe prompt and asserts HTTP 200 + ok:true. Currently the only way this class of regression surfaces is through user reports; a synthetic probe (weekly or per-deploy) would catch it within the same window as the Phase 6 audit-log integrity sweep.

Finding #10 — Core monitoring capability has no real data path (architectural)

FieldDetail
SeverityHigh (architectural / platform-maturity) — not a code bug; a gap between the product's stated purpose and its currently-shipped capability. Regurai is positioned as a monitor of the bank's AI systems (credit scoring, fraud detection, etc.); prior to Phase 5.3 there was no ingestion path — API, webhook, SDK, batch import — by which any external AI system could push decision data into Regurai. Every fairness chart, parity Δ, SHAP attribution, and Transparency Report rendered to date has been derived from useFairness's client-side synthetic generator. This is the conceptual root cause of which Findings #7 and #8 are downstream symptoms.
Date discovered2026-06-11 (Phase 5.3 scoping pass).
Date partially remediated2026-06-11 (the ingestion pipe and read path ship in Phase 5.3; the pipe will remain empty until a client integrates).
Affected surfaceConceptually: the entire AI-fairness, AI-workforce, and AI-assessments surface area. Concretely closed in 5.3: the fairness surface gains a real ingestion path; Workforce and Assessments remain mock-backed and continue to carry the demo banner.
Root causeThe product was built front-end first; the demo surface preceded the monitoring substrate and was never replaced by it. No single engineer made a wrong decision — the gap accumulated across the product's history.
Remediation (Phase 5.3)(a) POST /api/public/hooks/fairness-decisions ingestion route with per-tenant HMAC-SHA256 authentication. (b) public.fairness_ingest_secrets table (admin-only RLS; secret column stripped from authenticated SELECT via column grants — service-role-only readable). (c) Zod input validation including a hard allow-list for protected_group labels (age_band_*, gender_*, ethnicity_band_*, region_*, income_band_*, tenure_band_*, employment_status_*) — rejects raw special-category data at the schema layer (GDPR Art.9). (d) Defence-in-depth PII regex sweep on the full payload rejecting SSN/IBAN/email/credit-card shapes. (e) LiveFairnessPanel on /app/fairness reads fairness_decisions WHERE is_seed=false under tenant-scoped RLS and renders an explicit empty-onboarding state — never falls back to seeded data. (f) Persistent FairnessDemoBanner above all seeded sections labels them as demo. (g) Transparency PDF re-enablement gates implemented in evaluatePdfGates() (≥200 decisions in 90d, ≥30 per protected group, ≤14d recency, zero seed contamination).
What 5.3 does NOT doClient-side SDKs, batch/CSV import UI, polling-from-Regurai connectors, schema discovery, and real-time streaming are explicitly out of scope and deferred to Phase 5.6 — Client AI Connectors. AI Workforce and Assessments domains retain their compensating controls; full remediation tracked under Phase 5.x (Option B).
Open untilA real client integrates. Until then the pipe exists but stays empty by design — this is the correct state, and the empty-state UX makes that explicit.

| Tech-debt #12 | Transparency PDF re-enablement thresholds (≥200/90d, ≥30/group, ≤14d, zero seed) are initial engineering defaults, not regulatory floors. Revisit once real client integrations exist and produce typical decision volumes. Document any change here with rationale. | Low (process) | AI Governance | Phase 5.6 (Client AI Connectors) follow-up | AI Governance |

4. Open items (will NOT be silently resolved in later phases)

IDItemSeverityTypeTarget phaseOwner
Tech-debt #8approver_id enforcement on ai_approvals + access_request_approvals — see Incident #3 aboveClosed (Medium)ClosedClosed v1.16.3 — Option A+ shipped. Decision recorded in chapter 14 §9: strict (approver_id IS NULL OR approver_id = auth.uid()) WITH CHECK on both tables; delegation-during-leave routed through existing access_grants time-bounded role (ISO/IEC 27001 A.5.18). Harness tests/integration/sod-approver-self.ts 4/4 PASS. Runbook entry added in chapter 3. The compensating controls (audit-row truth, append-only audit, tenant isolation, access reviews) remain as defence-in-depth only — they do not prevent the forgery at write time and were not the basis of closure. Sign-off: decision recorded by project owner — see manual revision history for date/version.Product + Compliance (closed)
Tech-debt #9scripts/audit-logs-integrity-check.ts integrity tool — needs to run as a planned recurring control, not only as a one-shot post-fix audit. Suggested cadence: weekly (lightweight read-only sweep against the audit-trail corpus; SYSTEM-row summary persisted back into audit_logs for chain-of-custody). Worker-compatible endpoint (/api/public/hooks/audit-logs-integrity-check) and SECURITY DEFINER summary RPC (public.audit_logs_integrity_summary()) have been delivered as part of Phase 6; pg_cron wiring + runbook entry remain a follow-up Operations chapter pass.Low (preventive)Operations runbookPhase 7 (Operations doc pass)Operations
Tech-debt #10CI lint blocking (a) WITH CHECK (true) on public-schema policies and (b) UPDATE/ALL policies with USING but no matching WITH CHECK. Scope widened in 1.13.0 after the batch-9 sub-finding under Incident #5.High (preventive)Engineering / CIPhase 6 (in progress)scripts/lint-rls-policies.ts + rls-lint CI job delivered, baseline of 4 known historical/intentional cases frozen with explicit reasons.Platform Security
Incident #7 (re-enablement gate)Transparency PDF export is disabled at the UI pending Phase 5.3. The gate must be re-opened only in the same change that flips useFairness from seed-backed to telemetry-backed; re-enabling earlier reverts the remediation.High (process)Engineering / ProductPhase 5.3AI Governance
Incident #8 (full remediation)Compensating control (demo banner + read-only live-DB panel on /app/ai-assessments) shipped 2026-06-11. Full remediation requires (a) Phase 5.3 to back the Fairness surface with real telemetry, and (b) a separate Phase 5.x Option B batch to rebuild the Assessments domain model against the schema. AI Workforce surface remains out of Phase 5 scope and is tracked separately.MediumEngineeringPhase 5.3 (Fairness) + Phase 5.x (Assessments rebuild — Option B)AI Governance
Tech-debt #11Synthetic chatbot health probe — call chatbot-message with a known-safe prompt and assert HTTP 200 + ok:true + modelId in supported list on every deploy and on a weekly schedule. Closes the silent-failure window that hid Incident #9.Medium (preventive)Engineering / OpsPhase 7 (Operations doc pass) alongside Tech-debt #9 cron wiringPlatform Security + Operations
Tech-debt #13Admin UI to create / rotate / deactivate fairness_ingest_secrets (proposed component CreateIngestSecretDialog, gated to is_admin). Today the only way to provision an ingest secret is a manual DB insert by an operator with service-role access — acceptable while no client integration is live, not acceptable as a permanent state once Phase 5.6 (Client AI Connectors) begins onboarding. Recommended priority: build before the first real integrator is lined up, not on a fixed phase date. Until then the gap is documented here and the ingestion endpoint remains operational via the manual path.Medium (process)EngineeringPhase 5.3.1 — defer until first real integratorAI Governance
Tech-debt #14The defence-in-depth PII regex in /api/public/hooks/fairness-decisions catches SSN / IBAN / email / credit-card shapes inside features_redacted, but does not catch raw ISO-8601 date strings (e.g. "1990-05-12"). Closed in v1.16.2 — added bounded date patterns (YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, DD.MM.YYYY, MM/DD/YYYY, all year-constrained to 1900–2099) to the sweep. Re-verified end-to-end: all three date formats return HTTP 422; happy path and allow-listed group labels confirmed not false-positive. The broader structural validation (typed features_redacted values, SDK-side enforcement) remains in Phase 5.6.Closed (v1.16.2)EngineeringClosedAI Governance

5. Exploitation evidence

SourceResult
scripts/audit-logs-integrity-check.ts against live audit_logs corpus (206 rows)0 suspect rows across all 4 forensic checks.
Customer support tickets in the period 2026-06-01 → 2026-06-11No reports of cross-tenant data visibility, attribution mismatch, or audit-trail anomaly.
Third-party disclosureNone.

Customer notification was not indicated by this evidence. No SEV-1/SEV-2 incident was opened under the §10 incident response workflow.

6. Regression-prevention controls in place

  1. Automated isolation harnesstests/integration/tenant-isolation.ts, 32 tables × up-to-4 checks = 126 isolation assertions, executed end-to-end per CI run. Covers SELECT isolation, forged-INSERT rejection, cross-tenant UPDATE rejection, cross-tenant DELETE rejection.
  2. enforce_same_tenant() generic trigger — reusable defence-in-depth for any future child→parent FK. Attach with two lines of SQL.
  3. CI RLS-policy lint (Tech-debt #10, Phase 6) — scripts/lint-rls-policies.ts blocks WITH CHECK (true) and USING-without-WITH CHECK patterns on every PR.
  4. Scheduled audit_logs integrity check (Tech-debt #9, scheduled weekly once Operations runbook wiring lands) — public.audit_logs_integrity_summary() RPC + /api/public/hooks/audit-logs-integrity-check endpoint.

7. Document control

FieldValue
Document ownerSecurity team
Manual version at sign-off1.14.0
Sign-off date2026-06-11
Scope window2026-06-10 → 2026-06-11
Independent re-test cadenceEvery CI run (bun run tests/integration/tenant-isolation.ts --all)
Cross-referencesChapter 10 (Change Management) — batch-by-batch release notes, full ADRs, technical debt register; Chapter 5 (Security & Compliance) — control-framework mapping.