ADR-146: Per-resource cascade policy, resolved like disabled¶
Status: Accepted (amended by ADR-150: an owner stamp older than the stored generation is not written) Date: 2026-10-05 Issue: #676 Supersedes: ADR-112 in part (where the cascade decision comes from) Related: ADR-112, ADR-125, ADR-141, #674, #675, #677, #684, #686
Context¶
cascade is set once per entity at create_entity(), stored on the entity's META record,
copied onto every bucket item the entity owns, and cached per entity in
Repository._entity_cache as (cascade, parent_id, shard_counts). It applies to every
resource the entity acquires and cannot be changed.
The LLM gateway use case (#674) needs it to differ by resource for the same entity:
| Resource | Cascade | Why |
|---|---|---|
One per model (gpt-4, claude-sonnet, …) |
on | The org's per-model limit protects the provider's quota |
llm (the shared cost budget) |
off | The budget is per user; cascading it costs a write per call for nothing |
The same shape is already solved for disabled (ADR-125): a tri-state value on config
items, resolved by an independent walk, denormalised onto bucket items so the fast path
decides with no config read, and stamped eagerly by a fan-out when it changes.
Three facts about the current code shape the decision:
resolve_disabled()already reads, in full, the three config items the walk needs (entity(resource), entity(_default_), resource), on every slow path. A second value on those items costs no extra read.- Every bucket item already carries
cascadeandparent_id, and since #684 the slow path's rf-locked write re-stamps both from the owner's META on every pass. - The aggregator never reads
cascade; its Path 2 clone copies the item verbatim.
Decision¶
cascade must be decided per (entity, resource): a tri-state policy on resource and entity
config items (absent inherits; not on system config) resolved by the ADR-125 walk, falling
back to the entity's META cascade when no level sets one, and never cascading for an entity
without a parent. The effective policy must be stamped on every bucket item by every
slow-path write and by an eager fan-out on each change (resolving each bucket's own entity
and resource from strongly consistent reads), and the fast path must decide from that stamp
with no config read. Writing a policy must pass the ADR-141 version gate at 0.16.0 and raise
client_min_version to at least 0.16.0, capped at a development writer's own build and never
lowering it. The repository methods it adds are required members of RepositoryProtocol, not
a capability-gated option (ADR-109).
Partially supersedes ADR-112. Whether the parent is included is decided per
(entity, resource) by the resolved policy; ADR-112's META cascade remains the default when
no level sets one. ADR-112's other decisions stand: no per-call parameter, the child decides,
and cascade is aliased as a reserved word.
The decisions in detail — cache, self-healing stamps, fan-out, gate, surfaces, the
limits plan warning, #677 — and the cost table are in
docs/plans/2026-10-05-cascade-policy-design.md. They were agreed with the owner on
2026-10-04 during the v0.16.0 planning for #674.
Consequences¶
Positive
- Cascade can differ per resource for one entity, with no new mechanism: the same
config shape, walk, stamp and fan-out as disabled.
- Fully backward compatible when no policy is set.
- No cost on the fast or slow path.
Negative
- A policy change is O(buckets), not O(1), like disabling.
- The ADR-125 race remains: an acquire that read config before a policy write can create
a bucket the fan-out misses. It is corrected on that bucket's next slow pass, but a
bucket that only takes the fast path keeps the old policy until then.
- A v0.14 client ignores client_min_version and would decide cascade from META. A
pre-0.16 admin's full-replace config write drops the policy (same accepted risk as
ADR-142's hidden config).
- A cache entry per (entity, resource) is larger than one per entity.
Alternatives considered¶
- Make the entity flag mutable (
set_cascade(entity_id, bool)): one value per entity cannot express "models cascade, budget does not", which is the whole use case. - Cascade as a property of the resource only: cannot exempt or include one entity; the walk costs nothing extra, so there is no reason to give up per-entity control.
- Two
acquire()calls (one cascading, one not): two leases, two round trips and two reconciles per request; rejected by the owner during #674 planning. - Read the policy on the fast path: adds a config read to every call; the item stamp already exists for this purpose.
- Lazy, self-healing only (no fan-out): a bucket that only takes the fast path would never pick up the change.