ADR-038: A stale or one-sided graph degrades the verdict to Unknown, and says why
Status: Proposed.
Context
The verdict engine already produces Unknown four distinct ways and reaches Approve only through a
branded ApproveGate type whose sole constructor is gateForApprove (src/services/verdict-engine.ts).
But three observation gaps are currently invisible to a gate consumer:
- Absolute age is undetectable.
computeStaleSourcescompareslast_synced_atagainstasOf = max(entity.updated_at), explicitly never wall-clock now (src/rehearsal/service.ts:276-310;src/rehearsal/coverage.ts:34-40,INGEST_STALENESS_THRESHOLD_DAYS = 7). A uniformly 60-day-old graph yields zero freshness warnings — it measures relative drift between connectors, not age. And there is no automated ingestion, so "live" means "as of whenever a human last ran a CLI". - Cross-system reach fails silently. The 8 correlation rules
(
src/ingestion/stitching/rules/registry.ts) fire only when both connectors co-ingest into the same tenant;src/ingestion/stitching/bridge-coverage.ts:5-11states a partial refresh fails with "no error, just silently missing reach", and the diagnostic exits 0 on the wildcard-sided case that motivated it (:17-22— "a DIAGNOSTIC, not a hard gate"). It does not feed the rehearsal coverage factors at all. - Promotion has a hard fingerprint gate.
CounterpartChangedError→ 422 when the livecomputeCounterpartFingerprintno longer matches the value stamped at confirmation (src/rehearsal/promotion.ts:86-95,:129-131). Any tenant refresh that changes a paired entity's name/type/source breaks the rehearsal until an admin re-confirms the map.
Decision
- Emit absolute wall-clock
last_synced_atper contributingsource_systemon every gate verdict and render it on the gate surface and the status report. (Same item as ADR-034 item 6 — listed in both because it is required whether or not anything is persisted.) - A per-tenant absolute-age threshold degrades the verdict to
Unknownwith a named reason, rather than producing a confident answer over an old graph. Threshold is configuration (ADR-039), not a constant. - Freshness enters the approval as a class, not as a raw timestamp — and the distinction is what
makes the confirmation-time comparison usable. The
ApprovalPayload(ADR-036 Decision 2) carries both the derivedfreshness_classand the per-source_systemabsolutelast_synced_at, but only the class is insideapproval_content_hash: a graph that has aged across the threshold since the status report was posted changes the decision, so it must require a new human confirmation. CORRECTED 2026-07-25 — the raw per-source stamps are deliberately OUTSIDE the compared hash. An earlier draft of this decision said both were inside, and that is the more damaging direction of the error: a raw stamp moves on every sync of its source, including one that makes the data fresher, so comparing it makes each routine connector sync invalidate a pending approval and force the approver to re-confirm for a reason with no connection to their deployment. The stamps are sealed in the transcript and rendered to the approver; they are never compared. Also outside the compared hash isas_of— the tenant-wide maxupdated_atfromderiveAsOf(src/rehearsal/service.ts:245-252) — together withtotals.entities_materializedandtotals.baseline_paths. Those move whenever any unrelated entity anywhere in the tenant is touched; comparing them would refuse releases for reasons unconnected to the deployment and train the approver to click past the warning. They are stored on the hold and rendered as evidence and as the drift label; they are never the thing compared. The threshold itself lives in the policy row, so a threshold edit between report and confirmation also forces re-confirmation viapolicy_version. Getting this split wrong in either direction is the failure ADR-034 Context records in full. - Wire bridge coverage into the verdict. A
one_sidedbridge for any source system participating in the gated decision degrades toUnknownand names the missing partner connector. Under-computed reach must never present as a clean release. - Coverage factors are part of the answer, not a footnote. The unconditional
promotion-control-subtractionandpromotion-correlation-asymmetryinvariants render first in every gate output — check-run status report, UI, and the sealed record — exactly as they do in-product today. - A 422 from a changed counterpart is
Unknown+ held, never a release, and its status report names the pair that changed and the admin action required. - The gate output states what it did not observe. Off-cloud activity, unmapped destinations, and permission-denied scan scope are disclosed by name, consistent with the fail-safe (over-alerting) direction already implemented in promotion mode.
Alternatives considered
- Trust the existing relative staleness detector. Rejected: it is provably blind to uniform age, and a gate is the surface where that blindness becomes a false release.
- Block the gate on a fresh connector run. Rejected for slice 1 — no automated ingestion exists, and
the driver is AWS-only twice over (
src/workers/connector-driver.ts:172binary hardcoded;Dockerfile:1-29has no Python and no connector binary). Revisit when ADR-027 pipeline work lands. - Make bridge coverage a hard error in ingestion. Rejected here — that is a different decision with a different blast radius (it would fail scans). The gate consumes the signal; it does not change ingestion.
Consequences
- More
Unknownat the gate, which is correct and is already first-class product vocabulary (#1776/#1778). The pilot must be sized expecting holds, and the approval owner must expect interrupts. What anUnknownhold allows a human to do is an open decision, not settled here — the computed-verdict × allowed-human-disposition matrix in ADR-039 goes to the working session with its cells empty. - The disclosure this ADR forces into "the sealed record" is recorded, not attested. The record it
lands in is
decision_records: insert-only, pointer-linked, with an unkeyed integrity checksum (src/evidence/integrity.ts:25-36). It detects corruption, cross-tenant or cross-workload transplant and re-parenting; it does not detect a write-capable actor who recomputes the hashes. So "the coverage caveat is sealed into the record" means it was written and is verify-loud against corruption — not it cannot be altered. The keyed-MAC/signature plus predecessor-hash-chaining work is a named prerequisite before a T2 hold backs a production approval (ADR-036 Decision 3), and no surface may present these disclosures as tamper-proof before it ships. - The freshness signals are only comparable if the recomputation is byte-deterministic, which it is not today — the correlation tie-order defect and its fix are ADR-034 item 5. Until that lands, a freshness-class comparison at confirmation can differ for a graph that did not change.
- Two connector-side prerequisites are named, not assumed: the AWS connector stamps
sourceSystem"github_actions"whileDEFAULT_SYSTEM_CLASSIFICATION.external_systemscontains"github"(src/domain/system-classification/types.ts:59) and the matcher compares the prefix before the first":"— so the GitHub-OIDC node never matches the external-system rule. That is a bug-shaped fix that should ship regardless of the gate. evidenceCompleteness.sourcesremains the sole deletion-safety gate andscanScope.errors.permissionDeniedremains observability-only (src/ingestion/types.ts:204-221) — the gate must not be read as changing deletion semantics.