ADR-040: The Closed-Loop Control Decision — Authorized, Applied, Verified
Status
Accepted as target state — 2026-07-30. Records the decisions authorized after review of the six workstream demo scripts.
This is the single shared ADR for the closed loop. Per-workstream documents extend it and must not redefine these words. Amend this ADR rather than opening a parallel one — several ADRs describing one decision is the exact failure it exists to prevent.
Numbering note. ADR-034 through ADR-039 are claimed by an earlier open PR (the GitHub deployment gate). Three parallel drafts of this decision were independently written as
adr-034-closed-loop-control-decision.md; they are superseded by this document and their substantive content is absorbed here. Any surviving reference to "ADR-034" for the closed-loop vocabulary means this document — ADR-034 was never created and never will be.
Read this first. This ADR describes a target state. Where a property is already shipped it says so with a code citation; where it is not, it says not shipped in the same sentence. No paragraph should be read as current behavior unless it is marked as one. The Target versus shipped table is the authority on what may be demonstrated as working today.
Context
The platform can already assemble a cross-system authority path, evaluate it deterministically, and produce a finding with a breakage cone. What it cannot do is finish the sentence. Work stops at a proposal, and the proposal's fate is recorded as a work-tracker status typed by a human.
Three words carry most of the product's argument, and each meant different things in different documents:
- approved — sometimes "a human clicked accept on a remediation", sometimes "the business sanctioned this agent to do this job". Different facts, different lifetimes.
- applied — sometimes "we generated a control artifact", sometimes "a control is in force at the provider".
- verified — sometimes "somebody said it was done", sometimes "the provider refused the repeated action".
That ambiguity was the blocker, not missing engineering. Six workstreams could not describe one product because they were not describing the same lifecycle, and a buyer's technical reviewer comparing two of our documents would find the contradiction before we did.
The loop, in its settled form
understand authority → compare with approval → decide → apply → prove
Surfaced to users as a status rail:
Observed → Decided → Authorized → Applied → Verified
Every workstream feeds this one loop rather than defining a decision model of its own — but read that with the scoping note directly below it, not as "only the source varies":
| Workstream | What it contributes to the same loop |
|---|---|
| A — Airbnb | Kubernetes/EKS → AWS as the authority source |
| B — Vertex | A customer-published proprietary system as the authority source |
| C — Koch | Agent coverage across registry, cloud and vendor-hosted workflows |
| D — Experience | The one control-case view every workstream renders through |
| E — Enforcement | The apply-and-prove half, proven end to end in our own AWS organization |
| F — Foundations | The installation that makes any of it safe, current and repeatable |
| G — Enterprise | The same loop across isolated business units |
What "the same loop" does and does not mean. Once the loop closes once — in E1 — A, B, C and G reuse the control case and the evidence contracts defined here, instead of each inventing a decision model. That is the reason to do E first.
It does not mean they become the same implementation. Each provider and each proprietary system still needs its own source adapter, its own control artifact, and its own verification ceiling — and the ceiling differs materially (see §4). An earlier phrasing — "A, B, C and G become different sources of the same decision loop" — was directionally right and too broad for engineering: it reads as though only the source varies. Read the workstream table below as what each contributes, not as what each gets for free.
Correction to a previously recorded position
Internal review material stated that "the approved job does not exist anywhere." That was too
absolute, and it was repeated by more than one session. ApprovedBaseline is a real, validated,
accepted structure. The accurate statement is that approved intent exists as supplied report input
and not as a durable, reusable, owner-confirmed definition of a workload's business job.
This is therefore a promotion and hardening exercise, not greenfield design.
A related over-generalization is corrected here too: an employee's jobTitle is not an input to
approved agent authority. It describes a human's position, not the authority a business sanctioned
for a workload. Do not use it for this purpose.
Verified in code before writing this ADR
| Claim | Verified at |
|---|---|
An approved-baseline shape exists — allowed_authority, reachable_systems, constraints, expiry, owner, drift_trigger | src/reports/pre-deploy-reachability/types.ts:148; its own comment reads "§9 — proposed approval baseline. SUPPLIED, not computed." |
| The report API accepts and validates it — a malformed or foreign baseline is a 400 up front, not a worker failure | src/api/routes/reports.ts:77 |
| The decision surface distinguishes "no baseline supplied" from "drift outside the supplied baseline" | src/services/decision-surface-service.ts:725 — emits unknown:baseline_not_supplied, stating "this is missing input, not proof of no drift" |
| …but the baseline is matched on resource names | same file — the coverage row carries caveat("name_matched_baseline") |
completed → verified requires exactly one field: verified_by | src/api/routes/mitigation-actions.ts:93 (TRANSITION_REQUIRED_FIELDS) |
…and verified is terminal, so an unevidenced assertion is permanent | src/domain/mitigation-actions/types.ts (TERMINAL_STATUSES) |
| Evidence packs have no control, approver or provider-receipt section | src/domain/evidence-packs/types.ts:234 — nine required and two optional sections, all derived from the graph |
| Execution evidence carries an outcome… | src/domain/evidence/types.ts:12 — EvidenceOutcome = "success" | "failure" | "unknown" |
…and nothing reads it: a grep for outcome across src/evaluator/ returns zero lines | verified 2026-07-30 |
dormant-authority.ts returns "recently active" on the most recent evidence timestamp, with no outcome check | src/evaluator/rules/dormant-authority.ts:59 |
unproven-execution.ts returns early on any evidence record — and has no recency window at all | src/evaluator/rules/unproven-execution.ts:39-50 — getExecutionEvidence(entity._id, 1) with no cutoff |
| Stale findings auto-resolve on a later sync | src/evaluator/index.ts:620 (resolveStaleFindings), documented at 02-processing-pipeline §2.3 step 5 |
Decision
1. One definition each. These are the only meanings.
| Term | Definition | It is not |
|---|---|---|
| Observed | SecurityV0 has read, from a named scan run, configuration and/or execution evidence establishing that an authority path exists. | Not "live". Not "exercised" — a path can be observed and never used. |
| Decided | SecurityV0 has produced one deterministic recommendation — the narrowest control that removes the path — with its impact analysis. | Not an authorization. A decision permits nothing to happen. "We generated a control" is Decided, not Applied. |
| Authorized | A named person authorized this control, at a recorded time, against a recorded context, with an expiry where one applies. | Not risk acceptance (that is an attestation). Not a role or a team — a person. Not the workload's approved scope, which is what the business sanctioned the workload to do — a different object with a different lifetime, defined in §2. |
| Applied | A control is in force at the provider, and we have read it back from that provider with a source-stated time. | Not "we sent it". Not "we generated an artifact". "A ticket was closed" is a work-tracker state, not Applied. |
| Verified | Evidence produced by something other than SecurityV0 shows the control has the intended effect. | Not a status a human can assert. A person typing their name is not an artifact. Not "the finding disappeared". |
The two approvals — a collision in this ADR, named and resolved
This document previously used one word for two different facts, which is the exact failure it was written to prevent. Found by adversarial review, 2026-08-01.
| Approved scope | Approved (the rail step) | |
|---|---|---|
| What it is | The business sanctioned this workload to do these things | A named person authorized this control |
| Object | The Approved Job (§2) | One control definition |
| Actor | A workload owner, at installation | An approver, per decision |
| Lifetime | Versioned; expires; supersedes | One decision; expires; spent on use |
| If absent | unknown — missing input | The control is not authorized and must not be applied |
| Consequence of confusing them | An auditor cannot tell whether "approved" means the agent was authorized to hold the permission or the remediation was authorized to remove it |
Both meanings are legitimate and both stay. What is forbidden is the bare word. In any document or screen where both could be meant — which is every control case, because the comparison block and the rail sit on the same page — write "approved scope" or "the control was authorized". Never an unqualified "approved".
Open, and it needs a ruling because it changes a word on every screen. The stronger fix is to rename the rail step to
Authorized, giving Observed → Decided → Authorized → Applied → Verified, and leaving "approved" to mean the workload's sanctioned scope and nothing else. The ripple is small and now is the cheapest it will ever be: five rail statements in this PR (00-overview,01-data-model,07-ui-reporting, and twice here) and two files on the Kubernetes/AWS branch. Not applied unilaterally — it is the user-facing word on every screen.
Applied requires two things to be true, and the second is the one usually skipped:
- The change was made at the control point.
- A read-back has observed it there, and the observation carries a source-stated time.
2. Approved Job — promote ApprovedBaseline into a durable object
Definition. Approved names the authority a business has sanctioned for a specific identity to hold, confirmed by a named owner, with an expiry.
It is never inferred. The platform does not compute what should have been approved. Where a customer has no system of record for it, the value is customer-asserted and displayed with its provenance — asserted by NAME, DATE — and never implied to be more.
Its first version is created by the installation manifest, confirmed by a named owner. A verbal answer in a meeting is an input to that confirmation; it is never itself canonical. This is the front half of the work and it belongs to installation, not to a policy-authoring feature.
| Property | Today | Target |
|---|---|---|
| Origin | Supplied in a report request | Created by the installation manifest, versioned |
| Scope | One report type | Tenant-level, referenced by any decision |
| Matching | Resource names | Stable entity identity |
| Lifecycle | None | Draft → confirmed by named owner → active → superseded / expired |
| Owner | A string field | A named confirmer recorded with the confirmation |
Retained unchanged: allowed_authority, reachable_systems, constraints, expiry,
drift_trigger — and, importantly, the existing honesty behavior.
Binding rule — the part that must not be inherited from the current shape. The Approved Job
binds to stable entity identity, not resource names. Name matching silently loses its subject
when a resource is renamed, and it does so without an error — an approval that quietly stops
covering the thing it approved is worse than no approval, because it reads as green. Where only a
name is available, the row is unknown, not covered. Any hand-authored baseline in circulation will
need re-expressing.
Absence is a first-class state. "No Approved Job supplied" must remain distinguishable from
"reach outside the supplied Approved Job." That distinction already works
(decision-surface-service.ts:725) and must survive the promotion. Absence of an approval is
unknown, never clean.
"What was approved?" stays a required column on every decision surface. Where no Approved Job
exists the column shows unknown and says why. It is never removed, never blank, and never filled
in by a presenter.
Explicitly not this: an approved job is not a person's
jobTitle.Explicitly not this either: the Approved Job must not grow into a general policy-authoring product. It describes the sanctioned job for the identities under an installation's proof contract, and nothing wider.
Front half owned by workstream F.
3. Control case — one object joining decision, approval, control and receipts
Today these live in three places that do not reference each other: the finding, the mitigation action, and the evidence pack. There is nowhere to put a policy document or a denial receipt.
The control case is the join:
finding (what changed, why it matters)
+ approved job (what was sanctioned)
+ decision (narrowest control, breakage cone, unknown coverage)
+ approval (named human, timestamp, context, expiry)
+ control artifact(the exact policy document)
+ receipts (provider accepted · read-back · denied replay · reconciliation)
Why this is not more sections on an evidence pack. Evidence packs are sealed and immutable —
integrity_hash, sealed_at. A control case is mutable over time: approval arrives after the
decision, receipts after application, reconciliation after a later scan. The control case is
therefore a separate object that references sealed evidence packs, each rung sealing its own
artifact as it completes. Making packs mutable to accommodate this would destroy the property that
makes them worth sending to an auditor. (An earlier draft proposed adding a control-case section
to the pack; see Alternatives considered.)
The rail is reusable machinery, but it is not the mitigation lifecycle
proposed → assigned → in_progress → completed → verified
(src/domain/mitigation-actions/types.ts) is a work tracker: who owes what, by when.
Observed → Decided → Authorized → Applied → Verified is an evidence-backed control decision:
what is true, who sanctioned it, and what proves it. The state names coincide; the meanings do not.
| Control-case rail | Reusable machinery | Gap |
|---|---|---|
| Observed | finding active | — |
| Decided | proposed | — |
| Approved | assigned / in_progress + status_history[].changed_by | Semantics only |
| Applied | completed | No provider-confirmation receipt |
| Verified | verified | Requires only verified_by — a name, not a receipt |
Reuse the lifecycle, the enforced transitions and the named-actor history. Do not claim the
control rail already exists wholesale — the verified_by-only transition is the proof that it
does not. The evidence binding is the actual work.
Reuse the machinery; do not relabel the tracker. Renaming
MitigationActionto "Control Case" would import a work-tracker's semantics — including averifiedreachable with a name — into an evidence-backed record. The control case is a new persisted object. It may reuse the transition-validation and status-history mechanisms; it does not inherit the tracker's states.
The rail is a buyer view. The persistence model is an append-only event history.
Observed → Decided → Authorized → Applied → Verified is what a customer is shown. It is not the
storage model, and building it as a five-value status column guarantees the first rejected approval
or failed application has nowhere to live.
The durable record is an append-only event history bound to one stable control-case ID. Each event carries its actor, its timestamp, and its required artifacts — an event that cannot produce its artifacts cannot be appended. The rail state a customer sees is derived from that history, never stored as the source of truth.
These events are legal and must all be representable:
| Event | Required artifacts | Effect on the derived rail |
|---|---|---|
case_opened | finding reference, scan run ID, Approved Job version or unknown | → Observed (the rail's initial state) |
decision_recorded | control definition, impact analysis, unknown-coverage list | → Decided |
authorization_granted | named approver, timestamp, context hash, expiry if any | → Authorized |
authorization_rejected | named actor, reason | → Decided (returns to the decider) |
authorization_withdrawn | named actor, reason, prior approval reference | → Decided |
authorization_expired | prior approval reference, expiry that elapsed | → Decided; the control is no longer authorized |
approved_job_expired / approved_job_superseded | prior and successor Approved Job versions | approval invalidated; case returns for re-decision |
application_attempted | target control point, control artifact hash | no rail advance on its own |
application_failed | provider error, control artifact hash | → Decided, with the failure visible |
readback_confirmed | provider-stated active version, source-stated time | → Applied |
readback_mismatch | expected vs observed version, source-stated time | → Applied is revoked, not merely annotated |
verification_succeeded | replay result, provider denial receipt, control-case ID, policy hash | → Verified |
verification_failed | replay result showing the action still succeeds | → Applied, flagged; the control does not work |
verification_unavailable | the stated ceiling for that control point | → change_confirmed_effect_not_observable (terminal, honest) |
reconciled | scan run ID, finding resolution reference | corroboration only — never advances the rail by itself |
reconciliation_not_observable | the control point, and why the graph cannot see the control | closes moment 2 honestly; the finding becomes mitigated by an active control (see below) |
reopened_after_drift | drift finding reference, baseline version date | case reopens; prior verification retained in history, not deleted |
control_rolled_back / control_removed | actor, reason, provider read-back showing removal | → the case closes as no longer in force |
Four rules follow from this shape and are the point of adopting it:
-
Nothing is ever overwritten. A verification that later proves wrong is superseded by a new event, not edited away. The history is the audit trail; the rail is a projection.
-
readback_mismatchrevokesApplied. A control we believed was in force and is not is the most dangerous state in the model, because everything downstream reads as protected.And revoking the standing is not sufficient — the false claim must be retracted. Added 2026-08-01 after adversarial review. Between the moment we recorded a control as in force and the moment a read-back contradicted it, SecurityV0 told people a path was protected when it was not: other cases, reports, exports, tracker tickets and any decision taken on the strength of them. Silently flipping the case to
contradictedleaves every one of those standing.A mismatch therefore requires a downstream ledger: what consumed this case's standing during the window, so the false assurances can be withdrawn and their owners told. Without it we have built a product that can be confidently wrong and then quietly correct itself, which is worse than not making the claim.
This is a product requirement, not a screen affordance — the ledger has to exist in the domain before any surface can render it, and the exposure window is only meaningful once a read-back cadence exists to bound it.
-
A terminal state can be honest rather than green.
change_confirmed_effect_not_observableis a legitimate ending on control points that cannot produce a denial. -
Reopening does not erase. Drift after verification appends; it does not reset the case to a clean slate, or we lose the evidence that the control once worked.
mitigated by an active control is a new finding state and it does not exist yet.
FINDING_STATUSES is active | acknowledged | remediated | false_positive
(src/domain/findings/types.ts). None of those is correct for a finding whose path is still
configured but whose exercise is refused at the provider: remediated is false (nothing was
removed) and active is misleading (the action is blocked). Adding this state is part of P4,
and it must not be approximated by reusing remediated — that would reintroduce, in the data, the
exact "the grant was removed" claim §7 forbids in the narrative.
Ruling, 2026-07-31: it is a terminal standing beside Verified, not below it — and the case is
closed. Two things follow, and both are deliberate:
- It ranks with
Verified, not under it. The provider refuses the action; that is the same causal proofVerifiedrests on. What is missing is corroboration in our own data, which is a limit of our ingestion rather than a weakness in the control. Rendering it as a lesserAppliedwould state the opposite of what the evidence says. - The case closes; the denial is not kept alive by a recurring replay. A control case records what was true of a named configuration at a named time, and every receipt is already scoped that way. A recurring re-replay would make the case's standing depend on a job that could silently stop, which is a worse failure than an honestly-dated closure.
Amended 2026-08-01, after adversarial review. The original justification was partly wrong and the correction matters.
I justified closure partly on the grounds that the case "reopens on its own if the configuration
moves". For the flagship case it does not, and it cannot. reopened_after_drift fires on drift
the connector can see. The control here is an SCP, which we do not ingest — so the single most
important drift, the control being removed, is invisible to us by exactly the same mechanism that
makes reconciliation impossible. Someone detaches the policy and nothing in SecurityV0 changes.
I also deferred the residual risk to "a fleet-level concern". That was a hand-wave: no such assurance exists, it has no owner, and nothing tracks it. Deferring to a capability that does not exist is not a resolution.
The corrected position:
- A case may close as a dated historical record. "AWS refused this action at 10:04 UTC on 2026-07-30, citing this policy" is true then and stays true.
- A standing of
mitigated by an active controlis a present-tense claim and must not be displayed without its age. Wherever that standing appears it carries when it was last confirmed, and it ages visibly. A CISO asked "is that control still in force?" must be able to answer from the screen, or see plainly that we cannot answer. - Where the control is outside our ingested model, re-confirmation cannot come from a scan. It requires either re-executing the provider check or ingesting the control type. Both are real work with real owners; neither is a footnote.
- Fleet-level control assurance is therefore a named dependency of this state, not an aside.
Until it exists,
mitigated by an active controlis defensible only for as long as its confirmation is recent — and the surface must say how recent.
Consequence for sequencing: shipping this standing without an assurance age would let a dated
proof read as an ongoing guarantee. That is the same class of error as a permanently-pending
rung, and it fails in the direction that costs the most trust.
Vocabulary ruling, same date. Approved Job is the domain object — the durable,
owner-confirmed record described in §2, and the name used in schemas, APIs and this ADR.
approved scope is the user-facing term in UI copy, column captions and empty-state remedies.
One term per layer; do not mix them on a screen.
4. Verified requires an artifact, and there are two verification moments
The confirmed defect. completed → verified requires exactly one field: verified_by. A name.
No provider receipt, no read-back, no replay. And verified is terminal, so an unevidenced
assertion is permanent.
The decision: Verified becomes impossible without required evidence — at minimum the read-back
observation with its source-stated timestamp, and, where the control point can produce one, the
provider's own denial of the repeated action.
Migration note. Tightening
TRANSITION_REQUIRED_FIELDS["completed->verified"]in place would begin rejecting existing callers. The evidence requirement belongs on a new, evidence-bearing transition rather than as a silent tightening of the work tracker.
The two moments
| 1 · Enforcement verification | 2 · SecurityV0 reconciliation | |
|---|---|---|
| Question | Did the provider block it? | Is the dangerous path gone from the graph? |
| Who produces it | The provider | SecurityV0 |
| Artifact | Read-back of the active policy version and an identical replay returning a denial | The path is absent on a later refresh; the finding auto-resolves to remediated, stamped resolved_by_sync |
| Timing | Seconds to minutes | Next triggered or scheduled scan — lags by the cadence |
| Causal? | Yes — the replay ties the denial to the control | No |
Both timestamps belong on the control case:
Provider enforced — 10:04
SecurityV0 reconciled — 10:07
Auto-resolution is valuable but it is not causal.
resolved_by_syncproves the path was absent as of that scan. It does not prove a particular control caused the absence — the workload may have been deleted, or a source may have gone unread. Causality comes from moment 1. Never present reconciliation alone as proof that a control worked.
Moment 2 does not fire for controls the graph cannot see. Reconciliation requires the applied control to change something the connector ingests. An AWS SCP changes nothing the connector reads: the connector does not ingest SCPs, and
iam_policy_parser.pyskips everyEffect: Denystatement, so the configured Allow path survives in the graph after AWS starts refusing the call. For such controls the case ends at provider verified and the finding is marked mitigated by an active control — never "the grant was removed". See §7.
Verification is not uniform across control points
A demo that implies otherwise will be caught. The ceiling is agreed at installation, not argued about at verification time.
| Control point | What can be verified | Ceiling |
|---|---|---|
| AWS | Active policy and a CloudTrail AccessDenied on the repeated action | Strongest: the change and the block |
| Azure | The assignment changed | The 403 lands in the caller's logs, not ours — the customer supplies it |
| Entra | The grant is gone | The effect is not verifiable until existing tokens expire (60–90 min, randomised). Held as a live closed-loop demo control for this reason. |
| GitHub | Repository-scope reduction or App suspension, with a provider denial on replay | Strong, when the customer replays |
| Customer-published system | Whatever that system exposes; the denial its system issued can be published back as an execution_evidence record | Must be stated in the manifest at install time |
A decision carries the ceiling it was verified at. change_confirmed_effect_not_observable is a
legitimate terminal state. Forcing every control point into AWS's story is how a demo becomes a lie.
Where a surface cannot produce a provider-issued denial, name the receipt it does produce and call
that the ceiling.
One precision that must not be lost: a CloudTrail
AccessDeniedproves the API call was refused. It does not prove traffic did not leave. An egress claim needs a canary receipt or a VPC flow-logACCEPT/REJECTrecord, separately. The two are not interchangeable.
5. Who applies
The customer applies, by default and in every customer environment. SecurityV0 holds no credential that can change anything in a customer's environment, and the installation contract records that explicitly.
| Workstream / surface | Control point | Who applies |
|---|---|---|
| A — Airbnb (Kubernetes → AWS) | AWS IAM | Customer |
| B — Vertex (proprietary system) | Vertex's own gateway / permission service | Customer, through their existing control process |
| C — Koch | Customer-native | Customer |
| E1 — Enforcement flagship | AWS SCP in SecurityV0's own AWS organization, a single OU | SecurityV0 — and only here |
| G — Enterprise | Customer-native; borrows E for enforcement | Customer, per business unit |
This is not a current limitation awaiting a release. It is the read-only connector position and it is held. E1 exists precisely so the capability to compile and apply a provider-native control can be proven without a customer, in an environment we own and can say so about.
The publishing contract in B carries authority and execution data in. It is not the mechanism that changes the customer's system. The customer's existing control process makes the change with their own credential; the contract's role afterwards is to report the resulting evidence — the next publish no longer carries the removed grant, and the denial their system issued can be published back as execution evidence (carrying
outcome: "failure", per §6).
Who performs the replay
Applying and replaying are separate acts with separate actors, and conflating them is easy because both happen "at the provider".
In a customer environment, the customer replays. Replaying a prohibited action means acting as
the denied principal. SecurityV0 holds no credential that can do that, and acquiring one would be a
larger ask than the write access we already decline. The customer performs the replay through their
own process; we receive the artifact and bind it to the case. Where a customer will not or cannot
replay, the case ends at Applied with a read-back and no enforcement receipt — an honest ceiling,
recorded as such, not a failure.
In E1 only, SecurityV0 replays, because the principal, the account and the credential are all ours. That is the whole reason E1 exists as a capability proof rather than a customer engagement.
A surface that shows SecurityV0 replaying inside a customer tenant is describing something we do not do and have not asked to do.
6. Execution evidence must be outcome-aware — a prerequisite, not a follow-up
ExecutionEvidenceDoc already carries EvidenceOutcome = "success" | "failure" | "unknown". The
rules ignore it — a grep for outcome across src/evaluator/ returns zero lines — and treat
the existence of a record as proof of activity.
| Rule | Behavior | Consequence of a denied attempt |
|---|---|---|
dormant-authority.ts:59 | Takes the most recent evidence record; returns "not dormant" if its timestamp is inside the window, without inspecting outcome | A blocked attempt makes a dormant identity look recently active, suppressing the finding |
unproven-execution.ts:39 | Returns "proven" on any evidence record for the entity or its RUNS_AS targets — no recency window at all | A single denied attempt permanently suppresses unproven_execution for that entity |
unproven-execution.ts is the sharper of the two: with no recency window, one denial silently
converts a never-successfully-used path into a "proven" one for good.
Why this blocks the loop. The loop asks a customer to repeat a prohibited action and publish the resulting denial as evidence. On today's rules, doing exactly what the loop asks would:
- make the identity appear successfully active,
- suppress
dormant_authorityandunproven_executionon the very path a control was just applied to, and - do so silently, with no counter and no completeness signal.
The evidence proving we fixed something would delete the finding that said it was broken — at exactly the moment the flagship demo is trying to prove enforcement worked.
Three meanings stay distinct everywhere execution evidence is consumed:
| Meaning | Outcome | What it licenses |
|---|---|---|
| Successful execution | success | The authority was exercised. Dormancy and unproven-execution rules stand down. |
| Attempted but denied | failure | The authority was not exercised. Evidence about the control, not about the identity's activity. Dormancy rules unaffected. |
| Outcome unknown | unknown | Neither conclusion available. Must never be silently promoted to success. |
A denial is excellent proof that enforcement worked. It is not proof that authority was exercised successfully. Those are close to opposite conclusions about different subjects.
This is the same class of error as the document's existing warning that execution_count_30d = 0
means "we did not see anything", not "nothing happened":
Zero is not "idle." A record is not "success."
Both are values that read as facts about the customer's system when they are facts about our observation of it. A denied attempt must be positive evidence for a control case (moment 1) and not evidence of exercised authority — both readings available from the same record.
Outcome-aware evaluation is a prerequisite for publishing denial receipts. Detail in Execution Evidence Fidelity §11.
7. E1 — the flagship enforcement flow, in SecurityV0's own AWS organization
Why our own organization: it needs no customer, so there is no trust ask, no pilot negotiation and no scheduling dependency. What it does and does not prove: it proves the decision compiles into a control that works and does not decay. It does not prove a customer would grant us the authority to do it in theirs — and we do not want that authority.
Section ownership. The authoritative E1 narrative belongs to the enforcement workstream and lives in its own page. What follows is the summary ADR-040 carries so the vocabulary has a worked example. It has been corrected against the round-4 verified facts below; where the enforcement workstream's page and this summary differ, that page wins and this section is amended to match.
Scenario — an indirect provider-action authority path. An evaluation worker holds
lambda:InvokeFunction on a helper function whose role can act on a protected resource. The worker
never gained the downstream permission directly. It gained an indirect authority path through
another workload, expressed entirely through IAM and observable through CloudTrail.
worker → execution role → lambda:InvokeFunction → helper → helper role → protected resource
Do not call this an observed internet escape. We can represent the IAM path above. We cannot currently establish "the worker has no internet route" or "the helper has internet access": that needs route tables, NAT gateways and security groups the connector does not read.
egress_categoryis never a network-reachability fact. For a Lambda it comes from_classify_lambda_egress(function_name, env_var_keys)— substring matching over the function name and its environment-variable keys, never their values (transformer.py:1047,:3713). Elsewhere it is_classify_secret_egress()over a secret's name, description and tags (:1574,:3632), or a hardcoded literal (:1392). No network object is inspected by any of the three.
Test-fixture safety. The pre-deny attempt is made against a disposable, recoverable fixture — a versioned sandbox bucket and a throwaway object, with rollback tested before the policy is attached. The dangerous effect must never touch production data, and an unrecoverable deletion may never be an acceptance step.
s3:DeleteObjectmay remain the denied action.
The beat the whole flow exists for is the identical replay. Everything before it is setup. The durability beat is the most under-rated — adding a permissive Allow and being denied anyway demonstrates the fail-static property viscerally and proves we are not in the path.
SecurityV0 never sits in the request path. If the platform is down, an applied control is still in force.
E1 ends at provider verified. It cannot reconcile the graph, and it must say so.
This is the correction that most changes the demo. An earlier draft of this flow ended with a later scan showing the path gone and the finding reconciling. That cannot happen, for two independent reasons:
- the AWS connector does not ingest SCPs, so an attached deny is invisible to the graph; and
iam_policy_parser.pyskips everyEffect: Denystatement — its own docstring: "Deny statements are skipped — they affect evaluation but are not materialized as graph permission nodes." (iam_policy_parser.py:126-131; the skip itself is at:86-87and:155)
So after AWS correctly denies the call, the configured Allow path is still in the graph. Verification moment 2 (§4) does not fire for an SCP-based control, and claiming it would be a demonstrable falsehood at the climax of the flagship demo.
What E1 does instead: it ends at provider verified, and says out loud that the graph still overstates reach because SCPs are not modeled. The control case moves the finding to mitigated by an active control — never to "the grant was removed", because no grant was removed.
Rejected alternative: make SCP and Deny ingestion an E1 prerequisite. It would restore reconciliation and it is the larger piece of work — a connector change plus a parser change plus a policy-evaluation model that can express deny precedence. Not chosen. Recorded here so the option is visible rather than forgotten.
The consequence nobody sees until they click back
A path mitigated this way stays in the Access Paths list, and in the graph, permanently. That is the correct behavior — it is configured, and we render configuration — but it means every surface that lists paths will show this one as live, next to a case that says it is mitigated.
Any list or graph surface that can render a path must be able to render "mitigated by control
<id> · path not retired". Without it, a reviewer who follows the flagship demo and then clicks
back to the list is shown an unqualified live path immediately after being told it was handled, and
the strongest moment in the demo becomes the weakest. This is a small treatment on a shipped surface,
and it is a precondition for demonstrating the E1 case at all — not a follow-up to it.
Why this control, and not the obvious alternatives
| Choice | Reason |
|---|---|
Deny s3:DeleteObject | A consequential action, so the stakes are legible — attempted only against a disposable, recoverable fixture with rollback tested beforehand. An unrecoverable deletion may never be an acceptance step |
Not sts:AssumeRole | That is credential minting — grant-time, not action-time. A technical reviewer will say so |
| Resource-anchored, not principal-anchored | The downstream call is made by the helper's role, not the worker's. A principal-anchored deny on the worker misses it entirely — a confused-deputy hole |
| Principal allowlist, never a denylist | A denylist built from a poll silently misses any principal created since the poll — precisely the adversary we exist to catch |
| Deny only; no grant removal | Removing a grant is write-back. Acceptable in our own sandbox, but it has no ADR behind it as a product claim and it muddies the property this flow exists to prove |
Emitter safety constraints — non-negotiable
These are why this is safe to do at all.
- Our own AWS organization, a single OU, never the root.
- Deny statements only. Never emit, modify or reference an Allow — AWS documents that removing
FullAWSAccessmakes all member-account actions fail. - Always-present condition keys only (
aws:PrincipalArn,aws:RequestedRegion,aws:PrincipalAccount). UnderStringEqualsan absent key evaluates false and the deny silently does not fire — a fail-open deny wearing a fail-static label. - Resource-anchored with a principal allowlist, never an identity denylist.
- IAM Access Analyzer
ValidatePolicy(policyTypeSERVICE_CONTROL_POLICY) as a pre-attachment check. It validates grammar and best practice, not behavior — a staged OU rollout is the real pre-flight. - Never attach a policy in a customer's organization. That needs
organizations:AttachPolicyin their management account — the most powerful write in an AWS organization. We do not ask for it, and no document may imply a path that needs it.
Known limit — the read-back is not a connector round-trip
The AWS connector discovers Organizations accounts and OU paths but does not ingest SCPs. So the applied-policy read-back is a direct AWS call by the emitter, not a connector observation. Reading an applied control back through the same connector that found the problem would be a better story, and we do not have it.
E2 — preventive: the same product, before production
E2 is a preventive variation of E1, not a separate engineering stream. Same decision, same control, same receipts — the only difference is when the control exists. The control is active before the workload starts, so the first prohibited invocation is denied.
Nothing is detected, because nothing happened. There is no successful call to point at, because there never was one. This is prevention without an inline gateway — nothing routes through SecurityV0; the configuration changed before it mattered. CI/CD deployment blocking is held and is not the control point here.
E3 — public-incident reconstruction
Positioning and demo content only, explicitly labeled as reconstruction from public disclosures. It must never be presented as a faithful executable replay and must not become a product build.
The loop, end to end
Who acts at each step, and where the two verification moments fall.
Two readings of this diagram, and they differ at the last step.
- Customer surfaces (A, B, C, G): the
Customerparticipant is the customer, applying through their own control point, and moment 2 fires when the applied change is something the connector reads. - E1: the
Customerparticipant is SecurityV0 acting in its own AWS organization — and the last two steps do not happen. An SCP is invisible to the connector, so the flow ends at moment 1 and the finding becomes mitigated by an active control. See §7.
Installation and the first Approved Job
An installation is where the Approved Job first becomes real, because it is the only moment where a named human from the customer is asked to state the job on the record.
A verbal answer is never canonical, and an unconfirmed job is unknown — a displayed state, not
a blank. This mirrors the behavior the decision surface already implements for a missing baseline.
What this loop does not do
Design these in; do not append them.
| Not covered | Why |
|---|---|
| Detection during an attack sequence | Ingest is scheduled full-snapshot polling — 6h on AWS where a cadence is configured, human-triggered on the Microsoft path. No webhook receiver exists; event-driven triggers are recorded as rejected in ADR-027. |
| Internet-connection attempts | external_egress reads entity.properties.egress_category — a configuration read. No attempt detection of any kind exists. |
| Forged, correctly-signed tokens | Structural. A forged token changes no configuration, and a configuration reader cannot see it by construction. |
| Paths blocked by controls we do not model | Conditional Access, deny assignments, ABAC conditions, resource locks and PIM are registered as not-modeled (src/rehearsal/coverage.ts:377-412); four carry direction over_states_reach and PIM carries both. Our own code says the analysis over-states reach. |
| Drift older than the baseline window | Drift rules diff against the oldest of a 100-version window. After 100 scans, "since baseline" silently means "since ~100 scans ago". |
| Breakage cones at enterprise scale | Revocation rehearsal hard-throws above 10,000 entities (SYNC_MAX, src/rehearsal/service.ts:55). The async tier is an enum value, not a code path. |
The enforcement is real-time; the detection is periodic. Once a deny is attached the provider evaluates it on every request, forever, with SecurityV0 nowhere in the path. What is periodic is how fast we notice something worth denying. These are different clocks and documentation must not blur them.
Target versus shipped
No blurring. This table is the authority on what may be demonstrated as working today.
| Capability | State | Evidence |
|---|---|---|
| Authority path materialization across packaged and published sources | Shipped | — |
| Deterministic findings on published data | Shipped | Evaluator rules are overwhelmingly source-agnostic (src/evaluator/rules/); the exact count is unverified here — do not quote a ratio without checking it |
| Impact analysis / revocation rehearsal, bounded to the synchronous tenant limit | Shipped | SYNC_MAX = 10_000 |
ApprovedBaseline as supplied report input, validated, with drift computed against it | Shipped | pre-deploy-reachability/types.ts:148, reports.ts:77 |
Missing baseline renders as unknown, not clean | Shipped | decision-surface-service.ts:725 |
Mitigation lifecycle, enforced transitions, status_history.changed_by | Shipped | mitigation-actions/types.ts |
| Named approver, expiry, conditions, staleness on a review record | Shipped as attestation | domain/attestations/types.ts |
| Finding auto-resolution on a later sync | Shipped, and not causal | evaluator/index.ts:620 |
EvidenceOutcome on execution evidence | Shipped (unread) | field exists; grep outcome src/evaluator/ = 0 lines |
| Durable, owner-confirmed Approved Job bound to entity identity | Target | — |
| Approval presented as the gate on a control | Target | — |
| Outcome-aware evaluation | Target | — |
Verified gated on a required artifact | Target | today completed→verified needs only verified_by |
| Read-back after a change, as a modeled step | Target | — |
| Control case joining decision, approval, control and receipts | Target | packs have no such section — evidence-packs/types.ts:234 |
mitigated by an active control finding state | Target | FINDING_STATUSES is active | acknowledged | remediated | false_positive — src/domain/findings/types.ts |
reconciliation_not_observable as a first-class outcome | Target | no reconciliation status domain exists |
| Provider-native control applied in SecurityV0's own AWS organization (E1) | Target | no mutating customer-system call exists in src/ |
| Provider-native control applied in a customer environment | Held | — |
Implementation dependency order
The five decisions are not peers. Dispatching them as a flat list produces a control case with nothing to join and a verification rung with nothing to verify.
| Artifact | Depends on | Acceptance evidence | |
|---|---|---|---|
| P1 | Durable Approved Job schema and API | existing ApprovedBaseline | Tenant-scoped, versioned, stable IDs, named owner, constraints, expiry — and reused by two decisions, not one |
| P2 | Outcome-aware execution evaluation | — | Only successful execution proves activity; denied, failed and unknown never suppress dormancy or unproven-execution — and the connector-computed activity properties must be audited for the same defect, because both rules short-circuit on properties.last_observed_execution_timestamp / properties.execution_count_30d before any evidence record is read, and those carry no outcome concept at all. Filtering EvidenceOutcome alone does not meet this criterion |
| P3 | Provider control and verification receipts | P2 | Applied-control hash, provider read-back, provider outcome and reconciliation time stored separately — not collapsed into one field |
| P4 | Control case domain, API, UI | P1 + P3 | One stable case ID joins finding, Approved Job, decision, approval, exact control version, receipts |
| P5 | E1 AWS proof | P1–P4 | Sandbox OU, validation, named approval, attachment, read-back, identical replay denied, later Allow still denied, rollback tested |
Sequencing. P1 and P2 may run in parallel — P1 is a schema-and-API promotion, P2 is a rules-and-migration change, and they share no surface. P3 follows P2, because receipts that a denial is a denial are meaningless while the evaluator reads evidence existence. P4 follows P1 and P3, because the case has nothing to join until the Approved Job is durable and receipts exist. P5 starts after domain acceptance passes — not after the code merges.
One owner per artifact. Two owners on P3 or P4 reproduces exactly the failure this ADR was consolidated to fix.
Documentation ownership is not implementation ownership. Owning the page that describes an artifact confers no claim on building it, and nothing in this ADR assigns implementation. The workstream attributions elsewhere in this document record who wrote the decision down. P1–P5 above are dispatched separately.
Held — not roadmap, not target state
Recorded so they are not quietly re-adopted. A document that lists these as planned is wrong.
- Customer-environment automatic write-back. SecurityV0 changing a customer's configuration without a human applying it. Any change to this position needs its own ADR and a separate written agreement with the customer.
- Airbnb CI/CD blocking — a deployment gate that holds a customer's deploy.
- Autonomous enforcement — any control applied without a named human approving it.
- Publishing denial records before outcome semantics are fixed (§6). This is a sequencing gate, not a permanent hold.
- Koch / Happy Robot as a modeled workflow without customer confirmation.
- Entra as a live closed-loop demo control — the grant's removal is visible, but the effect is not verifiable until tokens expire, so there is no prompt replay artifact.
- The public OpenAI / Hugging Face reconstruction as a faithful executable replay.
Consequences
Good.
- One vocabulary across seven documents. A reviewer comparing any two finds the same definitions.
- The Approved Job is a promotion of working code, not a new subsystem, and the
unknownhandling the decision surface already implements becomes the model for the whole column. Verifiedstops being assertable — the single change that most improves how our evidence reads to a hostile reviewer.- Naming the outcome defect before building denial receipts avoids shipping a demo that contradicts itself at its climax.
- Once E1 works, A, B, C and G reuse the control case and evidence contracts rather than each inventing a decision model. Each still needs its own source adapter, control artifact and verification ceiling — the reuse is the contracts, not the implementation.
Costs and risks.
- Requiring artifacts for
Verifiedmeans some decisions sit inAppliedindefinitely on control points that cannot produce a denial. That is honest and it will look like incompleteness. It is preferable to the alternative. - Surfaces that today show a verified count will show fewer, and that is correct.
- Outcome-aware evaluation changes
unproven_executionanddormant_authoritybehavior for every existing tenant, not just enforcement ones. It needs its own migration thinking. - Binding the Approved Job to entity identity will surface
unknownrows on day one. That is correct behavior and will look like a regression to anyone comparing row counts. - Two verification moments is more UI than one, and the second lags by the scan cadence — which makes the cadence visible to customers. Honest, and also a support conversation. Collapsing them is a recurring temptation that loses the distinction between enforcement and reconciliation.
- The Approved Job is customer-asserted at first. Its provenance must be displayed every time it is shown, or it will be mistaken for something we derived.
- The control case is a new object with its own lifecycle, and it must not be solved by making evidence packs mutable.
When to reconsider
- A provider offers push notification of policy state, making reconciliation immediate — then the two verification moments may collapse into one for that surface only. Do not generalize it.
- The Approved Job proves unmaintainable at enterprise scale (many agents, frequent legitimate change) — reconsider whether approval attaches to the job or to a class of jobs, before weakening the named-owner requirement.
- A customer demands automatic write-back into their environment. That reopens a held item and needs its own ADR; it does not fall out of this one.
Alternatives considered
- Add a control-case section to the evidence pack. Rejected: packs are sealed at a point in time
(
integrity_hash,sealed_at) and a case accrues artifacts afterwards. Mutability would cost the auditor-grade property that makes packs worth sending to an auditor. The case references packs instead. - Treat the mitigation work-tracker statuses as the control rail directly. Rejected: it overstates
what exists.
verifiedthere is a human assertion, and conflating the two is how the documents drifted in the first place. - Keep the approved baseline name-matched for now. Rejected: a rename silently voids coverage with no error, and the failure mode is a green row that is not true.
- Deny by identity denylist rather than resource anchor plus principal allowlist. Rejected: a denylist built from a poll misses any principal created since the poll — the exact adversary.
References
- Architecture Overview — the loop in the system story
- Data Model — Approved Job, control case, evidence outcome records
- Processing Pipeline §2.7 — where verification and reconciliation sit
- API Layer — the control-case and Approved Job HTTP surface
- UI & Reporting §9B — the control-case view and rail rendering rules
- Execution Evidence Fidelity §11 — outcome semantics
- ADR-032: Severity vs Priority — the precedent this follows: split one overloaded concept into two, and gate the action-facing one on evidence
- ADR-005: Platform-Only Findings
- ADR-027: Automated Connector Pipeline — event-driven triggers recorded as rejected