ADR 0023: Is aa-api Meant to Carry a Policy Cascade?
Status: Accepted (2026-07-30, Option (a) — architecture). aa-api is wired to load a policy cascade from an operator-settable directory, using the same load_cascade_from_dir loader aa-gateway already uses (as AAASM-3499 did for the gateway), so the dashboard’s cascade-derived projections reflect the same policy source the gateway enforces. Read-only projection only — no enforcement path is touched. The interim empty-cascade truthfulness mitigation (ADR-0024) is a prerequisite and has already landed (AAASM-5106 / #1825). Implemented under AAASM-5299.
Date: 2026-07
Ticket: AAASM-5106 (Epic AAASM-5082)
This ADR frames a decision about where the multi-document policy cascade is supposed to live. It changes nothing. No loader, route, projection, schema, or enforcement path is modified by merging it. Every option below is a proposal; none is authorised by this document.
The question is narrow and answerable: aa-api serves every REST surface that
reports on the policy cascade, and aa-api has no way to load one. Whether that
is a wiring bug (as the directly analogous
AAASM-3499 was
judged to be for aa-gateway) or a correct reflection of a single-policy design
that the projections mis-modelled, is the decision this ADR asks for.
It complements ADR 0004 (governance enforcement flow), ADR 0017 (which ratified the Capability Matrix and Topology surfaces that read the cascade) and ADR 0018 (whose “honestly-null until sourced” discipline the interim mitigation below extends). It is also the companion to ADR 0024, “Semantics of an Empty or Unavailable Policy Cascade” — same ticket, authored independently, currently open on PR #1706 — which owns the mechanism for the interim mitigation this ADR calls for. The two are orthogonal and should be decided separately; see Traceability.
All line references were re-verified at main @ 7174c640.
Context
Two loaders, one of which nothing in aa-api can reach
PolicyEngine has two families of constructor:
| Constructor | Line | Populates scope_index? |
|---|---|---|
load_from_file | aa-gateway/src/engine/mod.rs:318 | No — scope_index: ScopeIndex::new() at mod.rs:383 |
load_from_file_with_budget | aa-gateway/src/engine/mod.rs:673 | No — scope_index: ScopeIndex::new() at mod.rs:695 |
load_cascade_from_dir | aa-gateway/src/engine/mod.rs:438 | Yes, via read_cascade_dir |
load_cascade_from_dir_with_budget | aa-gateway/src/engine/mod.rs:474 | Yes, via read_cascade_dir |
read_cascade_dir (mod.rs:508-599) is the only code anywhere that fills the
index — scope_index.insert(doc) at mod.rs:586.
aa-api builds its engine with load_from_file
(aa-api/src/state.rs:302), so its scope_index is empty from construction and
stays that way for the life of the process.
aa-gateway has a working operator mechanism; aa-api has none at all
aa-gateway routes on the shape of the path it is given:
#![allow(unused)]
fn main() {
// aa-gateway/src/server.rs:224-234 (fn load_policy_engine)
if policy_path.is_dir() {
tracing::info!(dir = %policy_path.display(), "loading policy cascade from directory");
PolicyEngine::load_cascade_from_dir_with_budget(policy_path, tracker)
} else {
PolicyEngine::load_from_file_with_budget(policy_path, tracker)
}
}
Operators reach it through aa-gateway --policy <FILE|DIR>, or through
aasm gateway start, whose full resolution order is --policy → $AA_POLICY →
~/.aasm/policy.yaml → ~/.aasm/policies/ → /etc/aasm/policy.yaml →
/etc/aasm/policies/ (aa-cli/src/commands/gateway/start.rs:209-210,
implemented at :217-246, forwarded verbatim at :94). Two of those six defaults
are cascade directories, and the function’s own documentation says why: “The
default policies/ directory locations let an operator drop scoped *.yaml
documents into a well-known path without any flag” (start.rs:214-216).
So the gateway does not merely accept a cascade — it looks for one by default,
with zero configuration. This is the capability documented in
docs/src/operations/policy-cascade-loader.md — a document that names
aa-gateway and aasm gateway start only (:38-53) and never mentions aa-api.
aa-api has no equivalent. Grepping its entire source for environment reads
returns AA_API_ADDR (aa-api/src/bin/aa-api-server.rs:41, config.rs:36),
AASM_API_AUTH (state.rs:202), AASM_API_KEY (state.rs:205), and
ALLOW_PRIVATE_EGRESS_ENV (destinations/validate.rs:109). There is no policy
path, no cascade directory, no config file key, and no CLI flag. The ticket’s
claim that no such setting is read anywhere in aa-api is confirmed.
Worse than “not operator-settable”: aa-api does not accept an operator policy at
all. It synthesises one — a hard-coded, budget-only envelope written to a
per-process temp file (state.rs:266-290) — and loads that. The durable
entrypoint local_hardened_at (state.rs:454) delegates to the same builder and
adds no policy source of its own.
The two processes are disjoint, and the projections are in the wrong one
This is the structural fact the ticket does not state, and it is the crux.
aa-gatewaydoes not depend onaa-api(noaa-apientry inaa-gateway/Cargo.toml); the dependency runs the other way (aa-api/Cargo.toml:21).aa-gateway’s own HTTP surface is three routes —/healthz,/api/v1/health,/api/v1/admin/status(aa-gateway/src/local_mode.rs:268,:277,:287) — plus the dashboard’s static assets (aa-gateway/src/dashboard_server.rs:36).- Every cascade-derived projection lives in
aa-api:routes/policies.rs:121,routes/topology.rs:426,routes/capability.rs:608. aasm start --mode localspawnsaa-api-server;--mode remotespawnsaa-gateway(aa-cli/src/commands/start.rs:157-161).
So: the process that can load a cascade serves none of the endpoints that report
on one, and the process that serves all of them cannot load one. No deployment
topology in the repository puts a populated scope_index behind
GET /api/v1/agents/{id}/capabilities, GET /api/v1/topology, or
GET /api/v1/policies/team/{team_id}.
apply_yaml does not close the gap, and could not today
apply_yaml (mod.rs:783) validates, writes history, and swaps the primary slot
(self.policy.store(...), mod.rs:796). It never touches scope_index. That is
already recorded in-tree at aa-api/src/routes/policies.rs:484-486.
Two further facts constrain any fix here:
POST /api/v1/policiesrejects every non-Global-scoped document (policies.rs:453-461), because the primary slot is global by nature and a narrower scope “would be silently globalised”. The handler’s own comment defers scoped installation “until scoped installation is wired into thescope_indexcascade (AAASM-4933 follow-up)”. So the API cannot install an Org/Team/Agent policy at all — the tiers the cascade exists to express.- The one API that does insert into the index,
load_policy(mod.rs:1873), takes&mut self.AppState.policy_engineisArc<PolicyEngine>(state.rs:45); the test code that usesload_policyhas to reach forArc::get_mut(state.rs:691) at construction time. It is structurally unusable from a live request handler.
Enforcement is genuinely unaffected — but not for the reason the ticket gives
evaluate falls back when the cascade is empty:
#![allow(unused)]
fn main() {
// aa-gateway/src/engine/mod.rs:845-846
if cascade.is_empty() {
return self.evaluate_primary(ctx, action);
}
}
(The ticket cites mod.rs:813-816; at 7174c640 the fallback is at :845-846.)
The sharper point: aa-api reaches evaluate only through the dry-run path,
which mutates nothing. Searching aa-api/src for .evaluate( returns zero
hits, but that grep answers the wrong question — the call is one level of
indirection away. POST /api/v1/policies/simulate
(aa-api/src/routes/mod.rs:109 → policies.rs:788) calls
state.policy_engine.simulate(&ctx, &action) at policies.rs:841, and
simulate is:
#![allow(unused)]
fn main() {
// aa-gateway/src/engine/mod.rs:880-882
pub fn simulate(&self, ctx: &aa_core::AgentContext, action: &aa_core::GovernanceAction) -> EvaluationResult {
self.ephemeral_for_simulation().evaluate(ctx, action)
}
}
ephemeral_for_simulation (mod.rs:894) shares the live cascade by Arc
(mod.rs:905), so the dry-run sees exactly the rules a real request would — the
in-tree comment at policies.rs:838 says so directly: “simulate runs the same
pipeline as the live evaluate”.
The conclusion survives, but for a narrower reason than “it never evaluates”.
The ephemeral engine gets its own rate_state, decision_cache, and a fresh
zero-spend budget tracker; it “writes no audit entry and applies no enforcement —
this method only returns a verdict; the caller performs no side effect from it”
(mod.rs:873-874). So aa-api runs the evaluator but never enforces with it, and
the surface that does run it is itself a cascade consumer — see the fourth item
in the blast radius below. Actual enforcement happens in aa-runtime/aa-gateway,
in a different process, against a policy loaded by a different code path.
That refines the ticket’s framing — “the gateway denies and the dashboard says there is nothing to deny with” is true, but the two are not the same engine disagreeing with itself. They are two processes with independent policy inputs, only one of which the operator can configure.
Was the cascade ever intended for aa-api?
The evidence is mixed, and this is precisely why it needs a human decision.
Points to “gateway-only by design”:
- AAASM-951 introduced the scope index for cascading evaluation — a runtime concern.
- AAASM-2023 is
titled “Gateway multi-document cascade loader” and motivates it entirely by
evaluate_with_cascadeversusevaluate_primary. Reporting is not mentioned. docs/src/operations/policy-cascade-loader.mddocuments only gateway entrypoints.
Points to “aa-api was in scope and was skipped”:
- AAASM-3499 — the
bug that the cascade was “unreachable from any shipped binary” — explicitly
enumerated
aa-apias one of the four affected paths, quoting the verystate.rscomment still present today. Its fix commit (dadd6061) touchedaa-gateway/src/main.rsandaa-gateway/src/server.rsand nothing else.aa-apiwas named, diagnosed, and left unfixed — with no recorded rationale. - The projections that read the cascade
(AAASM-5090,
AAASM-5099,
AAASM-5096) were
all specified against surfaces ratified in ADR 0017, and all were built in
aa-api. Somebody expected a cascade to be there. - The doc comment asserting
load_from_file“is the only public loader” (state.rs:243) is false and has been since AAASM-2023: bothload_cascade_from_dir(mod.rs:438) andload_cascade_from_dir_with_budget(mod.rs:474) arepub. The comment is a stale premise that has been load-bearing for theaa-apiwiring ever since.
Not verified: no ticket, commit message, or design note was found that states
a deliberate decision to keep aa-api single-policy. The absence of such a record
is itself part of why this ADR exists.
Blast radius
All four are shipped, all four read an index that is empty in every deployment. The ticket enumerates the first three; the fourth is added here.
1. Capability Matrix (AAASM-5090) — a fail-open on the page whose job is “what can this agent do”
capability.rs:608 collects the cascade; collect_merged_capabilities folds an
empty slice into an empty CapabilitySet. decide (capability.rs:480-488) then
reads:
#![allow(unused)]
fn main() {
if aa_core::capability_is_denied(&caps.deny, cap) { return Decision::Deny; } // empty deny → false
if caps.allow_is_restricted() && !caps.allow.contains(cap) { return Decision::Deny; }
Decision::Allow
}
allow_is_restricted() is self.allow_restricted || !self.allow.is_empty()
(aa-core/src/capability.rs:70-72) — false on an empty set. Both guards fall
through and every cell renders allow.
This is confirmed as the ticket states, with one nuance worth recording: decide
is individually correct. It is fail-closed given a cascade
(capability.rs:475-479 documents exactly that). The fail-open is entirely a
property of being handed an empty input, which is why no unit test catches it.
2. Topology permission chain (AAASM-5099)
topology.rs:427 sets node.policy_count = Some(cascade.len() as u32) — i.e.
Some(0), not None. Some(0) is a positive claim (“zero policies govern this
agent”); None would have been “not known”. Every tier of
effective_permissions renders empty for the same reason.
The crate already knows this distinction matters and applies it elsewhere: the
field’s own documentation states that the list / tree / team endpoints “leave it
null rather than emitting a misleading 0”
(aa-api/src/models/topology.rs:287-291). The graph endpoint is the one place
that emits the 0 — and it is the only place where the 0 is always wrong.
3. Policy affects[] and team active-policies (AAASM-5096)
affects is already absent-not-empty (policies.rs:230), and the create path
returns affects: None with an accurate explanation (policies.rs:484-488).
Contradiction with the ticket, worth stating plainly: the ticket’s “worst UI
consequence” — the Teams card asserting “No policy is in force for this team”
while a policy is enforced — is already fixed. AAASM-5096 made
TeamPoliciesResponse.policies required-but-nullable (policies.rs:633-634) and
the dashboard renders the null case as a distinct unknown state reading
“Policy data unavailable — the policy cascade is not currently loaded”
(dashboard/src/features/teams/TeamActivePoliciesCard.tsx:73-77), with the count
shown as — rather than 0 (:59). A regression test asserts the unknown state
does not render the “no policy” copy
(TeamActivePoliciesCard.test.tsx:48). The ticket describes a state of affairs
that the PR which surfaced the ticket had already remedied on that one surface.
The other three surfaces have not been given that treatment. That asymmetry — one surface honest, three surfaces asserting — is the concrete harm today.
4. Policy simulation (POST /api/v1/policies/simulate) — the oracle answers from a stub
Not named in the ticket, and arguably the worst of the four, because it is the one surface whose entire purpose is to answer “what would happen if?” with authority.
simulate_policy (policies.rs:788, routed at routes/mod.rs:109) calls
PolicyEngine::simulate (mod.rs:880-882), which evaluates against a throwaway
engine sharing the live cascade by Arc (mod.rs:905). With an empty cascade the
shared evaluate hits the same fallback as everything else
(mod.rs:845-846) and answers from evaluate_primary — which, in aa-api, means
the synthesised budget-only bootstrap document the process wrote to a temp file
at startup (state.rs:266-290).
So an operator asking “would this tool call be denied?” receives a confident, correctly-computed verdict derived from a policy nobody authored. Unlike the capability matrix, this surface has no notion of a cell it could mark unknown — it returns a verdict, a matched rule, and a reason. It is the surface where “empty cascade” is least visible and most consequential.
This also means the interim mitigation has a fourth consumer, and one that needs a different treatment from the other three: a matrix cell can decline to answer, but a simulate response either carries a caveat that the cascade was unavailable or it misleads.
Options
Option A — Wire aa-api to load_cascade_from_dir with an operator-settable directory
Mirror what AAASM-3499 did for aa-gateway: accept a policy path, route on
is_dir(), and load the cascade when it is a directory. aa-api already writes
its bootstrap policy into a directory (state.rs:266), so the mechanical change
is small — load_cascade_from_dir(&policy_dir, budget_alert_tx) is signature-
compatible (mod.rs:438-441) with the existing call at state.rs:302.
- Fixes: all four cascade consumers, at the source, for operators who supply a cascade directory. Restores the symmetry the docs already promise.
- Does not fix: deployments that supply no directory — the synthesised budget-only bootstrap would load as a one-document Global cascade, making the index non-empty and the projections technically correct but reporting a stub policy nobody authored. The interim mitigation is therefore still required under this option, to distinguish “cascade loaded, genuinely permissive” from “cascade absent”.
- Does not fix:
POST /api/v1/policies— an API-created policy still lands in the primary slot only, so it remains unreportable (see Option B). - Migration/compat: additive. Needs a new operator input (flag and/or env,
named consistently with the gateway’s
$AA_POLICY), a decision on the default when unset, and documentation inpolicy-cascade-loader.md. No wire-contract change; no schema change. - Open question it forces: if
aa-apiandaa-gatewayare run together (--mode remote), both would read the same directory independently. That is fine for a read-only projection but means two watchers and two parse paths over one directory — acceptable, but it should be a conscious choice, not a side-effect.
Option B — Make apply_yaml also populate the scope index
So that a policy created through the API is visible to the projections that report on policy.
- Fixes: the “I applied a policy and the dashboard still shows nothing” path,
which is the only way to get a real policy into
aa-apitoday. - Does not fix: anything for Org/Team/Agent tiers, because
POST /api/v1/policiesrejects non-Global documents outright (policies.rs:453-461). Option B on its own makes the cascade a one-element Global list — enough to un-blank the projections, not enough to make the Org/Team tiers of the Topology chain mean anything. It is necessary but not sufficient, and it presupposes the AAASM-4933 follow-up on scoped installation. - Migration/compat — the real cost: this changes
PolicyEnginesemantics, not justaa-apibehaviour. A non-empty index flipsevaluatefromevaluate_primarytoevaluate_with_cascade(mod.rs:845-846) for any embedder — andaa-apihas such a caller today.POST /api/v1/policies/simulateroutes throughsimulate→ephemeral_for_simulation().evaluate(...)(mod.rs:880-882), sharing the same cascadeArc(mod.rs:905). So the firstPOST /api/v1/policiesunder Option B would silently switch every subsequent simulation from the primary path to the cascade path. The verdicts should agree for a single Global document, but “should agree” is an assertion that needs a test, not an assumption — and it converts a reporting fix into a change in how a live endpoint computes its answer. It also needs an answer to “when the engine was loaded from a cascade directory, does an applied Global document replace the directory’s Global tier or stack on top of it?” - Also requires resolving the
&mut selfconstraint on the insert path (mod.rs:1873) versus theArc<PolicyEngine>the state holds —apply_yamlis&selfand swaps throughArcSwap, so the cascade’sArcSwap(mod.rs:1870-1872) is the natural mechanism, but that is a real change toload_policy’s contract.
Option C — Accept that aa-api is single-policy by design; re-point the projections at the primary document
Declare the primary slot the authoritative “active policy” for aa-api, treat the
cascade as a file-based gateway deployment feature, and rewrite the three
projections to read self.policy instead of collect_cascade_with_lineage.
- Fixes: the blank/fail-open projections, with no new operator surface and no
engine change. Honest about what
aa-apiactually holds. - Does not fix: anything about tiers. The Topology “permission chain” is a
four-tier Global/Org/Team/Agent visualisation ratified in ADR 0017; against a
single document it degenerates to one tier.
affects[]and the team mapping become “every visible agent” for a Global doc, which is true but uninformative. This is the option that changes what the UI promises, which is why it needs product sign-off and not only architecture’s. - Migration/compat: contradicts
docs/src/operations/policy-cascade-loader.mdunless that document is amended to state the cascade is gateway-only and invisible to the dashboard — an odd thing for a governance product to say. It would also leave AAASM-3499’s own analysis (which namedaa-api) standing as an unexplained loose end. - Note: if C is chosen,
collect_cascade_with_lineage’s threeaa-apicall sites (policies.rs:121,topology.rs:426,capability.rs:608) should be removed rather than left dormant, so the next reader does not re-derive this ticket. The fourth consumer,POST /api/v1/policies/simulate, is not a call-site removal: it reaches the cascade indirectly throughPolicyEngine::simulate(mod.rs:880-882), which sharescascadebyArc(mod.rs:905). Under Option C that path becomes permanently equivalent toevaluate_primaryinaa-api, which is consistent — but it means the simulation endpoint silently behaves differently inaa-apithan in a cascade-loadedaa-gateway, and that divergence should be documented rather than discovered.
Recommendation
Option A, with the interim mitigation as a hard prerequisite that lands first and independently. Option B is worth doing afterwards, but only behind the AAASM-4933 scoped-installation decision. Option C is not recommended.
The reasoning:
-
The precedent is exact. AAASM-3499 asked the identical question about
aa-gateway— loader exists, no shipped caller — and answered it by wiring the binary, not by deleting the capability. Its bug report namedaa-apiin the same breath. Answering the same question the opposite way for the second half of the same defect needs an affirmative reason, and none is recorded anywhere in the repository. -
Option C loses a promise the product has already made. The cascade is documented, tested, and reachable in
aa-gateway. Making it structurally invisible to the only UI the product ships means an operator can deploy Org/Team/Agent policies and have the governance dashboard show no trace of them. For a governance product that is a worse end state than the current bug, because it would be intentional. -
Option B is the wrong first move even though it is the most obviously “correct-feeling” one. It changes shared engine semantics to fix a reporting gap, it cannot express the tiers that make the cascade worth having, and it is blocked behind a scoped-installation decision that nobody has taken.
-
The mitigation matters more than the option. Whichever way this goes, the window between now and the fix is the dangerous part, and the capability matrix is currently rendering
allowfor capabilities that a gateway-side policy denies. That is fixable this week; the wiring decision is not.
What I am not recommending: that Option A be implemented under this ADR, or
that a --policy flag be designed here. The naming, the default-when-unset, and
the dual-watcher question above are all open, and Option A is a proposal.
Consequences
If Option A is accepted: aa-api grows an operator input it has never had,
and the documented cascade becomes visible in the dashboard for the first time.
Cost: a new configuration surface to name, document, and default safely; a second
watcher over the policy directory in co-deployed topologies; and the interim
mitigation is still needed for the no-directory case.
If Option B is accepted (alone): API-created Global policies become
reportable, the Org/Team/Agent tiers stay permanently empty, and PolicyEngine
acquires a semantics change whose blast radius is currently latent but not
theoretical. Accept only with the cascade-versus-primary precedence question
answered in writing.
If Option C is accepted: the projections become honest immediately and cheaply,
the four-tier Topology permission chain ratified in ADR 0017 needs an addendum
recording that it renders one tier in practice, and
docs/src/operations/policy-cascade-loader.md needs a prominent statement that
the cascade is a gateway-side enforcement feature with no dashboard
representation.
Under every option: enforcement is unchanged. evaluate has always fallen
back to evaluate_primary on an empty cascade (mod.rs:845-846), and the only
evaluator aa-api reaches is the dry-run one, which writes no audit entry and
applies no enforcement (mod.rs:873-874). Nothing in this ADR’s option space
makes the product more or less permissive at runtime — though Option B would
change what POST /api/v1/policies/simulate reports, which is why it carries a
test obligation the other two do not. The entire dispute is about what operators are
told.
Interim mitigation — do this regardless of the outcome
Every cascade-derived surface must distinguish “the engine carries no cascade” from “this agent/team genuinely has no policy”, and must render unknown rather than none. This is not contingent on any option above and should not wait for sign-off on one.
The pattern already exists in-tree. AAASM-5096 applied it to
TeamPoliciesResponse.policies: required-but-nullable
(aa-api/src/routes/policies.rs:633-634), documented so a client cannot shrug the
absence off with ?? [], and rendered by the dashboard as a distinct unknown
state (TeamActivePoliciesCard.tsx:73-77). ADR 0018 established the same
“present in the schema, honestly null until sourced” discipline for the enriched
decision record.
Three surfaces have not been given it:
- Capability Matrix (AAASM-5090) — the highest-severity of the three, because
it does not merely blank: it asserts
allow.decide(capability.rs:480-488) cannot distinguish an empty cascade from a genuinely permissive one, and theDecisionvocabulary has no unknown member (nameans “no such cell”, per ADR 0018). Some signal has to be added — a nullable cascade-loaded flag on the response, or a distinct cell state — so the page can decline to answer instead of answering wrongly. - Topology permission chain (AAASM-5099) —
policy_countisSome(cascade.len() as u32)attopology.rs:427, so an absent cascade reports the affirmative0.policy_countandeffective_permissionsare alreadyOption-typed (aa-api/src/models/topology.rs:291), soNoneis expressible on the wire today; the handler simply always populates them. - Policy simulation (
POST /api/v1/policies/simulate) — needs a different shape of fix from the other two, because it returns a verdict rather than a cell: there is nothing to leavenull. Either the response carries an explicit “cascade unavailable” caveat alongside the verdict, or the endpoint declines to answer. Silently returning a bootstrap-derived verdict is the one behaviour that should not survive.
The mechanism for all of this is specified in ADR 0024 — “Semantics of an Empty or Unavailable Policy Cascade” — which was authored independently against the same ticket and owns the remedy in detail. This ADR states that the distinction must be drawn and enumerates the surfaces that need it; 0024 decides how it is represented. The two are orthogonal by construction: 0024’s rule is required under every one of this ADR’s Options A/B/C, and nothing in 0024 depends on which is chosen. They should be decided separately and must not be merged into one record.
Whether that mitigation is a schema change (and therefore an openapi/v1.yaml
regeneration plus dashboard codegen) is an implementation question for the
follow-up ticket, and is treated in ADR 0024 rather than here.
Decision required from: architecture (+ product if Option C)
- Is
aa-apimeant to load a policy cascade at all? A/B (yes) or C (no). Given that AAASM-3499 namedaa-apiand did not fix it, is there a recorded or remembered reason, or was it an omission? - If yes: what is the operator input? A directory path by flag, by
environment variable, or by reusing the gateway’s
$AA_POLICY? And what happens when it is unset — keep synthesising the bootstrap policy, or refuse to start? - Should
apply_yamlpopulate the scope index (Option B)? This is aPolicyEnginesemantics change, not anaa-apichange. If yes: when the engine was loaded from a cascade directory, does an applied Global document replace that directory’s Global tier or stack on it? And is the resulting switch ofPOST /api/v1/policies/simulatefrom the primary path to the cascade path acceptable without a verdict-equivalence test? - Does the scoped-installation follow-up named at
policies.rs:448-452(AAASM-4933) get opened now, or doesPOST /api/v1/policiesstay Global-only indefinitely? - Product, only if Option C is on the table: is the Topology four-tier
permission chain ratified in ADR 0017 still the promise, given it would render
a single tier in every
aa-apideployment? - Is the interim mitigation approved to proceed immediately, decoupled from items 1–5? (Recommended yes — the capability matrix fail-open is live.) Its mechanism is not decided here — that is ADR 0024’s question, on PR #1706.
Until items 1 and 2 are answered, no implementation ticket should be opened against Options A or B. Merging this ADR authorises no implementation and changes no behaviour.
Reconsideration triggers
- Any decision to merge
aa-apiandaa-gatewayinto one process, which would dissolve the disjointness this ADR is really about. - AAASM-4933’s scoped-installation follow-up landing, which makes Option B able to express Org/Team/Agent tiers and materially strengthens it.
- A SaaS control-plane policy source (
cloud) becoming the cascade’s origin instead of a local directory, which would make Option A’s directory input the wrong shape. - Enforcement moving into
aa-api— today the only evaluator it reaches is the dry-run path, which mutates nothing (mod.rs:873-874). The whole “reporting-only” framing depends on that staying true; a second, non-ephemeralevaluatecaller inaa-apiwould invalidate it.
Traceability
- Frames the decision blocking AAASM-5106; surfaced during review of AAASM-5096 (PR #1703).
- Direct precedent:
AAASM-3499 (same
defect class, fixed for
aa-gateway,aa-apinamed but not fixed); origin of the loader: AAASM-2023; origin of the scope index: AAASM-951. - Affected projections:
AAASM-5090
(capability matrix),
AAASM-5099
(topology permission chain),
AAASM-5096
(
affects[]/ team active-policies); plusPOST /api/v1/policies/simulate, identified here rather than in the ticket. - ADR 0024 — “Semantics of an Empty or
Unavailable Policy Cascade” owns the mechanism for the interim mitigation
this ADR calls for. Same ticket (AAASM-5106), authored independently, postdating
this record, and open on PR #1706 — this link resolves once that merges.
Orthogonal, and to be decided separately: 0023
decides whether
aa-apiloads a cascade; 0024 decides what the product says when it has none — a rule required under every one of 0023’s options. Neither supersedes the other. - Surfaces ratified in ADR 0017; the “honestly-null until sourced” discipline is ADR 0018’s; enforcement-flow context is ADR 0004.
- Operator documentation for the cascade:
docs/src/operations/policy-cascade-loader.md.
Last updated: 2026-07-30 by Chisanan232