Evidence¶
Navigate: sidebar Monitor → Evidence (/evidence), or View Evidence
from any run.
Evidence is what separates a reconciliation platform from a monitoring tool: not "these datasets differ", but which rows, which columns, what values, under which policy, at what time — recorded append-only and exportable in a form an auditor can verify without trusting us.
Choosing what to look at¶
Pick a Mapping, then a Run. You can also paste a run id directly — the
field accepts either a run id or an execution-request id, despite the run_…
placeholder. Partitioned runs get an extra Partition selector.
Three views, each with a live count:
| View | Answers |
|---|---|
| Mismatch Keys | Which rows disagree. |
| Field Diffs | Which columns within those rows, and what the two values were. |
| Process Steps | What the platform actually did, phase by phase. |
Mismatch Keys¶
Columns: Key Values, Type, Source Hash, Target Hash, Sampled.
The Type vocabulary is:
| Value | Meaning |
|---|---|
missing_in_target |
The row exists in the source and not the target. |
extra_in_target |
The row exists in the target and not the source. |
row_hash_mismatch |
The row exists on both sides but its content fingerprint differs. |
The Runs page calls these "Missing in Tgt" and "Missing in Src". Same defects, different wording — worth knowing when you move between the two screens.
Field Diffs¶
Columns: Key, Column, Source, Target, Sampled. Repeated keys collapse to a ditto mark so a row with eight differing columns reads as one block.
Process Steps¶
A vertical timeline of the run's phases: Config Policy Locked, Readiness Evaluation, Prior Open Issues Rechecked, Proof — Row Count, Proof — Hash Fingerprint, Key Extraction, Field-Level Comparison, and In-Flight Recheck - Attempt N.
Each carries a state: Passed, Failed, Error, Held, Skipped, Running, or Not reached. Held is deliberate restraint — a readiness hold — and is styled differently from a failure on purpose.
Readiness Evaluation says when replication latency was not evaluated, and why: no replication tool is configured, the watermarks are sequence-domain (offsets or versions, which carry no time lag), or the watermarks could not be read as timestamps.
Key Extraction states the rows scanned on each side (the comparison-window
row counts from the proof phase), the throughput that implies, and whether row
hashes were computed inside the engines (native pushdown) or on the platform's
reference path. Where the engine pair has a documented key-extraction floor
(PostgreSQL and MySQL 25,000 rows/s, Snowflake and Databricks 50,000 rows/s; a
mixed pair uses the lower), the step says whether the run met it or was below
it. A run at less than half its floor also raises a throughput_floor anomaly
on the mapping; while that anomaly is open, later slow runs do not raise
another, and resolving it re-arms the check. Field-Level Comparison states how many mismatched keys were
comparable (present on both sides) and how many were one-sided; one-sided keys
are never fetched for field-level comparison. When the max_diff_rows cap limited
the comparison, the step says so - it names how many of the comparable rows were
fetched (the first N by key order, or at most N per partition on a partitioned
run) and shows a Capped by max_diff_rows value. A number the run did not record
reads "not measured" - never 0.
Resources used sits at the bottom of the timeline, collapsed. Open it to see what each system metered for the run's own statements, grouped by side and then by phase, in the provider's own units (bytes scanned, credits, DBUs, billed TiB, slot-hours, bytes transferred). The side header names the system; an engine with no provider meter is labelled usage only, no provider meter. Each phase has a query IDs disclosure with the provider's query or job IDs (monospace, copyable) so a figure can be matched to the warehouse's own query history. The compute arithmetic appears only for a side with a credit or DBU amount, and a Typical for this mapping line gives the p50 / p95 of comparable runs per unit. An amount the provider did not report reads unmeasured, never 0; there is no currency anywhere. Details in Reconciliation Consumption.
Masking — what you see and why¶
A masked value renders as a small grey chip reading [masked]. A genuinely
null value renders as italic NULL. There are three independent reasons a
value can be masked, and the distinction matters for an audit conversation.
| Mechanism | When it applies | Can it be undone? |
|---|---|---|
store_raw_diff_values = FALSE |
At write time. The diff worker never stores the values — it writes NULL and sets values_masked. |
No. No permission can reveal what was never recorded. |
mask_in_ui = TRUE |
At read time, per query. | Yes — it is policy, and it applies retroactively to historical evidence the moment you set it. |
| Data classification | At read time, by role. internal/confidential need evidence.read_raw; restricted needs audit.read; regulated needs evidence.export_restricted (auditor only). |
Yes, with the right role. |
Two honest caveats:
- The UI does not currently distinguish "never stored" from "stored but
you may not see it" — both show
[masked]. For an audit conversation that distinction matters; check the mapping'sstore_raw_diff_valuessetting to tell them apart. - A value suppressed purely by classification currently renders as
NULLrather than[masked], which is indistinguishable from a genuinely null value. Do not rely on the chip alone when classification gating is in play.
Mismatch-key hashes are never masked — they identify rows, they do not reveal contents.
Exporting¶
Export CSV exports the entire result set, not just the page you can see —
it walks every server page. Files are written with a UTF-8 byte-order mark so
Excel opens them correctly, and masked cells contain the literal [masked].
Requires the evidence.export permission. Without it the button is visible but
disabled.
Evidence bundles — the auditor deliverable¶
From a terminal run's detail page, Export for Auditor produces a signed ZIP. This is the artifact you hand to someone who does not have access to the platform and should not have to trust it.
What's inside¶
| Folder | Contents |
|---|---|
| (root) | manifest.json, README.txt |
run/ |
Run summary, a human-readable execution narrative, run attempts, watermark observations, in-flight rechecks |
config/ |
The resolved config snapshot, its checksum, and the known column set — the exact policy this run was pinned to |
evidence/ |
Proof results, mismatch keys, field diffs, schema-drift events, run metrics, diff limits |
audit/ |
The audit-log entries for the run and the status-transition chain |
verify/ |
verify_bundle.py and, for asymmetric (KMS / ECDSA) signing, public_key.pem |
Why it is tamper-evident¶
The manifest records a SHA-256 for every file, a hash_chain over all of
them, and a previous_bundle_hash linking to the prior bundle for the same
mapping — that last field means a removed bundle is as detectable as an
altered one. A detached manifest.json.sig signs SHA-256 of the manifest, so
one signature covers exactly the bytes that ship. Which key signs is the
tenant's Bundle signing key (Settings → Configuration defaults): a KMS key
(awskms://…, azurekv://…, gcpkms://…) or a secret holding an ECDSA
private key signs asymmetrically (ECDSA-P256) and the bundle embeds the public
key and its fingerprint; a secret holding an HMAC key signs symmetrically. The
Audit page shows which is in force and, for asymmetric signing, the public key
and fingerprint to publish to auditors. Period exports are signed containers
over individually signed per-run bundles.
Period bundles (a date range of runs for one mapping) are requested from
the mapping page — Mappings → mapping → Runs tab → Period audit bundles:
pick the range, Generate period bundle, and the row shows Generating…
until the evidence worker has built and stored the container, then
Download. The download is the exact signed container: the platform refuses
to keep bytes that do not hash to the recorded content_hash, re-checks the
hash as it streams, and the browser discards a file whose SHA-256 disagrees
with the server's X-Content-SHA256 header. A container larger than the
retention cap (evidence_bundle_max_persist_bytes, default 512 MiB) keeps its
signed metadata but is labelled Too large to retain rather than offered as a
download — unless an object store is configured. Per-run bundles are
streamed at export and are not retained — re-export them from the run.
Object store for containers (#2026). Set Evidence bundle object store
(Settings → Configuration defaults → Audit bundles; evidence_bundle_store_ref,
tenant value with a platform fallback) to an S3 bucket
(s3worm://<bucket>[/<prefix>]), an Azure Blob container
(azureworm://<account>/<container>[/<prefix>]) or a GCS bucket
(gcpworm://<bucket>[/<prefix>]) — the same destinations and cloud identity as
the audit-chain anchors; the value is a destination, never a credential. From
then on every period container is written to that bucket (create-once, the
SHA-256 recorded in the object's metadata) and the platform database keeps only
its location and hash, so the size cap no longer applies and the database and
its backups stop carrying container bytes. The row shows Ready (object store)
and Download streams the container from the bucket through the same
integrity check — the platform serves the exact object it recorded (by version
where the store keeps versions) and aborts the download if the bytes do not
hash to the recorded content_hash. If the object has been removed from the
bucket the download says so (object missing); if the bucket cannot be reached
it says that instead — never "no bytes". A store that is configured but cannot
be written fails the bundle request loudly rather than quietly falling back to
the database. GET /evidence-bundles/store states the store in force for
scripts. The deployment setting evidence_bundle_store_threshold_bytes
(default 0 = all) can keep small containers in the database and send only
larger ones out.
Retention applies to stored objects too: the purge deletes the object with the record and writes the store's answer per object on the audit entry — deleted, retained by store (the bucket's Object Lock / immutability / retention policy, or an identity without delete rights, refused; the entry carries the lock facts) or already absent. A refused delete is recorded as exactly that, never reported as freed.
Write-once anchoring (#2027). If the object store you name is a write-once
store — an S3 bucket with Object Lock, an Azure container with an immutability
policy, a GCS bucket with a retention policy — the container the platform writes
there is your write-once copy, and the platform attests it: alongside the
bundle's generation entry it writes a bundle.anchored audit entry naming the
store, the exact object (bucket, key, version), the store's own statement of the
protection, the date the protection lapses, and the container's SHA-256. (S3 and
GCS state that date per object; an Azure container-level immutability policy does
not expose a per-object date, so for Azure the entry carries the policy statement
and no date rather than an invented one.) This is
written only when the store confirmed the protection on that object — a
bucket without a lock shows Store unprotected on the row and is never presented
as an anchor. The row shows Anchored (hover for the lock mode and the
protected-until date). Verify anchor asks the server to read the anchored
object back from the store in full and compare it with the record: verified
means the bytes match and the store still reports the protection; invalid
means the write-once copy and the platform record disagree (one of them was
altered); absent means the copy is gone; unverifiable means the store could
not be reached — reported as exactly that, never as a pass. Scripts get the same
from POST /evidence-bundles/{id}/verify (anchor_valid, anchor.state).
When retention later removes the bundle record, the store's lock keeps the
anchored copy; the purge's audit entry records how many anchored copies were
kept and until when (anchored_copy_retained_until), and the retention line on
this card says the same. This is the posture SEC 17a-4 / FINRA 4511 ask for:
the record is on write-once storage from the moment it exists, the platform can
prove it, and deleting it is refused by the store, not by application code.
Retention. Bundle records — and the stored period containers behind them —
are kept for Bundle retention (days) (Settings → Configuration defaults;
default 2555 = 7 years). The tenant value cannot go below the platform value:
the platform row is a floor, not just a fallback. The built-in retention purge
schedule (a durable Temporal singleton, on by default and dry-run-first for the
first days after install — see Retention schedule below) that ages out run
evidence enforces it: on its next run, every bundle generated longer ago than the
retention window is deleted, its stored container goes with it (the purge checks
the cascade and aborts if a container survives), and one audit entry per mapping
is written before the delete — bundle and run ids, rows removed from both
tables, bytes freed, the cutoff and the policy that applied. The mapping's
Period audit bundles card states the policy in force, what is waiting for the
next purge run, and the last purge on record (GET /evidence-bundles/retention
is the same statement for scripts). A bundle ages from its own generation time,
not from its run's start.
Retention schedule (#2025). The purge runs automatically on a cadence (daily by default), so your retention policy is enforced without anyone remembering to run it — the difference between a policy and an intention. It is safe to run automatically because a legal hold always freezes evidence you must preserve, and it is dry-run-first after install so a new deployment attests a summary before it ever deletes. Each fire — dry-run, execute, or a paused no-op — writes one audit row, so an auditor can list every scheduled run. An operator can pause the schedule (an approved, audited act): a paused schedule still fires but deletes nothing, which is itself a visible compliance fact. Settings → Retention Schedule shows the state (enabled, cadence, dry-run window, paused, last fire) and is where operators pause and resume.
The hash chain across a purge. The first surviving bundle still names the
purged predecessor in previous_bundle_hash. That is retention, not tampering,
and the server-side verification says so: the purge's audit entry records the
newest hash it removed for the mapping, so verify_evidence_bundle reports the
link as CHAIN_HEAD_PURGED — with the purge date and the policy applied — and
chain_link_valid stays true. A predecessor missing with no purge entry
naming it is BROKEN. The offline verifier prints the link for the record; it
never judges it (it has no view of the predecessor) and it is never a reason on
its RESULT line.
Legal holds — freezing evidence against the purge. When evidence must be
preserved for litigation, a regulatory inquiry, or an active audit, place a
legal hold. A hold freezes a scope — a whole tenant, one mapping, or a single
bundle — and the retention purge skips it: a held record past its cutoff is never
deleted. Placing and releasing a hold needs an approved evidence.legal_hold
elevation (a second person approves, the same separation of duties as an ad-hoc
purge) and both acts are audited. Settings → Legal Holds lists what is held
and is where you place and release holds; a held run or bundle carries a Held
label with the reason, and the Period audit bundles / retention card shows active
holds and what they hold back (nothing under a hold is ever counted as "eligible"
for the next purge). When the purge skips a held record it records the skip —
which hold, how many rows, which table — so the trail shows the hold working, not
a silent gap. Release the hold when the matter closes and the scope ages out
under the normal policy again; a hold given an expiry stops protecting once it
passes.
The bundle row and its audit entry are written before the ZIP is released. If either write fails, you get an error and no bundle — evidence is never handed out unrecorded.
Verifying — the part that matters to an auditor¶
# asymmetric (KMS / ECDSA) signing — anyone can verify with the PUBLIC key:
python verify/verify_bundle.py <path-to-this-zip> --public-key public_key.pem
python verify/verify_bundle.py <path-to-this-zip> --public-key-fingerprint <sha256-hex>
# HMAC signing — needs the tenant's signing key from its key custodian:
python verify/verify_bundle.py <path-to-this-zip> --key-from-env PARIDITY_BUNDLE_HMAC_KEY
It recomputes every file hash and the hash chain, then checks the detached
signature. Exit code 0 means integrity and authenticity verified; 1
means tampered, unsigned, wrong key, or the wrong kind of key — the RESULT
line names the reason. It needs only Python 3.9+ (no packages — ECDSA
verification is built into the single file) and it does not contact
Paridity — that independence is the point.
Where the public key comes from matters. The copy embedded at
verify/public_key.pem is a convenience: a bundle rebuilt by an attacker would
carry the attacker's key. The assurance path is a key — or just its SHA-256
fingerprint — obtained out of band from the data owner's KMS or key
custodian (aws kms get-public-key, az keyvault key download, gcloud kms
keys versions get-public-key). The verifier never trusts the embedded key
unpinned; --public-key-fingerprint pins it to what you were given.
A server-side verification endpoint also exists for round-tripping a downloaded bundle, though there is no button for it in the UI today.
Masking modes¶
| Mode | Effect |
|---|---|
masked (default) |
Applies the store_raw_diff_values chain and each row's stored masking. |
metadata_only |
Strips every value and every row hash — counts and structure only. |
full |
Raw values where policy permits. platform_admin only — a request from anyone else is silently downgraded to masked, and the downgrade itself is audited. |
One documentation caveat: the
README.txtinside each bundle states that fields withmask_in_uiare labelled<MASKED>. No code emits that label. Field-levelmask_in_uireaches the bundle only indirectly, when the write-time gate had already suppressed the value. Treat the README's wording as aspirational until it is corrected.
Finding earlier bundles¶
A terminal run's detail page lists Prior Audit Bundles with the generation date, who requested it, whether it was signed, and the file count and size. The list is read-only — re-downloading an earlier bundle is not available in the UI.
Masked values and Reveal¶
A masked cell shows [masked] because policy suppressed the raw value — either
the store_raw_diff_values chain, or automatically because the field is
encrypted at rest (a field with a decrypt expression never stores plaintext
in evidence; there is no setting that overrides this).
Masked does not mean lost. For runs on tenants with an evidence key configured, the platform stores each side's value encrypted at run time, plus a keyed fingerprint. The fingerprint alone answers most triage questions — has the source changed again since the last run, do these rows share one wrong value — without anyone seeing content.
Reveal appears on masked rows for users holding evidence.read_raw
(stewards, auditors, admins — the same permission that has always separated
value evaluation from triage). It offers two modes, and the difference
matters:
- As of run (default) decrypts the stored values as the run compared them — a point-in-time record, not a re-read of the live systems — which is what impact assessment needs when a decision or report relied on the run.
- Now (live) re-reads the current values directly from the source and target systems (it can take up to ~30 seconds while both systems are queried). Each side is labelled unchanged since run or changed since run, verified against the fingerprints the run recorded — so you know immediately whether the row moved after the comparison, including when it was deleted outright ("row no longer exists"). Use it to check whether a discrepancy has since been corrected before acting on it.
Two things to know before clicking either:
- Every reveal writes an audit entry — who, which run, which keys and columns, and which mode. Viewing a sensitive value is itself an audited event, and the dialog says so up front.
- Runs from before the evidence key was configured have no stored envelopes; Reveal explains this rather than failing. Only their fingerprints exist (and live mode reports "no fingerprint to compare" for those rows).
Enabling Reveal. Reveal depends on one tenant setting: the evidence key
(evidence_kek_ref), a pointer into your secrets backend — never the key
itself. A platform admin sets it on Settings → Platform & Tenant Defaults →
Evidence key (secrets pointer); the same screen shows Masked-value reveal:
available or not configured, and GET /tenant/config-defaults reports it as
masked_evidence_reveal_available. Until it is set, the platform does not
offer Reveal and then apologise: the Reveal control on masked rows is
disabled and the Field Diffs view says why —
- "Masked-value reveal is not configured for this tenant — set the evidence key under Settings" when the tenant has no key, or
- "This run was recorded before the evidence key was configured; only fingerprints exist" when the tenant has one now but this run's pinned configuration did not (runs pin their configuration at start; the key takes effect on each mapping's next run).
Each run's detail carries reveal_available_as_of_run,
reveal_available_live and masked_evidence_reveal_configured so an
integration can tell the two cases apart the same way. Development stacks ship
with a dev-only key already configured (env://RECON_DEV_EVIDENCE_KEK); a real
deployment points at AWS Secrets Manager, Azure Key Vault, GCP Secret Manager,
Infisical or Vault. See the configuration reference for the option itself.