Skip to content

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

Open
camreeves wants to merge 6 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 6 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).

@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.

camreeves and others added 6 commits August 6, 2026 03:32
…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.
Addresses Alex's review. The theme across all three points was the same:
an error anywhere in the flow degraded to "proceed", when the only safe
default for a destructive action is "stop".

Plan resolution no longer swallows failures. `queryDomains`,
`queryApplications` and `querySystems` used to fall back to an empty
list, which reads as "nothing found" rather than "we could not look".
The domain case was the dangerous one: the org-zone ownership check is
the only thing preventing this flow from deleting a zone tree that
another domain still points at, and an unavailable list made a shared
zone look unshared. `collectPages` now raises instead of returning a
truncated list for the same reason — a short answer and a complete one
were indistinguishable, and a sharing domain could sit on any unread
page.

`runCascade` stops at the first failure. Steps are ordered by
dependency — `planDomainCascade` appends the org zone after the systems
inside it — so continuing past a failed system removal deleted the zone
out from under it. Everything after the failure is now reported as
skipped rather than silently not attempted, and the receipt shows those
rows. The existing copy ("so nothing further was deleted") already
described this behaviour; now it is true.

Confirmation is withheld while a selected option has no usable
breakdown. Previously a rejected `details()` left the plan null, the
cascade branch was skipped, and `actions.remove` still ran — deleting
the parent on its own and orphaning everything the cascade existed to
remove. The modal now disables confirm in that state, and `item.service`
refuses the plain removal as a second line of defence. Options that
start enabled resolve their breakdown on init, so they cannot sit
selected-with-no-details and hold the button disabled for good.

Two existing tests asserted the old behaviour and have been inverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@camreeves
camreeves force-pushed the PPT-1203-cascade-delete branch from 8347cf3 to d77aaad Compare August 5, 2026 17:32
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/placeos?upgradeToPro=build-rate-limit

@camreeves

Copy link
Copy Markdown
Author

Thanks @MrYuion — all three were real, and the shared root was exactly as you put it: every failure path degraded to "proceed". Fixed in d77aaad3, rebased onto develop (Nx 23).

1. Domain lookup failures could delete another domain's org zone

Agreed, and this was the one that could actually destroy someone else's data. The ownership check is the only thing standing between this flow and another domain's zone tree, and .catch(() => []) turned "we could not look" into "nothing found".

queryDomains no longer catches, so planDomainCascade rejects and no plan is produced. I did the same for queryApplications and querySystems — same class of mistake, and a zone whose systems could not be listed would otherwise be deleted with those systems still inside it.

Your "partially paginated" wording sent me to collectPages, which had a second version of the same bug: it stopped at MAX_PAGES and returned a short list indistinguishable from a complete one. A sharing domain could sit on any unread page. It now raises instead. There's a test that drives it past the cap.

2. Failed system deletion did not prevent deleting its zone

Agreed. runCascade now stops at the first failure, and steps after it are recorded as skipped rather than quietly not attempted — the receipt shows them, and they're marked SKIPPED in the copied TSV so a partial run can't be misread as a complete one.

Worth noting the user-facing copy already said "so nothing further was deleted". The string was describing behaviour the code didn't have. It does now.

3. Failed plan resolution silently fell back to ordinary deletion

Agreed, and this is the one that would have quietly orphaned things in normal use, since it needs nothing more than a flaky lookup. Done in both places you asked for:

  • Block confirmation — the modal now withholds confirm while a selected option has no usable breakdown (blocked(), alongside the existing resolving()).
  • Fail closed in the serviceitem.service refuses the plain actions.remove when the cascade option was selected but no plan resolved, rather than falling through. Second line of defence, since the modal should already prevent it.

While doing this I found an adjacent bug: an option declared enabled: true was never toggled, so its details() was never called — under the new gate it would have sat selected-with-no-details and held the confirm button disabled permanently. Options that start enabled now resolve their breakdown on init. Nothing currently ships with enabled: true, so it was latent.

Tests

Two existing tests asserted the old behaviour and have been inverted — treats a failed lookup as no systems rather than failing and continues past a failing step and reports it were encoding the bugs. New coverage for each point: the ownership-lookup failure, the pagination cap, the stop-and-skip ordering, the confirmation gate, and the default-enabled resolution.

Backoffice suite: 900 passed, lint clean, production build green.

One thing I did not change

showSystem and showZone re-reads still tolerate a failure — showZone warns visibly (ORG_ZONE_MISSING), and showSystem treats an unreadable system as "leave it alone". Both under-delete rather than over-delete, so neither can destroy anything unexpected, but the showSystem one is silent. Distinguishing "genuinely deleted since we listed it" from "could not read it" needs the error status, and I didn't want to guess at the shape unverified. Happy to tighten it if you'd rather it failed closed too.

@camreeves
camreeves requested review from MrYuion August 5, 2026 17:33
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