Skip to content

Risk Classification (automatic execution vs human approval vs denial)

The risk-classification table (architecture.instructions.md § Control Loop) classifies every candidate action’s baseline as auto, hil, or deny. The unified RiskGate can lower the final outcome through its six-axis ceiling, including to shadow. This file is authoritative for the baseline classification rules: their shape, initial rule table, ownership, and update process. It resolves P0 Open Decision “Risk-classification policy (auto vs human approval) and initial policy approver” from security-and-identity.md.

Customer-agnostic: every value below (cost threshold, tag key, resource-group name) is a default in the upstream; a fork tunes them via config (generic-scope.instructions.md).

AreaStateEvidenceNotes
Table loading, ordering, and first-match evaluationimplementedrisk_table.py, test_risk_table.pyThe loader validates the catalog and applies the ordered fail-closed decision table in focused tests.
Feature extraction and environment classificationimplementedfeature.py, test_control_loop_authority.pyTyped features are extracted, and missing or unknown environment tags resolve to production risk.
Unified authority decision and never-raising ceilingimplementedauthority.py, test_authority.pyThe table baseline and contextual ceilings combine without raising authority.
Existing control-loop audit projectionimplemented_helpers.py, test_control_loop_authority.pyAudit data includes the matched rule, final decision, quorum, and resolved ceiling.
Approval and change-governance enforcementin-progresscheck-risk-table-change.py, test_check_risk_table_change.py, Change Process, CODEOWNERSA commit gate now enforces the metadata half of the contract - a strictly increasing version, unchanged Owner-tier ownership, a written justification on every rule, and a fail-close default that stays last - and classifies the change direction so a loosening edit cannot hide behind a patch bump. The two-person quorum and Owner-tier review half is branch protection on the deployment’s fork and stays unproven from a local checkout.
Replay-complete feature and catalog metadataimplementedauthority.py, test_authority.pyThe authority audit payload serializes the exact feature vector and the risk-table catalog version, and focused checks replay a recorded payload against its own catalog version after the table changes.
DateStateChangeEvidenceRemaining
2026-08-13in-progressAdopted an evidence-bounded implementation ledger without reconstructing earlier delivery history.current change; current source and focused checks listed in the scope table; risk-focused checks passed 113 cases and readiness coordinator checks passed 33 cases.Prove governance enforcement, add replay-complete metadata, and retain governed runtime evidence.
2026-08-14implementedSerialized the exact feature vector and the risk-table catalog version into the authority audit payload so a historical decision replays against the revision that classified it.current change; authority.py, test_authority.py; focused authority, evaluator, and control-loop authority checks passed 43 cases.Prove governance enforcement and retain governed runtime receipts.
2026-08-14implementedAdded the remaining ceiling inputs - role, graph count, live-probe reading, and the two fail-safe flags - to the same audit payload so a replay reconstructs the six-axis ceiling without re-querying a probe or re-reading control-plane health.current change; authority.py, test_authority.py; focused safety check, runbook, workflow, skills, and control-loop authority checks passed 438 cases.Prove governance enforcement and retain governed runtime receipts.
2026-08-14in-progressEnforced the metadata half of the change contract with a commit gate and made loosening edits legible in the version string.current change; check-risk-table-change.py, test_check_risk_table_change.py; focused gate checks passed 26 cases, and the gate was exercised against the shipped table for the unchanged, loosening-without-bump, loosening-patch-bump, and loosening-minor-bump cases.Approval quorum and Owner-tier review stay branch protection on the deployment’s fork; retain governed runtime receipts.
  • A commit gate enforces the metadata contract on every risk-table change - increasing version, unchanged owner group, a written justification per rule, a single fail-close default that stays last - and refuses a loosening change that only bumps the patch version.
  • Prove the two-person approval quorum and the Owner-tier review for loosening changes. That evidence lives in the deployment’s branch protection, not in this repository.
  • The authority audit payload serializes the exact feature vector and the catalog version, and a focused check replays a recorded payload against its own version after a tightening table change.
  • Retain governed runtime receipts for risk decisions on one pinned revision before promoting any scope row to validated.
  • Runtime path: rule-catalog/risk-classification.yaml - catalog-as-code, reviewed via PR like rules/assignments/exemptions/overrides. Repository CODEOWNERS names the GitHub fdai-owners team. The deployment’s branch protection/CI applies the two-person aw-approvers governance contract (user-rbac-and-identity.md § 5.1).
  • Policy owner: the aw-owners Entra security group. Ownership sits with Owner-tier because the table gates the entire autonomy surface.
  • Evaluation: first-match wins. Rules are ordered from strictest (deny) to most permissive (auto); a case that matches no rule falls through to the default: hil fail-close entry.

Relationship to the Execution-Model Six-Axis Ceiling

Section titled “Relationship to the Execution-Model Six-Axis Ceiling”

This table is the authoritative baseline decision. The unified RiskGate (execution-model.md) evaluates this table as its risk_table axis (Axis A) and then takes the min() of that result and six ActionType-context ceiling axes (tier, ActionType ceiling, static blast, live blast, role, env). The six-axis ceiling can only ever lower autonomy further; it never overrides or raises a decision this table made. Signals that need detected issue-level data - cost_impact_monthly, destructive, irreversible (with its quorum: 2), data_plane_touched, verifier_confidence - are evaluated here and only here; the ceiling axes deliberately do not re-derive them. There are not two decision engines: there is this table, plus a never-raising ceiling layered on top.

Standing authorization does not raise an hil baseline to auto or alter the matched rule. Instead, it supplies a pre-existing, human-authored Approval after the escalation deadline when the exact A3-E envelope remains valid. Thor may execute the approved human approval action while the audit retains the original risk rule, approval identity, standing-authorization id, and authority class. Silence without that Approval remains a no-op. See Escalation and Standing Authority.

The safety check composes a feature vector for every candidate action from the ontology signals it already has (llm-strategy.md § Rule-to-Decision Lookup Pipeline). No new data collection is introduced.

DimensionTypeSource
policy_violationboolOPA/Rego verifier decision
destructiveboolontology ActionType.operation ∈ {delete, drop, purge, detach}
irreversibleboolontology ActionType.irreversible == true (a rolled-back state cannot fully restore the pre-action state)
blast_radiusenum resource | resource_group | subscriptionapplies_to × scope of the affected resource(s); when ActionType.blast_radius.computation == graph_derived, the safety check walks Resource→Resource links (default contains + reverse depends_on, depth 2) and maps the affected-resource count to a bucket
rollback_pathenum pr_revert | scripted | pitr | snapshot_restore | state_forward_onlyremediates action’s rollback contract (no none value - every ActionType MUST declare an undo path)
reversibleboolshortcut for irreversible == false
environmentenum prod | non-prodsee Environment Detection
data_plane_touchedboolontology ActionType.interfaces include DataPlaneMutating
graph_staleboolontology ActionType.interfaces include RequiresInventoryFresh AND the target Resource’s inventory record exceeds freshness_ttl
cross_resource_impactintActionType.blast_radius.computation == graph_derived ⇒ count of affected Resources returned by the traversal; unknown when the graph is unavailable and the ActionType lacks GraphTraversalRequired
cost_impact_monthlynumber (USD/month)rule’s remediation.cost_impact_monthly_usd estimate, or observed post-hoc reconciliation
verifier_confidencenumber [0..1]LLM quality-gate signal (only set for T2-produced actions)

Dimensions are strictly typed; a rule that references an unknown key fails at CI load.

# rule-catalog/risk-classification.yaml (upstream default; fork MAY tune thresholds)
version: 1.0.0
owner_group: aw-owners
rules:
# ── DENY (never execute) ──
- id: deny-policy-violation
if: { policy_violation: true }
decision: deny
reason: "policy-as-code verifier rejected the action"
- id: deny-subscription-blast
if: { blast_radius: subscription }
decision: deny
reason: "no autonomous change spans a full subscription"
- id: deny-graph-stale
if: { graph_stale: true }
decision: deny
reason: "inventory graph is stale; refuse to act on a possibly-ghost resource"
# ── HIL (human approval required) ──
- id: hil-irreversible
if: { irreversible: true }
decision: hil
reason: "irreversible mutation always requires an approver quorum >= 2"
quorum: 2
- id: hil-destructive
if: { destructive: true }
decision: hil
reason: "delete/drop/purge/detach always requires an approver"
- id: hil-prod
if: { environment: prod, allowlist_prod_auto: false }
decision: hil
reason: "prod defaults to HIL unless the rule is on the prod-auto allowlist"
- id: hil-data-plane
if: { data_plane_touched: true }
decision: hil
reason: "data-plane mutations always require an approver"
- id: hil-cost
if: { cost_impact_monthly: '>= 100' }
decision: hil
reason: "cost impact above the auto threshold"
- id: hil-resource-group-blast
if: { blast_radius: resource_group }
decision: hil
reason: "RG-wide changes require an approver"
- id: hil-low-confidence
if: { verifier_confidence: '< 0.85' }
decision: hil
reason: "T2 quality-gate confidence below auto threshold"
# ── AUTO (execute without approval) ──
- id: auto-low-risk
if:
all:
- reversible: true
- blast_radius: resource
- cost_impact_monthly: '< 100'
- data_plane_touched: false
decision: auto
reason: "reversible, resource-scoped, low cost, control-plane only"
# ── FAIL-CLOSE ──
- id: default-hil
default: hil
reason: "no matching rule - fail toward safety"

Rule ordering (MUST): deny rules come first, then hil, then auto, then the default: hil catch-all. First-match wins so the strictest applicable rule dominates. CI validates the order (denies before hils before autos) and rejects any rule that could be dead-code by a preceding broader rule.

This section is the single authoritative environment classifier for the whole control plane. Both execution-model.md (the env axis, via ActionType.prod_downgrade.detection_ref) and action-ontology.md (env_scope) resolve “prod” vs “non-prod” through this rule, never through a second definition.

environment: prod vs non-prod is derived from the target resource-group tag:

  • Canonical tag key: fdai:env (written by the Terraform base tag set).
  • Compatibility keys: environment and Environment are accepted for resources that predate the namespaced tag. When both exist, fdai:env wins.
  • Values: prod / productionprod; non-prod / dev / test / staging / qanon-prod
  • Missing or unrecognized tag → prod (fail-safe: unknown environment is treated as the highest-risk category)

Enforcement: an Azure Policy assignment SHOULD deny resource-group creation without the fdai:env tag, so the fail-safe path never applies in a governed environment. The policy assignment is a Phase 1 deliverable in phase-1-rule-catalog-t0.md.

The binary prod / non-prod axis above is the authoritative runtime classifier. The dev-to-ops handoff gate (operational-readiness.md) needs one thing the runtime axis does not carry: a direction. It reads the target environment on the ownership_transfer signal and gates on whether the transfer is a promotion toward prod.

To keep a single definition, the lifecycle stages are an ordering over the exact tag values the classifier already recognizes - no new tag, no second classifier:

dev < test < staging < qa < prod

  • The stages dev, test, staging, qa all resolve to non-prod on the runtime axis; the ordering is used only to answer “is the target stage prod” at handoff time.
  • A transfer whose target stage is prod is a promotion into production: the ORR treats any critical detected issue as blocking regardless of the active profile default, reusing the same fail-safe posture as prod_downgrade (a downgrade never raises autonomy).
  • A missing or unrecognized target stage resolves to prod, the same fail-safe as Environment Detection, so an un-tagged handoff is gated at the strictest level.
  • The ordering never widens autonomy: a lower target stage never unlocks an auto path the runtime axis would have gated.

The ordering is a doc-level contract consumed only by the ORR gate; it adds no runtime axis to risk-classification.yaml. The runtime risk table still sees only environment: prod | non-prod.

  • Auto ceiling: $100 / month per action.
  • Rationale: covers small right-sizing / stop-idle / tier-adjust fixes without approving large disposals. Chosen conservatively for Phase 1 observation mode measurement; the threshold is a config value, adjustable via a governance PR after measurement.
  • The estimate comes from the rule’s remediation.cost_impact_monthly_usd field; if the rule cannot estimate, the value is unknown → treated as >= 100 → human approval.

A tiny set of very-low-risk rules MAY be marked as auto-eligible in prod (allowlist_prod_auto: true). Candidates for the initial allowlist (evaluated in observation mode before promotion):

  • Tag fix (add missing owner / cost-center / environment tags).
  • Release of unattached public IP addresses.
  • NSG allow-any-source rule removal on resources with no data-plane exposure.

Every allowlist entry is a separately promoted assignment and passes the standard observation mode → enforce gate (architecture.instructions.md § Observation mode → Enforce Promotion). The allowlist is not a bypass; it is an opt-in reduction of the prod default.

Updating the risk table follows the standard governance PR flow:

  • Any change to risk-classification.yaml requires a quorum of 2 aw-approvers and a Justification: block in the PR body.
  • Loosening changes (widening auto, raising cost threshold, removing a deny) require an Owner-tier reviewer (member of aw-owners) in the quorum.
  • Tightening changes (adding a deny, lowering cost threshold, moving auto→human approval) MAY merge with regular quorum - safety-side changes never need Owner approval.
  • The table version is bumped on every change and captured in the catalog version, so the risk decision that classified any historical action is reconstructable (llm-strategy.md § Signature Composition).

The approval quorum lives in branch protection and cannot be read from a local checkout, so check-risk-table-change.py enforces only the half that the diff decides. On every commit that touches the table it requires:

  • A strictly increasing MAJOR.MINOR.PATCH version, so no change reaches the audit payload wearing the version of the revision it replaced.
  • An unchanged owner_group, so a table edit cannot quietly re-home ownership away from the Owner tier that the table’s impact scope demands.
  • A non-empty reason on every rule, which is the in-file half of the PR Justification: block.
  • Unique rule ids, exactly one fail-close default, and that default last - a default that drifts upward would stop catching the cases no rule matched.
  • At least a minor version bump for a loosening change. The catalog version is all a replayed audit record shows, so a loosening that hid behind a patch bump would be indistinguishable from a typo fix months later.

Direction classification is fail-closed. Widening a decision, dropping a guardrail rule, lowering a quorum, editing a match condition, or reordering rules all count as loosening, because first-match evaluation means the gate cannot prove any of those narrows the table on its own. Only a provably safety-side edit is reported as tightening, so an unrecognized edit shape raises the review bar rather than lowering it. The gate never claims to have checked the reviewer quorum or the Owner-tier reviewer; it prints which one the change needs.

The current control-loop audit entry records:

  • The matched rule id (default-hil on fail-through).
  • The final decision (auto / hil / shadow / deny) and quorum.
  • The resolved_ceiling with every axis that contributed to the final decision.
  • The catalog_version of the risk-classification.yaml revision that classified the action.
  • The feature_vector snapshot, including the dimensions that were unset, so an absent signal is distinguishable from a dropped one.
  • The ceiling_inputs block: resolved role, graph-derived affected count, the recorded live-probe reading, its consecutive-failure streak, and the system_degraded and kill_switch_engaged fail-safe flags.

The last three make replay self-contained: a recorded payload is re-evaluated against the table version it names and the inputs it carries, so a later catalog change cannot silently rewrite a historical decision and a replay never re-queries a live probe.

A future retrospective can filter the audit log by matched rule id to identify over-triggered rules (e.g. “every prod change is human approval because everything hits Rule 5”) and propose refinements via the same governance PR flow.

  • Whether to add a time_of_day gate (business hours vs off-hours) as a future dimension - deferred until observation mode measurement shows a real need.
  • Whether to compute a numeric risk_score in addition to the deterministic rule table (would only kick in on ties or as a tie-breaker - the deterministic table remains authoritative).
  • Fork override policy: can a fork loosen the upstream defaults (e.g. raise the cost threshold), or only tighten? Recommended default: tightening is free, loosening requires an audited Owner override.