Skip to content

feat(delete): optionally remove resources associated with a zone or domain (PPT-1203) - #307

Open
camreeves wants to merge 5 commits into
developfrom
PPT-1203-cascade-delete
Open

feat(delete): optionally remove resources associated with a zone or domain (PPT-1203)#307
camreeves wants to merge 5 commits into
developfrom
PPT-1203-cascade-delete

Conversation

@camreeves

@camreeves camreeves commented Jul 29, 2026

Copy link
Copy Markdown

Closes PPT-1203. Also addresses the behaviour reported in PROJ-845.

The problem

Deleting a zone has never removed the systems inside it. Zone#destroy cascades child zones, trigger instances, metadata, settings and group links, but sys.zones is a text array with no foreign key — so Zone#remove_array_references just strips the id and any system left with no zones is orphaned.

The confirmation copy already claimed otherwise:

Deleting this zone will immediately remove systems without another zone

It never did. That is exactly the complaint in PROJ-845, and placeos-dev.aca.im currently carries 6 systems with zones = {} as a result, plus one pointing at a zone-123 that does not exist.

Tearing down a domain is worse — it is the manual, one-at-a-time process described in PPT-1203, and OAuth applications orphan silently because oauth_applications.owner_id has no foreign key back to authority.

What this adds

An opt-in checkbox on the delete confirmation. It is off by default — deleting an item without touching what hangs off it stays the default behaviour, unchanged.

Default — nothing is queried until you ask

Ticking it resolves a plan first and shows exactly what would go, before anything is confirmed:

Zone Domain

Afterwards the dialog becomes a receipt of what actually went — type, name and id, in removal order, ending with the item itself. "Copy list" puts it on the clipboard as TSV for pasting into a ticket.

The rules it applies

Zones — removes systems whose every zone falls inside the subtree, i.e. exactly those that would be left with no zone. A system shared with a zone outside it is kept, as today, and the dialog says so. The backend then takes their modules, triggers, metadata and settings; the zone delete takes the rest.

Domains — removes the domain's OAuth applications and its staff API tenant (matched on domain name, the same rule staff-api.component.ts already uses to list them). Zones are only reachable from a domain through the authority.config.org_zone convention.

That convention is not exclusive, and this is the important bit. On dev:

authority                        | domain                 | org_zone        | resolves to
---------------------------------+------------------------+-----------------+-----------------
B2B Testing                      | b2btest.aca.im         | zone-DnTc8chjVb | ORG PlaceOS Dev
GSA Testing                      | gsa.aca.im             | zone-EDvzySzrvj | ORG GSA
Local Development                | localhost              | (none)          |
PlaceOS Dev (placeos-dev.aca.im) | placeos-dev.aca.im     | zone-DnTc8chjVb | ORG PlaceOS Dev
PlaceOS Office                   | office.placeos.com     | (none)          |
PlaceOS Office [DEV]             | office-dev.placeos.com | zone-DnTc8chjVb | ORG PlaceOS Dev
PlaceOS Sydney                   | syd.place.tech         | zone-E6fK3MBuji | PlaceOS
connect.hio.com.au               | connect.hio.com.au     | (none)          |

Three domains share one org zone and three have none at all. Cascading zones off a domain unconditionally would destroy another customer's estate, so the org zone tree is only included when no other domain references it, and the dialog names the domains that caused it to be skipped:

Drivers and repositories are deliberately out of scope — they are install-wide and shared across domains, and deleting one destroys every module using it everywhere.

Notes for review

Nothing existing changes. The receipt and the cascade only engage when the option is ticked. Users, drivers, modules, triggers, and zones/domains with the box left off all behave exactly as before — there are e2e tests asserting both (shows no receipt for a delete without the option, leaves systems alone when the option is left off).

Destructive reads go to the database, not the index. GET /systems?zone_id= is Elasticsearch-backed and can lag. Since a stale zones array would mean deleting a system that still belongs somewhere, the index only nominates candidates — each is re-read through showSystem and re-checked before it makes the list.

It is frontend-orchestrated, so a large teardown is N delete requests rather than one. That was a deliberate call: it works against every deployed backend today with no coordinated release, and gives per-item progress and precise partial-failure reporting. A rest-api DELETE /zones/:id?recursive=true could replace the inner loop later without changing anything the user sees — worth a follow-up ticket.

Partial failures show the same receipt with the failures called out, and the item is deliberately left in place rather than deleted on top of a broken cascade.

Two drive-by fixes, both in styles.css and both affecting checkboxes app-wide on the dark theme: the label and the empty box outline were rendering near-black (1.18:1 and 1.21:1) because the app's mat-checkbox rules target .mdc-checkbox__background / .mdc-label and never win against Material's own selectors, so the colours still came from the prebuilt light palette. Set at the token layer instead. Nothing hit this before because no other <mat-checkbox> in the app has label content.

Also here: the zones mock filtered on a parent query param the API has never sent (now parent_id, including comma lists and root), mock zones gained the parent_id hierarchy their zones arrays already implied, and config/proxy.conf.js accepts PLACEOS_DOMAIN so the dev server can point at a local stack.

Screenshots live in docs/ppt-1203/ (512KB). Happy to move them out of tree if you would rather not carry them.

Testing

  • Unit — 894 passing, up from 835. New: cascade-delete.spec.ts (24), cascade-locale.spec.ts (14, pins singular/plural against the real locale file), plus 23 on the confirm modal.
  • E2Ee2e/src/cascade-delete.spec.ts, 9 tests. Full chromium suite 84 passing, 1 skipped.
  • Lint, tsc, production build — clean.
  • Against a local stack with real data, driven through the UI:
Scenario Result
org > building > 2 levels, 3 enclosed systems + 1 straddling an outside zone, option on tree, both enclosed systems and their modules gone; straddling system survives holding only its outside zone
Same shape, option off zone gone, system orphaned — pre-existing behaviour, unchanged
Domain with org zone, systems, 2 OAuth apps and a staff tenant all removed in one action; every id on the receipt confirmed absent from the API afterwards, with only the intended survivors left
Two domains sharing an org zone, delete one domain gone; shared zone, its system and the other domain all untouched

A seeded sandbox that reproduces all four is in tasks/PPT-1203/demo (not part of this branch).

…omain (PPT-1203)

Deleting a zone has never removed the systems inside it. `Zone#destroy`
cascades child zones, trigger instances, metadata, settings and group links,
but `sys.zones` is a text array with no foreign key, so the zone id was simply
stripped and any system left with no zones became an orphan (PROJ-845 — the
dev install currently carries six such systems). The confirmation copy already
claimed otherwise: "Deleting this zone will immediately remove systems without
another zone".

The delete confirmation now offers "Also delete associated resources",
**off by default** — deleting an item without touching what hangs off it stays
the default behaviour. Switching it on resolves a plan first and shows exactly
what would go before anything is confirmed.

Zones remove the systems whose every zone falls inside the subtree, so a system
shared with a zone outside it is kept, as it is today. The backend takes their
modules, triggers, metadata and settings.

Domains remove their OAuth applications (`oauth_applications.owner_id` has no
foreign key, so these orphan today) and their staff API tenant, matched on
domain name the same way the admin screen does. Zones are only reachable from a
domain through the `authority.config.org_zone` convention, which is not
exclusive — three domains share one org zone on the dev install — so the zone
tree is only included when no other domain references it, and the dialog names
the domains that caused it to be skipped.

Because the removals are destructive, the system index (Elasticsearch, which
can lag) only nominates candidates; each one is re-read through `showSystem`
and re-checked against the database before it makes the list.

Also here:
- `ConfirmModalComponent` gains optional checkboxes with lazily resolved
  detail, reported back on the confirmation event. Existing callers pass no
  options and are unaffected.
- The zone mock filtered on a `parent` query param the API has never sent;
  it now honours `parent_id`, including comma separated lists and `root`.
  Mock zones gained the `parent_id` hierarchy their `zones` arrays imply.
- `config/proxy.conf.js` accepts `PLACEOS_DOMAIN` so the dev server can point
  at a local stack.
- `ZONES.DELETE_MSG` now describes what actually happens. The stale
  translation was dropped from the non-English locales so they fall back to
  the corrected source string rather than repeating the old claim.

Verified against a local PlaceOS stack: an org > building > two levels tree
with three systems (two enclosed, one straddling an outside zone) and their
modules. With the option on, the tree, both enclosed systems and both modules
were removed while the straddling system survived holding only its outside
zone; with it off, the zone went and the system was left orphaned exactly as
before. A domain delete removed its org zone tree, orphaned system, OAuth
application and staff API tenant; a domain sharing its org zone with another
left the zone, its system and the other domain untouched.
The org zone line already reads "and everything beneath it", so appending the
nested zone plan's own scope line stacked two "Scope:" sentences on top of
each other in the dialog.
The checkbox label and the empty box outline both rendered near-black on the
dark background. The app already styles `mat-checkbox`, but those rules target
`.mdc-checkbox__background` and `.mdc-label`, which never win against Angular
Material's own selectors — so the colours were still coming from the prebuilt
`indigo-pink` (light) palette. Nothing hit this before because no other
`<mat-checkbox>` in the app has label content, and the faint outline was easy
to miss.

Set at the token layer instead, where Material actually reads them, using
`currentColor` so they follow whichever theme is active. Measured on the delete
confirmation:

  label outline   dark   1.21:1 -> 10.84:1   (WCAG wants 3:1 for controls)
  label text      dark   1.18:1 -> 10.84:1   (WCAG wants 4.5:1 for body text)

Light theme is unchanged at 20.12:1. This affects every checkbox on the dark
theme, all of which had the same invisible outline.

The cascade warning line used `text-warning` — a yellow that only reads on a
dark background (1.39:1 on the light theme). It now tints the block and keeps
the inherited text colour, matching how warnings are done elsewhere. Secondary
text nudged from 60% to 70% opacity for the same reason.

Every line in the dialog now passes WCAG AA in both themes.
A toast saying "Removed 6 associated resources" is not something you can act
on afterwards. When a cascade runs, the confirmation now becomes a receipt
listing every resource that went — type, name and id — in the order they were
removed, ending with the item itself. A "Copy list" button puts it on the
clipboard as tab separated rows for pasting into a ticket.

Cascade steps carry a `CascadeResource` ({type, id, name}) instead of a
pre-rendered progress string, and `CascadeOutcome.removed` is that list rather
than a count, so the receipt reports what actually happened rather than what
was planned.

Partial failures now show the same receipt with the failures called out
separately, and the item is deliberately left in place — previously this was a
notification that named only the first failure and left the operator guessing
which of the rest had gone.

Existing delete flows are untouched: the receipt only appears when a cascade
actually ran, so every other resource type still closes the dialog and shows
its usual notification. There is an e2e test asserting exactly that.

`receiptToTsv` is a pure function so the clipboard formatting is tested
without DI. It also keeps `@angular/cdk/clipboard` out of the spec, which
upsets vitest's `vi.mock` hoisting analysis and made it warn about mocks it
had previously accepted.

Verified against the local stack: deleting a domain with two OAuth apps, a
staff tenant, an org zone tree and three enclosed systems produced an 8 row
receipt, and every id on it was confirmed absent from the API afterwards
while the four intended survivors remained.
Captured from the local stack for the pull request. 512KB total — say the
word if the team would rather not carry them in tree and I will move them
out.
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
backoffice Ready Ready Preview, Comment Jul 29, 2026 4:09am

@MrYuion MrYuion left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cascade flow needs to fail closed before this is safe to merge.

  • [P1] Domain lookup failures can delete another domain's org zone: converting queryDomains failures to an empty list makes an unavailable or partially paginated ownership list look like no other domain references the zone.
  • [P1] Failed system deletion does not prevent deleting its zone: runCascade continues after errors, while domain plans append the org-zone deletion after system removals, so a failed system can be orphaned.
  • [P1] Failed plan resolution silently falls back to ordinary deletion: when details resolution rejects, the selected option has no plan, the cascade branch is skipped, and actions.remove still runs. Please make plan resolution fail closed, stop dependent deletion steps after a failure, and block confirmation unless every selected option has a valid plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants