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).
Implementation status
Section titled “Implementation status”Implementation scope
Section titled “Implementation scope”| Area | State | Evidence | Notes |
|---|---|---|---|
| Table loading, ordering, and first-match evaluation | implemented | risk_table.py, test_risk_table.py | The loader validates the catalog and applies the ordered fail-closed decision table in focused tests. |
| Feature extraction and environment classification | implemented | feature.py, test_control_loop_authority.py | Typed features are extracted, and missing or unknown environment tags resolve to production risk. |
| Unified authority decision and never-raising ceiling | implemented | authority.py, test_authority.py | The table baseline and contextual ceilings combine without raising authority. |
| Existing control-loop audit projection | implemented | _helpers.py, test_control_loop_authority.py | Audit data includes the matched rule, final decision, quorum, and resolved ceiling. |
| Approval and change-governance enforcement | in-progress | check-risk-table-change.py, test_check_risk_table_change.py, Change Process, CODEOWNERS | A 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 metadata | implemented | authority.py, test_authority.py | The 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. |
Implementation history
Section titled “Implementation history”| Date | State | Change | Evidence | Remaining |
|---|---|---|---|---|
| 2026-08-13 | in-progress | Adopted 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-14 | implemented | Serialized 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-14 | implemented | Added 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-14 | in-progress | Enforced 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. |
Remaining work
Section titled “Remaining work”- 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.
Where the Table Lives
Section titled “Where the Table Lives”- Runtime path:
rule-catalog/risk-classification.yaml- catalog-as-code, reviewed via PR like rules/assignments/exemptions/overrides. Repository CODEOWNERS names the GitHubfdai-ownersteam. The deployment’s branch protection/CI applies the two-personaw-approversgovernance contract (user-rbac-and-identity.md § 5.1). - Policy owner: the
aw-ownersEntra 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 thedefault: hilfail-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.
Classification Dimensions
Section titled “Classification Dimensions”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.
| Dimension | Type | Source |
|---|---|---|
policy_violation | bool | OPA/Rego verifier decision |
destructive | bool | ontology ActionType.operation ∈ {delete, drop, purge, detach} |
irreversible | bool | ontology ActionType.irreversible == true (a rolled-back state cannot fully restore the pre-action state) |
blast_radius | enum resource | resource_group | subscription | applies_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_path | enum pr_revert | scripted | pitr | snapshot_restore | state_forward_only | remediates action’s rollback contract (no none value - every ActionType MUST declare an undo path) |
reversible | bool | shortcut for irreversible == false |
environment | enum prod | non-prod | see Environment Detection |
data_plane_touched | bool | ontology ActionType.interfaces include DataPlaneMutating |
graph_stale | bool | ontology ActionType.interfaces include RequiresInventoryFresh AND the target Resource’s inventory record exceeds freshness_ttl |
cross_resource_impact | int | ActionType.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_monthly | number (USD/month) | rule’s remediation.cost_impact_monthly_usd estimate, or observed post-hoc reconciliation |
verifier_confidence | number [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.
Initial Rule Table (upstream default)
Section titled “Initial Rule Table (upstream default)”# rule-catalog/risk-classification.yaml (upstream default; fork MAY tune thresholds)version: 1.0.0owner_group: aw-ownersrules: # ── 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.
Environment Detection
Section titled “Environment Detection”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:
environmentandEnvironmentare accepted for resources that predate the namespaced tag. When both exist,fdai:envwins. - Values:
prod/production→prod;non-prod/dev/test/staging/qa→non-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.
Environment Promotion (handoff target)
Section titled “Environment Promotion (handoff target)”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,qaall resolve tonon-prodon the runtime axis; the ordering is used only to answer “is the target stageprod” at handoff time. - A transfer whose target stage is
prodis a promotion into production: the ORR treats anycriticaldetected issue asblockingregardless of the active profile default, reusing the same fail-safe posture asprod_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.
Cost Impact Threshold
Section titled “Cost Impact Threshold”- 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_usdfield; if the rule cannot estimate, the value isunknown→ treated as>= 100→ human approval.
Allowlist for Prod-Auto
Section titled “Allowlist for Prod-Auto”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.
Change Process
Section titled “Change Process”Updating the risk table follows the standard governance PR flow:
- Any change to
risk-classification.yamlrequires a quorum of 2aw-approversand aJustification: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).
What the commit gate proves
Section titled “What the commit gate proves”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.PATCHversion, 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
reasonon every rule, which is the in-file half of the PRJustification: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-hilon fail-through). - The final decision (
auto/hil/shadow/deny) and quorum. - The
resolved_ceilingwith every axis that contributed to the final decision. - The
catalog_versionof therisk-classification.yamlrevision that classified the action. - The
feature_vectorsnapshot, including the dimensions that were unset, so an absent signal is distinguishable from a dropped one. - The
ceiling_inputsblock: resolved role, graph-derived affected count, the recorded live-probe reading, its consecutive-failure streak, and thesystem_degradedandkill_switch_engagedfail-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.
Open Decisions
Section titled “Open Decisions”- Whether to add a
time_of_daygate (business hours vs off-hours) as a future dimension - deferred until observation mode measurement shows a real need. - Whether to compute a numeric
risk_scorein 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.