Skip to content

Commit ce15831

Browse files
jopemachineclaude
andcommitted
feat(BA-6704): cascade app config subtree deletion from the definition
Extend the cascade so retiring a config name (deleting its app_config_definitions row) removes its allow-list entries and fragments in one statement, via a single migration and exactly two FK constraints: - app_config_allow_list.config_name -> app_config_definitions: CASCADE. - Drop the redundant direct app_config_fragments.config_name -> app_config_definitions FK. A fragment already requires a matching allow-list entry (composite FK, NOT NULL) whose config_name is registered, so registration is guaranteed transitively; the direct FK only blocked the cascade. Fragments still cascade from their allow-list entry via the composite FK. Folded into migration a560420476b6 (no separate migration). Cascade, downgrade/upgrade round-trip, and the 2-FK end state verified against a local DB. BEP-1052 updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 36eab5e commit ce15831

7 files changed

Lines changed: 135 additions & 39 deletions

File tree

changes/12518.feature.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Cascade app config fragment deletion when their allow-list entry is purged (composite FK with `ON DELETE CASCADE`), and drop the now-redundant update/purge write gates
1+
Cascade app config deletion down the `definition → allow-list → fragment` subtree via `ON DELETE CASCADE` FKs, so a fragment can no longer exist without its allow-list entry

proposals/BEP-1052-scoped-app-config-redesign.md

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,16 @@ Three scopes cover the use cases (`public` for the pre-login shell):
6666
`domain` (same-domain read / admin write), `user` (owner+admin read /
6767
owner-modify + admin write).
6868
- **Explicitly registered names.** Every config name lives in
69-
`app_config_definitions`; fragments and allow-list entries reference it by
70-
foreign key. No fragment may exist for an unregistered `config_name`.
71-
- **Reads are unconditional.** The merge reads `app_config_fragments`
72-
joined to `app_config_allow_list` only for each fragment's `rank` — an
73-
indexed `(config_name, scope_type)` join. No permission or policy is
74-
evaluated at read time.
69+
`app_config_definitions`; allow-list entries reference it by foreign key,
70+
and fragments reference it transitively through their allow-list entry (a
71+
fragment requires an entry, and the entry requires a registered name — so
72+
no direct fragment FK is needed). No fragment may exist for an
73+
unregistered `config_name`.
74+
- **Reads are unconditional.** The merge **must** join
75+
`app_config_fragments` to `app_config_allow_list``rank` lives only on
76+
the allow-list entry, so the ordering cannot be computed without it (an
77+
indexed `(config_name, scope_type)` join). The join is for `rank` alone;
78+
no permission or policy is evaluated at read time.
7579
- **Allow-list = the write gate and the merge order.** `app_config_allow_list`
7680
holds **one record per `(config_name, scope_type)`**; a fragment at
7781
that scope may be created **only if** the record exists — through the
@@ -150,18 +154,23 @@ purging one cascades to its fragments.
150154

151155
### Integrity
152156

153-
- **Every** fragment create (admin or regular) requires (a) a registered
154-
`config_name` (FK to `app_config_definitions`) and (b) an
157+
- **Every** fragment create (admin or regular) requires an
155158
`app_config_allow_list` row for the write's `(config_name,
156159
scope_type)` — enforced both by the write gate (domain error) and by
157-
the composite FK. Updates and purges of an existing fragment need no
158-
gate: the FK guarantees the entry exists while the fragment does.
160+
the composite FK. That entry references a registered `config_name`, so
161+
registration is guaranteed transitively; the fragment needs no direct
162+
FK to `app_config_definitions`. Updates and purges of an existing
163+
fragment need no gate: the FK guarantees the entry exists while the
164+
fragment does.
159165
- A regular (non-admin) mutation is further restricted to the caller's
160166
own `user` row; admin mutations may target any scope (still gated by
161167
the allow-list) and are the only writes that may touch the allow-list
162168
and `app_config_definitions`.
163-
- `app_config_definitions` purge is rejected while any fragment or allow-list
164-
entry still references the `config_name` (`ON DELETE NO ACTION`).
169+
- `app_config_definitions` purge **cascades down the whole subtree**: its
170+
allow-list entries are removed by the `config_name` FK (`ON DELETE
171+
CASCADE`), and their fragments cascade from those entries in turn — so
172+
retiring a config name clears its allow-list and fragments in one
173+
statement.
165174
- `app_config_allow_list` purge **revokes the grant and drops its data**:
166175
the fragments at that `(config_name, scope_type)` are removed by the
167176
`ON DELETE CASCADE` FK, so a revoked value disappears from the merge
@@ -307,8 +316,8 @@ likewise `null` — clients fall back to their built-in defaults.
307316
grant; the cascade drops the existing `user` fragments with it.
308317
- **Admin reorders contributions** — set the allow-list entries'
309318
`rank`s (per `(config_name, scope_type)`, not per fragment).
310-
- **Admin retires a config name** — purge the allow-list entries (their
311-
fragments cascade), then the `app_config_definitions` row (purge is rejected
312-
while references remain).
319+
- **Admin retires a config name** — purge the `app_config_definitions`
320+
row; the allow-list entries and their fragments cascade with it, so no
321+
prior cleanup is needed.
313322
- **Admin audit** — cross-scope fragment search and cross-user merged
314323
search for support.

src/ai/backend/manager/models/alembic/versions/a560420476b6_cascade_fragment_deletion_from_allow_list.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
"""cascade fragment deletion from app_config_allow_list
1+
"""cascade app config subtree deletion
22
3-
Add a composite FK from ``app_config_fragments (config_name, scope_type)`` to
4-
``app_config_allow_list`` with ``ON DELETE CASCADE`` (BEP-1052): removing an
5-
allow-list entry removes every fragment written under it, so a revoked value
6-
disappears from the merge in the same statement. Fragments whose allow-list
7-
entry was already removed (previously kept and still merged) are deleted before
8-
the FK is added — the same outcome the cascade would have produced.
3+
Cascade deletion down ``definition -> allow_list -> fragment`` (BEP-1052):
4+
5+
1. Composite FK ``app_config_fragments (config_name, scope_type) ->
6+
app_config_allow_list`` ``ON DELETE CASCADE`` (orphaned fragments deleted first).
7+
2. ``app_config_allow_list.config_name -> app_config_definitions`` -> ``CASCADE``.
8+
3. Drop the redundant direct ``app_config_fragments.config_name ->
9+
app_config_definitions`` FK — the composite FK already guarantees a registered
10+
``config_name`` transitively, and this FK only blocked the definition delete.
11+
12+
The released allow-list creation migration entered a wrong (non-convention) name
13+
for the definitions FK, so its name differs between DBs; we drop both candidate
14+
names with ``IF EXISTS``.
915
1016
Revision ID: a560420476b6
1117
Revises: 66d0f891ed20
@@ -25,8 +31,7 @@
2531

2632

2733
def upgrade() -> None:
28-
# Fragments without an allow-list entry are unrepresentable once the FK exists;
29-
# remove them as the cascade would have when the entry was purged.
34+
# 1. fragment -> allow_list composite FK (drop pre-existing orphans first).
3035
op.execute(
3136
sa.text(
3237
"""
@@ -48,9 +53,53 @@ def upgrade() -> None:
4853
["config_name", "scope_type"],
4954
ondelete="CASCADE",
5055
)
56+
# 2. allow_list -> definitions: NO ACTION -> CASCADE.
57+
# The released creation migration entered a wrong (non-convention) FK name, so the
58+
# actual name differs by DB — drop whichever exists before recreating.
59+
op.execute(
60+
"ALTER TABLE app_config_allow_list "
61+
"DROP CONSTRAINT IF EXISTS fk_app_config_allow_list_config_name"
62+
)
63+
op.execute(
64+
"ALTER TABLE app_config_allow_list "
65+
"DROP CONSTRAINT IF EXISTS fk_app_config_allow_list_config_name_app_config_definitions"
66+
)
67+
op.create_foreign_key(
68+
"fk_app_config_allow_list_config_name_app_config_definitions",
69+
"app_config_allow_list",
70+
"app_config_definitions",
71+
["config_name"],
72+
["config_name"],
73+
ondelete="CASCADE",
74+
)
75+
# 3. fragments -> definitions: drop the redundant direct FK (it blocks the cascade).
76+
op.execute(
77+
"ALTER TABLE app_config_fragments "
78+
"DROP CONSTRAINT IF EXISTS fk_app_config_fragments_config_name_app_config_definitions"
79+
)
5180

5281

5382
def downgrade() -> None:
83+
op.create_foreign_key(
84+
"fk_app_config_fragments_config_name_app_config_definitions",
85+
"app_config_fragments",
86+
"app_config_definitions",
87+
["config_name"],
88+
["config_name"],
89+
ondelete="NO ACTION",
90+
)
91+
op.execute(
92+
"ALTER TABLE app_config_allow_list "
93+
"DROP CONSTRAINT IF EXISTS fk_app_config_allow_list_config_name_app_config_definitions"
94+
)
95+
op.create_foreign_key(
96+
"fk_app_config_allow_list_config_name_app_config_definitions",
97+
"app_config_allow_list",
98+
"app_config_definitions",
99+
["config_name"],
100+
["config_name"],
101+
ondelete="NO ACTION",
102+
)
54103
op.drop_constraint(
55104
"fk_app_config_fragments_config_name_scope_type",
56105
"app_config_fragments",

src/ai/backend/manager/models/app_config_allow_list/row.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
class AppConfigAllowListRow(Base): # type: ignore[misc]
1919
"""Permission to write config fragments for one config name at one scope type.
2020
21-
A config fragment may be created only when a matching row exists here; without
22-
one, the write is rejected. Fragments reference this table by
23-
``(config_name, scope_type)`` with ``ON DELETE CASCADE``, so removing an entry
24-
also removes every fragment written under it. ``rank`` is the merge priority the
25-
entry's fragments carry (low → high; higher wins) — admin-owned here so fragment
26-
owners cannot re-order the merge. There is at most one row per
27-
``(config_name, scope_type)``, and only admins create or remove these rows.
21+
A config fragment may be created only when a matching row exists here.
22+
Deletion cascades both ways: fragments reference this table by
23+
``(config_name, scope_type)`` and this table references
24+
``app_config_definitions`` by ``config_name``, both ``ON DELETE CASCADE`` —
25+
so retiring a config name clears its entries and fragments. ``rank`` is the
26+
merge priority the entry's fragments carry (low → high; higher wins) —
27+
admin-owned so fragment owners cannot re-order the merge. At most one row per
28+
``(config_name, scope_type)``; only admins create or remove these rows.
2829
"""
2930

3031
__tablename__ = "app_config_allow_list"
@@ -43,7 +44,7 @@ class AppConfigAllowListRow(Base): # type: ignore[misc]
4344
config_name: Mapped[str] = mapped_column(
4445
"config_name",
4546
sa.String(length=128),
46-
sa.ForeignKey("app_config_definitions.config_name", ondelete="NO ACTION"),
47+
sa.ForeignKey("app_config_definitions.config_name", ondelete="CASCADE"),
4748
nullable=False,
4849
)
4950
scope_type: Mapped[AppConfigScopeType] = mapped_column(

src/ai/backend/manager/models/app_config_definition/row.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313

1414

1515
class AppConfigDefinitionRow(Base): # type: ignore[misc]
16-
"""One registered ``config_name`` (admin-managed)."""
16+
"""One registered ``config_name`` (admin-managed).
17+
18+
Purging a row cascades to its allow-list entries (``ON DELETE CASCADE``) and,
19+
through them, to their fragments.
20+
"""
1721

1822
__tablename__ = "app_config_definitions"
1923

src/ai/backend/manager/models/app_config_fragment/row.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,8 @@
2020
class AppConfigFragmentRow(Base): # type: ignore[misc]
2121
"""One scoped app config fragment — a single JSON document at ``(config_name, scope_type, scope_id)``.
2222
23-
A fragment hangs off its ``app_config_allow_list`` entry (FK on
24-
``(config_name, scope_type)``, ``ON DELETE CASCADE``): it exists only while the
25-
entry does, and its merge priority is the entry's ``rank`` — the fragment row
26-
carries no rank of its own.
23+
A fragment's merge priority is its allow-list entry's ``rank`` — the fragment
24+
row carries no rank of its own.
2725
"""
2826

2927
__tablename__ = "app_config_fragments"
@@ -51,7 +49,6 @@ class AppConfigFragmentRow(Base): # type: ignore[misc]
5149
config_name: Mapped[str] = mapped_column(
5250
"config_name",
5351
sa.String(length=128),
54-
sa.ForeignKey("app_config_definitions.config_name", ondelete="NO ACTION"),
5552
nullable=False,
5653
)
5754
scope_type: Mapped[AppConfigScopeType] = mapped_column(

tests/unit/manager/repositories/app_config_allow_list/test_repository.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,42 @@ async def test_purge_cascades_to_fragments(
267267
)
268268
assert remaining == 0
269269

270+
async def test_definition_purge_cascades_to_allow_list_and_fragments(
271+
self,
272+
database: ExtendedAsyncSAEngine,
273+
repository: AppConfigAllowListRepository,
274+
definition_repository: AppConfigDefinitionRepository,
275+
) -> None:
276+
# Deleting the definition cascades to its allow-list entry and its fragment.
277+
definition = await definition_repository.create(
278+
Creator(spec=AppConfigDefinitionCreatorSpec(config_name="theme"))
279+
)
280+
entry = await _create_entry(repository, "theme", AppConfigScopeType.PUBLIC)
281+
async with database.begin_session() as db_sess:
282+
db_sess.add(
283+
AppConfigFragmentRow(
284+
config_name=entry.config_name,
285+
scope_type=entry.scope_type,
286+
scope_id="public",
287+
config={"k": "v"},
288+
)
289+
)
290+
await db_sess.flush()
291+
292+
await definition_repository.purge(
293+
Purger(row_class=AppConfigDefinitionRow, pk_value=definition.id)
294+
)
295+
296+
async with database.begin_readonly_session() as db_sess:
297+
remaining_entries = await db_sess.scalar(
298+
sa.select(sa.func.count()).select_from(AppConfigAllowListRow)
299+
)
300+
remaining_fragments = await db_sess.scalar(
301+
sa.select(sa.func.count()).select_from(AppConfigFragmentRow)
302+
)
303+
assert remaining_entries == 0
304+
assert remaining_fragments == 0
305+
270306

271307
class TestSearch:
272308
async def test_search_returns_all_with_total_count(

0 commit comments

Comments
 (0)