Skip to content

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

Merged
camreeves merged 9 commits into
developfrom
PPT-1203-cascade-delete
Aug 6, 2026
Merged

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

Conversation

@camreeves

@camreeves camreeves commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

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 Aug 6, 2026 3:36am

@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
Contributor 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
camreeves and others added 2 commits August 6, 2026 13:11
…l review found

A multi-lens review of this PR found 26 substantiated defects, several of
the same class Alex raised — the previous commit fixed his three findings
without fixing their cause.

Plan resolution, remaining fail-open paths:
- `domainTenants` still swallowed every staff-API failure. That listing
  crosses a service boundary and is admin gated, so it fails for reasons
  unrelated to the domain having no tenant. The plan then rendered
  "Nothing else to remove" — a positive claim of absence — and the tenant
  survived the domain that owned it, still holding its encrypted calendar
  credentials.
- `zoneSubtreeIds` truncated silently at MAX_ZONES, the same mistake
  `collectPages` had. It now raises.

Over-deletion:
- The org-zone sharing check compared ids, but deleting the zone destroys
  everything beneath it. A domain whose own org zone was a *child* of
  this one was invisible to the check and destroyed with it. The check
  now tests membership of the whole subtree, reusing that walk for the
  plan rather than repeating it.
- `inside()` used `zones.every(...)`, and `[].every()` is true, so a
  system with no zones counted as "entirely inside" and was deleted. A
  system whose zones were just cleared, still listed by the lagging
  search index, is exactly that case.

Half-deleted states with no record:
- The dialog stayed dismissable while the cascade ran, and the footer is
  hidden during loading, so Escape was the only thing to press. That
  nulled the component the progress callback writes to, threw outside
  `runCascade`'s try, and abandoned an irreversible run with no receipt
  and no notification. It now disables close for the duration, guards the
  derefs, and the chain has a catch so nothing can fail silently.
- A cascade that fully succeeded followed by a failed delete of the item
  itself discarded the receipt entirely — the only record of what had
  already gone — and re-rendered a confirmation whose breakdown was no
  longer true. It now shows the partial receipt.

Modal:
- `resolving()` is scoped to selected options, so unticking releases the
  confirm button instead of holding it until an abandoned request settles.
- `_resolveDetails` guards against a second in-flight resolution, so a
  stale rejection cannot overwrite a good breakdown.
- Failures render via `describeError` rather than as "[object Response]",
  which is what ts-client's raw `Response` rejection produced.
- Dropped the dead `CASCADE.SUCCESS` keys and the spec that pinned them.

Every fix is mutation-tested: reverting each one individually fails a
test, including the receipt and TSV rendering of skipped rows, which the
review showed could be deleted wholesale with the suite still green.

910 tests pass, lint clean, production build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`log()` takes an array of loggable values, not `unknown`. The previous
commit passed the raw rejection and only unit tests and lint were run
before pushing, both of which use a looser type-check than the AOT
production build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e zone-walk cap

Two findings from the re-review, neither blocking but both worth having.

`CASCADE.FAILED` fell back to the resource *name* when the rejection had
no `.message` — and ts-client rejects with a raw `Response`, which never
has one, so the toast read "Error: Level 31 Boardroom" for a 403. The
status is the one thing an admin needs to know when an irreversible run
has stopped part-way, and the receipt already names the resource. Uses
`describeError`, which this commit's predecessor added for exactly this
and wired into the modal but not here.

`zoneSubtreeIds` raising instead of silently truncating at MAX_ZONES was
not covered — reverting it left the suite green. Now pinned.

911 tests, lint clean, production build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds opt-in, frontend-orchestrated cascade deletion for zones and domains, including preflight plans, progress reporting, and deletion receipts.

  • Resolves zone subtrees and removes systems that would otherwise become orphaned.
  • Removes domain-owned OAuth applications and staff tenants, and conditionally removes unshared org-zone trees.
  • Extends the confirmation modal with asynchronous options, partial-failure receipts, and clipboard export.
  • Updates locale content, mocks, styling, proxy configuration, and unit/E2E coverage.

Confidence Score: 4/5

The stale destructive plan can delete systems or zone trees whose ownership changed after the preview, so that race should be fixed before merging.

Cascade eligibility is checked while rendering the preview, but confirmation later runs captured deletion closures without repeating the system-zone and cross-domain sharing checks; the clipboard export also needs formula-safe cell encoding.

Files Needing Attention: src/app/common/item.service.ts, src/app/common/cascade-delete.ts, src/app/overlays/confirm-modal.component.ts

Security Review

The new TSV clipboard export does not neutralize formula-leading resource names, so spreadsheet software may interpret a pasted receipt cell as a formula.

Important Files Changed

Filename Overview
src/app/common/cascade-delete.ts Implements cascade discovery and sequential deletion, but destructive steps execute plan-time ownership decisions without revalidation.
src/app/common/item.service.ts Integrates the opt-in cascade into item deletion and receipts while retaining the resolved plan across an unbounded confirmation interval.
src/app/overlays/confirm-modal.component.ts Adds asynchronous option details and receipts; TSV export should neutralize formula-leading cells.
src/app/common/actions.ts Registers zone and domain cascade planners with their existing item actions.
src/app/mocks/backend/zones.mock.ts Updates mock parent filtering to match parent_id, comma-list, and root query behavior.

Sequence Diagram

sequenceDiagram
    actor Admin
    participant Modal
    participant Planner
    participant API
    Admin->>Modal: Enable cascade
    Modal->>Planner: Resolve cascade plan
    Planner->>API: Read zones, systems, domains, applications, tenants
    API-->>Planner: Current ownership snapshot
    Planner-->>Modal: Scope, summary, warnings, captured delete steps
    Note over Admin,API: Ownership may change while confirmation remains open
    Admin->>Modal: Confirm deletion
    Modal->>Planner: Run cached plan
    Planner->>API: DELETE captured resources
    Planner->>API: DELETE zone/domain
    API-->>Modal: Results and partial failures
    Modal-->>Admin: Deletion receipt
Loading

Reviews (1): Last reviewed commit: "fix(delete): report the actual error whe..." | Re-trigger Greptile

Comment on lines +293 to +296
details: async () => {
plan = await cascade.plan(item);
const { scope, summary, warnings } = plan;
return { scope, summary, warnings };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cascade plan becomes stale

If system-zone assignments or domain org-zone references change after the preview is resolved, confirmation executes the cached deletion plan without revalidating ownership, causing a system assigned outside the subtree or another domain’s newly shared zone tree to be deleted.

Comment on lines +143 to 159
'FAILED',
]),
...(result.skipped || []).map((item) => [
item.type,
item.name,
item.id,
'SKIPPED',
]),
];
return rows.map((row) => row.join('\t')).join('\n');
}

export const CONFIRM_METADATA = {
height: 'auto',
};

export interface ConfirmRepsonse {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security Neutralize formulas in TSV receipts

A resource name beginning with =, +, -, or @ is copied verbatim into a TSV cell, so pasting the receipt into formula-capable spreadsheet software evaluates the name as a formula instead of literal text.

How this was verified: receiptToTsv directly joins resource names into tab-separated rows, and copyAll copies that output to the clipboard.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@camreeves

Copy link
Copy Markdown
Contributor Author

Merging this now. Summary of what changed since the last review, for the record.

@MrYuion — your three findings were correct, and fixing them was not enough. Before merging I ran an adversarial review over the whole diff (six lenses, each finding then handed to a separate agent whose only job was to refute it). It surfaced 26 substantiated defects, including a fourth instance of the exact fail-open pattern you flaggeddomainTenants still had .catch(() => []), thirty lines below a comment I had written asserting there were none left.

The ones that mattered:

  • domainTenants fail-open. That listing crosses a service boundary and is admin-gated, so it fails for reasons unrelated to the domain having no tenant. The plan then rendered "Nothing else to remove" — a positive claim of absence — and the tenant survived the domain that owned it, still holding its encrypted calendar credentials.
  • The org-zone ownership check was id equality, but the delete takes the whole subtree. A domain whose own org_zone was a child of the one being deleted was invisible to the check and destroyed with it. Same failure you identified in your first point, reached by a different route. It now tests membership of the full subtree, and that walk is computed once and threaded into planZoneCascade rather than repeated.
  • zoneSubtreeIds truncated silently at MAX_ZONES — the same mistake collectPages had. It raises now.
  • inside() used zones.every(...), and [].every() is true, so a system with no zones counted as "entirely inside" and was deleted. A system whose zones were just cleared, still returned by the lagging search index, is exactly that case.
  • The dialog stayed dismissable during the run while the footer is hidden, so Escape was the only thing to press — which nulled the component the progress callback writes to, threw outside runCascade's try, and abandoned an irreversible operation with no receipt and no notification.
  • A cascade that fully succeeded followed by a failed delete of the item discarded the receipt — the only record of what had already gone — and re-rendered a confirmation whose breakdown was no longer true.

Plus the modal fixes (resolving() scoped to selection so unticking releases the button, an in-flight guard so a stale rejection can't overwrite a good breakdown, and describeError instead of [object Response]).

Every fix is mutation-tested — each one reverted individually, confirming a specific test goes red. That includes the receipt and TSV rendering of skipped rows, which the review showed could be deleted wholesale with the suite still green.

A second adversarial pass over the fixed code returned zero blockers (36 findings refuted, 4 non-blocking, of which the two worth having are in the final commit).

911 tests, lint clean, production build green. Happy to walk through any of it, and if you'd rather something came out, say so and I'll revert it.

@camreeves
camreeves merged commit 080dd4a into develop Aug 6, 2026
9 checks passed
@MrYuion
MrYuion deleted the PPT-1203-cascade-delete branch August 6, 2026 03:51
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