Skip to content

How Paridity produces audit-grade, independently-verifiable evidence

The claim

Every reconciliation run Paridity executes produces a complete, immutable audit trail - config state, phase timings, evidence, and terminal outcome - that an external auditor can verify independently, without trusting the platform's own dashboards. Evidence in the ev schema is append-only, the run's compliance timestamp (compare_started_at) never changes once set, and the trail can be exported as a cryptographically signed, hash-chained bundle that is verifiable offline.

This is a narrow, falsifiable claim about trail completeness and tamper-evidence, not about data-matching accuracy - see the companion accuracy proof doc for the hash_total / row_hash / field_diff correctness claim. The two are independent: this document is about whether you can trust the record of what happened, not whether the reconciliation itself was correct.

How it works (deterministic)

Paridity's audit trail is built from deterministic writes at defined points in the run lifecycle - never inferred or reconstructed after the fact.

  1. Config audit before comparison. Before any data comparison begins, the workflow resolves the run's policy through the 7-layer config resolution chain and writes an ev.ev_config_audit row recording the config_checksum - the SHA-256 of the canonicalized, resolved config JSON (sorted keys, sorted arrays, metadata fields stripped). This proves the policy was fixed and known before the run touched any data, and whether it changed since the previous run of the same mapping. config_checksum is a policy fingerprint - it says nothing about the data itself, and must never be confused with hash_total.
  2. Append-only evidence. All ev.* tables (proof results, mismatch keys, field diffs, run metrics, in-flight rechecks, evidence bundles, and the stored period-bundle containers) are INSERT-only. No application code path issues UPDATE or DELETE against them except two documented, narrow lifecycle exceptions (below) and the retention purge script, which itself writes an audit record before deleting anything. Derived figures - dashboard summaries, hourly rollups, anomaly baselines, per-run diff limits, consumption ledgers - are not evidence and do not live in ev at all. Since #1724 they sit in their own proj schema, where being rewritten in place is their purpose: migration db/migrations/177_projection_schema.sql moved the four such tables that had sat in ev (and were being upserted every few minutes) into proj, and later projections such as proj.run_consumption (260_proj_run_consumption.sql) are created there directly. An auditor reads ev.*; proj.* is recomputable from it at any time and is never an audit source. The split is what makes "ev.* is append-only" a rule with no remembered exceptions rather than an approximation.
  3. An immutable audit anchor. compare_started_at is set once, when the run's CreateRunActivity executes, and is never written to again - not during in-flight rechecks, not on reruns. It is the timestamp an auditor anchors the whole trail to.
  4. Per-phase timing. Readiness, SCD-invariant (scd_invariants, only for a mapping with a declared SCD scope), proof, key extraction, field diff, and in-flight recheck (lag_retry) phases each write a run.run_attempts row with phase, started_at, completed_at, outcome, and worker_id (the Temporal activity/workflow identity that ran it) - so an auditor can see exactly how long each phase took and who/what executed it.
  5. Append-only watermark history. run.run_watermarks uses a composite (run_id, recorded_at) key, so every readiness evaluation - including retries - adds a new row rather than overwriting the last observed watermark. Nothing about what the platform saw at each check is lost.
  6. Terminal-status and admin-action logging. Every terminal run status (pass_clean, pass_resolved_lag, fail_hash, fail_count, fail_field, schema_drift_error, system_error, readiness_error, config_error, cancelled, skipped, no_new_data, no_new_data_open_issues - the RUN_TERMINAL_STATES set) and every admin action (cancel, bypass_readiness, retention purge) is written to cfg.audit_log with actor, entity, action, and timestamp - through a single enrichment chokepoint, so every call site gets the same guarantees rather than each populating them individually. The action is run.<status>, so a terminal outcome is filterable by name. config_error (#3034) is the platform declining to compare as configured after readiness passed - an unresolvable decryption key reference, a filter that does not validate, duplicate rows per key, a partition set that does not cover the table - where a retry cannot change the verdict and an operator must change the configuration. It is kept apart from system_error (a platform fault) so an auditor can tell a refusal from an outage: it is a distinct value of run.run_status_t (migration 245_run_status_config_error.sql), has its own audit action run.config_error (246_audit_action_config_error.sql), raises a WARNING-level config_refusal notification rather than the CRITICAL system_error page (247_notification_trigger_config_refusal.sql), and is reachable only from the post-readiness states - the allowed edges are declared in RUN_VALID_TRANSITIONS (src/models/internal/enums.py) and enforced by src/services/state_machine.py, which rejects any other transition. The runs list shows it as Config refusal.
  7. Network attribution. actor_ip is extracted from the request (X-Forwarded-For or direct connection, in the API authentication layer) and stamped onto API-originated audit entries.
  8. Tamper-evidence on the audit log itself. The audit log is hash-chained: each sealed row carries entry_hash = SHA-256(prev_hash + canonical(row)), so altering, deleting, or reordering any row breaks every entry_hash after it. Chains are periodically sealed with a signature over the chain head, so a full-database rewrite that recomputes every hash still fails verification against the last signed checkpoint.
  9. Signed, hash-chained evidence bundles. An auditor or platform_admin can generate a single exportable archive (ev.ev_evidence_bundles) per run containing the resolved config snapshot, config audit, readiness, proof, mismatch keys, field diffs, in-flight rechecks, run metrics, and the relevant audit-log entries - whose manifest commits to every file's SHA-256 and is sealed with a detached signature over exactly the bytes that ship - ECDSA-P256 from a KMS-backed key (AWS KMS, Azure Key Vault, GCP KMS) or a local ECDSA key, so an outside auditor verifies with the public key alone, or HMAC-SHA256 with a customer-held key - linked to the mapping's previous bundle via previous_bundle_hash, forming a tamper-evident sequence over time. Period exports are a signed container over individually signed per-run bundles.
  10. Retention deletion is itself audited. The one process that is allowed to delete ev.* rows (the retention-purge process) writes a cfg.audit_log row - table, mapping, deleted-row count, cutoff, retention policy applied - before each DELETE, so a purge never happens silently. Evidence bundles are covered by the same process under bundle_retention_days (tenant value floored at the platform value): the bundle's audit entry additionally records the stored-container rows and bytes removed by the cascade, the bundle and run ids, and the hash-chain boundary - so a later chain verification can prove a missing predecessor was purged (CHAIN_HEAD_PURGED) rather than tampered with.
  11. Signed single sign-on requests, with key rotation that never leaves an unsigned gap. Access to the platform is the first ITGC an auditor asks about. Where a customer's identity provider is configured to require signed requests (saml_config.sign_requests, per provider), the sign-in, sign-out and sign-out-response messages Paridity sends are signed RSA-SHA256 with one deployment-wide key held as a PEM bundle in the customer's secrets backend (SAML_SP_SIGNING_KEY_REF, Helm app.saml.spSigningKeyRef) - the key is a pointer, never a column, and production refuses to enable such a provider or to start without it (SAML_SP_SIGNING_KEY_REQUIRED) rather than sending an unsigned request in its place. The certificate the IdP verifies against is published with its SHA-256 fingerprint at GET /saml/{idp_id}/metadata and GET /saml/{idp_id}/sp-info and on the Authentication settings screen. Rotation (#3281) is two-phase: a next bundle is staged (SAML_SP_SIGNING_KEY_NEXT_REF, Helm app.saml.spSigningKeyNextRef) and both certificates are published side by side while every IdP picks up the new one; only the current key ever signs, the staged private key is parsed to prove it matches its certificate and then discarded; cut-over is a pointer move. A staged bundle that is unusable, identical to the current one, or unreachable at boot stops the API from starting or returns 503 SAML_SP_SIGNING_KEY_NEXT_INVALID on the publish surfaces - the platform never publishes one certificate while the operator believes two are published. Code: src/services/saml_sp_signer.py, src/services/saml_sp_service.py, src/api/routers/saml.py, src/api/routers/identity_providers.py; the IdP onboarding runbook (section 4b) is available to reviewers on request through your Paridity account contact.

Note: AI never determines correctness or completeness of the audit trail. Every fact above is a deterministic database write triggered by a defined workflow/activity step or a SQL script - not an inference. Where Paridity's AI features touch runs or evidence at all, they assist triage and explanation only; they never decide whether a run passed, never auto-confirm an admin action, and never write to ev.* or cfg.audit_log.

The two approved append-only exceptions

Evidence is append-only with exactly two documented lifecycle exceptions, both narrow and both logged:

  • ev.ev_anomalies - the lifecycle columns acknowledged_by, acknowledged_at, acknowledged_note, resolved_at, resolved_reason may be updated as an operator works an anomaly. Every other column on the row is immutable and is never touched by that update.
  • ev.ev_evidence_bundles - a two-phase write: the INSERT creates a pending row (metadata only); one subsequent UPDATE fills file_count, total_size_bytes, content_hash, previous_bundle_hash, manifest, and - since the period container is signed over exactly the bytes that ship (#2014) - manifest_sha256, signature, and signing_key_id, once generation completes. No further mutation is permitted after that. The statement is the one in src/workers/evidence/generate_bundle.py (GenerateEvidenceBundleActivity, the period container); a per-run bundle is written complete in a single INSERT (src/api/routers/evidence_bundles.py) and never updated. The three signature columns are not new columns: manifest_sha256 and signing_key_id date from migration 023_evidence_bundles.sql and signature from 062_evidence_bundles_enhancement.sql; #2014 added them to the existing UPDATE rather than introducing a second one.

Nothing else in ev is rewritten. The mutation sites the code tree may contain are declared once, in src/services/sox_audit_assertions.py: APPROVED_EV_MUTATION_SITES holds exactly the two sites above, APPROVED_EV_UPDATE_PATHS names the only columns each may touch (every other column is immutable by complement, so a column added later is immutable by default), and DERIVED_EV_PROJECTIONS - the list that would widen the rule - has been deliberately empty since the proj split. The static scan and the live xmin check (below) are both asserted against those declarations.

The evidence

Claim component How it's enforced Result
Config audit written before proof The config-audit write executes in the reconciliation workflow before the proof phase begins; recorded in ev.ev_config_audit. Verified in code - the activity executes before the proof phase begins on every run path.
config_checksum canonicalization is order/whitespace-insensitive Canonicalization and checksum computation in the config-resolution layer. Verified in code - sorted keys, sorted arrays, metadata stripped, json.dumps(..., sort_keys=True, separators=(",", ":")), SHA-256 over UTF-8.
compare_started_at is never updated after being set The run state machine (src/services/state_machine.py) rejects any status transition whose extra fields name compare_started_at (ValueError: compare_started_at is immutable), and every status write goes through it; the column is set once by CreateRunActivity. Verified in code and pinned by unit tests: one asserts the rejection, one asserts no evidence activity can update the column, and two assert the in-flight recheck and config re-pin UPDATEs never name it (the test pins are available to reviewers on request through your Paridity account contact). There is still no database-level trigger: db/migrations/001_base_schema.sql documents the column as immutable but no CREATE TRIGGER on run.runs exists (searched the migration set for triggers and for functions referencing the column). Immutability therefore rests on the application chokepoint plus the tests, not on the database engine.
ev.* evidence is append-only except two documented exceptions The SOX audit-trail verifier's check G4, a static code scan, and the single sanctioned deletion path (the retention purge). Verified and actively enforced, not left to convention. Check G4 snapshots every ev.* row's Postgres xmin across a real run and fails if any row's xmin moves outside the two approved lifecycle updates - a content comparison would miss a no-op rewrite, whereas xmin moves on UPDATE and nothing else. A separate static scan rejects both UPDATE ev.… and INSERT … ON CONFLICT DO UPDATE anywhere in the code tree - checking one pattern alone would miss the other. Retention purge is the only sanctioned DELETE FROM ev.*; the ev_anomalies/ev_evidence_bundles exceptions match the documented lifecycle columns (APPROVED_EV_UPDATE_PATHS in src/services/sox_audit_assertions.py). The scan is a unit test in the platform's suite (#1713, available to reviewers on request through your Paridity account contact); G4 is in scripts/verify_sox_audit_trail.py, which snapshots every base table in the ev schema by catalogue lookup (so a table added later is covered without editing the check). The proj split (#1724) is what made this row true as written: the live xmin check found four derived tables in ev being rewritten by upsert behind the "two exceptions" claim; migration 177_projection_schema.sql moved them to proj instead of adding them as exceptions, DERIVED_EV_PROJECTIONS is deliberately empty, and proj.* is by design not scanned - rewriting a projection is its purpose, and no projection is an audit source.
Watermark history is append-only A composite primary key (run_id, recorded_at) on the watermark-history table. Verified - the table records one row per observation, preserving full watermark history for SOX rather than overwriting the last-observed value.
Per-phase timestamps + worker_id in run.run_attempts The run.run_attempts table plus per-phase attempt-writing activities in the reconciliation workflow for readiness, proof, and other phases. Verified - readiness and proof phases both confirmed writing attempt rows with worker_id in the workflow.
Terminal statuses + admin actions logged to cfg.audit_log A single audit-log write chokepoint in the services layer. Verified - single chokepoint function referenced from workflow/worker/API call sites.
A configuration refusal is a named terminal, not an outage (config_error, #3034) The config_error value on run.run_status_t (migration 245), its audit action run.config_error (migration 246), its WARNING notification trigger config_refusal (migration 247), the allowed edges in RUN_VALID_TRANSITIONS (src/models/internal/enums.py) enforced by src/services/state_machine.py, and the terminal action map in src/workflows/reconcile_mapping.py. Verified in code - config_error is in RUN_TERMINAL_STATES, is reachable only from proof_running, proof_passed, diff_queued, diff_running, lag_retry_pending and transient_error (never from readiness, whose refusals stay readiness_error), has no outgoing edges, and writes one run.config_error audit row through the same chokepoint as every other terminal. All three enum values landed together so the audit write cannot fail for a missing action value (the defect that left readiness_error without audit rows from 2026-06-04 to 2026-08-19, about eleven weeks, repaired by migration 181). The SOX audit-trail harness (scripts/verify_sox_audit_trail.py) drives a dedicated leg - a mapping whose decryption key reference cannot be resolved - and asserts the run terminates config_error with its audit row, never system_error.
actor_ip populated from request The API authentication layer. Verified - the auth layer extracts X-Forwarded-For and feeds it to actor_ip.
Hash-chained, tamper-evident audit log The audit-log hash-chain columns (entry_hash, prev_hash, sealed_at) and the chain-verification logic in the services layer. Verified in code - chain fields exist, verification recomputes the chain from genesis and checks stored hashes.
Signed checkpoints anchor the chain against a full re-seal forgery Checkpoint records in cfg.audit_chain_checkpoints, exposed via the /audit/seal and /audit/verify endpoints. Supported - HMAC signing is the default (shipped with a development-insecure key that must be replaced before production); an ECDSA-P256 asymmetric signer is shipped and selectable via configuration. Each checkpoint is verified under the key that signed it, so rotating a signing key does not strand history - the outgoing key is declared among the deployment's recognized previous keys. Verification is tri-state: a checkpoint whose key this deployment no longer declares reads as unverified, never as tampering.
KMS-backed signing (private key never enters the app) The AWS KMS, Azure Key Vault, and GCP KMS signing clients in the services layer. Supported - the AWS KMS, Azure Key Vault, and GCP KMS signers are each live-verified against a real key in that cloud. The private key never leaves the vault/KMS, so a full application or database compromise still cannot forge a checkpoint.
Automatic chain sealing The automatic chain-sealing workflow, gated by a configuration flag. Supported - on by default since #2729 (set audit_chain_sealing_enabled=false / app.audit.sealingEnabled=false to opt out); manual "Seal now" / "Verify" via /audit/seal and /audit/verify are always available regardless of the flag.
External anchor / WORM export of the sealed chain Five external anchor clients in the services layer - S3 Object Lock, Azure Blob immutability, GCS retention lock, an RFC 3161 timestamp authority, and SIEM forwarding - wired into the seal/verify workflow and the /audit/seal endpoint. Verified - each signed checkpoint can be anchored to a write-once external store (S3 Object Lock, Azure Blob immutability, or GCS retention lock), timestamped by an RFC 3161 authority, and forwarded to a SIEM. Live-verified against real cloud stores, all five anchors together on one deployment; on verify, the four write-once anchors read back valid and the SIEM forward reads delivery_attested. A deliberate delete or overwrite is refused by the store itself (e.g. S3 AccessDenied, Azure BlobImmutableDueToPolicy), not by application code.
Signed, hash-chained evidence bundles The evidence-bundle schema (content_hash, previous_bundle_hash, signature, masking_state), the bundle generation service, the bundle signer resolver, and the bundle API endpoints. Verified - table, hash-chain columns, RBAC-gated generate/list/get/verify endpoints, and audit logging on every action all present. The bundle is built once and signed over exactly the bytes that ship: a detached signature over the manifest, which commits to every file's SHA-256 (reworked 2026-08 - the earlier format signed a pre-rebuild archive hash that never shipped and was not offline-verifiable). Which key signs is the tenant's bundle_signing_key_ref (settable through the API and the Settings screen, tenant-level with a platform fallback): a KMS key (AWS KMS / Azure Key Vault / GCP KMS) or a local ECDSA key signs asymmetrically - ECDSA-P256, the same signer adapters as the audit-chain checkpoints - and the bundle embeds the public key plus its SHA-256 fingerprint; a secrets-URI HMAC key keeps symmetric signing. Period exports are signed containers (#2014): the container manifest lists every nested per-run bundle by SHA-256 and carries its own detached signature; the signature over the container is recorded on the bundle row as manifest_sha256, signature and signing_key_id by the same single pending-to-generated UPDATE that completes the row (src/workers/evidence/generate_bundle.py), and APPROVED_EV_UPDATE_PATHS names exactly those columns, so a recorded signature can never be rewritten after generation. The container bytes are persisted in the platform database (append-only, inside the customer VPC) and downloadable through the product; the download serves exactly the signed bytes - the writer refuses to store bytes that do not hash to the recorded content_hash, the endpoint re-checks the hash as it streams, and every download is audit-logged before release. Live-verified 2026-08-28: a 327-run period container requested, generated and downloaded through the product verified credential-less with the KMS public key (#2018 transcript, available to reviewers on request through your Paridity account contact). Object-store tier (#2026): a tenant (or the platform) may name an S3 / Azure Blob / GCS bucket - the same destinations and cloud identity as the audit-chain anchors - and period containers are then written there (create-once, SHA-256 in the object metadata) with the database keeping only the location, hash and the store's receipt; the download streams the exact recorded object through the same hash check, and a container whose object is missing or whose store cannot be reached is refused with a named reason. Live-verified 2026-08-29 against a real S3 Object Lock bucket and LocalStack (#2026 transcript, on request as above), and 2026-08-30 on a real Azure Blob container under an immutability policy and a real GCS bucket under a retention policy - the same 29 legs each as S3, with the S3 runs repeated green first (#2038 transcript, on request as above).
Standalone offline verification tool The standalone offline bundle-verification tool shipped with the platform (also embedded inside every bundle). Verified - and not merely "ships": automated writer↔verifier agreement tests generate real bundles through the production writer for every signer kind (HMAC, local ECDSA, AWS KMS, Azure Key Vault, GCP KMS, and the period container) and run the shipped verifier against the produced bytes; every negative (tampered member, stripped signature, wrong key, wrong fingerprint, attacker-embedded key, HMAC key against an asymmetric bundle, public key against an HMAC bundle, tampered nested bundle) was proven to fail against a deliberately broken verifier before being trusted. Public-key mode is stdlib-only (ECDSA-P256 implemented in the single verifier file, no packages to install): --public-key <pem> for a key obtained out of band, or --public-key-fingerprint <sha256> to pin the copy embedded in the bundle. The embedded key is never trusted unpinned. The verifier is fail-closed: unsigned, unverifiable, wrong-key, wrong-kind-of-key, and fingerprint-mismatch outcomes exit non-zero with a named reason; hash-level-only checking requires an explicit, loudly-labelled flag. Live-verified 2026-08-28 against a real AWS KMS key: a bundle and a period container signed via KMS were verified in a throwaway container with no cloud credentials, no packages, and only the public key read directly from KMS; six can-fail legs exited 1 with the named reason (#2014 transcript, available to reviewers on request through your Paridity account contact). Azure Key Vault and GCP KMS bundle signing are covered by the same adapters that were live-verified for audit checkpoints (#1729) and by fake-client agreement tests, not by a live bundle run.
Bundles anchored to write-once storage (SEC 17a-4 / FINRA 4511 posture) The bundle-anchor service, the bundle.anchored audit action (migration 192), the anchor block on the bundle metadata endpoints, the anchor read-back in POST /evidence-bundles/{id}/verify, and the anchored_copy_retained_until facts on the retention purge's audit entry. Verified - when a tenant's object store is a write-once store (S3 Object Lock, Azure Blob immutability, GCS retention lock - the same destinations as the audit-chain anchors), the signed period container written there is the write-once copy, and the platform attests it rather than making a second one: in the same transaction as bundle.generate it writes a bundle.anchored audit entry naming the store, the exact object (bucket, key, version), the store's own protection statement and retain-until date, and the container's SHA-256 - only when the store confirmed immutability protection on that object; an unprotected bucket is shown as unprotected and never attested as an anchor. Bundle metadata and the Period audit bundles card show the anchor posture. Verification reads the anchored object back in full from the store and compares its SHA-256 with the platform record (anchor_valid): a write-once copy that disagrees with the record, or has vanished, fails the verification even when the caller's copy agrees with the record; an unreachable store is reported as unverifiable, never as a pass. The retention purge asks the store to delete the object with the record; a locked store refuses, and the purge's audit entry records how many anchored copies the store kept and the latest date the store said its protection lapses (anchored_copy_retained_until) - the copy outlives the platform record under the store's own lock, findable by its recorded location. Live-verified 2026-08-29 against a real S3 Object Lock bucket through the product and against a LocalStack COMPLIANCE-locked bucket (where the lock alone - no IAM - refused the delete): attestation, full read-back verification, tampered-copy / altered-record / absent-copy can-fails, overlay resistance, and the purge record (#2027 transcript, available to reviewers on request through your Paridity account contact). Live-verified 2026-08-30 on a real Azure Blob container under an immutability policy and a real GCS bucket under a retention policy - the same 29 legs each as S3, with the S3 runs repeated green first so the store-neutral refactor could not have changed what the S3 proof asserts. Each store's behaviour is asserted as that store's own: Azure refuses both the delete and an overlay write with BlobImmutableDueToPolicy, GCS with its retention-policy 403, and where Azure's container-level policy exposes no per-object retain-until date the record carries none rather than an invented one - pinned both ways (#2038 transcript, on request as above).
Masking is enforced end-to-end (mask_in_ui as a field-level setting - there is no tenant-wide mask policy today - and the store_raw_diff_values AND-chain across tenant, mapping and field) The mask_in_ui / store_raw_diff_values resolution rules and the bundle masking_state (full/masked/metadata_only). Supported - masking policy computation is verified in the config resolver (see the companion accuracy proof doc); bundle-level enforcement of the same policy at export time is reflected in the masking_state column. Independent confirmation that bundle generation re-applies mask_in_ui/store_raw_diff_values per field at export time (rather than only at live-UI query time) is a recommended verification step.
Retention purge is itself audited The retention-purge process. Verified - writes one cfg.audit_log row per (table, mapping) pair before each DELETE, including disabled mappings.
Evidence-bundle retention (bundle_retention_days) is enforced The bundle leg of the retention-purge process, the shared retention resolver, and GET /evidence-bundles/retention. Verified - the same purge process deletes bundle records generated longer ago than the resolved bundle_retention_days (tenant value, floored at the platform value - a tenant cannot shorten bundle retention below the platform minimum). The stored period container is removed by the database cascade, which the purge verifies before committing (an orphaned container aborts the run). A container kept in the tenant's object store (#2026) is deleted from the store by the same purge, and the store's answer is written per object on the same audit entry - deleted, retained by store (a bucket under Object Lock / immutability / retention, or an identity without delete rights, refused - recorded with the lock facts, never reported as freed) or already absent; a store that cannot be consulted aborts that group before any database change. Live-verified 2026-08-29: S3 refused the delete of a locked object and the audit entry recorded retained_by_store with the lock; on an unlocked store the object was deleted and confirmed absent. The audit entry written before the delete records rows removed from both tables, bytes freed, bundle and run ids, cutoff, policy, and the hash-chain boundary. Live-verified 2026-08-28 on the development database: a bundle past the cutoff and its container removed, a bundle inside the window untouched, the audit entry present, and a cutoff that qualified nothing deleted nothing (#2021 transcript, available to reviewers on request through your Paridity account contact). The nightly SOX audit-trail gate (check G5) seeds an aged bundle with a stored container and asserts the same.
Hash chain stays honest across retention The server-side chain verification (verify_evidence_bundle) and the purge's audit record. Verified - the first bundle surviving a purge names a predecessor that no longer exists; verification resolves that hash against the purge's audit entry and reports CHAIN_HEAD_PURGED (with purge date and policy) as a valid link. A missing predecessor that no purge entry names is BROKEN; a predecessor that still exists but disagrees is BROKEN regardless of any purge entry - retention can explain an absence, never a mismatch.
Legal holds freeze evidence against the purge, and the skip is audited The legal-hold entity (cfg.legal_holds), the hold exclusion on every purge leg, and the place/release API (POST /legal-holds, POST /legal-holds/{id}/release, GET /legal-holds). Verified - a legal hold on a tenant, a mapping, or a single bundle makes the retention purge skip the held scope: a held record past its cutoff is never deleted. Placing and releasing a hold is gated by an approved elevation (evidence.legal_hold, the same separation-of-duties gate as an ad-hoc purge) and both acts are written to cfg.audit_log. When the purge skips a held record it writes a legal_hold.skipped audit row naming the hold(s) and the count per table, so an auditor sees the hold working, not merely an absence of deletions. Releasing the hold makes the scope eligible again; an expired hold (past its optional expires_at) no longer protects. Live-verified 2026-08-29 on the development database: a held bundle past the cutoff survived --execute (its xmin unchanged) while an unheld aged bundle was purged in the same run, the skip was on the audit record naming the hold, releasing the hold made the bundle eligible and the next run deleted it, an expired hold protected nothing, and the API refused an ungated place with APPROVAL_REQUIRED and accepted it with an approved elevation - 24/24 legs (#2024 transcript, available to reviewers on request through your Paridity account contact). The live proof exercised the bundle, mapping, and tenant scopes against the bundle leg; the run-evidence and metrics legs apply the same hold exclusion (the identical covers_mapping predicate and active-hold SQL, which ran live with zero-row results in the same harness) and their non-zero skip paths are proven by unit tests rather than a live seed.
Retention purge runs on a built-in schedule A durable Temporal singleton (RetentionPurgeWorkflow) on the scheduler worker that runs the same run_purge chokepoint on a cadence, plus the deployment settings (retention_purge_*) and the schedule state on cfg.retention_purge_schedule. Verified - the platform ships a built-in scheduler that runs the retention purge automatically (daily at 04:00 UTC by default), so a retention policy is executed rather than merely intended. It is on by default but dry-run-first: fires within the first retention_purge_dry_run_days after install (default 7) compute and attest what would be deleted without deleting anything, so a fresh deployment never deletes on day one. It ships default-on safely because legal holds (#2024) land first - automatic deletion always skips a scope under a preservation order. Each fire writes one cfg.audit_log row (retention_purge.executed) so an auditor can list every scheduled run. Live-verified 2026-08-29 on the development database: the boot singleton fired dry-run-first on its own; a scheduled dry-run fire deleted nothing; a scheduled execute fire purged an aged bundle while a legal hold made it skip a held bundle in the same fire (the held bundle's xmin unchanged, a legal_hold.skipped row naming the hold); every fire wrote exactly one attestation row (#2025 transcript, available to reviewers on request through your Paridity account contact).
Pausing the scheduled purge is an approval-gated, audited compliance fact The pause/resume endpoints (POST /evidence-bundles/retention/pause|/resume), the evidence.retention_purge approval gate, and the retention_purge.paused/.resumed audit actions. Verified - an operator can pause the built-in schedule; a paused schedule still fires but deletes nothing (the no-op is attested with mode skipped_paused), which is itself a visible compliance fact - GET /evidence-bundles/retention reports enforcement=retention_purge_paused. Pausing and resuming require an approved evidence.retention_purge elevation (the same separation-of-duties gate as an ad-hoc purge; 403 APPROVAL_REQUIRED without one) and both acts are written to cfg.audit_log. Live-verified 2026-08-29: a paused schedule left a past-cutoff bundle intact, resume deleted it, the API refused an ungated pause and accepted an elevated one, and both acts were audited.
SAML sign-in requests are signed, and the signing key rotates without an unsigned gap (#250, #3281) The per-provider sign_requests opt-in (src/models/identity_provider.py), the SP signer (src/services/saml_sp_signer.py), the SP settings builder that pins RSA-SHA256 / SHA-256 (src/services/saml_sp_service.py), the metadata and sp-info publish surfaces (src/api/routers/saml.py), the IdP-enable guard (src/api/routers/identity_providers.py), and the Helm values app.saml.spSigningKeyRef / app.saml.spSigningKeyNextRef rendered to SAML_SP_SIGNING_KEY_REF / SAML_SP_SIGNING_KEY_NEXT_REF (deploy/helm/paridity/values.yaml, templates/configmap.yaml). Verified in code (PR #3288, merged 2026-10-26). One RSA key per deployment, loaded from a secrets-URI PEM bundle (PKCS#8 key + X.509 certificate; a non-RSA key is refused because RSA-SHA256 is the pinned algorithm); the PEM never lands in the database, settings or logs. Production with sign_requests on and no key is a refusal at IdP-enable (422 SAML_SP_SIGNING_KEY_REQUIRED) and at API start, never an unsigned send. The certificate and its SHA-256 fingerprint are public by design (/saml/{idp_id}/metadata as a signing KeyDescriptor, /saml/{idp_id}/sp-info, and the Authentication settings screen). Rotation: with a next ref staged the metadata carries both certificates (current first) and sp-info reports sp_signing_rotation_staged: true with the next certificate, its fingerprint and expiry; only the current key signs - the signing path reads the next certificate from cache and never loads the next private key, which is parsed once to prove it matches its certificate and then discarded. A staged bundle that is unusable, whose certificate equals the current one, or that is set while production has no current key fails closed: the API refuses to start naming which ref failed, and the publish surfaces answer 503 SAML_SP_SIGNING_KEY_NEXT_INVALID. Cut-over is a pointer move (spSigningKeyRef to the next secret, clear spSigningKeyNextRef); rollback within the window is the reverse move. Runbooks: the IdP onboarding runbook (section 4b) and the security checklist, available to reviewers on request through your Paridity account contact. Scope label: the key refs are a Helm / environment setting and the two-phase procedure is operator-run; the UI shows the certificates and the staged state but does not perform the rotation. This control is not live-verified against a customer IdP in this document's evidence; it is verified by code reading and the unit suite that merged with PR #3288.

Verify it yourself

  1. Confirm append-only evidence. As an auditor or platform_admin, attempt an UPDATE or DELETE against any ev.* table other than the two documented exceptions, from an ordinary application code path (not a database console) - there is no API surface that permits it. Ask your Paridity contact to walk through the retention-purge process if you want to see the one exception in code.
  2. Recompute a config_checksum. Pull a run's resolved config snapshot (cfg.config_versions.resolved_config_snapshot, linked via run.runs.config_version_id), apply the documented canonicalization rules (strip spec_version/generated_at/ generated_by, sort all object keys and the documented arrays, serialize with no whitespace, UTF-8 encode), take SHA-256, and confirm it matches the config_checksum stored on ev.ev_config_audit for that run.
  3. Verify an evidence bundle offline. Generate a bundle for a run (POST /runs/{execution_request_id}/evidence-bundle), download it, and run the shipped verifier. With a KMS / ECDSA signing key configured, anyone can verify with the public key alone - obtain it out of band from the data owner's KMS (e.g. aws kms get-public-key, az keyvault key download, gcloud kms keys versions get-public-key) and run python tools/verify_bundle.py --bundle <file> --public-key <pem>, or pin the embedded copy with --public-key-fingerprint <sha256>. With an HMAC key, run --key-from-env HMAC_KEY with the tenant's key from its own secrets backend. The verifier is fail-closed: exit 0 means every file hash, the hash chain, and the detached signature all verified; an unsigned, tampered, wrong-key, or wrong-kind-of-key bundle never reports as verified. The platform's own POST /evidence-bundles/{bundle_id}/verify round-trip endpoint is the server-side complement; the offline tool is the one that does not require trusting the platform, and the out-of-band public key is the one that does not require trusting the bundle.
  4. Walk the bundle hash chain. Generate two sequential bundles for the same mapping and confirm the second bundle's previous_bundle_hash equals the first bundle's content_hash.
  5. Verify the audit-log hash chain. Call GET /audit/verify (or the equivalent chain-verification endpoint) and confirm it reports the chain intact; if automatic sealing is enabled, confirm the signature on the latest checkpoint validates against the configured signing key/public key.
  6. Trace a run's phase timings. Pull run.run_attempts for a run_id and confirm there is a row for each phase the run went through (readiness, proof_count and proof_hash, and - on a failed proof - key_extraction, field_diff, lag_retry; plus scd_invariants where the mapping declares an SCD scope), each with started_at, completed_at, and worker_id populated.
  7. Trace a terminal status to its audit entry. Pick a run that reached a terminal status and confirm cfg.audit_log has a matching entry with actor, action, entity, and timestamp - and, for an admin-triggered cancellation or a bypass_readiness run, that a reason is present.
  8. Verify the write-once copy, not just your copy. For a period bundle written to a write-once store, call POST /evidence-bundles/{bundle_id}/verify and read anchor_valid / anchor.state: the server reads the anchored object back from the store in full and compares its SHA-256 with the platform record. Ask your Paridity contact to alter the platform record, tamper the anchored object, or remove it in a sandbox - verification must report the disagreement or the absence, never a pass, and an unreachable store must report unverifiable rather than either.
  9. Watch a legal hold stop a deletion. Place a hold (POST /legal-holds) on a mapping whose evidence is already past its retention cutoff - the API must refuse an ungated request with APPROVAL_REQUIRED until an evidence.legal_hold elevation is approved - then run the retention purge. The held records survive, cfg.audit_log carries a legal_hold.skipped row naming the hold and the per-table count, and releasing the hold makes the next purge delete them.
  10. Confirm no projection lives in ev. List the base tables in the ev schema (information_schema.tables WHERE table_schema = 'ev') and confirm none of run_summary, metric_rollup_hourly, run_metric_baselines, diff_run_limits or run_consumption (with or without an ev_ prefix) is among them; they are in proj, whose schema comment states it is never an audit source. Then run the static scan (the #1713 unit test in the platform's suite, available to reviewers on request through your Paridity account contact) and confirm it reports exactly the two declared mutation sites.
  11. Trace a configuration refusal. Pick a run whose status is config_error (shown as Config refusal) and confirm cfg.audit_log holds a run.config_error row for it with actor, entity and timestamp, that the run's error_code names the configuration defect, and that no run.system_error row exists for the same run. If the deployment uses notifications, confirm the alert fired under the config_refusal trigger at WARNING, not under system_error.
  12. Check the certificate your IdP verifies against. Call GET /saml/{idp_id}/sp-info (or open the Authentication settings screen) and compare sp_signing_certificate_fingerprint_sha256 with the certificate uploaded to your identity provider. During a staged rotation, confirm sp_signing_rotation_staged is true, that GET /saml/{idp_id}/metadata lists both certificates as signing keys, and that the signature on a captured AuthnRequest still verifies against the current certificate; after cut-over confirm the fingerprint has moved and sp_signing_rotation_staged is false.

Tip: An SE-led session can walk through steps 2-6 live against a sandbox mapping, including a deliberately tampered bundle so you can see verification fail as expected.

Limitations

Scope Status Notes
Append-only ev.* evidence with two documented lifecycle exceptions Verified ev_anomalies lifecycle columns and the ev_evidence_bundles pending→generated update are the only sanctioned mutations; core evidence columns are never touched. Enforced on every run, not by convention alone: the SOX audit-trail check G4 snapshots each row's xmin across a real run, and a static scan rejects both UPDATE ev.… and INSERT … ON CONFLICT DO UPDATE in the tree.
Derived projections live outside ev (proj schema, #1724) Verified Migration 177 moved the four rewritten-in-place tables out of ev; migration 260 created a later projection directly in proj. proj.* is recomputable from ev.*, is never an audit source, and is deliberately not covered by the append-only checks. It is data-plane: derived from evidence values, so it stays inside the customer VPC like ev.
Config audit before proof (config_checksum) Verified Confirmed in the workflow code path for every run.
compare_started_at immutability Supported Enforced by the state-machine chokepoint (a write naming the column is rejected) and pinned by unit tests; not by a database-level trigger or constraint - see the evidence table.
Configuration refusals are a distinct audited terminal (config_error) Verified Migrations 245/246/247; edges in RUN_VALID_TRANSITIONS, enforced by the state machine; one run.config_error audit row per run; WARNING config_refusal notification. Exercised by a dedicated leg of the SOX audit-trail harness. Readiness-phase refusals remain readiness_error; partition sub-rows have no config_error value of their own and record system_error at the partition level while the run terminates config_error.
Signed SAML requests and two-phase SP key rotation Supported Verified in code and by the unit suite merged with PR #3288 (2026-10-26); not live-verified against a customer identity provider in this document's evidence. The key refs are Helm / environment settings; the rotation is operator-run from the runbook, with the UI showing the certificates and the staged state. Only providers configured with sign_requests receive signed requests; an IdP that does not verify request signatures is unaffected either way.
Per-phase timing + worker_id in run.run_attempts Verified Confirmed for readiness and proof; the same attempt-writing activity is called for each phase.
Append-only watermark history Verified Migration-level enforcement ((run_id, recorded_at) composite PK).
Audit-log hash chain (tamper-evidence) Supported Chain + verification code shipped; sealing is on by default (#2729) - a deployment that opts out has a hash-chain mechanism present but not actively advancing until sealing is re-enabled.
HMAC checkpoint signing Supported Default signer; symmetric - only as strong as the secret's storage (an inherent property of HMAC signing, not a Paridity-specific weakness). Ships with an insecure default key that must be changed before production use.
ECDSA-P256 asymmetric signing Supported Shipped; lets an external auditor verify with only the public key.
Asymmetric (KMS / ECDSA) evidence-bundle signing - third-party verification with the public key alone Verified Live-verified against AWS KMS: bundle + period container verified credential-less with the public key read from KMS, no shared secret; six can-fail legs. Azure Key Vault / GCP KMS bundle signing use the live-verified checkpoint adapters and are covered by agreement tests, not a live bundle run.
Period evidence bundles downloadable through the product, bytes exactly as signed Verified Live-verified 2026-08-28 (#2018): period container requested via the API, persisted, downloaded, and verified credential-less with the KMS public key; sha256(download) == server content_hash; named refusals for pending / over-cap / not-retained; download audit-logged; serving mutates no evidence row.
Period containers can live in the customer's own object store, with no size cap and no bytes in the platform database Verified Live-verified 2026-08-29 (#2026): with evidence_bundle_store_ref set to a real S3 Object Lock bucket the container was written by the product (create-once, SHA-256 in the object metadata), the database row recorded location + hash + the store's receipt and no bytes, the download streamed the exact object byte-identical and verified offline, S3 itself refused the retention delete (retained_by_store on the audit entry with the lock facts), and on LocalStack a tampered object was refused mid-stream and an unlocked object was deleted and confirmed absent. Live-verified 2026-08-30 (#2038) on a real Azure Blob container under an immutability policy and a real GCS bucket under a retention policy - the same 29 legs each, with the S3 runs repeated green first so the store-neutral refactor could not have changed what the S3 proof asserts. Each store's behaviour is asserted as that store's own: Azure refuses the delete and an overlay write with BlobImmutableDueToPolicy, GCS with its retention-policy 403, and where Azure's container-level policy exposes no per-object retain-until date the record carries none rather than an invented one - pinned both ways.
Period bundles anchored to write-once storage, attested and verifiable by read-back (SEC 17a-4 / FINRA 4511 posture) Verified Live-verified 2026-08-29 (#2027): the container the product wrote to a real S3 Object Lock bucket was attested (bundle.anchored, same transaction as generation, with the lock mode, retain-until, object version and SHA-256), read back in full by the verify endpoint and matched; a tampered local copy, a platform record altered to disagree with the anchored object, and an absent anchored copy each failed verification with a named reason; S3 refused the delete of the anchored version and an overlay write left the anchored version verifiable; the retention purge recorded the anchored copy the store kept and until when. On LocalStack a COMPLIANCE lock alone (no IAM) refused the delete. The store's lock is what makes the copy write-once - the platform attests and verifies it; an unlocked bucket is reported unprotected, never anchored. Live-verified 2026-08-30 (#2038) on a real Azure Blob container under an immutability policy and a real GCS bucket under a retention policy - the same 29 legs each, with the S3 runs repeated green first so the store-neutral refactor could not have changed what the S3 proof asserts. Azure refuses both the delete and an overlay write with BlobImmutableDueToPolicy and GCS with its retention-policy 403 - each store's own refusal, not application code - and where Azure's container-level policy exposes no per-object retain-until date the record carries none rather than an invented one, pinned both ways.
AWS KMS signing (private key never leaves KMS) Supported Live-verified against a real AWS KMS key.
Azure Key Vault / GCP KMS signing Supported Both signers live-verified against a real key in that cloud, alongside AWS KMS.
External WORM anchor (S3 Object Lock / Azure Blob immutability / GCS retention lock / RFC 3161 TSA / SIEM forward) Verified All five anchors live-verified together against real cloud stores; a deliberate delete or overwrite is refused by the store itself, not by application code.
Automatic continuous chain sealing Supported On by default since #2729; manual "Seal now" remains available. A deployment that opts out and never clicks Seal has a verifiable-on-demand chain, not a continuously-advancing one.
Signed, hash-chained evidence bundles Verified Schema, generation service, and RBAC-gated API confirmed in code.
Bundle masking enforcement at export time Supported Masking policy computation itself is verified; independent confirmation that bundle generation re-applies mask_in_ui/store_raw_diff_values per field at export time is a recommended verification step - flagged in the evidence table.
Retention purge is audited before deletion Verified Confirmed in the retention-purge process.
Evidence-bundle retention is enforced, cascade included Verified Bundle records and their stored containers age out under bundle_retention_days through the same audited purge; the chain boundary is recorded so verification reports a purged predecessor as CHAIN_HEAD_PURGED, never as tampering. Live-verified 2026-08-28.
Retention purge runs unattended on a built-in schedule Verified A durable Temporal singleton runs the purge automatically (daily by default), on by default and dry-run-first for the first days after install; legal holds make automatic deletion always skip preserved evidence. Live-verified 2026-08-29 (#2025): the boot singleton fired dry-run-first, a scheduled execute fire purged an aged bundle while honouring a hold, and every fire wrote one attestation row.
Pausing the schedule is approval-gated and audited Verified Pause/resume requires an approved evidence.retention_purge elevation and is written to cfg.audit_log; a paused schedule fires but deletes nothing (attested), and the retention status reports the paused state. Live-verified 2026-08-29.
Deployment model Verified The entire platform - including all evidence, the audit log, and any signing keys - runs inside your VPC. Nothing described in this document sends evidence or audit data to Paridity-operated infrastructure.