Skip to content
PrivacyAutomated.ai Taking you from zero to privacy™
Features How it works Tour Resources Pricing FAQ
Log in Get started

← Back to Security & Trust

For technical buyers

Trust Architecture

An AI privacy compliance tool is only as good as the engineering invariants it actually holds — not the ones it markets. This page is the reference for those invariants: thirteen properties we engineer for, the code paths where they live, and how a procurement team can verify each one independently.

Thirteen invariants we engineer for
  • Tenant isolation is enforced by the database, not the application.
  • The audit log is append-only at the schema level — no API path can mutate it.
  • A daily Merkle root of every audit event is published to a public GitHub repository, signed with our own Ed25519 key, and anchored to two independent public-good transparency systems (OpenTimestamps → Bitcoin, and Sigstore Rekor) so the chain has third-party time-stamps outside our control.
  • DSAR identity verification uses cryptographic tokens with race-protected state transitions.
  • Citations are validated against the actual retrieval set — hallucinated chunk references are stripped before they reach the user.
  • Grounding is verified by a separate judge model that re-reads only the cited chunks and asks "do these support the claim?"
  • Confidence is a composite of three independent signals, structured so the absence of valid grounding reduces the maximum achievable score by exactly the citation-validity weight — a tendency, not a hard guarantee; the hard guarantee that an unsupported answer cannot auto-deliver lives in the judge-model invariant above.
  • The off-site backup is restored every week and the audit chain inside the restored copy is walked — "we have backups" converted from a probability into a fact.
  • Every LLM call is captured verbatim — system prompt, message list, request parameters, raw response — so any answer the product produced can be replayed against the same model.
  • Every system prompt is hash-pinned in a public registry and the hash is recorded on each LLM call, so any captured response is traceable back to the exact prompt that produced it.
  • Every production image is built in CI under SLSA Build Level 3 provenance, signed by GitHub’s OIDC identity through Sigstore Fulcio, and recorded in Sigstore Rekor — so the supply chain that puts images on the production host is independently inspectable, not asserted.
  • No AI-authored output becomes a regulatory record without an authenticated human signing it off. Drafts and signed determinations live in two separate tables; the schema CHECK constraint refuses any signed row whose signed_off_by_user_id is null or whose citations array is empty.
  • The conflict-detection feature is gated on a per-jurisdiction external UPL review and can only cite from a curated controlling-texts corpus shipped inside the SLSA-attested image. The reviewed-jurisdictions manifest and the corpus are both anonymously fetchable.
The regulator’s entry point

The thirteen invariants below substantiate the substrate; the place a regulator, auditor, or opposing counsel actually uses it is the public verifier portal at app.privacyautomated.ai/verify. They drop the signed JSON of an evidence packet they received from a customer (a closed DSAR, an approved DPIA), and get back, in plain language: “This record is authentic. Sealed on <date> for <controller>. Anchored to Bitcoin block X.” No account, no API key, no contact with PrivacyAutomated required.

The verifier exposes three drill-downs ordered by setup cost, the same pattern as Invariant 3’s Bitcoin verification ladder: read the verdict (zero setup) · check our side offline with openssl pkeyutl · verify the Bitcoin anchor independently via OpenTimestamps against a Bitcoin RPC of your choice. The URL is printed on the first page of every signed PDF a customer sends so the seal is self-routing — the regulator doesn’t need a separate email telling them where to go.

1Tenant isolation is enforced by the database

Every tenant table has PostgreSQL Row-Level Security with both ENABLE and FORCE ROW LEVEL SECURITY, and the application connects as a non-superuser, non-BYPASSRLS role. For any tenant table that has the policy applied, a developer who forgets to add WHERE workspace_id = ? to a query gets back zero rows, not another tenant's data.

Most multi-tenant SaaS isolates customer data in application code. That works until a single missing filter leaks one tenant's data to another. We move the enforcement boundary one layer down: into Postgres itself.

Every API request enters a workspace-scoped session (db.workspace_session(workspace_id)) that sets app.current_workspace on the connection. Every tenant table has an RLS policy that reads that variable. Read paths are filtered by USING; write paths are gated by WITH CHECK so a misrouted write cannot land in another tenant's row. FORCE ROW LEVEL SECURITY means the policy applies even when the connection role owns the table — we do not rely on the app role being non-owning to provide isolation. The app role (app_rw) is non-superuser, non-BYPASSRLS, non-createdb, and non-createrole; the privileged postgres role is used only by migrations.

How to verify: ask for a database role-based audit. On a Postgres console:
  • \\d+ documents (or any tenant table) and confirm the policy reads (workspace_id = (current_setting('app.current_workspace'::text, true))::uuid);
  • SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'app_rw' returns f | f;
  • SELECT relforcerowsecurity FROM pg_class WHERE relname = '<table>' returns t for every tenant table;
  • read apps/api/tests/test_rls_writecheck_integration.py and apps/api/tests/test_tenancy.py, both of which exercise the isolation against a real Postgres instance in CI.
What we don't claim: we don't yet have an automated CI check that fails when a new migration adds a tenant table without an RLS policy. New tables are caught by code review and the integration tests above; a regression-fail-on-add CI step is on the roadmap.

2The audit log is append-only and tamper-evident at the schema level

The audit_events table has a database trigger (audit_no_update, function audit_immutable()) that raises on any UPDATE or DELETE before the write touches a row. The trigger applies to every database role while the trigger is enabled — including the application role (app_rw) and the table owner. A Postgres superuser can disable the trigger, perform an UPDATE/DELETE, and re-enable it; that is a property of Postgres, not a bug. The prev_hash/row_hash columns and the SHA-256 chain of prev_hash | workspace_id | event_type | payload exist so that tampering by a superuser is detectable on the next run of the chain-integrity verifier — a tampered row's recomputed hash will not match the stored hash, and the chain link to the next row will break. The property the system gives you is tamper-evident, not tamper-proof, which is what regulators and auditors typically require — nothing reasonably can prevent a superuser from writing to a Postgres database, but the system can make their changes provably visible.

Every regulatory-relevant state transition — DSAR verified, DPIA approved, vendor added, document deleted, plan changed — writes an audit_events row with actor identity, action name, entity type, entity id, and a timestamp. The write happens in the same database transaction as the state change, so it cannot be skipped or batched out.

We treat deletion as a tombstone, not a removal: when a customer deletes a document or a workspace, the audit trail of operations on it survives. This is what makes the trail regulator-defensible — the trail must outlive the data it describes.

How to verify: on a Postgres console,
  • SELECT tgname, pg_get_triggerdef(oid) FROM pg_trigger WHERE tgrelid = 'audit_events'::regclass AND NOT tgisinternal returns the audit_no_update trigger definition (BEFORE DELETE OR UPDATE, executes audit_immutable());
  • UPDATE audit_events SET payload='{}' WHERE id='<any-id>' executed as any role raises — the trigger fires before privilege checks for the table owner;
  • SELECT column_name FROM information_schema.columns WHERE table_name = 'audit_events' AND column_name IN ('prev_hash','row_hash') returns both columns;
  • read apps/api/src/audit/logger.py — _row_hash() computes the SHA-256 over the chained payload.
What we now verify: a Celery beat task (audit.verify_chain_integrity, defined in apps/api/src/audit/integrity.py) re-walks every workspace's audit chain daily at 04:30 UTC. For each row it recomputes the SHA-256 hash and asserts the link to the previous row. The verifier distinguishes race signatures (the row's internal hash is self-consistent but its chain link is wrong — a producer-side concurrent-write race) from tamper signatures (the row's internal hash doesn't recompute — the content or hash has been altered). Tamper findings page on-call; race findings warn. Either kind is recorded as a chain_integrity_verified audit event in the same ledger — meaning the verifier's own runs extend the chain forward, and an operator can see "verified through <date>" by reading the most recent verification row.

Day-one disclosure. The verifier's first production run (June 3, 2026) found 60 chain breaks in our own dogfood workspace. All 60 had the race signature (recompute proves the rows are internally self-consistent). Cause: write_audit previously selected the most-recent row's hash without serializing concurrent writes per workspace, so two near-simultaneous writes could chain off the same predecessor. We shipped a pg_advisory_xact_lock the same day; the integration test test_audit_concurrent_writes_integration.py proves 50 threads writing 4 rows each now produce an intact chain.

Second hardening pass (June 4, 2026). A targeted re-audit found a second narrow window the advisory lock alone didn't close: audit_events.created_at defaulted to transaction_timestamp(), which is constant within a transaction. Two rows written in the same transaction, or two transactions BEGIN-ing in the same microsecond, could share created_at — and the writer's “find previous row” SELECT then disagreed with the verifier's (created_at, id) walk order. We migrated the column default to clock_timestamp() (per-statement microsecond resolution) and added an id tiebreaker to the writer's SELECT to match the verifier exactly. The 200-row concurrent-write test now passes deterministically. See migration 0042_audit_clock_timestamp for the full rationale.

The 60 historical findings remain in the immutable ledger because the design prevents us from deleting them — which is also the point. Future verifier runs will continue to surface those 60 with the race verdict. All verifier runs since both fixes landed have reported zero new race signatures. The observation window is short — we will state this empirically as “no new races over N verifier runs / M days” once the verifier has accrued that history.

Customer-pullable surfaces. The verifier's most recent verdict is exposed at GET /audit/chain-status, rendered as a dashboard tile in-app (emerald / amber / rose by race-vs-tamper count). And buyers who want to keep their own offline copy of the audit ledger can hit GET /audit/export-signed (Enterprise plan): the workspace's chain comes back as a zip with the audit rows in NDJSON, a detached Ed25519 signature, the verification key in PEM, and a VERIFY.md with the exact openssl pkeyutl -verify one-liner. That bundle verifies offline against the same key whether or not PrivacyAutomated is still running — addressing the "what if the vendor disappears?" concern directly. The signing key's SHA-256 fingerprint is below.
Signed-export verification key
Algorithm: Ed25519, SubjectPublicKeyInfo, SHA-256 fingerprint.

The currently-advertised fingerprint and its rotation history are served from the public manifest at app.privacyautomated.ai/api/keys/signing (look for purpose = "audit_export"). The manifest preserves superseded keys with their validity dates, so an artifact signed under a now-rotated key still verifies against its historically valid fingerprint. The PEM itself is at /api/audit/verification-key.

If the fingerprint in your export bundle does not appear anywhere in the manifest (current or superseded), email support@privacyautomated.ai to reconcile.

3A daily Merkle root of every audit event is published to a public GitHub repository

At 05:00 UTC every day, the production deployment computes an RFC 6962 SHA-256 Merkle root over every audit_events row written across every workspace during the just-closed UTC day, signs it with a separate Ed25519 key, and publishes the signed JSON to a public GitHub repository we cannot silently rewrite: github.com/privacyautomated/audit-transparency-log .

The customer story this enables. The signed export from Invariant 2 proves that the bytes in your zip are exactly what came out of our database at export time. It does not prove that the database itself wasn’t quietly rewritten before your export. The transparency log closes that gap: if we ever rewrote yesterday’s audit log, the published root for that day was already committed to a third-party-witnessed Git history. Rewriting it would require a force-push that anyone watching the repo (customer, regulator, journalist, GitHub’s own commit-graph APIs) can detect.

RFC 6962, not roll-your-own. Same SHA-256 Merkle tree construction Certificate Transparency uses; same domain-separation prefixes (0x00 for leaves, 0x01 for inner nodes); same largest-power-of-two split rule. A customer can verify with any off-the-shelf RFC 6962 library; we are not asking anyone to trust a bespoke hash scheme. Unit tests against the spec’s known-answer values are at apps/api/tests/test_audit_transparency.py.

Separate signing key from the export bundle key. The export-bundle key proves “came from our DB at export.” The transparency key proves “was the committed state at midnight.” Different statements, different keys — a compromise of one does not compromise the other. The signed bytes also carry a PA-TRANSPARENCY-V1\n domain-separation prefix, so even if the keys were ever reused a transparency-root signature could not be replayed as an export-bundle signature.

Verify a published root with three curls.
curl -s https://raw.githubusercontent.com/privacyautomated/audit-transparency-log/main/roots/2026-06-02.json -o root.json
curl -s https://raw.githubusercontent.com/privacyautomated/audit-transparency-log/main/transparency-key.pem -o key.pem
python3 -c '
import base64, json
from cryptography.hazmat.primitives.serialization import load_pem_public_key
doc = json.load(open("root.json"))
sig = base64.b64decode(doc.pop("signature_ed25519_base64"))
doc.pop("signing_key_fingerprint_sha256")
canonical = json.dumps(doc, sort_keys=True, separators=(",", ":")).encode()
load_pem_public_key(open("key.pem","rb").read()).verify(
    sig, b"PA-TRANSPARENCY-V1\n" + canonical
)
print("verified:", doc["merkle_root_sha256"])
'
Update (2026-06-05): the v1 honesty list shortened. Two items previously listed under "what we have not yet built" landed this week:
  1. Self-serve inclusion-proof endpoint — live. GET /api/audit/inclusion-proof/{event_id} returns an RFC 6962 audit path that proves the named event is committed to the day’s published Merkle root. Workspace-scoped (so a customer never learns events in another tenant) and offline-verifiable — the proof references neighbour leaves by SHA-256 hash only, so a customer holding their own signed export bundle reconstructs and verifies without any further round-trip to us. The response includes the leaf-input recipe, the audit path, the published root hash, and a pointer at the canonical public root file in the audit-transparency-log GitHub repo.
  2. OpenTimestamps anchoring — live as of 2026-06-06. Every daily signed root is submitted to three independent OpenTimestamps calendar servers (a.pool, b.pool, finney). Each calendar batches our digest into its per-second Merkle tree and commits the tree root to the Bitcoin blockchain via an OP_RETURN transaction. The resulting .ots receipt is written to disk alongside the signed JSON and committed to the public audit-transparency-log repo. After the first Bitcoin confirmation lands (~10 min to 6 hr), a scheduled ots upgrade pass rewrites each receipt in place with a complete Bitcoin block proof. Once upgraded, a verifier can confirm the root’s existence at a specific Bitcoin block height without trusting us, the calendars, or GitHub — only the Bitcoin network. An un-upgraded receipt still verifies, but only against the calendars; the upgrade step is what makes the Bitcoin-only claim true.

    First receipt: 2026-06-05.json.ots (byte-identical mirror at github.com/privacyautomated/audit-transparency-log). As of 2026-06-06 the upgrade pass has run. The receipt embeds two BitcoinBlockHeaderAttestation entries — block 952577 and block 952595. The two Bitcoin transactions our digest rides into block 952577 are f3972dfc… (via the alice.btc calendar) and 2cc5b109… (via the bob.btc calendar). The block 952595 attestation comes from a separate path in the same receipt; the finney calendar (eternitywall.com) path remains calendar-pending across subsequent upgrade passes (re-checked 2026-06-06 from a clean shell). Two of three calendars Bitcoin-bound is the steady state for this receipt and likely will be: eternitywall’s aggregation cadence is slower than the OpenTimestamps calendars by design, and we deliberately submit to three calendars precisely so any one being slow or out doesn’t block the proof — the two confirmed Bitcoin transactions above are independently sufficient. The pending finney line in ots info is therefore a belt-and-suspenders artifact, not a missing proof.

    How a reviewer checks it. Three paths, ordered by setup cost. Pick whichever matches what you have on hand.
    1. Block explorer — zero setup. The two Bitcoin transactions above resolve to mempool.space. Each transaction's OP_RETURN output commits to a calendar's Merkle tree, and our digest is a leaf in that tree. Existence of the txs at the named blocks is the Bitcoin-network attestation; the next two paths add the cryptographic linkage from our digest to those tx outputs.
    2. ots info — no Bitcoin node required. pip install opentimestamps-client then ots info 2026-06-05.json.ots prints the receipt's full proof tree, including every BitcoinBlockHeaderAttestation(N) and the commitment path from our digest up to each block. Offline-verifiable: confirms our side of the chain (digest → calendar batch → tx commitment) without contacting Bitcoin at all.
    3. ots verify — requires a Bitcoin node. ots verify 2026-06-05.json.ots -f 2026-06-05.json runs both checks (our side and Bitcoin's side) end-to-end and exits zero on success. The default invocation reads ~/.bitcoin/.cookie for a local Bitcoin Core RPC; if you don't run a node, pass --bitcoin-node http://<public-rpc>:8332 pointing at any Bitcoin node you trust. Without either, the command exits with “Could not connect to Bitcoin node” — that means the verifier couldn't reach Bitcoin, not that the receipt is broken; use ots info instead.
    This closes the “Git history is still GitHub-mediated” gap that earlier versions of this page disclosed.
  3. Sigstore Rekor anchoring — live as of 2026-06-06. Every daily signed root is also submitted to Sigstore Rekor as a hashedrekord entry — the public-good transparency log run by the Linux Foundation, the same log our SLSA Build Level 3 build attestations land in (see Invariant 11 below for the build pipeline; this sub-item is the audit-log Merkle-root channel). This adds a second independent witnessing channel alongside the OpenTimestamps anchor above: if either Bitcoin or Sigstore fails the way an unlikely catastrophe might, the other holds.

    We sign each daily root’s SHA-256 with a dedicated ECDSA P-256 key (separate from the Ed25519 transparency key that signs the root JSON itself — ECDSA is the well-trodden Sigstore path; signing the digest under utils.Prehashed(SHA256) avoids the cryptography-library double-hash gotcha that costs Rekor a 400). The receipt sidecar <date>.json.rekor.json is committed to the same public log alongside the signed JSON and the OpenTimestamps receipt; it carries the Rekor UUID, log index, integrated time, signed tree head, and inclusion proof.

    First receipt: 2026-06-05.json.rekor.json (byte-identical mirror at github.com/privacyautomated/audit-transparency-log). Rekor UUID 108e9186e8c5677ad025d406a338e06213fdc244e5c7cdcecd6ff849770efb3e82c151f7f7e56f2c at logIndex 1740024874; cross-check the entry at rekor.sigstore.dev/api/v1/log/entries/<uuid> and, with the CLI installed, run rekor-cli verify --uuid <uuid> (NOT just get) so the inclusion proof against Rekor’s current signed tree head is checked, not merely the entry retrieved. Anonymous; no authentication. Re-run from a clean shell on 2026-06-06 returns exit 0 with the full Merkle inclusion proof printed.

    Whose key signed the hashedrekord entry? The entry itself carries the signer’s public key inline. For the witnessing claim to be PrivacyAutomated’s — not merely “someone signed our digest” — a reviewer cross-checks the embedded key against our published key. The Rekor signing key has the same key-manifest treatment as the Ed25519 transparency key: enumerated at /api/keys/signing under purpose = "rekor_anchor", PEM at /api/audit/rekor-key, fingerprint 4fdc56a8db8084c3fbb2e51def89155080c42bdbc397eeaf05d7d90466a3b713 (SHA-256 of the DER SubjectPublicKeyInfo). The embedded spec.signature.publicKey.content inside the live Rekor entry decodes to a PEM whose SHA-256 fingerprint equals exactly the above — verified end-to-end 2026-06-06. Without this cross-check the Rekor channel would prove integrity but not provenance; with it, the channel is self-rooting in the same sense the Bitcoin channel is (Bitcoin needs no key trust at all; Rekor needs the reviewer’s one-shot key fingerprint check).

    This closes the second "Still on the roadmap" item earlier versions of this page disclosed.
  4. Self-verification — the page checks itself, every 6 hours. The 2026-06-06 publish-script regression (see footer item (6) for the full forensic account) clobbered the showcase Bitcoin-bound .ots receipt for several hours before manual re-reading caught it. The substrate held but the published artifact briefly diverged from the prose claim. That gap is now operationalized: a host script (scripts/verify_trust_claims.sh, SLSA-attested in the same chain Invariant 11 describes) re-fetches every load-bearing artifact this page names by exact byte — receipt size, SHA-256, both Bitcoin block heights, both transaction ids, Rekor UUID, logIndex, embedded publicKey fingerprint, manifest entries, byte-identity with the GitHub mirror — from a fresh container with no cached state, asserts each one, and writes a structured pass/fail report. The cron runs every 6 hours; the latest result lives at audit-transparency-log/trust-claims/latest.json in the same public repo as the daily roots, and every prior run is preserved as trust-claims/YYYY-MM-DD.json. A reviewer can confirm both that the verifier ran recently (timestamp inside the file) and what it asserted (the named assertions inside the file). Failure exits the cron non-zero and pages an operator; the publish step in the same cron chain is gated on a clean verifier pass, so a divergence never propagates silently to the public log. First run 2026-06-06: 16 assertions, all passed.
  5. Still on the roadmap. The publish step is still host-side rather than hardened CI; a tlog-style per-day consistency proof endpoint (proving day N’s tree is a strict extension of day N-1’s) is the next step. The roadmap moves at the speed that customer evidence asks for it.
The README in the public repo is the canonical "does prove / does not prove" honesty statement.

One conceptually-distinct proof shares this same Rekor log: SLSA Build Level 3 attestations for every production deploy. That mechanism is its own claim — the audit-event Merkle root above proves what the system committed to at midnight; SLSA proves what was built and shipped into production. Same landing surface (Sigstore Rekor), different claim. See Invariant 11 below for the build-pipeline mechanism, evaluator path, and honest verifier-UX limits.
Transparency-log verification key
Algorithm: Ed25519, SubjectPublicKeyInfo, SHA-256 fingerprint. Separate from the export-bundle key above — "came from our DB at export" and "was the committed state at midnight" are different statements with different keys.

The currently-advertised fingerprint and its rotation history are served from /api/keys/signing (look for purpose = "transparency_root"). The PEM itself is at /api/audit/transparency-key and is also snapshotted in the public repo as transparency-key.pem; all three return byte-identical bytes.

If the fingerprint inside any published root’s signing_key_fingerprint_sha256 does not appear anywhere in the manifest, do not trust that root and email support@privacyautomated.ai.
Continuity. The public log lives under the github.com/privacyautomated organisation account, not a personal account — the organisation supports multiple owners and survives any individual human account action (suspension, rename, loss). GitHub independently attests the organisation's association with privacyautomated.ai via a DNS-challenge domain verification; the attestation is readable as the is_verified field at api.github.com/orgs/privacyautomated and as a "Verified" badge on the organisation profile. We point at the API field rather than restating its value here, so a reviewer reads the live state from GitHub directly rather than trusting a static line on this page. The API also surfaces the canonical repository URL at /api/audit/transparency-roots, so customer verification scripts pick up any future relocation automatically.

·109 jurisdictions, statutory deadlines computed from a typed table

The DSAR engine does not ask the LLM what the response deadline is. The regulation's deadline is a typed field on a 109-row table at apps/api/src/dsar_jurisdictions.py. Every row carries a statute citation (article / section number) and a public URL to the authoritative source.

Coverage breakdown:

  • EU/EEA — 30 jurisdictions under GDPR Art. 12(3) (30 days + 60-day extension for complex requests).
  • Other Europe — 10 including the UK, Switzerland (revFADP), Crown Dependencies (Guernsey / Jersey / Isle of Man), Andorra, Monaco, Serbia, Ukraine.
  • North America — 21 including Canada (PIPEDA), Quebec Law 25, and 19 US state laws (CCPA/CPRA, VCDPA, CPA, CTDPA, UCPA, TDPSA, OCPA, MCDPA, NJ, DE, MD, IN, IA, TN, MN, NH, KY, RI).
  • South America — 7 including Brazil (LGPD, 15 days), Argentina (10 days), Colombia, Peru (20 days), Ecuador, Uruguay, Chile (currently in regulatory transition; encoded but marked for legal verification, see below).
  • Central America & Caribbean — 6 including Mexico (LFPDPPP), Panama, Costa Rica, Jamaica, Barbados, Trinidad & Tobago.
  • Asia-Pacific — 16 including China (PIPL), India (DPDP), Japan (APPI), Korea (PIPA — 10 days), Singapore, Australia, NZ, Thailand, Indonesia, Vietnam, Philippines, Malaysia, Hong Kong, Taiwan, Sri Lanka, Nepal.
  • Middle East — 5 including Israel, UAE, Saudi Arabia, Qatar, Bahrain. (Turkey’s KVKK is encoded in the Europe/Middle East region.)
  • Africa — 14 including South Africa (POPIA), Nigeria, Kenya (7 days), Egypt, Ghana, Mauritius, Morocco, Tunisia, Uganda, Rwanda, Zambia, Zimbabwe, Botswana, Angola.

The arithmetic. 109 jurisdictions total = 85 high-confidence + 24 marked for legal review. Of the 109, 97 have a quantitative day-counted deadline encoded as a numeric field and 12 use qualitative statutory language (“without undue delay”, “reasonable time”) which is surfaced verbatim to the privacy team rather than the system inventing a fixed number. Chile sits in the legal-review set because its current Law 19.628 (2 working days) is being superseded by Law 21.719 with a different timeline; the value in the table is the safer of the two but should not be cited externally until the transition resolves.

How to verify:
  • Open apps/api/src/dsar_jurisdictions.py — every row carries statute_section and statute_source_url;
  • tests/test_dsar_jurisdictions.py pins anchor values for GDPR, CCPA, LGPD, PIPA, Iowa (the 90-day outlier), and others; 35 tests on every commit;
  • Property tests assert: every row cited, deadlines bounded [1, 365], extension provisions require a stated condition, US state codes use the US-XX prefix to avoid CA = Canada collision.
What we don’t yet claim: 85 rows are high-confidence (the engineering-author was confident in the deadline value and citation); 24 rows are marked legal_review_needed = True in code — jurisdictions where the value is plausibly contested, the law has recently changed, or sub-national variations exist that the row doesn’t fully capture. A future attorney pass should confirm these before any contractual-claim usage. This module is engineering-author encoded with citations; it is not a substitute for an attorney’s review on any specific contract or regulator interaction.

4DSAR verification is cryptographic and race-protected

Verification tokens are 32-byte server-generated secrets, transmitted only via email, and the state transition that confirms verification uses SELECT ... FOR UPDATE to prevent the double-submit race that would otherwise double-fan-out department tasks.

DSAR verification is the privacy-team-critical step — it is where an attacker would try to impersonate a data subject and extract someone else's data. We engineer it as a small, auditable state machine: awaiting_verification → verified → in_progress → completed, each transition logged.

The token is generated by Python's secrets.token_urlsafe (~256 bits of entropy), never logged, and the link arrives only at the email address the requester themselves supplied. The race protection is the part most systems miss: two simultaneous tab-submits would otherwise both pass the "first time" check and both fan out, doubling work and emails. The FOR UPDATE serialises the state read.

How to verify: trace routes/dsars.py:public_submit_verification in the API. The db.get(Dsar, dsar_id, with_for_update=True) is at the head of the state-change block. The token generation is at the creation point in dsar.py:create_from_email using secrets.token_urlsafe(32).

5Citations are validated against the retrieval set

Every chunk_id the LLM returns as a citation is cross-referenced against the actual retrieval result. Hallucinated chunk references are stripped before the answer reaches the user, and citation validity is a direct input into the composite confidence score.

LLMs hallucinate citations confidently. A typical failure mode: the model writes "Per policy section 3.2 [1], we retain customer data for 18 months" where [1] is a fabricated chunk id and the underlying policy actually says seven years. A vanilla RAG system would surface that to the user as a cited fact.

Our Q&A engine maintains the set of chunk_ids that retrieval actually returned. The answer's citations are filtered against that set; chunk ids the LLM made up are silently dropped, and the boolean valid_citations feeds the composite confidence score below.

How to verify: read qa/engine.py:answer_question — the citation-validity filter immediately follows the primary LLM call and runs before composite confidence is computed.

6Grounding is verified by a separate judge model

A second model call — the judge — re-reads only the cited chunks and asks: "Does every factual claim in the answer trace to at least one of these chunks?" If even one claim is unsupported, the answer is routed for human review rather than auto-answered.

Citation validity (Invariant 4) catches references to chunks that don't exist. It does not catch the more subtle failure where the cited chunk exists but doesn't actually support the claim — the LLM cited a policy paragraph about retention to support a claim about cross-border transfers, for example. The judge model is the layer that catches that.

The judge is given the question, the draft answer, and only the cited chunks (not the full retrieval set, which would let it "find" support for anything). It returns a verdict, a confidence, and a list of specific claims it considers unsupported. If grounded: false comes back, the router escalates — the answer never reaches the user without a human review.

How to verify: read qa/engine.py:answer_question for the judge call. The judge prompt is docs/prompts/qa_judge_v1.txt. The escalation gate on judge disagreement is in escalation/router.py:maybe_escalate.

7Confidence is a composite of three signals

The score that gates auto-answer combines three independent signals (highest-ranked retrieved-chunk relevance, the primary LLM's self-reported confidence, and a binary citation-validity flag) in a weighted sum. The structural property is that the absence of valid grounding reduces the maximum achievable composite by exactly the weight of the citation-validity term, irrespective of the other two signals. Combined with anything less than maximal chunk-relevance and model-confidence values, this typically drops the composite below the auto-answer threshold; with both other signals at their maxima, an unsupported answer can still reach the ceiling (currently 0.8 against a default 0.7 threshold).

The composite is therefore a tendency, not a hard guarantee, and is not the layer that strictly prevents unsupported content from reaching a user. The strict guarantee lives in Invariant 5 above: the independent grounding-verification judge model re-reads only the cited sources and routes any answer it cannot affirm to human review, regardless of the composite. The composite gate exists to surface uncertainty early and reduce the judge's load; the judge gate exists to make “an unsupported answer cannot auto-deliver” a property of the system. Read these two invariants together.

LLM-stated confidence on its own is famously over-confident. Chunk relevance alone says nothing about whether the answer actually uses the chunks. Citation validity alone says nothing about whether the claim was right. Treating each as one of three independent signals and combining them in a linear sum lets us assert the structural property above — that the citation-validity flag bounds the maximum achievable composite by exactly its weight — without overclaiming that any one combination of values is impossible. The specific numeric weights (currently 0.4 / 0.4 / 0.2) are an implementation choice; the structural reduction property is the invariant.

How to verify: the formula lives in qa/engine.py:_composite_confidence. The structural ceiling is pinned by a parametrized property test in apps/api/tests/test_composite_confidence_property.py that runs 400 assertions on every commit, written against the formula's shape rather than its specific weights. Detailed architecture notes, including the exact weighting choice and the tuning methodology (and its limitations) are in docs/architecture/composite-confidence.md, available on request as part of due diligence.

Empirical evaluation status. We have completed five comparative studies against commodity single-source retrieval-augmented generation on the same base LLM: a grounding/routing benchmark, a sycophancy-resistance pilot, a conflicting-intent detection pilot, an out-of-corpus hallucination pilot at N=30 (LLM-judge classified), and a hallucination replication at N=150 (also LLM-judge classified, with a 30-scenario HARDER subset designed to probe rare divergence under adversarial conditions — leading framings, close-corpus matches, compound questions). All five are null at the largest sample size. At N=150 the PA pipeline refused 145/150 (95% Wilson CI 92.4–98.6%) versus naive RAG's 147/150 (95% CI 94.3–99.3%); McNemar paired exact two-sided p = 0.625 on REFUSED, p = 0.25 on HALLUCINATED; the HARDER subset showed McNemar p = 1.00. We do not currently claim a measurable AI-pipeline marginal value on any of these five axes. The architecture's defensible value is the structural property of Invariant 7 (no valid citation → maximum composite reduced by exactly the citation weight; combined with the judge gate in Invariant 6, an unsupported answer cannot auto-deliver) plus the audit/determinism properties documented elsewhere on this page; the LLM-level outcome on which the gate fires is not separately demonstrated. Full methodology, raw per-scenario data, and the corrigendum on the N=30 result are in docs/benchmark/hallucination-pilot-n150-2026-06-03.md and the four earlier pilot writeups in the same directory.

8The off-site backup is restored and audit-chain-walked every week

Every Sunday at 06:00 UTC a host-side script pulls the latest off-site backup from our off-site object store (intentionally the off-site copy, not the local one), pg_restores it into a throwaway database, walks the audit hash-chain of the workspace with the most events inside the restored copy, and writes a structured JSON report. Any tamper finding in the restored chain triggers a hard fail and pages on-call. The report is served to authenticated workspace members at /api/ops/restore-status; procurement, ENISA evaluators, and auditors get access via an authenticated workspace login or through the vendor questionnaire response. The reports are intentionally not served to anonymous browsers — see the authentication note below.

Why this exists. The most common SaaS failure mode is “we have backups but nobody has ever restored them.” A backup that has never been restored is a probability, not a fact. The weekly drill converts the probability into a fact — once a week, every week, on a schedule we publish.

What the script actually checks (in order).
  1. B2 download. Lists the latest dump in the bucket's backup prefix and downloads it. Fails the run if no dumps exist or the file is < 1 KB.
  2. pg_restore into a throwaway DB dropped on exit regardless of outcome.
  3. Row counts on every major table, both prod and restored side, recorded into the report. Restored should match prod within a few rows worth of post-dump writes.
  4. Schema version — reads alembic_version in the restored DB and records the migration revision. A mismatch with prod is a documentation issue, not necessarily a failure, but it’s surfaced.
  5. Audit-chain walk inside the restored copy. Picks the workspace with the most rows and recomputes each row’s SHA-256 hash chain. Tamper count must be 0 (writer-race breaks are tolerated and surfaced separately, mirroring the daily integrity verifier from Invariant 2). If the restored audit chain has tamper findings the whole run is marked FAIL.
Every run produces a report — including the failure path. The script writes the report file via an EXIT trap, so even a failed run lands in /ops/restore-reports/YYYY-MM-DD.json with status=FAIL and the specific step that failed (b2_download, pg_restore, row_counts, schema_version, or chain_walk). Silence in the report archive would be misleading; failure with attribution is the signal an authenticated reviewer reads.

Where it runs. The script lives at /opt/privacy-automated/scripts/weekly_restore_verify.sh (source in scripts/weekly_restore_verify.sh of the private repo) and is wired into the host crontab. The report directory is bind-mounted read-only into the API container; the API never executes the restore itself, so a compromised API container has no path to manipulate the report.

Why authenticated, not public. An earlier version of this page exposed the restore-status and the full historical report archive at unauthenticated URLs. The reports record per-table row counts (a cross-day plot of which is a usage and growth signal), the migration revision, the off-site backup-store prefix, the restore host name, and the failure-step taxonomy — useful operational telemetry for a customer or evaluator but a free reconnaissance map if served to the open internet on a fixed schedule. For a privacy- operations vendor that's a disproportionate own-goal; gating the surface costs the verifiability argument nothing because the audience that needs the evidence (procurement, ENISA, auditors) gets it via an authenticated session or via the vendor questionnaire.

Update (2026-06-04): pgaudit is now active. The Postgres extension pgaudit provides a SECOND independent audit channel at the database level — even if the application’s write_audit path has a bug or is bypassed, every DDL / role / write SQL statement is still logged by the database engine itself. We run with pgaudit.log = 'ddl,role,write' and pgaudit.log_parameter = off, so the audit log captures the statement text without leaking parameter values (which may contain customer PII). The application audit chain, the weekly restore drill, the daily Merkle root, and the database-engine pgaudit log are now four independent channels covering the same evidence.

What we don't yet claim. One honest gap remaining:
  1. Restore-time is measured, recovery-time is not yet committed. Today’s drill takes about 30 seconds on the current DB size (~1 MB compressed dump). The drill measures run_finished_at - run_started_at but we are not yet publishing a customer-facing RTO guarantee — that requires drilling at production scale and committing to a target. Coming after the next round of customer load.

9Every LLM call is captured verbatim and can be replayed

Every call to the Anthropic Messages API made by the production deployment — Q&A inference, the hard-block intent classifier, the grounding judge, DPIA generation, vendor research — records a row in llm_captures that holds the verbatim system prompt, message list, request parameters, model name, and raw response text. A workspace owner can later pull up any captured call and have the API replay it against the same model, returning the original and the new response side-by-side.

The customer story. "If you discover the AI gave a buggy answer to one of our customers last month, can you reproduce the exact same answer for debugging? Can you prove the answer we got at the time was deterministic from our inputs?" Yes — that's what this surface is for. A row in llm_captures is the same data that went over the wire to Anthropic; POST /llm-captures/{id}/replay sends it back to the same model and returns both responses.

Workspace-scoped, RLS-enforced. The captures table sits behind the same Postgres FORCE ROW LEVEL SECURITY policy that protects every other tenant table (Invariant 1). When a workspace is deleted, its captures cascade.

Retention and per-data-subject erasure. Captures contain prompt and response text verbatim, which can include personal data. The defaults are calibrated for that:
  • Each workspace carries a configurable retention setting (workspaces.llm_capture_retention_days, default 90 days). A daily Celery beat task at 06:30 UTC deletes captures past that horizon. Indefinite retention is not the default; the customer can also tighten retention to as low as the business need requires (the replay feature loses usefulness below about a week).
  • Article 17 erasure exercised by an individual data subject is handled per-capture. The customer's privacy admin queries GET /api/llm-captures?contains=<subject-identifier> — an email, name, internal ID, or any other distinguishing field the subject's record carries — reviews the matching rows, and deletes each via DELETE /api/llm-captures/{id}. Both endpoints are workspace-scoped under RLS and admin-gated. The 90-day TTL also bounds the maximum exposure window between an answer being generated and the capture being deleted, independent of any customer action. Workspace cascade-deletion is available as the heavier hammer.
  • This processing is named in the data-processing agreement; capture is not a hidden behaviour.
Replay-sufficiency and data minimisation are different claims: the captures hold what replay requires (which is everything), and the TTL plus the per-capture deletion path is what brings the practice into line with Article 5(1)(e) storage limitation for the customer's own controllership.

Best-effort capture, not blocking. The capture write is wrapped in a try/except that swallows any DB or observability failure — the LLM call continues regardless. If the capture infrastructure breaks, the customer-facing answer still comes back; only the audit trail degrades, not the product.

What we don't claim. Public frontier language models are not strictly bit-deterministic across sampling. Two replays of the same prompt can differ. The replay endpoint returns BOTH responses and the customer judges whether divergence is semantically meaningful — we do not auto-assert "no drift." Empirically, short factual prompts at default sampling tend to reproduce closely (often identically); long generative outputs — DPIA drafts, vendor research summaries — vary in surface phrasing while remaining roughly stable in structure. Replay is evidence of current model behaviour under the same input, not a deterministic re-derivation.

10Every system prompt is hash-pinned in a public registry and recorded in each LLM call

Every system prompt this deployment runs — the Q&A system prompt, the multi-label hard-block classifier, the grounding judge, the query-rewriter — lives as a versioned file in docs/prompts/*.txt. At startup the deployment computes a SHA-256 hash of each file and exposes the manifest at /prompts (public, unauthenticated). When an LLM call fires inside a customer's workspace, the prompt's ID and hash are recorded into the llm_captures.prompt_id / .prompt_hash columns from Invariant 9. A customer who saved qa_judge_v1's hash last quarter can pull up any answer today and see whether the hash on the capture matches.

How to use this in practice.
  1. Fetch the manifest at https://app.privacyautomated.ai/api/prompts and record the hash for the prompt you care about (e.g. the qa_judge_v1 entry). The manifest is the live source of truth; inlining a specific hash on this page would go stale on every prompt revision, so the page does not.
  2. Inspect a workspace capture at GET /llm-captures/{id}. The prompt_id and prompt_hash fields tell you which prompt produced that specific call. If we ship a new revision (e.g. qa_judge_v2), the manifest lists both the new and the old, and old captures continue to carry the old hash.
  3. A prompt-hash MISMATCH means the prompt has changed since the call fired — either we shipped a new revision (legitimate, visible in our changelog) or something replaced the file between startup and the call (which the registry cannot do post-load: prompts are loaded once at startup and not re-read). Either way, the mismatch is detectable.
Read-only at runtime. The registry caches the prompt content + hash at module import. A file swapped on disk does NOT change the running hash without a process restart — intentional, because a moving hash would defeat the customer's pin.

Coverage. Every customer-facing LLM call path is wired today — the query rewriter, the main Q&A inference, the grounding judge, the hard- block classifier, and all five DPIA / privacy-assessment call sites (intent classifier, intake suggester, intake- reply parser, duplicate checker, full DPIA generator). Each call's prompt_id and prompt_hash land in llm_captures directly. The remaining unwired paths are background scripts — vendor research, regulatory-news ingest, internal benchmark harnesses — that do not produce customer-visible artifacts; their captures carry prompt_id = NULL today, and the verbatim system prompt is still recorded under Invariant 9 so a reviewer who needs to know which prompt fired can match by exact text.

11Every production image is built in CI under SLSA Build Level 3 provenance, signed by GitHub’s OIDC identity, and logged to Sigstore Rekor

Invariants 1–10 are about what happens at runtime, inside the application. This one is about what happens before runtime — the supply chain that puts container images on the production host in the first place. Every push to main triggers a GitHub Actions workflow (.github/workflows/build-attest.yml) that builds the API, Worker, and Web container images inside a hosted, isolated Ubuntu runner. The build provenance is generated by actions/attest-build-provenance@v2, signed by GitHub’s OIDC identity through Sigstore Fulcio, and recorded in Sigstore Rekor — the same public-good transparency log carrying the daily audit-event Merkle-root anchor under Invariant 3. Our own deploy.sh never builds images on the operator’s laptop; it pulls the pre-built images by their :sha-<full-git-sha> tag after CI has recorded a Sigstore-signed attestation for the digest. The build-side mechanism (actions/attest-build-provenance@v2 on a hosted, isolated Ubuntu runner, with provenance signed by GitHub’s OIDC identity through Sigstore Fulcio and logged to Rekor) is the recipe GitHub itself qualifies as producing SLSA Build Level 3-meeting provenance, satisfying the two L3 gates that bite at our scale: build-platform integrity (the runner is isolated from job-controlled inputs that could influence the OIDC token) and non-falsifiable provenance (the signature is anchored in a public transparency log the builder does not control).

Where the records land. Each successful prod deploy writes a self-contained build record to builds/sha-<full-git-sha>.json in the same public repo as the daily Merkle roots. The record names the git SHA, the GHA workflow run URL, and the SHA-256 digests of all three images that went live. From there an evaluator can:
  1. Data check — works anonymously, no cryptography. Read the build record from the public Git history; cross-check the image digest against the GHCR manifest with docker manifest inspect ghcr.io/privacyautomated/privacy-copilot/<svc>:sha-<sha> or cosign tree ghcr.io/… (the latter also lists every attached SLSA provenance artifact, which is enough to confirm the attestation exists in OCI referrer storage).
  2. Attestation contents — works anonymously, no cryptography on the signature itself. Run cosign download attestation ghcr.io/… to pull the signed DSSE envelope. Decode the envelope’s payload (base64) to read the in-toto Statement: the subject names the image digest, the predicate (SLSA provenance v1) names the GitHub workflow ref, commit SHA, and run URL that built it. The envelope’s Fulcio certificate names the OIDC identity (https://github.com/privacyautomated/privacy-copilot/.github/workflows/build-attest.yml@refs/heads/main) and issuer (https://token.actions.githubusercontent.com). Reading the envelope alone confirms what was claimed; verifying the cryptographic signatures on the claim is the next step.
  3. Full cryptographic verification. Two paths today, both with current ecosystem caveats. For org-authenticated reviewers: gh attestation verify oci://ghcr.io/privacyautomated/privacy-copilot/<svc>:sha-<sha> --owner privacyautomated — this requires a GitHub token with read:packages scope; the gh CLI’s default OAuth flow does not currently grant the scope cleanly even for org members, so most evaluators will need a fine-grained PAT. For trustless anonymous verification: cosign verify-attestation --type slsaprovenance1 --certificate-identity-regexp '^https://github.com/privacyautomated/privacy-copilot/\.github/workflows/build-attest\.yml@.*' --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' ghcr.io/…. This invocation against attest-build-provenance@v2 bundles (Sigstore Bundle v0.3) is at the edge of what current cosign releases verify cleanly — see honest limits below.
The chain is therefore: git commit → GHA build → Sigstore-attested image digest → image ref recorded in our public log → running container in production. Every step is independently inspectable by a party who has only public information (steps 1 and 2 above); full cryptographic verification depends on which side of the auth / ecosystem-tooling fence the reviewer sits on (step 3).

Honest limits of v1 — the verifier-UX gap. Reading our own page back and trying every command on it from a clean shell (2026-06-06) surfaced a discrepancy between the cryptographic substrate (which works) and the one-line third-party verifier UX (which doesn’t yet, cleanly, for this bundle format). Three specific things:
  • The deploy-box re-verifies that CI logged the attestation but does not itself run gh attestation verify on the deploy host. The GitHub CLI’s OAuth flow returns different access levels for api.github.com versus ghcr.io and the deploy-box token can list packages but is denied the manifest HEAD that the verify command issues.
  • An earlier version of this page told evaluators to “query Rekor at search.sigstore.dev using the image digest — no auth required.” Tested anonymously: that query returns []. attest-build-provenance@v2 records the SLSA provenance to Rekor indexed by the in-toto envelope hash, not by the image digest, so the natural-looking digest search misses the attestation. The claim is now removed; the actual anonymous-discovery path (cosign download attestation) replaces it in the evaluator list above.
  • The one-line trustless verifier (cosign verify-attestation against the GHCR-stored attestation, with explicit --certificate-identity-regexp / --certificate-oidc-issuer flags) is at the edge of what current cosign releases handle cleanly for the Sigstore Bundle v0.3 format that attest-build-provenance@v2 emits. We’ve watched the upstream cosign issue tracker reach this; the fix lands when the bundle-format support catches up.
The cryptographic chain itself is real and complete: the attestation is signed by GitHub’s OIDC identity through Sigstore Fulcio, the signature is anchored in Sigstore Rekor, and the artifact is stored in GHCR’s OCI referrer storage — all three pieces are anonymously fetchable and inspectable today. What is in flux is the one-command UX for an anonymous third party to pull the whole proof together. The page will date-stamp the cosign-verify-clean state when it lands.

12No AI-authored output becomes a regulatory record without an authenticated human signing it off

When an AI inference produces something a regulator might one day look at — a cross-jurisdictional conflict flag on a DSAR, a determination about whether a deletion request collides with a retention obligation — the output never lands directly in the workspace record. It lands in a separate determination_drafts table marked pending_review. A signed-off determinations row is only created when an authenticated human user POSTs to /determination-drafts/{id}/promote; the endpoint refuses (HTTP 401) when no user is in the session. The migration that created the tables installs a database CHECK constraint that refuses any determinations row whose signed_off_by_user_id is null or whose citations array is empty — schema-level, not application-level, so the property holds even if the route handler is bypassed.

The split matters because the alternative is the failure mode the rest of the privacy-AI category quietly accepts: an LLM emits a determination, the app writes it to a row, the privacy lead sees it later, and the audit chain records “the system determined X” with no human in the loop. Promoting a draft writes a determination.signed_off event into the audit chain (Invariant 2) that names the user id, the draft it came from, and the prompt id + hash that produced the draft (Invariants 9 and 10). Rejecting a draft writes a determination.rejected event with the user’s required reason. The draft itself is preserved either way so a replay (Invariant 9) can reproduce what the AI proposed, alongside what the human decided.

How to verify.
  1. Read the migration at alembic/versions/0044_invariant_12.py and confirm the CHECK constraint: signed_off_by_user_id IS NOT NULL AND jsonb_array_length(citations) > 0. This constraint is what makes the property a database guarantee rather than an application guarantee.
  2. Read the route handler at routes/determinations.py: promote_draft() raises 401 when current_user_id() returns None, and the helper at audit/determinations.promote() writes the signed-off audit event in the same transaction as the row insert.
  3. For a customer with a deployed signed-off determination, the audit chain itself shows the property in action: filter GET /audit?event_type=determination.signed_off for the workspace, find the matching determinations row, and confirm its signed_off_by_user_id matches the audit event’s actor — the same person had to be authenticated for both writes to land.
  4. The draft’s prompt_id and prompt_hash link the human decision back to the exact prompt revision that produced the AI’s suggestion (Invariant 10), and the llm_capture_id links to the verbatim LLM call (Invariant 9). The chain is therefore: captured LLM call → hash-pinned prompt that produced it → draft awaiting review → signed-off determination → audit-chain event linking the user to both.
Coverage today. The feature with determinations live in production is Direction #1 (cross-jurisdictional conflict detection). Any future AI-authored regulatory output we add — a deletion- vs-retention recommendation, a breach-notification trigger flag — will land in the same determination_drafts table and ride the same promote-or-reject pipeline. The substrate is feature-agnostic by design so the property generalises without a per-feature exception.

Why this is on the page now. We held this invariant back until a customer had exercised the full chain end-to-end — an LLM drafted a real conflict flag, a human privacy lead reviewed it and promoted it, and the audit chain recorded the transition. Until that happened, the substrate was real but the property was a posture, not a fact. As of 2026-06-07 the fact holds.

13The conflict-detection model can only cite from a curated, externally-reviewed corpus, and the feature is gated per-jurisdiction on a signed UPL review

Cross-jurisdictional conflict detection (Direction #1) is the first feature in the product that touches the boundary of unauthorized practice of law. We engineered the boundary to be enforced by two independent mechanisms, both inspectable from outside the running system:

  1. Per-jurisdiction UPL gate. The feature is only available for jurisdictions where an external counsel review has been filed and signed. The reviewed-jurisdictions manifest lives at /api/upl/reviews (public, unauthenticated) and names who reviewed, when, under what tier, and with any time-limited expiry. A workspace whose primary jurisdiction is not in this manifest cannot invoke the feature — the route refuses with jurisdiction_not_reviewed before any LLM call fires.
  2. Closed citation menu. The conflict-detection LLM is given a scoped slice of a curated statutory corpus (50+ provisions across GDPR, UK GDPR, CCPA, FRCP-26 / 37(e) preservation rules, IRS §6001, SOX, HIPAA, etc.) in its user message, and its system prompt instructs it to cite only from that menu. The post-validator rejects any output whose controlling_text_id isn’t in the supplied menu, with the route surfacing HTTP 422 to the caller. A hallucinated statute reference cannot survive the validator and reach a customer.

Both the UPL-reviews manifest and the controlling-texts corpus ship inside the SLSA-attested container image (Invariant 11). A change to either file is a founder-signed commit, rebuilds the image, gets its own Sigstore-signed attestation, and shows up in the public audit-transparency-log’s builds/sha-<sha>.json record. A regulator can therefore confirm at a point in time: “the conflict-detection feature, at the build that was running when this customer used it, was restricted to these 11 jurisdictions and this corpus of texts.”

How to verify.
  1. Fetch the live UPL manifest anonymously: curl https://app.privacyautomated.ai/api/upl/reviews. Each entry carries {code, name, tier, reviewed_by, reviewed_at, analysis_ref, is_expired}. The analysis_ref points back to the in-image source document (docs/policies/upl-reviews/part-b-analysis.md); the is_expired flag captures any time-limited review (e.g. Utah’s sandbox) past its end date.
  2. Fetch the controlling-texts corpus from the in-image manifest: docs/policies/controlling-texts/manifest.json. Each entry has an id, a citation reference, the jurisdiction it belongs to, and the summary_text the LLM is allowed to paraphrase. Adding or removing an entry is a source-controlled change that triggers a SLSA-attested rebuild.
  3. Fetch the conflict-detection system prompt’s hash from the public prompt registry (Invariant 10): curl https://app.privacyautomated.ai/api/prompts | jq '.prompts[] | select(.id == "conflict_detection_v2")'. The prompt’s text in the deployed image is the source of the “closed citation menu” rule, the mandatory disclaimer text, and the “describe two facts in parallel; never conclude” self-check list. The hash binds a customer’s captured call to the exact prompt bytes that produced it.
  4. The route handler at routes/conflict_detection.py emits explicit gate-failure codes: feature_globally_disabled (501) when no jurisdiction has a review, jurisdiction_not_reviewed (403) when the workspace’s primary jurisdiction lacks one, workspace_flag_disabled (403) when the per-workspace toggle is off, and validation_failed (422) when the LLM’s draft failed post-validation. Every refusal path is documented and inspectable; none fail open.
What this invariant doesn’t do. It doesn’t make the AI’s output a legal conclusion — the explicit point of the system prompt is to describe two facts (the statute and the customer’s observed state) in parallel and refuse to conclude which controls. The draft is then signed off under Invariant 12 by a human privacy lead, who is the only party in the chain authorised to act on it. And it doesn’t substitute for licensed counsel — every signed-off draft carries the mandatory disclaimer that names this explicitly and the in-product copy avoids framing the feature as a replacement for a lawyer.

Currently reviewed jurisdictions (live data, 2026-06-07): GB-ENG, EU-DE (Germany under EU GDPR), US-AZ, US-UT (time-limited, sandbox to 2027-08-14), US-CA, US-NY, US-FL, US-DE, AU, CA-BC, CA-ON. Always cross-check this list at /api/upl/reviews — it’s the authoritative source; this page’s snapshot date-stamps how it looked when the page was last updated.

What we don't claim

We don't make published-benchmark claims about our pipeline's marginal value over commodity RAG. The benchmarks we ran during development were designed for behavioural-regression monitoring, not for comparative accuracy — they grade conservative escalation as failure, which measures the wrong axis for an architecture engineered for verifiable refusal. We're constructing a property-specific benchmark that measures the dimensions the architecture is actually designed for — verifiable refusal under unsupported queries, sycophancy resistance, conflicting-intent detection, prompt-injection robustness, and multi-source authoritative synthesis. We'll publish results when we have data that meaningfully tests those invariants, not before.

We don't claim our auto-answer rate is the highest in the category. For an AI privacy tool, we'd rather over-escalate to the customer's privacy team than auto-answer something we shouldn't — the asymmetry of cost is steep. That conservative posture is engineered: the auto-answer disposition is only produced when every independent verifier asserts true; any single gate failure routes to human review.

We don't claim we've never had a bug. Our changelog lists the audit findings we've fixed and the ones still open. We try to be honest about where we are in the SOC 2 timeline (in flight, not certified yet) and what's stale (this page itself ships under Last updated: June 6, 2026 — if it's older than two months when you read it, ask us what's changed).

Last updated: June 7, 2026. Substantive changes since previous date: (1) the SLSA attestation paragraph is rewritten to attribute verification to the CI layer (where it happens) rather than the deploy host (where the deploy-box re-verify TODO is disclosed below); the Build-Level-3 claim is now defended against the v1.0 spec by naming the specific gates we satisfy and citing GitHub's qualification of actions/attest-build-provenance@v2. (2) The Invariant 8 step list no longer prints the off-site backup prefix or the throwaway-DB naming convention — the authenticated-not-public paragraph below that section explicitly gates exactly those signals, and the bullets and the paragraph now agree. (3) OpenTimestamps anchoring is now live: the first .ots receipt (roots/2026-06-05.json.ots) landed in the public audit-transparency-log on 2026-06-06 after two operational fixes — a stale uv.lock that was silently dropping the opentimestamps dependency from the container image (so anchor_file() returned None on every daily run), and a Starlette route-precedence bug where the generic {filename} route shadowed the {filename}.ots route (so the public API rejected every .ots URL with HTTP 400 against the .json-only regex). The ots upgrade pass that converts a fresh calendar-bound receipt into a Bitcoin block proof runs on a six-hour host cron; receipts transition from calendar-bound to Bitcoin-bound within ~6 hours of being stamped. (4) Same-day update: the upgrade pass ran against the first receipt; the showcase (2026-06-05.json.ots) is now anchored to Bitcoin blocks 952577 and 952595 with two confirmed transactions on-chain at mempool.space. Verification options on Invariant 3 now name three ordered-by-setup-cost paths: block-explorer click (zero setup), ots info (no Bitcoin node required), and ots verify (requires a Bitcoin node, with the --bitcoin-node <RPC> flag documented for reviewers without a local one) — a reviewer running the verifier from a clean shell should no longer get a “Could not connect to Bitcoin node” error and misread it as a problem with our receipt. (5) Same-day update: Sigstore Rekor anchoring is now live as a second independent witnessing channel under Invariant 3 — every daily root’s SHA-256 is also submitted to the public Rekor transparency log as a hashedrekord entry, signed with a dedicated ECDSA P-256 key kept separately from the Ed25519 transparency key. The first receipt (roots/2026-06-05.json.rekor.json) lives at Rekor UUID 108e9186e8c5677ad025d406a338e06213fdc244e5c7cdcecd6ff849770efb3e82c151f7f7e56f2c, logIndex 1740024874, and cross-checks from a clean shell at rekor.sigstore.dev/api/v1/log/entries/<uuid> with no authentication required. The implementation pinned down two subtle gotchas worth the public record: Rekor’s Ed25519 verifier path is Ed25519ph (with the RFC 8032 dom2 prefix Python’s cryptography library doesn’t expose for signing) — the cleaner answer is ECDSA P-256 + SHA-256; and on that ECDSA path, signing must use utils.Prehashed(SHA256) to avoid the library double-hashing the digest, which Rekor rejects with “invalid signature when validating ASN.1 encoded signature.” With this anchor live, only one item from earlier roadmap disclosure remains: a tlog-style per-day consistency proof endpoint proving day N’s tree strictly extends day N-1’s. (6) A technical reviewer pass on the new cryptographic claims surfaced a publish-script regression worth disclosing in the same retract-and-fix posture as the SLSA verifier-UX note above. The showcase OpenTimestamps receipt (2026-06-05.json.ots) was Bitcoin-bound to blocks 952577 and 952595 (commit dbab489, 2779 bytes); three subsequent commits overwrote it with smaller calendar-only re-stamps (565 / 530 / 600 bytes) because the daily Celery transparency task was re-run during Rekor-anchor smoke testing, and the publisher script treated “any content change” as “a legitimate upgrade.” An OTS upgrade is strict-superset (more Bitcoin proofs, fewer pending), not a fresh re-stamp. Three fixes landed: the Bitcoin-bound 2779-byte receipt restored from git history (commit de76681); the publisher script now refuses an .ots overwrite that would shrink the file or grow its pending-attestation count; the daily worker task now skips OTS stamping and Rekor submission when a sidecar already exists for that date (receipt-level idempotency, defense-in-depth). Restored receipt re-verified from a clean shell on 2026-06-06: ots info shows both BitcoinBlockHeaderAttestation(952577) and BitcoinBlockHeaderAttestation(952595); rekor-cli verify --uuid <ours> exits 0 with full Merkle inclusion proof printed. The page’s specific factual claim about which blocks anchor the showcase now matches the published bytes again; the substrate held throughout, but the published artifact briefly didn’t. Structural change in the same edit: SLSA Build Level 3 supply-chain provenance is now its own Invariant 11 rather than a nested update inside Invariant 3. The audit-log daily Merkle root and the build-image attestation share a landing surface (Sigstore Rekor) but are conceptually distinct proofs; separating them keeps Invariant 3 about the audit-event chain only, and gives a technical reviewer a single anchor for the build-pipeline argument. Sub-change: the ECDSA P-256 Rekor signing key now appears in /api/keys/signing under purpose = "rekor_anchor" with PEM at /api/audit/rekor-key; fingerprint 4fdc56a8db8084c3fbb2e51def89155080c42bdbc397eeaf05d7d90466a3b713 matches the publicKey embedded inside the live hashedrekord entry, verified end-to-end from a clean shell. Closes the “whose key?” gap the Bitcoin channel doesn’t have because Bitcoin needs no key trust at all. (7) Building on item (6) above: the page now checks itself. A host cron runs scripts/verify_trust_claims.sh every 6 hours, asserting every load-bearing factual claim this page names by exact byte against the live published artifacts — from a fresh container with no cached state — and writes the pass/fail report to audit-transparency-log/trust-claims/. See the new Invariant 3 sub-item for what specifically gets asserted (receipt sizes, Bitcoin block heights, transaction ids, Rekor UUID/logIndex, embedded publicKey fingerprint, byte-identity with the GitHub mirror). The cron chains verify → publish so a divergence never propagates silently. The 2026-06-06 regression that motivated this would now be caught within 6 hours; the verifier’s pass/fail history is itself an auditable public artifact. First run 2026-06-06: 16 of 16 asserts passed. (8) Header consistency: “Ten invariants we engineer for” bumped to “Eleven invariants” to match the body after SLSA was split out, and the summary list now carries the SLSA one-liner so a reviewer who skims sees the supply-chain invariant without having to scroll to the bottom of the page. (9) The Invariant 3 OpenTimestamps text now reflects the receipt’s actual steady state rather than “will resolve on the next upgrade pass”: two of three calendars (alice.btc and bob.btc) are Bitcoin-bound, and the third (eternitywall’s finney calendar) remains calendar-pending across subsequent upgrade passes. The two confirmed Bitcoin transactions are independently sufficient; the pending finney line in ots info is a belt-and-suspenders artifact, not a missing proof. Re-verified from a clean shell on 2026-06-06. (10) Two new invariants land on the page: Invariant 12 (no AI-authored output becomes a regulatory record without an authenticated human signing it off) and Invariant 13 (the conflict-detection model can only cite from a curated, externally-reviewed corpus, gated per-jurisdiction on a signed UPL review). Both held back from this page until the substrate was exercised end-to-end against a real customer DSAR — an LLM-drafted conflict flag (using the conflict_detection_v2 system prompt, hash 83f4d5a3… in /api/prompts), scoped to the customer’s primary jurisdiction (US-CA) from the controlling-texts corpus, promoted to a signed determinations row by an authenticated user, and recorded as a determination.signed_off event in the audit chain. The matching count of summary bullets at the top of the page is now thirteen; the structural argument for the two new properties is in the body sections rather than condensed into the changelog because the substrate is the point. The reviewed- jurisdictions manifest at /api/upl/reviews is the live source of truth for Invariant 13 — this page’s snapshot of the 11 currently-reviewed jurisdictions date-stamps the 2026-06-07 state; cross-check against the manifest before relying on it. For questions, a security questionnaire, or a copy of our security packet: info@privacyautomated.ai.

PrivacyAutomated.ai

Privacy compliance, built right™.

Product

Features How it works Tour Resources Pricing Changelog DSAR deadline calculator

Company

FAQ Security Trust Architecture SOC 2 Readiness Verify Status Contact LinkedIn

Legal

Privacy Terms DPA Sub-processors Submit a privacy request

© PrivacyAutomated.ai. All rights reserved.

Privacy · Terms · DPA · Sub-processors · Security