0029 — Product deletes archive; destruction is out of band
- Status: Accepted
- Date: 2026-09-21
- Deciders: Gera, decision
b90010259(2026-09-21), answering thep7-actor-viewquestion about theignored-propertiesdoor.
Decision
Gera, verbatim:
"only admins could ever quote unquote delete. But deleting data should actually never delete it, it should just mark it as archive so we don't see it. If we ever wanna delete it, it should be done with like claude code or something behind the scenes."
As three rules:
- Only admins may reach a "delete". Ordinary in-company acts open to every workspace member under ADR-0135 / ALIGN1; a delete does not. A delete is not "an otherwise valid in-scope settings write", so ALIGN1 does not retire its gate.
- A product "delete" never destroys. It sets an archive mark, the record and every child row stay exactly where they are, and the mark is reversible by clearing it.
- Real destruction is out of band — a script or hand-run action behind the scenes, never a product button. No product route hard-deletes the record this rule covers.
This restates an earlier ruling; it does not supersede one
Decision b89009726 / D-0909-14 (2026-09-09), also Gera, recorded verbatim
in propflowai at src/lib/data/types.ts:9784-9786:
"we never truly delete, we just kinda mark the data as archived. So we should always like keep it. Just have an archived sort of way of describing it. I'm not sure what's like the common archiving strategy."
b90010259 is the same rule, applied to a second door, plus the answer to
the mechanism question the earlier one left explicitly open. Nothing in
b89009726 is reversed. A reader who finds only one of the two has the rule but
not its reach; this ADR exists so neither is found alone.
The earlier ruling already has one implementation in the tree — the withdrawn
unrouted-thread marker at types.ts:9784 — which its own docblock is careful to
scope: "NOT A GENERAL 'ARCHIVED CONVERSATION' STATE." That caution stands.
This ADR does not make it general either; it settles properties, names the
idiom, and lists what is still owed.
What changed in code
propflowai p7-actor-view part 4b. POST /api/settings/ignored-properties
with alsoDelete: true called deleteProperty(id) — queryByPK(PROP#{id})
followed by batchDelete over every item in the partition: the META row,
every UNIT#, every occupancy, every appliance, every knowledge row, and every
conversation, tour and work order born on that line.
It now writes one archivedAt + archivedBy stamp on the META row and
removes nothing.
- The mark:
Property.archivedAt/Property.archivedBy, mirroring the idiom already used byWorkOrder.archivedAt,Tenant.archivedAtandLease.archivedAt. Deliberately not aProperty.lifecyclevalue:lifecycleis portfolio-architecture IDENTITY with a fixed enumeration (active | lease_up | transferred | sold | placeholder), and archival is a product state, not a change of what the building is. - The hiding: one predicate,
filterVisibleProperties(src/lib/domain/properties/visibility.ts) — the same place the test-property and org-scope rules already live. It drops archived buildings before the platform-staff bypass, because staff are product readers too and the ruling says the mark exists "so we don't see it." - The children stay. They are hidden with their parent because every product property list runs through that predicate. Nothing is removed, so the revert is one attribute.
Consequences
Out-of-band destruction still has no tool. The ruling's "claude code or something behind the scenes" half is not built. Until it is, archived rows accumulate and are reachable only by direct table access. That is the intended trade — accumulation is recoverable, destruction is not.
The archived state has no UI. Nothing lists archived properties or offers un-archive; recovery today means clearing the attribute by hand.
The archive stops reads, not sends — and that is the next part. archivedAt
has exactly ONE consumer, filterVisibleProperties, which is a list
visibility predicate. So an archived building disappears from every screen
while its conversations, work orders, tours and renewal / turnover / collections
timers keep running: no outbound path consults the field. The cascade this
replaced could not produce that state, because destroying the partition is what
stopped those workflows. A building nobody can see that is still texting its
residents is worse than either previous state, and closing it is the next part
of this work, not a nicety.
The predicate a send path would consult is the same one, applied to the
building a message is about rather than to a list: a send is refused when the
Property it concerns carries archivedAt — read through the property the
outbound path already resolves, never a second spelling of the rule. The
surfaces that would have to ask: Clara's conversation reply paths, maintenance
dispatch, tour confirmations and reminders, the renewal saga's timers, turnover
and the collections walker.
Fan-out readers do not inherit the hiding. filterVisibleProperties hides
the property. The ~25 queryAllPropertyPartitions call sites enumerate
partitions through getAllPropertyIds (dynamo/helpers.ts:2348), which does
not consult it — so an archived building's work orders and conversations can
still surface in account-wide fan-outs. Naming it rather than fixing it: that
predicate is also what cron, sync and Lambda paths read, and narrowing it would
hide rows from legitimate background work. Its own slice, with its own ruling on
whether background work should see archived buildings at all.
Other product-facing hard deletes — follow-ups, NOT built here
Found by git grep -nE "deleteItem\(|batchDelete\(|DeleteCommand" -- src/app/api
plus a sweep for delete-shaped facade calls. Each needs its own read of whether
rule 2 applies; this ADR does not pre-judge them.
| Route | Call | Note |
|---|---|---|
properties/[id]/route.ts:316 |
deleteProperty |
Same primitive, already refused in production (:292), so dev-only. Closing it makes rule 3 literally true. |
conversations/[id]/route.ts:121 |
deleteConversation |
The cohort b89009726 was originally about. |
leasing/prospects/[id]/route.ts:221 |
deleteProspect |
|
activity-log/route.ts:267 |
deleteActivityLogEntry |
|
maintenance-manuals/route.ts:330 |
deleteMaintenanceManual |
|
email/ingestion-log/[id]/route.ts:85 |
deleteEmailIngestionRecord |
|
admin/users/[id]/route.ts:297 |
deleteUser |
Admin surface. |
admin/companies/route.ts:515 |
deleteOrganizationRow |
A create-rollback, not a user act — likely out of scope for rule 2. |
admin/maintenance/work-orders/kill-all, admin/pipeline-test/delete-conversation, admin/spine-orphans/*, admin/smoke-results/live |
various | Staff/bench tooling — arguably already "behind the scenes" in the ruling's sense. |
mfa/disable/route.ts:82,108, mfa/enroll/verify/route.ts:103,156 |
DeleteCommand |
Credential material. Rule 2 should probably NOT extend here — retaining a revoked MFA factor is a security liability, not a recovery aid. Named so the exception is deliberate. |
The send-side gate — the next part, not built here
| Surface | What it would ask |
|---|---|
| Clara's conversation reply paths | the Property the thread is about carries archivedAt → do not send |
| Maintenance dispatch | same, on the work order's property |
| Tour confirmations and reminders | same, on the tour's property |
| Renewal saga timers | same, at the timer's wake |
| Turnover, collections walker | same, at each step |
One question, asked of the building the message concerns — not a second copy of the visibility rule.
The rule is about records a customer can lose, not about every delete in the
tree. A reader applying rule 2 mechanically to the MFA rows would make the
product less safe, which is why the table carries a verdict column rather than a
checklist.