04 — Evidence Model and Freshness Engine¶
| Status | IMPLEMENTED: verification, confidence levels and ledgers in domain/evidence.py and store/evidence_store.py |
| Related | ADR-004, ADR-007, ADR-016, ADR-017, config/evidence-types.yaml, schemas/evidence/evidence-manifest.schema.json |
1. Principles¶
- Evidence is an observation, not a claim. It records who observed what, about which exact thing (commit, digest, setting), when, with which tool and version.
- The platform collects evidence; it does not accept assertions. Pipeline evidence is pulled from GitHub and verified (section 4). Configuration evidence is read from authoritative APIs. Only manual evidence is entered by people, and it carries their identity and an SoD check.
- Evidence is immutable. Corrections are new records (revocation, supersession), never edits.
- Evidence expires. Freshness is computed, not assumed. Expired evidence is retained but no longer satisfies controls.
- Every claim is traceable from dashboard number → control state → evaluation → evidence IDs → raw artefact SHA-256 in WORM storage.
2. Evidence record¶
| Field | Type | Notes |
|---|---|---|
evidence_id |
UUIDv7 | Time-ordered; external display EV-<base32> |
evidence_type |
text | Registry key, e.g. scan.sast |
application_id |
UUID | Resolved at ingestion from repo → app links (nullable for org-level evidence) |
scope_type / scope_ref |
enum / text | repository + repo node ID · artifact + digest · cloud_scope · organisation |
subject |
jsonb | What was observed: {repo, ref, commit_sha} or {artifact_digest, artifact_name} or {setting_path} |
producer_type |
enum | approved_workflow · platform_connector · manual · external_system |
producer_ref |
text | e.g. example-org/security-workflows/.github/workflows/sast.yml@<sha>, connector:github@1.4.2, user:<entra-oid> |
tool / tool_version |
text | e.g. codeql / 2.19.1; trivy / 0.56.2 |
policy_ref |
text | Ruleset or policy version, e.g. codeql-suite:security-extended@<pack-version> |
observed_at |
timestamptz | When the observation occurred (tool run end) |
ingested_at |
timestamptz | When the platform recorded it |
result |
enum | pass · fail · findings · inconclusive · error · observed (for config snapshots) |
summary |
jsonb | Normalised summary (counts by severity, languages covered, URLs crawled, settings values) |
confidence |
enum | C0–C3, M1, M2 (section 3) |
confidence_reasons |
text[] | e.g. ["approved_workflow_verified","attestation_verified","non_ephemeral_runner"] |
run_ref |
jsonb | {run_id, run_attempt, workflow_ref, event, actor, runner_environment} |
raw_location |
text | Blob URI (platform-generated) |
raw_sha256 |
bytea[] | Digests of raw files |
attestation_ref |
text | Attestation bundle digest (Sigstore) if present |
valid_from / valid_until_hint |
timestamptz | Initial freshness computation. The authoritative state is the freshness engine's (section 5). |
prev_hash / record_hash |
bytea | Hash chain (section 6) |
The canonical JSON form of a record (RFC 8785 JCS) is what is hashed.
3. Confidence levels¶
| Level | Name | Definition | Example | Can satisfy |
|---|---|---|---|---|
| C0 | Declared | A team claim in .security/*.yaml or a form, with no observation |
sast: enabled: true in a YAML file |
Nothing (informational only) |
| C1 | Configured | The platform observed a setting via an authoritative API | Ruleset requires 2 approvals; workflow file exists | Configuration controls only |
| C2 | Executed | The platform verified an approved workflow ran on a specific commit/digest and produced a result | sast.yml@sha run 123 on commit abc, CodeQL 2.19.1, 3 languages |
Activity controls |
| C3 | Attested / authoritative | C2 + verified Sigstore attestation and hash-matched raw output, or data pulled directly by the platform from the system of record | GHAS code-scanning analyses API; secret-scanning alerts API | All automated controls |
| M1 | Manual, self | Recorded by the owning team with artefacts | Team records threat-model review | Manual controls where policy allows |
| M2 | Manual, independent | Recorded or approved by someone outside the team; SoD enforced | AppSec architecture review sign-off | All manual controls |
Downgrade rules (applied automatically, recorded in confidence_reasons): evidence from a non-ephemeral self-hosted runner is capped at C2 (threat T-03); evidence whose tool version is on the revocation list is invalid; evidence produced on a non-default branch can satisfy only PR-scoped controls.
Invariant: "The platform must never claim a control is satisfied because a repository contains a configuration file." A workflow file present in the repo is C1. Every activity control requires ≥ C2, and
tools/catalogue/validate.pyenforces that no control's minimum confidence is unreachable from its evidence types.
4. Producer verification¶
The trusted producer registry (config/trusted-producers.yaml, E08) lists:
workflows:
- path: example-org/security-workflows/.github/workflows/sast.yml
evidence_types: [scan.sast]
approved_shas: [<sha of v1.3.0>, <sha of v1.4.0>] # release process appends; old SHAs retire after 90 days
connectors:
- id: github
evidence_types: [repo.ruleset, repo.security_settings, ...]
Verification steps for workflow evidence (all must pass, otherwise the evidence is rejected with a reason code and an audit event):
- Run exists and completed: GET the workflow run via the GitHub App.
conclusion ∈ {success, failure}. A failed scan step is still evidence, with resulterror. - Approved caller: the run's
referenced_workflows[]contains an approvedpath@shafor the evidence type → otherwiseUNAPPROVED_WORKFLOW_REF. - Attestation: the artifact's attestation verifies (Sigstore bundle), and its certificate's Build Signer URI equals the approved workflow
path@ref, with the matching source repository → otherwiseATTESTATION_INVALID. Until artifact attestations are confirmed for the org (assumption A-05), steps 1–2 alone give C2. - Integrity: the manifest's
subjectdigests equal the SHA-256 of downloaded raw files → otherwiseDIGEST_MISMATCH. - Binding: manifest
commit_shaequals the run'shead_sha, and the manifest repo equals the webhook's repository ID → otherwiseBINDING_MISMATCH. - Limits: size, file count and decompression ratio are within limits → otherwise
REJECTED_SIZE.
5. Freshness engine¶
5.1 Policies¶
Configured per evidence type (config/evidence-types.yaml) and overridable per control.
| Policy | Valid while | Expires when | Typical types |
|---|---|---|---|
continuous |
Last poll within ttl |
Poll gap > ttl (connector failure surfaces as STALE, not silently "fine") |
Repo settings, GHAS alerts, cloud posture |
commit_bound |
Evidence commit is the default-branch HEAD, or every commit since touches none of paths |
A meaningful change lands, or ceiling_days passes |
SAST, SCA, IaC, workflow lint |
artifact_bound |
Evidence digest is the current build / a currently deployed digest | A new digest is deployed/built and lacks evidence, or ceiling_days passes |
SBOM, container scan, provenance |
time_bound |
observed_at + valid_days |
Time | Pen test, training, manual attestations |
event_bound |
No invalidating event since observed_at |
An event in invalidated_by, or ceiling_days |
Threat model, architecture review, DAST |
5.2 Meaningful change (commit_bound)¶
on push to default branch (webhook) for repo R, new HEAD H:
for each commit_bound evidence E on R with E.commit = C:
changed = GitHub compare(C...H).files # cached per (C,H)
if any(fnmatch(f, E.type.paths) for f in changed):
supersede E at H (state SUPERSEDED, reason "meaningful change: <first 5 paths>")
else:
E remains valid for H (recorded as an evidence_validity_event so the chain of reasoning is auditable)
The compare result is truncated by GitHub above 300 files. Truncation is treated as a meaningful change (fail safe).
5.3 Derived state¶
Evidence rows are immutable. Freshness state lives in evidence_validity_events (append-only) and the current projection evidence_current_state:
ACTIVE → EXPIRING (warn_days) → EXPIRED · ACTIVE → SUPERSEDED · ANY → REVOKED
Revocation. If a tool version is later found defective (e.g. a scanner release that silently skipped a language), a revocation rule (tool = X and tool_version in [...]) marks matching evidence REVOKED, triggers re-evaluation of affected controls, and notifies owners. Integrity incidents (such as the trivy-action tag compromise) use the same mechanism to invalidate evidence from a compromised window.
5.4 Scheduler¶
An hourly sweep transitions time-based states and enqueues control re-evaluation. A daily job emits "expiring in N days" notifications to owners (batched digest, not per item).
6. Immutability and audit (summary of ADR-016)¶
Raw artefacts ──▶ Blob container evidence-raw (version-level immutability, time-based retention, locked)
path: sha256/<aa>/<bb>/<digest> (content-addressed; dedups identical uploads)
Manifest ──▶ Blob evidence-manifests/<yyyy>/<mm>/<evidence_id>.json (immutable)
Record ──▶ PostgreSQL evidence (partitioned monthly; INSERT-only grant; UPDATE/DELETE trigger raises)
Chain ──▶ record_hash = SHA-256( prev_hash ‖ JCS(record without hash fields) ) per partition
Checkpoint ──▶ daily: {partition, last_record_hash, count, generated_at} → Blob evidence-checkpoints (immutable)
+ emitted to Sentinel as an audit event (an external copy outside the platform's control)
Verification ──▶ daily recompute of the chain + 1% random blob re-hash; mismatch = P1 platform incident
7. Manual evidence¶
Manual evidence (threat-model review, architecture review, pen test, SAMM interview, manual control attestation) is recorded through a form that captures: scope; method; outcome; reviewer (from the session, never typed); a reference to version-controlled artefacts (commit SHA of .security/threat-model/**) or uploaded attachments (stored in WORM with a hash); and requirement or control IDs covered. M2 requires the reviewer not to be in the application's owning team (checked against application.yaml owners + GitHub team membership + Entra group). Self-review attempts are rejected and audited.
8. Example: pipeline SAST evidence (normalised record)¶
{
"evidence_id": "0192f1a8-7c3e-7b21-9f00-3c1b7a5e2d10",
"evidence_type": "scan.sast",
"application_id": "6f1c…",
"scope_type": "repository",
"scope_ref": "R_kgDOJx…",
"subject": { "repo": "example-org/claims-api", "ref": "refs/heads/main", "commit_sha": "3f2a9c…" },
"producer_type": "approved_workflow",
"producer_ref": "example-org/security-workflows/.github/workflows/sast.yml@8d1e…",
"tool": "codeql", "tool_version": "2.19.1",
"policy_ref": "codeql-suite:security-extended",
"observed_at": "2026-10-07T10:14:22Z",
"result": "findings",
"summary": { "languages": ["java", "javascript"], "languages_detected": ["java", "javascript"], "alerts": { "critical": 0, "high": 2, "medium": 5 } },
"confidence": "C3",
"confidence_reasons": ["approved_workflow_verified", "attestation_verified", "github_hosted_runner"],
"run_ref": { "run_id": 11223344, "run_attempt": 1, "event": "push", "runner_environment": "github-hosted" },
"raw_location": "https://<acct>.blob.core.windows.net/evidence-raw/sha256/ab/cd/abcd…",
"raw_sha256": ["abcd…"],
"attestation_ref": "sha256:9e1f…"
}