What
"This subtree is trashed" is stored N times — every page carries its own isTrashed/trashedAt (schema/core.ts:45,87), pages.parentId has no FK (core.ts:91), and consistency depends entirely on app-level recursion. That recursion exists in two independent implementations, and several writers skip it entirely:
Cascade implementations (duplicated logic)
apps/web/src/services/api/page-service.ts:286-322 recursivelyTrash (canonical)
apps/web/src/lib/ai/tools/page-write-tools.ts:233-278 — its own copy (withChildren recursion / move-children-up), mirroring pageService by hand
Non-cascading raw writers (the drift generators)
packages/lib/src/repositories/page-repository.ts:222-245 (trash, trashMany — single-level)
lib/integrations/google-calendar/sync-service.ts:319,505 — direct db.update(pages).set({isTrashed:true})
lib/machines/machine-settings-runtime.ts:147
api/pages/[pageId]/tasks/[taskId]/route.ts:560 — trashes a single task page even though task pages can have children
Proof the drift class is real: scripts/repair-orphaned-trashed-pages.ts — "Historically, trashing a page did NOT trash its children … those live descendants surfaced as bogus top-level items" — a recursive-CTE repair script. Any of the remaining non-cascading writers can recreate exactly the bug that script repairs.
Proposed direction
One cascade, one owner: move recursivelyTrash into the shared service/repository layer, have the AI tool call it instead of maintaining a copy, and make the raw single-level writers either call the cascade or be renamed to make their non-cascading nature impossible to reach for by accident (trashSinglePageUnsafe). Alternatively, derive trash state at read time from the nearest trashed ancestor (recursive CTE / closure table), so subtree-trash is stored once and the repair script class disappears entirely.
Filed from the sources-of-truth audit.
What
"This subtree is trashed" is stored N times — every page carries its own
isTrashed/trashedAt(schema/core.ts:45,87),pages.parentIdhas no FK (core.ts:91), and consistency depends entirely on app-level recursion. That recursion exists in two independent implementations, and several writers skip it entirely:Cascade implementations (duplicated logic)
apps/web/src/services/api/page-service.ts:286-322recursivelyTrash(canonical)apps/web/src/lib/ai/tools/page-write-tools.ts:233-278— its own copy (withChildren recursion / move-children-up), mirroring pageService by handNon-cascading raw writers (the drift generators)
packages/lib/src/repositories/page-repository.ts:222-245(trash,trashMany— single-level)lib/integrations/google-calendar/sync-service.ts:319,505— directdb.update(pages).set({isTrashed:true})lib/machines/machine-settings-runtime.ts:147api/pages/[pageId]/tasks/[taskId]/route.ts:560— trashes a single task page even though task pages can have childrenProof the drift class is real:
scripts/repair-orphaned-trashed-pages.ts— "Historically, trashing a page did NOT trash its children … those live descendants surfaced as bogus top-level items" — a recursive-CTE repair script. Any of the remaining non-cascading writers can recreate exactly the bug that script repairs.Proposed direction
One cascade, one owner: move
recursivelyTrashinto the shared service/repository layer, have the AI tool call it instead of maintaining a copy, and make the raw single-level writers either call the cascade or be renamed to make their non-cascading nature impossible to reach for by accident (trashSinglePageUnsafe). Alternatively, derive trash state at read time from the nearest trashed ancestor (recursive CTE / closure table), so subtree-trash is stored once and the repair script class disappears entirely.Filed from the sources-of-truth audit.