Skip to main content

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:

  1. Absolute age is undetectable. computeStaleSources compares last_synced_at against asOf = 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".
  2. 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-11 states 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.
  3. Promotion has a hard fingerprint gate. CounterpartChangedError422 when the live computeCounterpartFingerprint no 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

  1. Emit absolute wall-clock last_synced_at per contributing source_system on 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.)
  2. A per-tenant absolute-age threshold degrades the verdict to Unknown with a named reason, rather than producing a confident answer over an old graph. Threshold is configuration (ADR-039), not a constant.
  3. 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 derived freshness_class and the per-source_system absolute last_synced_at, but only the class is inside approval_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 is as_of — the tenant-wide max updated_at from deriveAsOf (src/rehearsal/service.ts:245-252) — together with totals.entities_materialized and totals.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 via policy_version. Getting this split wrong in either direction is the failure ADR-034 Context records in full.
  4. Wire bridge coverage into the verdict. A one_sided bridge for any source system participating in the gated decision degrades to Unknown and names the missing partner connector. Under-computed reach must never present as a clean release.
  5. Coverage factors are part of the answer, not a footnote. The unconditional promotion-control-subtraction and promotion-correlation-asymmetry invariants render first in every gate output — check-run status report, UI, and the sealed record — exactly as they do in-product today.
  6. 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.
  7. 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:172 binary hardcoded; Dockerfile:1-29 has 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 Unknown at 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 an Unknown hold 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" while DEFAULT_SYSTEM_CLASSIFICATION.external_systems contains "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.sources remains the sole deletion-safety gate and scanScope.errors.permissionDenied remains observability-only (src/ingestion/types.ts:204-221) — the gate must not be read as changing deletion semantics.