From b28128a568b637abbab954f48f6305f3361ddee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:59:35 +0000 Subject: [PATCH 1/3] docs(adr): ADR-0148 records the recycle bin as a separate store, restore under the original ids, retention in days The maintainer ruled the recycle-bin shape on #3146 (ruling record 6629858351): one sys_recycle_bin entry per delete operation with the cascade set snapshotted, the delete staying physical and every read path untouched; restore under the original ids with three named refusals; retention as days (default 30, OS_RECYCLE_BIN_RETENTION_DAYS) through a host resolver hook on the resolveFeatures pattern, so the framework knows days and never plans. The record names, for every new surface, the reader that will enforce it and the card that lands it, and says the keys ADR-0049 removed stay removed. ADR-0049 gains one dated note on its Status line; its decision text is unchanged. docs/adr/README.md is regenerated by scripts/adr-index.mjs. Claude-Session: https://claude.ai/code/session_01WGufzfC3wLXmeCXwxa963a Co-authored-by: Claude --- .../0049-no-unenforced-security-properties.md | 2 +- ...re-under-original-ids-retention-in-days.md | 368 ++++++++++++++++++ docs/adr/README.md | 7 +- 3 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md diff --git a/docs/adr/0049-no-unenforced-security-properties.md b/docs/adr/0049-no-unenforced-security-properties.md index d2a3dc3d38f..51764f00d64 100644 --- a/docs/adr/0049-no-unenforced-security-properties.md +++ b/docs/adr/0049-no-unenforced-security-properties.md @@ -1,6 +1,6 @@ # ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) -**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. +**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. **Amended 2026-11-05** ([ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md), #30234, ruling record 6629858351 on #3146) — the recycle bin returns as a **new** surface whose readers that record names (a separate `sys_recycle_bin` store, restore under the original ids, retention in days through a host hook); the keys this gate removed — `enable.trash` and `softDelete` (16.x, #2377), `allowRestore` and `allowPurge` (#12497) — **stay removed**, their tombstones and ADR-0087 conversions stand, and none returns under its old spelling. The gate is enforced, not reversed; this decision text is unchanged. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) **Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). diff --git a/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md new file mode 100644 index 00000000000..6cb89159098 --- /dev/null +++ b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md @@ -0,0 +1,368 @@ +# ADR-0148: The recycle bin is a separate store — one entry per delete operation, restore under the original ids, retention in days through a host hook + +**Status**: Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on +[#3146](https://github.com/objectstack-ai/objectstack/issues/3146) (ruling record +[`6629858351`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6629858351), verbatim: +「同意,企业版功能在 cloud 中立卡后续开发」). **This record declares; it implements nothing.** Nothing below ships on +`main` today: every delete is a physical delete, and each mechanism here is cut as a child of #3146 that triage filed +([`6648418282`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6648418282)) — +[#30235](https://github.com/objectstack-ai/objectstack/issues/30235) (spec), +[#30237](https://github.com/objectstack-ai/objectstack/issues/30237) (engine), +[#30239](https://github.com/objectstack-ai/objectstack/issues/30239) (Console) — each landing after this record and +citing it. `docs/adr/` is Tier H (Prime Directive #14): the record lands by the maintainer's hand (merge or enqueue), +and that act is its acceptance. +- **Date**: 2026-11-05 +- **Card**: [#30234](https://github.com/objectstack-ai/objectstack/issues/30234) (#3146 slice 1), the first of the + four children. The card restates the ruling; where the two differ in words, the ruling binds. +- **Decided by**: the maintainer, 2026-11-04, in the cloud coordinator session, verbatim and untranslated — + 「记录删除回收站的功能还没有开发」, 「其他主流类似系统是怎么处理的」, 「保留期按『Free 7 天 / 团队版及以上 30 + 天』开源项目怎么办呢?这个功能可以放在企业版吗?建议放在企业版吗?」, and on the proposal the coordinator put back, + 「同意,企业版功能在 cloud 中立卡后续开发」. The proposal's text is the ruling record; [Decision](#decision) restates + each of its items in the ruling's own terms and adds no mechanism of its own. +- **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — the gate that removed + the old keys and the gate every new key below passes, with its reader in the same change), + [ADR-0057-system-data-lifecycle-and-retention](./0057-system-data-lifecycle-and-retention.md) (the one platform + sweeper the bin's purge runs on), [ADR-0055](./0055-master-detail-controlled-by-parent.md) (master-detail — the + cascade a delete removes and a restore brings back together), + [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) D2 (mechanism open, entitlement commercial), + [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the envelope every restore refusal answers in), + [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (the retired keys' conversions stand; the new keys arrive + as a protocol addition), [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) (why the + shape frees a unique value at delete, and why a restore can find it taken), + [ADR-0100](./0100-credential-field-channels.md) (the `secret` channel the snapshot keeps out), + [ADR-0041](./0041-flow-trigger-family.md) (record triggers — why a restore is its own event, never a create). +- **Amends**: [ADR-0049](./0049-no-unenforced-security-properties.md) — its `**Status**:` line gains one dated note + pointing here; its decision text is unchanged. The note says the keys that gate removed stay removed + ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). +- **Leaves unchanged**: every read path (query, aggregate, REST, the analytics engine's SQL, rollups, lookup pickers, + search, AI tools, MCP and export); the physical delete the engine performs today and its cascade order; the + tombstones of `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` and their ADR-0087 conversions; the + `sys_file` tombstone and its reap; the release of `sys_secret` handles on a committed delete. +- **Consumers**: `@objectstack/spec` (the recycle-bin object, the retention hook's contract, the refusal codes, the + restored event — #30235), `@objectstack/objectql` (snapshot, restore, purge, empty, the TTL reap on the + LifecycleService — #30237), `@objectstack/plugin-security` (the restore and bin-admin grants, the fail-closed + destructive backstop — #30235 / #30237), `@objectstack/rest` (the doors the Console calls — #30237), the Console under + `ui/` (the admin page and the toast — #30239), and `objectstack-ai/cloud` as the one caller of the retention hook + (its own card, per the ruling). + +--- + +## Provenance + +- The ruling was taken on this repository's card #3146 and is recorded there (ruling record 6629858351, + 2026-11-04T11:50Z). It governs open code, so the record lives here (Prime Directive #13). +- **The commercial half is cloud's.** Per-plan retention (Free 7 days, Team and above 30 days), per-object retention, + legal hold, longer retention, delete and restore audit reports, bulk restore and erasure proof are a cloud card by the + same ruling (「企业版功能在 cloud 中立卡后续开发」). They are not restated here, and this record does not number that + card. Where this record and the cloud record disagree about what the commercial boundary is, the cloud record + decides; what this record decides, and cloud does not, is the open mechanism and the hook the commercial layer calls. +- **Why now.** cloud#3565 rules that the AI chat may write records after a confirm card, and its step 3 — deleting + from chat — waits on deletes being recoverable. The Console's delete is permanent today. (A cloud record is not + readable from this repository's sessions; the ruling's summary of cloud#3565 is what this record reads.) +- The ruling says the framework knows 「days, never plans (ADR-0012)」. This repository's ADR-0012 is the messaging + platform (superseded by ADR-0030) and says nothing about plans; the citation resolves to **cloud ADR-0012**, which + the open seam's own docblock names. See [Where "days, never plans" is written down](#where-days-never-plans-is-written-down). + +--- + +## TL;DR + +Every delete on `main` is a physical delete at the driver, and nothing the spec ever declared about soft delete had a +reader: `enable.trash` and `softDelete` were removed in the 16.x line and `allowRestore` / `allowPurge` with #12497, +all under ADR-0049, because nothing read them. The maintainer ruled the shape the recycle bin takes when it is built: + +1. **A separate store, not a column.** On delete the engine snapshots the rows the delete removes — the cascade set — + into one system object, `sys_recycle_bin`, as **one entry per delete operation**, and then deletes physically as + today. Restore writes the rows back **under their original ids**. Purge removes the entry. No read path changes. +2. **Open source owns the mechanism**: snapshot, restore, empty, permanent delete, TTL purge and permissions. + **Retention is a number of days, default 30**, read from `OS_RECYCLE_BIN_RETENTION_DAYS`; a host may inject it + through a resolver hook on the pattern of `resolveFeatures`. The framework knows days, never plans. +3. **Cloud sets per-plan retention through that hook**, and the enterprise value layer is cloud's own card. + +Nothing here is shipped. For every new surface the record names the reader that will enforce it and the card that +lands it, so the ADR-0049 gate the old keys failed is the gate the new ones pass. + +--- + +## Context — what `main` ships today, and why the old keys went + +**The delete path.** `ObjectQL.delete` collects the rows a delete removes before it removes any +(`packages/objectql/src/engine-delete-cascade.ts#collectCascadeDeleteSet`), refuses a `restrict` relation inside its own +cascade with `409 DELETE_RESTRICTED` (`packages/objectql/src/engine-delete-cascade.ts#refuseRestrictsInsideOwnCascade`), +clears the `set_null` lookups that pointed at the removed rows (the `onDelete` vocabulary of +`packages/spec/src/data/field.zod.ts`: `cascade` / `restrict` / `set_null`), and removes the rows at the driver. A +committed delete then releases the `sys_secret` handles the removed rows held +(`packages/objectql/src/engine-secret-internal-fields.ts#releaseSecretHandles`, #27581). Nothing is kept: the rows +are gone, their unique values are free, and their ids are not reused. + +**The one platform sweeper.** `packages/objectql/src/lifecycle/lifecycle-service.ts#LifecycleService` is the single +enforcer of ADR-0057 `lifecycle` declarations: it reaps rows past `retention.maxAge` or past +`ttl.field + ttl.expireAfter`, in bounded batches, under a system context, with a per-object reap guard that can veto +or finish a row's removal. ADR-0057 §3.3 forbids a second sweeper. + +**The precedent the ruling names.** `sys_file` already dies in two steps. +`packages/services/service-storage/src/attachment-lifecycle.ts` tombstones an orphaned attachments-scope file +(`status='deleted'` + `deleted_at=now`) when its last join row goes away, un-tombstones it on re-attach inside the +grace window, and registers a reap guard that re-verifies zero references at sweep time and reclaims the bytes; the +`lifecycle` declaration on `packages/services/service-storage/src/objects/system-file.object.ts` has the +LifecycleService reap the tombstone 30 days after `deleted_at`. Detection and scheduling stay inside the one sweep. + +**The keys ADR-0049 removed, and why.** The June 2026 property liveness audits (#1878, under `docs/audits/`; the +ObjectSchema sheet as #3146 quotes it) found `enable.trash` (default `true`, "enable soft-delete with restore +capability") and `softDelete` (`enabled` / `field: 'deleted_at'` / `cascadeDelete`) with **zero behaviour-changing +readers**, duplicated and both dead. A default-true flag promising a recycle bin was a false +affordance: authors wrote `trash: false` believing they were opting out of a soft delete that never ran. Both were +removed in the 16.x line (#2377) and tombstoned — `enable.trash` in +`packages/spec/src/data/object.zod.ts#CAPABILITIES_RETIRED_KEY_GUIDANCE`, `softDelete` in the same file's top-level +unknown-key guidance, whose text is the standing fact: 「there is no soft-delete/recycle-bin runtime … Deletes are hard +deletes」. The permission lifecycle bits `allowRestore` / `allowPurge` claimed to gate `restore` / `purge` operations +that never entered the engine's dispatch vocabulary (pinned by +`packages/objectql/src/engine-middleware-operation-vocabulary.test.ts`); they were removed with #12497 (the maintainer +accepting #1883's recommendation B, 2026-08-26), conversion +`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`, and the evaluator's +`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS` backstop denies a dispatched +`restore` or `purge` fail-closed. The tombstone in `packages/spec/src/security/permission.zod.ts` says the bits +「return with the M2 lifecycle initiative alongside the operation it gates」. [D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired) +says how: as **new** keys, with their readers, never as the tombstoned spellings. + +**Not this surface.** ADR-0045's "trash-can discard" is the teardown of an unpublished build (a package renamed into a +trash namespace and collected after seven days). It is a different object at a different layer and is untouched here. + +**What the mainstream does**, read for the ruling and adopted rather than reinvented: Dataverse keeps deleted rows in a +"bin" data source and restores them with the same record id; ServiceNow records deletes in `sys_audit_delete`; +Salesforce marks rows `IsDeleted` and filters every query — the other shape, and the one not chosen; HubSpot keeps a +permanent delete for privacy erasure and does not restore workflow enrollments; ServiceNow's script restore is the +pitfall that loses `created_by` / `created_at`. + +--- + +## Decision + +### D1 — Shape: a separate recycle-bin store, one entry per delete operation, the delete stays physical + +On delete, the engine **snapshots the rows the delete removes** — the cascade set +`packages/objectql/src/engine-delete-cascade.ts#collectCascadeDeleteSet` already computes, master and details in cascade +order — into **one system object, `sys_recycle_bin`**, as **one entry per delete operation**, and then **deletes +physically as today**. An entry records what was removed (the objects and ids, the rows as they stood), who deleted +and when, and the retention window it was given. Restore writes the rows back under their original ids +([D2](#d2--restore-under-the-original-ids-with-reasons-for-refusal)); purge removes the entry; empty purges every entry +the caller may see. + +**Chosen over an `IsDeleted` / `deleted_at` column, for the reasons the ruling gives:** + +- **Every read path stays untouched** — query, aggregate, REST, the analytics engine's SQL, rollups, lookup pickers, + search, AI tools, MCP and export. A column would have to be filtered on every one of them, and the first path that + forgets shows deleted rows. +- **Unique keys free immediately.** A deleted row occupies no unique value, so the next insert of the same value is + ordinary (ADR-0120 scopes stay as they are). The cost moves to restore, which may find the value taken and refuses. +- **The per-tenant schema change is one table**, not a column on every object. +- **It is the mainstream shape** for a platform that restores under the same id (Dataverse's bin data source, + ServiceNow's `sys_audit_delete`). Salesforce's `IsDeleted` column is the other shape, and it is not chosen. + +The entry is a row of a system object, so it is organization-owned like every other row (ADR-0131), stored by the +default datasource, and counted toward storage — [D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules) +says it is shown separately. The object's field list is #30235's to declare; its name and the one-entry-per-operation +rule are decided here. + +### D2 — Restore under the original ids, with reasons for refusal + +A restore **writes the snapshotted rows back under their original ids**, master and details together, and **keeps +`created_by` and `created_at`** as they were (the ServiceNow script-restore pitfall, closed by rule). It **emits its +own `restored` event, never a create**: a flow triggered on record creation (ADR-0041) does not fire for a restore, +flow enrollments are not restored (HubSpot), audit records a restore as a restore, and rollups recompute from the +rows that are back. + +**A cascaded child is not restored alone** (Dataverse): the entry is the unit of restore, and a detail that went with +its master comes back with its master. + +**A restore refuses with a reason and never forces a write** when: + +- a **unique value has since been taken** by a live row; +- the **parent was purged** — the row's master-detail parent is no longer in the bin and no longer live; +- the **object's package was uninstalled** — the object no longer exists in the registry. + +Each refusal is a named code in the ADR-0112 envelope (#30235 names them; #30237 answers them and pins one test per +refusal), so the Console and an AI caller read the reason, not a generic failure. + +**Lookups.** A reference that `set_null` cleared at delete is **re-linked on restore only if nothing has changed it +since** (Salesforce: 「only restores lookup relationships that have not been replaced」). A reference somebody has since +pointed elsewhere stays where they pointed it; the restore does not overwrite a live write. + +**Permissions.** **A person restores what they deleted; an admin sees all** — lists every entry, restores any, and +empties the bin. The grants are new permission keys ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)), +not the tombstoned `allowRestore` / `allowPurge`. The `restore` and `purge` verbs are already in the evaluator's +fail-closed destructive class, so until the new grants map them, a dispatched restore is denied — the ADR-0049 posture +this record keeps. + +### D3 — Retention in days, default 30, through a host resolver hook; open source owns the mechanism + +**Open source owns the mechanism**: snapshot, restore, empty, permanent delete, TTL purge, and the permissions above. + +**Retention is a number of days.** The default is **30**, read from **`OS_RECYCLE_BIN_RETENTION_DAYS`** (a config +value under the `OS_{DOMAIN}_{NAME}` rule of Prime Directive #9). **A host may inject retention through a resolver +hook, the same pattern as `resolveFeatures`**: the hook receives an opaque token (the cloud distribution passes its plan +string, exactly as it does to `resolveFeatures`) and answers a number of days, or nothing to keep the default. The +framework validates a positive whole number and **knows days, never plans**: no plan name, no catalog, no entitlement +appears in this repository. The hook's contract is #30235's to declare; the rule that it answers days is decided here. + +**The TTL purge runs on the existing LifecycleService** (ADR-0057 §3.3: one sweeper, never a bespoke one), following +the `sys_file` tombstone pattern: an entry is purged at the end of its resolved window by the platform sweep, under the +system context, in bounded batches, with a reap guard where the engine needs one. **Permanent delete stays available +for privacy erasure and never enters the bin** (HubSpot). + +**Cloud sets per-plan retention through that hook — Free 7 days, Team and above 30 days** — and the enterprise value +layer (per-object retention, legal hold, longer retention, delete and restore audit reports, bulk restore, erasure +proof) is a cloud card. It is not in this record, not in open source, and it reaches the open code only as the hook's +caller. + +### D4 — v1 scope, forward-only, later items, and the privacy rules + +**In v1**, adopting mainstream rules rather than inventing new ones: + +- business objects — not `sys_*` objects; +- a single delete and its master-detail cascade, snapshotted as one entry and restored together; a cascaded child + cannot be restored on its own; +- an admin recycle-bin page: list, restore, empty; +- the TTL purge on the LifecycleService, as D3; +- a 「已删除 · 撤销」 toast after a delete, which restores through the engine's door; +- restore keeps `created_by` / `created_at`; +- permanent delete stays available for privacy erasure and never enters the bin. + +**Forward-only.** Deletes made before the bin existed are not recoverable (Dataverse). The release notes of the engine +slice say so. + +**Later** — named so they are not mistaken for v1, each filed as its own card when it is due: + +- bulk delete into the bin, with a size cap; +- holding attachments and `sys_secret` rows until purge — today a committed delete releases the `sys_secret` handles + the rows held (#27581), and v1 leaves that as it is; +- a member-facing 「我删除的」 page. + +**The privacy rules**, which bind v1 and everything after it: + +- **FLS applies when listing.** A snapshot holds every field; the bin shows a caller a field only where the caller may + read it on the live object. +- **Secret fields stay out of the snapshot, or are encrypted.** In v1 they stay out: the `secret` channel's values + (ADR-0100) are not written into the entry, the committed delete still releases their handles, and a restored record's + secret fields come back empty, to be re-entered. Holding them until purge is a later item. +- **Purge on organization or environment deletion.** An organization's or environment's entries go with it. +- **Bin storage counts toward quota and is shown separately.** + +**Risks the ruling plans for**, carried into the cards: restore fidelity (references, cascades, files, approvals +pending on the record) — the mitigation is a narrow v1 that refuses the rest, with a test per refusal; delete latency — +one extra read and one extra write per delete, which hosted Turso shows — the mitigation is one snapshot write per +operation and a cap on bulk. + +### D5 — The readers that will exist, and the keys that stay retired + +ADR-0049 removed the old keys because nothing read them. The new surface ships **with its readers**, each named here +with the card that lands it. Every row below is new; none is a tombstoned spelling returning. + +| New surface | The reader that enforces it | Card | +|---|---|---| +| `sys_recycle_bin` — one entry per delete operation; the cascade set snapshotted; who, when, and the resolved window | written by the engine's delete path; read by restore, purge, empty and the TTL reap; listed by the admin page under FLS | #30235 declares; #30237 writes and reads; #30239 lists | +| `OS_RECYCLE_BIN_RETENTION_DAYS` (default 30) | the retention resolver's default, when the host hook answers nothing | #30237 | +| the host retention resolver hook — answers days, never a plan | the TTL reap, which purges an entry at the end of its resolved window; the cloud distribution is its caller | #30235 (contract); #30237 (reader); the cloud card (caller) | +| the `restore`, `purge` and `empty` operations and the REST doors the Console calls | the engine; `packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS` already lists `restore` and `purge`, and `packages/plugins/plugin-security/src/permission-evaluator.ts#OPERATION_TO_PERMISSION` must map them to the new grants, or the fail-closed backstop denies them; the operation vocabulary pin gains the verbs | #30235 (vocabulary); #30237 (engine, doors, mapping) | +| the restore and bin-admin grants — new permission keys: a person restores what they deleted, an admin sees all | the permission evaluator in `@objectstack/plugin-security` | #30235 declares; #30237 enforces | +| the restore refusal codes — unique value taken, parent purged, package uninstalled | the engine answers them in the ADR-0112 envelope; the Console reads them in its language | #30235 names; #30237 answers; #30239 shows | +| the `restored` event | automation (a record-created trigger does not fire; enrollments are not restored), audit, rollups | #30235 names; #30237 emits | +| the admin recycle-bin page and the 「已删除 · 撤销」 toast | the Console | #30239 | + +**The old keys stay retired.** `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` are tombstones, their +ADR-0087 conversions stand, and none of them returns: an author who writes one is refused with the prescription the +tombstone carries, and a stored artifact from before 16.x never silently re-enables anything. The new surface uses new +spellings on purpose, so that a key which once meant "nothing happens" never comes to mean "a snapshot is kept". The +retired `apiMethods` values `restore` and `purge` (`packages/spec/src/data/api-derivation.ts`) are likewise unchanged. +ADR-0049's Status line carries the dated note. + +**No object-level authoring key is named by this record.** v1 applies to every business object and to no `sys_*` +object by rule, not by a flag. Per-object retention is enterprise and lives in cloud; if an open per-object key is +ever ruled, it arrives with its reader in the same change, as ADR-0049 requires. + +### D6 — What this record does not decide + +- **The spellings**: the entry's field list, the hook's signature, the refusal code names, the event name and the + grant names. #30235 fixes them, citing this record, under the rules already in force (snake_case machine names, + the ADR-0112 ledger, ADR-0049's reader-in-the-same-change). +- **When the window is resolved**: stamped on the entry at delete time, or resolved by the sweep. Either satisfies + this record, which requires only that the days come from the resolver, that one sweeper purges, and that no entry is + purged before its window ends. #30237 chooses and says why; a window shown to the person on the toast or the page + argues for stamping it. +- **The bulk cap's number**, the Console's wording, and the cloud card's shape. + +--- + +## Where "days, never plans" is written down + +Measured at `main` for this record, because the ruling cites "ADR-0012" and the citation does not resolve in this +registry: + +- **No ADR under `docs/adr/` states the principle.** This repository's ADR-0012 is the messaging platform, superseded + by ADR-0030; neither mentions plans. A grep of every record for `resolveFeatures`, "plan-agnostic" and "plan name" + finds nothing. +- **The seam states it, and cites cloud ADR-0012.** The docblock of + `packages/cloud-connection/src/runtime-config-plugin.ts` heads its feature seam "open-core boundary — cloud + ADR-0012": the open package owns the **mechanism** (serve a per-request `features` map); which feature keys exist and + **which billing plan unlocks them** is a distribution concern that must never be enumerated in the open framework. + Hosts inject policy through `resolveFeatures`, which receives an opaque token — the cloud distribution passes the + plan string — and returns an open-ended map merged verbatim. The framework neither names nor knows the keys. The + package's 9.5.0 changelog entry introduced `resolveFeatures` as "plan-agnostic" and deprecated the billing-vocabulary + `resolvePlanFeatures`. +- **The nearest ADR here is ADR-0132 D2**, which draws the same line for the organization wall: the mechanism is open + and carries no licence check of any kind; only the entitlement stays commercial. + +So the ruling's "ADR-0012" is **cloud ADR-0012**, cited as Prime Directive #13 requires (never as a bare number, which +resolves against this registry), and this record applies the seam's rule to retention: the framework carries a number +of days and a default; the plan that maps to those days lives in the host that calls the hook. + +--- + +## Consequences + +**Positive.** + +- A delete becomes recoverable without a single read path learning about deletion. The defect class ADR-0049 closed — + a promise with no reader — cannot reopen, because every key above is listed with the code that reads it. +- The AI chat's delete step (cloud#3565) has the door it was waiting for, in the open runtime. +- The open/commercial line is the one the runtime already draws for features: open code knows days, the host knows + plans. Nothing in this repository changes when cloud changes a plan's retention. + +**Negative / cost.** + +- **One extra read and one extra write per delete** (the snapshot), visible on hosted Turso. Bounded by one snapshot + write per operation; bulk delete is a later item with its own cap. +- **Bin storage** is real storage until purge, counted and shown separately. +- **Restore is best-effort by design**: the three refusals are permanent for that entry (a taken unique value, a + purged parent, an uninstalled package) and the person is told why rather than given a forced write. +- **Forward-only**: nothing deleted before the engine slice lands is in the bin. +- A restore brings a record back without its secret-channel values until the later item holds them. + +--- + +## Non-goals + +- The enterprise layer (per-plan and per-object retention, legal hold, audit reports, bulk restore, erasure proof) — + cloud's card, reached only through the hook. +- An `IsDeleted` / `deleted_at` column on business objects, or any filter on a read path. +- A bin for `sys_*` objects, or for the unpublished-build discard of ADR-0045. +- Bulk delete into the bin, holding attachments and `sys_secret` rows until purge, and the member-facing page — the + "later" items above, each its own card when due. +- Reviving `enable.trash`, `softDelete`, `allowRestore` or `allowPurge` under their old spellings. + +--- + +## Rollout — the cards, in order + +1. **This record** (#30234, Tier H) lands by the maintainer's hand. The ADR-0049 Status note lands with it. +2. **#30235 — spec**: `sys_recycle_bin`, the retention hook's contract (days only), the refusal codes, the restored + event and the grants, each with its liveness row naming the reader in #30237 or #30239. A protocol addition + (`Metadata-protocol: added`). +3. **#30237 — engine**: snapshot on delete, restore under the original ids with the refusals, purge and empty, the TTL + reap on the LifecycleService, the permission mapping, the REST doors, a test per refusal, and release notes that say + the bin is forward-only. +4. **#30239 — Console**: the admin recycle-bin page (list, restore, empty) and the 「已删除 · 撤销」 toast, reading the + refusals in the Console's language. +5. **The cloud card**: per-plan retention through the hook, and the enterprise layer. Not tracked here. + +#3146 is the tracking parent and closes when its children land. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5fec4692447..81fc4b6fe2b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -13,11 +13,11 @@ ## Summary -148 records under `docs/adr/` (143 numbers), 11 under `ui/docs/adr/` (listed separately below). +149 records under `docs/adr/` (144 numbers), 11 under `ui/docs/adr/` (listed separately below). | Status | Records | |---|---| -| Accepted | 129 | +| Accepted | 130 | | Proposed | 5 | | Superseded | 4 | | Draft | 3 | @@ -26,7 +26,7 @@ | Amended | 1 | | Partially implemented | 1 | | Revised | 1 | -| **Total** | **148** | +| **Total** | **149** | ## Shared numbers @@ -215,6 +215,7 @@ A bare `ADR-0006` names the decision; its current text is [0006-project-environm | [ADR-0145](./0145-ui-deprecation-tombstones-retire-on-the-spec-window.md) | A `?: never` tombstone on the ui authoring face retires on the spec's own window — one major after the retire… | Accepted — 2026-10-28, #27251 · Amended (2026-10-28, #27291 — one clause e… | 2026-10-28 | — | — | | [ADR-0146](./0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) | Package-level override — every customization of an installed package lives in its paired `.custom` package, t… | Accepted — 2026-10-31 — rewritten in place from the Proposed draft of 2026… | 2026-10-31 | ADR-0126, ADR-0131, ADR-0005, ADR-0048, ADR-0141, ADR-0070 | — | | [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md) | Membership-scoped RLS (`FIELD in currentuser.orguserids`) lowers to an engine-internal organization-member se… | Proposed — 2026-11-02. `docs/adr/` is Tier H (Prime Directive #14): the re… | 2026-11-02 | — | — | +| [ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md) | The recycle bin is a separate store — one entry per delete operation, restore under the original ids, retenti… | Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on | 2026-11-05 | — | — | ## ObjectUI registry — `ui/docs/adr/` (`ui-` prefix) From 9b4571803ea413e102b1f4d270305813e269f082 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:27:07 +0000 Subject: [PATCH 2/3] docs(adr): ADR-0148 marks the record's own choices, stamps the purge deadline at delete, and reads the ADR-0087 conversions as main carries them Contract review 6649543275 on #30263 found two must-fixes and seven should-fixes, all text. This commit answers them in one change: - The claim that the old keys' ADR-0087 conversions "stand" was false: only the allowRestore / allowPurge conversion (entries/18) is still carried; the 16.x enable.trash conversion has aged out of the load path (retired in the census, no entry file) and softDelete never had one. ADR-0148's Builds on, Leaves unchanged, Context and D5 now say exactly that, and ADR-0049's Status note says the same. - The retention window is now decided once: the resolver is asked at delete and its answer is stamped on the entry as the purge deadline (D1, D3, D5, Rollout), with the reasons, and D6 no longer leaves it open. - sys_recycle_bin and OS_RECYCLE_BIN_RETENTION_DAYS were the ruling's examples; the record now says it fixes them. The hook's input, output and validation, the stamped deadline, the default datasource and "secret fields out in v1" are marked as the record's choices, listed once in a new D7. The "adds no mechanism of its own" sentence is gone. - "Empty" is an admin's verb (the ruling gives a person restore of their own deletes and an admin everything); the non-admin reading is removed. - The reference-field vocabulary is deleteBehavior, not onDelete. - The Status value is one line again; docs/adr/README.md is regenerated. - #30235's body lists the object, the hook contract and the refusal codes; the record now says it adds the restored event's name and the grant keys to that card, and moves the operation-vocabulary pin (an objectql test) to #30237. Claude-Session: https://claude.ai/code/session_01WGufzfC3wLXmeCXwxa963a Co-authored-by: Claude --- .../0049-no-unenforced-security-properties.md | 2 +- ...re-under-original-ids-retention-in-days.md | 179 +++++++++++------- docs/adr/README.md | 2 +- 3 files changed, 112 insertions(+), 71 deletions(-) diff --git a/docs/adr/0049-no-unenforced-security-properties.md b/docs/adr/0049-no-unenforced-security-properties.md index 51764f00d64..2dde77b6ad5 100644 --- a/docs/adr/0049-no-unenforced-security-properties.md +++ b/docs/adr/0049-no-unenforced-security-properties.md @@ -1,6 +1,6 @@ # ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) -**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. **Amended 2026-11-05** ([ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md), #30234, ruling record 6629858351 on #3146) — the recycle bin returns as a **new** surface whose readers that record names (a separate `sys_recycle_bin` store, restore under the original ids, retention in days through a host hook); the keys this gate removed — `enable.trash` and `softDelete` (16.x, #2377), `allowRestore` and `allowPurge` (#12497) — **stay removed**, their tombstones and ADR-0087 conversions stand, and none returns under its old spelling. The gate is enforced, not reversed; this decision text is unchanged. +**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. **Amended 2026-11-05** ([ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md), #30234, ruling record 6629858351 on #3146) — the recycle bin returns as a **new** surface whose readers that record names (a separate `sys_recycle_bin` store, restore under the original ids, retention in days through a host hook); the keys this gate removed — `enable.trash` and `softDelete` (16.x, #2377), `allowRestore` and `allowPurge` (#12497) — **stay removed**: their tombstones stand; the `allowRestore` / `allowPurge` conversion (`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`) stands; the 16.x `enable.trash` conversion has aged out of the load path as ADR-0087 provides (`object-enable-trash-mru-removed`, retired in `packages/spec/src/conversions/retired-after.census.json`, no entry file); `softDelete` never had one; and none returns under its old spelling. The gate is enforced, not reversed; this decision text is unchanged. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) **Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). diff --git a/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md index 6cb89159098..7a27845ff2b 100644 --- a/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md +++ b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md @@ -1,16 +1,6 @@ # ADR-0148: The recycle bin is a separate store — one entry per delete operation, restore under the original ids, retention in days through a host hook -**Status**: Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on -[#3146](https://github.com/objectstack-ai/objectstack/issues/3146) (ruling record -[`6629858351`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6629858351), verbatim: -「同意,企业版功能在 cloud 中立卡后续开发」). **This record declares; it implements nothing.** Nothing below ships on -`main` today: every delete is a physical delete, and each mechanism here is cut as a child of #3146 that triage filed -([`6648418282`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6648418282)) — -[#30235](https://github.com/objectstack-ai/objectstack/issues/30235) (spec), -[#30237](https://github.com/objectstack-ai/objectstack/issues/30237) (engine), -[#30239](https://github.com/objectstack-ai/objectstack/issues/30239) (Console) — each landing after this record and -citing it. `docs/adr/` is Tier H (Prime Directive #14): the record lands by the maintainer's hand (merge or enqueue), -and that act is its acceptance. +**Status**: Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on [#3146](https://github.com/objectstack-ai/objectstack/issues/3146) (ruling record [`6629858351`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6629858351), verbatim: 「同意,企业版功能在 cloud 中立卡后续开发」). **This record declares; it implements nothing.** Nothing below ships on `main` today: every delete is a physical delete, and each mechanism here is cut as a child of #3146 that triage filed ([`6648418282`](https://github.com/objectstack-ai/objectstack/issues/3146#issuecomment-6648418282)) — [#30235](https://github.com/objectstack-ai/objectstack/issues/30235) (spec), [#30237](https://github.com/objectstack-ai/objectstack/issues/30237) (engine), [#30239](https://github.com/objectstack-ai/objectstack/issues/30239) (Console) — each landing after this record and citing it. `docs/adr/` is Tier H (Prime Directive #14): the record lands by the maintainer's hand (merge or enqueue), and that act is its acceptance. - **Date**: 2026-11-05 - **Card**: [#30234](https://github.com/objectstack-ai/objectstack/issues/30234) (#3146 slice 1), the first of the four children. The card restates the ruling; where the two differ in words, the ruling binds. @@ -18,7 +8,9 @@ and that act is its acceptance. 「记录删除回收站的功能还没有开发」, 「其他主流类似系统是怎么处理的」, 「保留期按『Free 7 天 / 团队版及以上 30 天』开源项目怎么办呢?这个功能可以放在企业版吗?建议放在企业版吗?」, and on the proposal the coordinator put back, 「同意,企业版功能在 cloud 中立卡后续开发」. The proposal's text is the ruling record; [Decision](#decision) restates - each of its items in the ruling's own terms and adds no mechanism of its own. + each of its items in the ruling's own terms. Where the record fixes something the ruling offered as an example or + left open, it says so at the point it does it and lists it once in + [D7](#d7--what-this-record-fixes-that-the-ruling-left-open); those are the record's choices, not the ruling's. - **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — the gate that removed the old keys and the gate every new key below passes, with its reader in the same change), [ADR-0057-system-data-lifecycle-and-retention](./0057-system-data-lifecycle-and-retention.md) (the one platform @@ -26,8 +18,9 @@ and that act is its acceptance. cascade a delete removes and a restore brings back together), [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) D2 (mechanism open, entitlement commercial), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the envelope every restore refusal answers in), - [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (the retired keys' conversions stand; the new keys arrive - as a protocol addition), [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) (why the + [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (the retired keys' tombstones stand, and their conversions + age out on its schedule — [D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired) says which one is + still carried; the new keys arrive as a protocol addition), [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) (why the shape frees a unique value at delete, and why a restore can find it taken), [ADR-0100](./0100-credential-field-channels.md) (the `secret` channel the snapshot keeps out), [ADR-0041](./0041-flow-trigger-family.md) (record triggers — why a restore is its own event, never a create). @@ -36,14 +29,17 @@ and that act is its acceptance. ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). - **Leaves unchanged**: every read path (query, aggregate, REST, the analytics engine's SQL, rollups, lookup pickers, search, AI tools, MCP and export); the physical delete the engine performs today and its cascade order; the - tombstones of `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` and their ADR-0087 conversions; the - `sys_file` tombstone and its reap; the release of `sys_secret` handles on a committed delete. -- **Consumers**: `@objectstack/spec` (the recycle-bin object, the retention hook's contract, the refusal codes, the - restored event — #30235), `@objectstack/objectql` (snapshot, restore, purge, empty, the TTL reap on the - LifecycleService — #30237), `@objectstack/plugin-security` (the restore and bin-admin grants, the fail-closed - destructive backstop — #30235 / #30237), `@objectstack/rest` (the doors the Console calls — #30237), the Console under - `ui/` (the admin page and the toast — #30239), and `objectstack-ai/cloud` as the one caller of the retention hook - (its own card, per the ruling). + tombstones of `enable.trash`, `softDelete`, `allowRestore` and `allowPurge`, and the one ADR-0087 conversion `main` + still carries for them (`allowRestore` / `allowPurge`); the `sys_file` tombstone and its reap; the release of + `sys_secret` handles on a committed delete. +- **Consumers**: `@objectstack/spec` (what #30235's body lists — the recycle-bin object, the retention hook's contract, + the refusal codes — and what this record adds to that card, the `restored` event's name and the two grant keys, both + spec surfaces; see [D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)), `@objectstack/objectql` + (snapshot, restore, purge, empty, the TTL reap on the LifecycleService, the operation vocabulary pin — #30237), + `@objectstack/plugin-security` (the restore and bin-admin grants enforced, the fail-closed destructive backstop + mapped — #30237), `@objectstack/rest` (the doors the Console calls — #30237), the Console under `ui/` (the admin page + and the toast — #30239), and `objectstack-ai/cloud` as the one caller of the retention hook (its own card, per the + ruling). --- @@ -89,8 +85,8 @@ lands it, so the ADR-0049 gate the old keys failed is the gate the new ones pass **The delete path.** `ObjectQL.delete` collects the rows a delete removes before it removes any (`packages/objectql/src/engine-delete-cascade.ts#collectCascadeDeleteSet`), refuses a `restrict` relation inside its own cascade with `409 DELETE_RESTRICTED` (`packages/objectql/src/engine-delete-cascade.ts#refuseRestrictsInsideOwnCascade`), -clears the `set_null` lookups that pointed at the removed rows (the `onDelete` vocabulary of -`packages/spec/src/data/field.zod.ts`: `cascade` / `restrict` / `set_null`), and removes the rows at the driver. A +clears the `set_null` lookups that pointed at the removed rows (a reference field's `deleteBehavior` in +`packages/spec/src/data/field.zod.ts`: `set_null` / `cascade` / `restrict`), and removes the rows at the driver. A committed delete then releases the `sys_secret` handles the removed rows held (`packages/objectql/src/engine-secret-internal-fields.ts#releaseSecretHandles`, #27581). Nothing is kept: the rows are gone, their unique values are free, and their ids are not reused. @@ -115,7 +111,11 @@ affordance: authors wrote `trash: false` believing they were opting out of a sof removed in the 16.x line (#2377) and tombstoned — `enable.trash` in `packages/spec/src/data/object.zod.ts#CAPABILITIES_RETIRED_KEY_GUIDANCE`, `softDelete` in the same file's top-level unknown-key guidance, whose text is the standing fact: 「there is no soft-delete/recycle-bin runtime … Deletes are hard -deletes」. The permission lifecycle bits `allowRestore` / `allowPurge` claimed to gate `restore` / `purge` operations +deletes」. What `main` carries for them under ADR-0087: the 16.x conversion `object-enable-trash-mru-removed` has aged +out — it is retired from the load path (listed as such in `packages/spec/src/conversions/retired-after.census.json`) +and no entry file remains, which is why the `enable.trash` tombstone tells an author to apply the edit by hand or run +the tool on a release that still carries it; `softDelete` never had a conversion entry, only the tombstone. The +permission lifecycle bits `allowRestore` / `allowPurge` claimed to gate `restore` / `purge` operations that never entered the engine's dispatch vocabulary (pinned by `packages/objectql/src/engine-middleware-operation-vocabulary.test.ts`); they were removed with #12497 (the maintainer accepting #1883's recommendation B, 2026-08-26), conversion @@ -143,10 +143,13 @@ pitfall that loses `created_by` / `created_at`. On delete, the engine **snapshots the rows the delete removes** — the cascade set `packages/objectql/src/engine-delete-cascade.ts#collectCascadeDeleteSet` already computes, master and details in cascade order — into **one system object, `sys_recycle_bin`**, as **one entry per delete operation**, and then **deletes -physically as today**. An entry records what was removed (the objects and ids, the rows as they stood), who deleted -and when, and the retention window it was given. Restore writes the rows back under their original ids +physically as today**. The ruling offered `sys_recycle_bin` as an example (「for example `sys_recycle_bin`」); this record +fixes it as the name. An entry records what was removed (the objects and ids, the rows as they stood), who deleted and +when, and **the purge deadline, stamped on the entry at delete time** from the days the retention resolver answers +([D3](#d3--retention-in-days-default-30-through-a-host-resolver-hook-open-source-owns-the-mechanism) says why stamping, +and that it is the record's choice). Restore writes the rows back under their original ids ([D2](#d2--restore-under-the-original-ids-with-reasons-for-refusal)); purge removes the entry; empty purges every entry -the caller may see. +and is an admin's verb ([D2](#d2--restore-under-the-original-ids-with-reasons-for-refusal), Permissions). **Chosen over an `IsDeleted` / `deleted_at` column, for the reasons the ruling gives:** @@ -159,10 +162,11 @@ the caller may see. - **It is the mainstream shape** for a platform that restores under the same id (Dataverse's bin data source, ServiceNow's `sys_audit_delete`). Salesforce's `IsDeleted` column is the other shape, and it is not chosen. -The entry is a row of a system object, so it is organization-owned like every other row (ADR-0131), stored by the -default datasource, and counted toward storage — [D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules) -says it is shown separately. The object's field list is #30235's to declare; its name and the one-entry-per-operation -rule are decided here. +The entry is a row of a system object, so it is organization-owned like every other row (ADR-0131) and counted toward +storage — [D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules) says it is shown separately. The record +fixes, and nothing ruled, that it is stored by the default datasource, as every `sys_*` object is. The object's name, +the one-entry-per-operation rule and the stamped deadline are decided here; the rest of the field list is #30235's to +declare. ### D2 — Restore under the original ids, with reasons for refusal @@ -198,17 +202,27 @@ this record keeps. **Open source owns the mechanism**: snapshot, restore, empty, permanent delete, TTL purge, and the permissions above. -**Retention is a number of days.** The default is **30**, read from **`OS_RECYCLE_BIN_RETENTION_DAYS`** (a config -value under the `OS_{DOMAIN}_{NAME}` rule of Prime Directive #9). **A host may inject retention through a resolver -hook, the same pattern as `resolveFeatures`**: the hook receives an opaque token (the cloud distribution passes its plan -string, exactly as it does to `resolveFeatures`) and answers a number of days, or nothing to keep the default. The -framework validates a positive whole number and **knows days, never plans**: no plan name, no catalog, no entitlement -appears in this repository. The hook's contract is #30235's to declare; the rule that it answers days is decided here. +**Retention is a number of days.** The default is **30**, read from **`OS_RECYCLE_BIN_RETENTION_DAYS`** — the ruling +offered that name as an example (「for example `OS_RECYCLE_BIN_RETENTION_DAYS`」); this record fixes it, under the +`OS_{DOMAIN}_{NAME}` rule of Prime Directive #9. **A host may inject retention through a resolver hook, the same pattern +as `resolveFeatures`**, and the framework **knows days, never plans**: no plan name, no catalog, no entitlement appears +in this repository. That much is the ruling. The hook's shape is this record's choice, made so that #30235 has a +contract to declare: it receives the same opaque token `resolveFeatures` receives (the cloud distribution passes its +plan string), it answers a number of days or nothing, nothing keeps the default, and a day count is a positive whole +number — zero and anything that is not such a number are refused as a configuration defect and never read as "now" or +"forever". #30235 spells the signature and the refusal. + +**When the days become a deadline — stamped at delete time.** The ruling decided days-through-a-hook and no more; this +record decides that the resolver is asked once, at delete, and its answer is written on the entry as the purge +deadline. Reasons: the toast and the admin page show the person a date, and a later change of the host's answer (a plan +change, a new default) must not shorten a window a person was already shown; the sweep then reads one stored column, +as the `sys_file` reap reads `deleted_at`, and calls no resolver per entry. The consequence is accepted: a longer window +applies to deletes made after the change, not before. No entry is purged before its stamped deadline. **The TTL purge runs on the existing LifecycleService** (ADR-0057 §3.3: one sweeper, never a bespoke one), following -the `sys_file` tombstone pattern: an entry is purged at the end of its resolved window by the platform sweep, under the -system context, in bounded batches, with a reap guard where the engine needs one. **Permanent delete stays available -for privacy erasure and never enters the bin** (HubSpot). +the `sys_file` tombstone pattern: an entry is purged once its stamped deadline has passed, by the platform sweep, under +the system context, in bounded batches, with a reap guard where the engine needs one. **Permanent delete stays +available for privacy erasure and never enters the bin** (HubSpot). **Cloud sets per-plan retention through that hook — Free 7 days, Team and above 30 days** — and the enterprise value layer (per-object retention, legal hold, longer retention, delete and restore audit reports, bulk restore, erasure @@ -242,9 +256,11 @@ slice say so. - **FLS applies when listing.** A snapshot holds every field; the bin shows a caller a field only where the caller may read it on the live object. -- **Secret fields stay out of the snapshot, or are encrypted.** In v1 they stay out: the `secret` channel's values - (ADR-0100) are not written into the entry, the committed delete still releases their handles, and a restored record's - secret fields come back empty, to be re-entered. Holding them until purge is a later item. +- **Secret fields stay out of the snapshot, or are encrypted.** The ruling allows either; this record chooses **out** + for v1, because the ruling's later item (holding attachments and `sys_secret` rows until purge) leaves the committed delete + releasing the handles in v1, so an encrypted copy of a value would point at a handle that no longer exists. So the + `secret` channel's values (ADR-0100) are not written into the entry, and a restored record's secret fields come back + empty, to be re-entered. Holding them until purge is the later item, and it may revisit this choice. - **Purge on organization or environment deletion.** An organization's or environment's entries go with it. - **Bin storage counts toward quota and is shown separately.** @@ -260,21 +276,26 @@ with the card that lands it. Every row below is new; none is a tombstoned spelli | New surface | The reader that enforces it | Card | |---|---|---| -| `sys_recycle_bin` — one entry per delete operation; the cascade set snapshotted; who, when, and the resolved window | written by the engine's delete path; read by restore, purge, empty and the TTL reap; listed by the admin page under FLS | #30235 declares; #30237 writes and reads; #30239 lists | +| `sys_recycle_bin` — one entry per delete operation; the cascade set snapshotted; who, when, and the purge deadline stamped at delete time | written by the engine's delete path, which asks the retention resolver once and stamps the deadline; read by restore, purge, empty and the TTL reap; listed by the admin page under FLS | #30235 declares (its body lists the object); #30237 writes and reads; #30239 lists | | `OS_RECYCLE_BIN_RETENTION_DAYS` (default 30) | the retention resolver's default, when the host hook answers nothing | #30237 | -| the host retention resolver hook — answers days, never a plan | the TTL reap, which purges an entry at the end of its resolved window; the cloud distribution is its caller | #30235 (contract); #30237 (reader); the cloud card (caller) | -| the `restore`, `purge` and `empty` operations and the REST doors the Console calls | the engine; `packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS` already lists `restore` and `purge`, and `packages/plugins/plugin-security/src/permission-evaluator.ts#OPERATION_TO_PERMISSION` must map them to the new grants, or the fail-closed backstop denies them; the operation vocabulary pin gains the verbs | #30235 (vocabulary); #30237 (engine, doors, mapping) | -| the restore and bin-admin grants — new permission keys: a person restores what they deleted, an admin sees all | the permission evaluator in `@objectstack/plugin-security` | #30235 declares; #30237 enforces | -| the restore refusal codes — unique value taken, parent purged, package uninstalled | the engine answers them in the ADR-0112 envelope; the Console reads them in its language | #30235 names; #30237 answers; #30239 shows | -| the `restored` event | automation (a record-created trigger does not fire; enrollments are not restored), audit, rollups | #30235 names; #30237 emits | +| the host retention resolver hook — answers days, never a plan | the engine's delete path, which stamps the deadline from the answer; the cloud distribution is its caller | #30235 (contract; its body lists the hook); #30237 (reader); the cloud card (caller) | +| the `restore`, `purge` and `empty` operations and the REST doors the Console calls | the engine; `packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS` already lists `restore` and `purge`, and `packages/plugins/plugin-security/src/permission-evaluator.ts#OPERATION_TO_PERMISSION` must map them to the new grants, or the fail-closed backstop denies them; the operation vocabulary pin (an objectql test) gains the verbs | #30237 (engine, doors, mapping, the vocabulary pin) | +| the restore and bin-admin grants — new permission keys: a person restores what they deleted, an admin sees all | the permission evaluator in `@objectstack/plugin-security` | #30235 declares — this record adds the grant keys to that card, whose body does not list them, because the permission shape is a spec surface; #30237 enforces | +| the restore refusal codes — unique value taken, parent purged, package uninstalled | the engine answers them in the ADR-0112 envelope; the Console reads them in its language | #30235 names (its body lists the codes); #30237 answers; #30239 shows | +| the `restored` event | automation (a record-created trigger does not fire; enrollments are not restored), audit, rollups | #30235 names — this record adds the event's name to that card, whose body does not list it, because the event vocabulary is a spec surface; #30237 emits | | the admin recycle-bin page and the 「已删除 · 撤销」 toast | the Console | #30239 | -**The old keys stay retired.** `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` are tombstones, their -ADR-0087 conversions stand, and none of them returns: an author who writes one is refused with the prescription the -tombstone carries, and a stored artifact from before 16.x never silently re-enables anything. The new surface uses new -spellings on purpose, so that a key which once meant "nothing happens" never comes to mean "a snapshot is kept". The -retired `apiMethods` values `restore` and `purge` (`packages/spec/src/data/api-derivation.ts`) are likewise unchanged. -ADR-0049's Status line carries the dated note. +**The old keys stay retired.** `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` are tombstones and none of +them returns: an author who writes one is refused with the prescription the tombstone carries. What `main` carries for +them under ADR-0087 is exactly this: the `allowRestore` / `allowPurge` conversion +(`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`) stands; the 16.x conversion +`object-enable-trash-mru-removed` has aged out of the load path as ADR-0087 provides (retired in +`packages/spec/src/conversions/retired-after.census.json`, no entry file), so a 15.x artifact that still writes +`enable.trash` is refused by the tombstone and migrated by hand or on a release that still carries it; `softDelete` never +had a conversion. This record changes none of that. The new surface uses new spellings on purpose, so that a key which +once meant "nothing happens" never comes to mean "a snapshot is kept". The retired `apiMethods` values `restore` and +`purge` (`packages/spec/src/data/api-derivation.ts`) are likewise unchanged. ADR-0049's Status line carries the dated +note. **No object-level authoring key is named by this record.** v1 applies to every business object and to no `sys_*` object by rule, not by a flag. Per-object retention is enterprise and lives in cloud; if an open per-object key is @@ -282,15 +303,34 @@ ever ruled, it arrives with its reader in the same change, as ADR-0049 requires. ### D6 — What this record does not decide -- **The spellings**: the entry's field list, the hook's signature, the refusal code names, the event name and the - grant names. #30235 fixes them, citing this record, under the rules already in force (snake_case machine names, - the ADR-0112 ledger, ADR-0049's reader-in-the-same-change). -- **When the window is resolved**: stamped on the entry at delete time, or resolved by the sweep. Either satisfies - this record, which requires only that the days come from the resolver, that one sweeper purges, and that no entry is - purged before its window ends. #30237 chooses and says why; a window shown to the person on the toast or the page - argues for stamping it. +- **The spellings**: the entry's remaining fields (beyond the deleting principal, the delete time, the stamped + deadline and the snapshot), the hook's signature and its refusal text, the refusal code names, the `restored` + event's name and the two grant keys' names. #30235 fixes them, citing this record, under the rules already in force + (snake_case machine names, the ADR-0112 ledger, ADR-0049's reader-in-the-same-change). The event name and the grant + keys are this record's addition to #30235's scope ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). - **The bulk cap's number**, the Console's wording, and the cloud card's shape. +### D7 — What this record fixes that the ruling left open + +Each of these is the record's choice, not the ruling's, and is marked as such where it is made: + +- the object's name `sys_recycle_bin` and the variable's name `OS_RECYCLE_BIN_RETENTION_DAYS` — the ruling's examples, + fixed here ([D1](#d1--shape-a-separate-recycle-bin-store-one-entry-per-delete-operation-the-delete-stays-physical), + [D3](#d3--retention-in-days-default-30-through-a-host-resolver-hook-open-source-owns-the-mechanism)); +- the hook's input, output and validation — the `resolveFeatures` token in, days or nothing out, a positive whole number + or a refused configuration defect ([D3](#d3--retention-in-days-default-30-through-a-host-resolver-hook-open-source-owns-the-mechanism)); +- the purge deadline stamped on the entry at delete time, not resolved by the sweep + ([D1](#d1--shape-a-separate-recycle-bin-store-one-entry-per-delete-operation-the-delete-stays-physical), + [D3](#d3--retention-in-days-default-30-through-a-host-resolver-hook-open-source-owns-the-mechanism)); +- the entry stored by the default datasource ([D1](#d1--shape-a-separate-recycle-bin-store-one-entry-per-delete-operation-the-delete-stays-physical)); +- secret fields kept out of the snapshot in v1, of the two the ruling allowed + ([D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules)); +- the `restored` event's name and the grant keys declared by #30235, whose body lists neither + ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). + +The ruling itself gives a person the restore of what they deleted and gives an admin everything (list, restore, empty); +this record adds no permission beyond that. + --- ## Where "days, never plans" is written down @@ -355,12 +395,13 @@ of days and a default; the plan that maps to those days lives in the host that c ## Rollout — the cards, in order 1. **This record** (#30234, Tier H) lands by the maintainer's hand. The ADR-0049 Status note lands with it. -2. **#30235 — spec**: `sys_recycle_bin`, the retention hook's contract (days only), the refusal codes, the restored - event and the grants, each with its liveness row naming the reader in #30237 or #30239. A protocol addition - (`Metadata-protocol: added`). -3. **#30237 — engine**: snapshot on delete, restore under the original ids with the refusals, purge and empty, the TTL - reap on the LifecycleService, the permission mapping, the REST doors, a test per refusal, and release notes that say - the bin is forward-only. +2. **#30235 — spec**: what its body lists — `sys_recycle_bin`, the retention hook's contract (days only), the refusal + codes — plus what this record adds to it, the `restored` event's name and the two grant keys; each with its liveness + row naming the reader in #30237 or #30239. A protocol addition (`Metadata-protocol: added`). +3. **#30237 — engine**: snapshot on delete with the deadline stamped from the resolver's answer, restore under the + original ids with the refusals, purge and empty, the TTL reap on the LifecycleService, the permission mapping and + the operation vocabulary pin, the REST doors, a test per refusal, and release notes that say the bin is + forward-only. 4. **#30239 — Console**: the admin recycle-bin page (list, restore, empty) and the 「已删除 · 撤销」 toast, reading the refusals in the Console's language. 5. **The cloud card**: per-plan retention through the hook, and the enterprise layer. Not tracked here. diff --git a/docs/adr/README.md b/docs/adr/README.md index 81fc4b6fe2b..b122939d1f3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -215,7 +215,7 @@ A bare `ADR-0006` names the decision; its current text is [0006-project-environm | [ADR-0145](./0145-ui-deprecation-tombstones-retire-on-the-spec-window.md) | A `?: never` tombstone on the ui authoring face retires on the spec's own window — one major after the retire… | Accepted — 2026-10-28, #27251 · Amended (2026-10-28, #27291 — one clause e… | 2026-10-28 | — | — | | [ADR-0146](./0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) | Package-level override — every customization of an installed package lives in its paired `.custom` package, t… | Accepted — 2026-10-31 — rewritten in place from the Proposed draft of 2026… | 2026-10-31 | ADR-0126, ADR-0131, ADR-0005, ADR-0048, ADR-0141, ADR-0070 | — | | [ADR-0147](./0147-membership-scoped-rls-lowers-to-an-engine-internal-org-member-semi-join-leaf.md) | Membership-scoped RLS (`FIELD in currentuser.orguserids`) lowers to an engine-internal organization-member se… | Proposed — 2026-11-02. `docs/adr/` is Tier H (Prime Directive #14): the re… | 2026-11-02 | — | — | -| [ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md) | The recycle bin is a separate store — one entry per delete operation, restore under the original ids, retenti… | Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on | 2026-11-05 | — | — | +| [ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md) | The recycle bin is a separate store — one entry per delete operation, restore under the original ids, retenti… | Accepted — 2026-11-05, recording the maintainer's ruling of 2026-11-04 on… | 2026-11-05 | — | — | ## ObjectUI registry — `ui/docs/adr/` (`ui-` prefix) From 0d59704e85602e1c5a648fd349de079ac95b6d97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:45:30 +0000 Subject: [PATCH 3/3] docs(adr): ADR-0148 names ADR-0087's kit rule, the ref a snapshot would hold, and resolve-at-sweep as the rejected alternative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delta review 6649866889 on #30263 passed with four should-fixes, all text: - "Aged out of the load path" named the wrong concept: both conversions are retiredFromLoadPath. The difference main carries is ADR-0087's kit rule — the enable.trash kit's conversion was removed when 18 was cut (its tombstone stays), the allowRestore/allowPurge kit is carried at 18 and ages out at 19, and neither replays at load. ADR-0049's Status note and ADR-0148's Builds on, Leaves unchanged, Context and D5 now say "kit". - D4's reason for keeping secrets out of the snapshot no longer conflates the value with the ref: under ADR-0100 the row carries only the secret ref, so the snapshot would hold a ref the committed delete released, and encrypting the value into the entry would open a second store outside sys_secret. - D7 gains the two choices it omitted: no object-level authoring key (by rule, not by a flag) and the privacy rules as standing rules that bind v1 and everything after it; D5 and D4 mark them where they are made. - D3 names resolve-at-sweep (the LifecycleService's lifecycle-namespace windows, global and per tenant; the sys_file reap's deleted_at plus a static expireAfter) as the alternative the record rejects, and leaves the reap shape on the stamped column to #30237. docs/adr/README.md is unchanged by this commit (the Status lines did not move). Claude-Session: https://claude.ai/code/session_01WGufzfC3wLXmeCXwxa963a Co-authored-by: Claude --- .../0049-no-unenforced-security-properties.md | 2 +- ...re-under-original-ids-retention-in-days.md | 68 ++++++++++++------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/docs/adr/0049-no-unenforced-security-properties.md b/docs/adr/0049-no-unenforced-security-properties.md index 2dde77b6ad5..0aa0420b2cb 100644 --- a/docs/adr/0049-no-unenforced-security-properties.md +++ b/docs/adr/0049-no-unenforced-security-properties.md @@ -1,6 +1,6 @@ # ADR-0049: Spec must not declare security properties the runtime does not enforce (enforce-or-remove gate) -**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. **Amended 2026-11-05** ([ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md), #30234, ruling record 6629858351 on #3146) — the recycle bin returns as a **new** surface whose readers that record names (a separate `sys_recycle_bin` store, restore under the original ids, retention in days through a host hook); the keys this gate removed — `enable.trash` and `softDelete` (16.x, #2377), `allowRestore` and `allowPurge` (#12497) — **stay removed**: their tombstones stand; the `allowRestore` / `allowPurge` conversion (`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`) stands; the 16.x `enable.trash` conversion has aged out of the load path as ADR-0087 provides (`object-enable-trash-mru-removed`, retired in `packages/spec/src/conversions/retired-after.census.json`, no entry file); `softDelete` never had one; and none returns under its old spelling. The gate is enforced, not reversed; this decision text is unchanged. +**Status**: Accepted (2026-06-15) — implemented: fail-closed `DESTRUCTIVE_OPERATIONS` (`packages/plugins/plugin-security/src/permission-evaluator.ts#DESTRUCTIVE_OPERATIONS`), lifecycle bits RBAC-gated, `apiEnabled` enforced (`runtime/src/api-exposure.ts`), `PolicySchema` removed, EXPERIMENTAL tag convention live. Two gate-valid disposition deviations: agent access-control shipped experimental-tagged (not enforced), `flow.runAs` kept + enforced (not removed). `action.disabled` CEL enforcement to confirm in objectui. **Amended 2026-09-04** (#14402) — recording that an author-written validation rule's `message` became translatable in 17.3.0 (#14381) under the object-scoped bundle key `objects.._validations..message`. That is this gate being **enforced, not reversed**: the 17.0.0 retirement of the top-level `validationMessages` group stands, and the two are not the same route. See **"Amendment (2026-09-04): validation-rule messages are translatable again — enforced, not reversed"** at the end. **Amended 2026-11-05** ([ADR-0148](./0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md), #30234, ruling record 6629858351 on #3146) — the recycle bin returns as a **new** surface whose readers that record names (a separate `sys_recycle_bin` store, restore under the original ids, retention in days through a host hook); the keys this gate removed — `enable.trash` and `softDelete` (16.x, #2377), `allowRestore` and `allowPurge` (#12497) — **stay removed**: their tombstones stand; under ADR-0087's kit rule the `allowRestore` / `allowPurge` kit (`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`, retired after 17.2.0) is carried at 18 and ages out at 19, while the `enable.trash` kit's conversion (`object-enable-trash-mru-removed`, 16.x) was removed when 18 was cut and only its tombstone remains; neither conversion replays at load (both `retiredFromLoadPath`, `packages/spec/src/conversions/retired-after.census.json`); `softDelete` never had one; and none returns under its old spelling. The gate is enforced, not reversed; this decision text is unchanged. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (artifact vs runtime overlay), [ADR-0010](./0010-metadata-protection-model.md) (package provenance), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (authoring lifecycle) **Consumers**: `@objectstack/spec` (security/identity schemas), `@objectstack/plugin-security` (`PermissionEvaluator`, `SecurityPlugin`), spec authors, the metadata-property liveness audit follow-ups (#1878 P0 cluster). diff --git a/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md index 7a27845ff2b..8a2ecac8a3d 100644 --- a/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md +++ b/docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md @@ -18,9 +18,9 @@ cascade a delete removes and a restore brings back together), [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) D2 (mechanism open, entitlement commercial), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the envelope every restore refusal answers in), - [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (the retired keys' tombstones stand, and their conversions - age out on its schedule — [D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired) says which one is - still carried; the new keys arrive as a protocol addition), [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) (why the + [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (the retired keys' tombstones stand, and their kits age out + on its schedule — [D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired) says which kit this major + still carries; the new keys arrive as a protocol addition), [ADR-0120](./0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md) (why the shape frees a unique value at delete, and why a restore can find it taken), [ADR-0100](./0100-credential-field-channels.md) (the `secret` channel the snapshot keeps out), [ADR-0041](./0041-flow-trigger-family.md) (record triggers — why a restore is its own event, never a create). @@ -29,8 +29,8 @@ ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). - **Leaves unchanged**: every read path (query, aggregate, REST, the analytics engine's SQL, rollups, lookup pickers, search, AI tools, MCP and export); the physical delete the engine performs today and its cascade order; the - tombstones of `enable.trash`, `softDelete`, `allowRestore` and `allowPurge`, and the one ADR-0087 conversion `main` - still carries for them (`allowRestore` / `allowPurge`); the `sys_file` tombstone and its reap; the release of + tombstones of `enable.trash`, `softDelete`, `allowRestore` and `allowPurge`, and the one ADR-0087 kit `main` still + carries for them (`allowRestore` / `allowPurge`, carried at 18, gone at 19); the `sys_file` tombstone and its reap; the release of `sys_secret` handles on a committed delete. - **Consumers**: `@objectstack/spec` (what #30235's body lists — the recycle-bin object, the retention hook's contract, the refusal codes — and what this record adds to that card, the `restored` event's name and the two grant keys, both @@ -111,10 +111,14 @@ affordance: authors wrote `trash: false` believing they were opting out of a sof removed in the 16.x line (#2377) and tombstoned — `enable.trash` in `packages/spec/src/data/object.zod.ts#CAPABILITIES_RETIRED_KEY_GUIDANCE`, `softDelete` in the same file's top-level unknown-key guidance, whose text is the standing fact: 「there is no soft-delete/recycle-bin runtime … Deletes are hard -deletes」. What `main` carries for them under ADR-0087: the 16.x conversion `object-enable-trash-mru-removed` has aged -out — it is retired from the load path (listed as such in `packages/spec/src/conversions/retired-after.census.json`) -and no entry file remains, which is why the `enable.trash` tombstone tells an author to apply the edit by hand or run -the tool on a release that still carries it; `softDelete` never had a conversion entry, only the tombstone. The +deletes」. What `main` carries for them is set by ADR-0087's kit rule (its addendum of 2026-10-26: a retired key's +tombstone and conversion — the kit — live one major past the one they retired in): the `enable.trash` kit's conversion, +`object-enable-trash-mru-removed` (retired at 16.x), was removed when 18 was cut, so no entry file remains and the +tombstone that stays tells an author to apply the edit by hand or run the tool on a release that still carries it; the +`allowRestore` / `allowPurge` kit (`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`, +retired after 17.2.0) is carried at 18 and ages out at 19. Neither conversion replays at load: both are +`retiredFromLoadPath`, listed as such in `packages/spec/src/conversions/retired-after.census.json`, and the permission +one runs under `os migrate meta` only. `softDelete` never had a conversion entry, only the tombstone. The permission lifecycle bits `allowRestore` / `allowPurge` claimed to gate `restore` / `purge` operations that never entered the engine's dispatch vocabulary (pinned by `packages/objectql/src/engine-middleware-operation-vocabulary.test.ts`); they were removed with #12497 (the maintainer @@ -215,9 +219,14 @@ number — zero and anything that is not such a number are refused as a configur **When the days become a deadline — stamped at delete time.** The ruling decided days-through-a-hook and no more; this record decides that the resolver is asked once, at delete, and its answer is written on the entry as the purge deadline. Reasons: the toast and the admin page show the person a date, and a later change of the host's answer (a plan -change, a new default) must not shorten a window a person was already shown; the sweep then reads one stored column, -as the `sys_file` reap reads `deleted_at`, and calls no resolver per entry. The consequence is accepted: a longer window -applies to deletes made after the change, not before. No entry is purged before its stamped deadline. +change, a new default) must not shorten a window a person was already shown. The alternative the record rejects is +the one the LifecycleService already runs: resolving an object's window at sweep time from the `lifecycle` settings +namespace, globally and per tenant (ADR-0057 §3.2, `packages/objectql/src/lifecycle/lifecycle-service.ts`), as the +`sys_file` reap does with `deleted_at` plus a static `ttl.expireAfter` — a window resolved at sweep can move after the +person was shown it, and a host's days are not a per-object setting. With the deadline stamped, the sweep reads one +stored column and asks no resolver; how the reap is declared on that column (a `ttl` on the deadline with no added +window, or a reap guard) is #30237's. The consequence is accepted: a longer window applies to deletes made after the +change, not before. No entry is purged before its stamped deadline. **The TTL purge runs on the existing LifecycleService** (ADR-0057 §3.3: one sweeper, never a bespoke one), following the `sys_file` tombstone pattern: an entry is purged once its stamped deadline has passed, by the platform sweep, under @@ -252,15 +261,17 @@ slice say so. the rows held (#27581), and v1 leaves that as it is; - a member-facing 「我删除的」 page. -**The privacy rules**, which bind v1 and everything after it: +**The privacy rules**, which bind v1 and everything after it (the ruling lists them among the risks to plan for; +making them standing rules is the record's choice, [D7](#d7--what-this-record-fixes-that-the-ruling-left-open)): - **FLS applies when listing.** A snapshot holds every field; the bin shows a caller a field only where the caller may read it on the live object. - **Secret fields stay out of the snapshot, or are encrypted.** The ruling allows either; this record chooses **out** - for v1, because the ruling's later item (holding attachments and `sys_secret` rows until purge) leaves the committed delete - releasing the handles in v1, so an encrypted copy of a value would point at a handle that no longer exists. So the - `secret` channel's values (ADR-0100) are not written into the entry, and a restored record's secret fields come back - empty, to be re-entered. Holding them until purge is the later item, and it may revisit this choice. + for v1. Under ADR-0100 a business row carries only the opaque `secret:` ref and the value lives in `sys_secret`; + the committed delete releases those refs in v1 (#27581 — holding `sys_secret` rows until purge is the later item), so + a snapshot of the row would hold a ref that dangles, and encrypting the value into the entry would open a second + store for a secret outside `sys_secret`. So the `secret` channel's refs are not written into the entry, and a restored + record's secret fields come back empty, to be re-entered. The later item may revisit this choice. - **Purge on organization or environment deletion.** An organization's or environment's entries go with it. - **Bin storage counts toward quota and is shown separately.** @@ -287,18 +298,20 @@ with the card that lands it. Every row below is new; none is a tombstoned spelli **The old keys stay retired.** `enable.trash`, `softDelete`, `allowRestore` and `allowPurge` are tombstones and none of them returns: an author who writes one is refused with the prescription the tombstone carries. What `main` carries for -them under ADR-0087 is exactly this: the `allowRestore` / `allowPurge` conversion -(`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`) stands; the 16.x conversion -`object-enable-trash-mru-removed` has aged out of the load path as ADR-0087 provides (retired in -`packages/spec/src/conversions/retired-after.census.json`, no entry file), so a 15.x artifact that still writes -`enable.trash` is refused by the tombstone and migrated by hand or on a release that still carries it; `softDelete` never -had a conversion. This record changes none of that. The new surface uses new spellings on purpose, so that a key which +them is what ADR-0087's kit rule leaves at this major: the `allowRestore` / `allowPurge` kit +(`packages/spec/src/conversions/entries/18/permission-allow-restore-purge-removed.ts`, retired after 17.2.0) is carried +at 18 and ages out at 19; the `enable.trash` kit's conversion (`object-enable-trash-mru-removed`, 16.x) was removed when +18 was cut and only its tombstone remains, so a 15.x artifact that still writes `enable.trash` is refused by the +tombstone and migrated by hand or on a release that still carries it; neither conversion replays at load (both are +`retiredFromLoadPath` in `packages/spec/src/conversions/retired-after.census.json`); `softDelete` never had a +conversion. This record changes none of that. The new surface uses new spellings on purpose, so that a key which once meant "nothing happens" never comes to mean "a snapshot is kept". The retired `apiMethods` values `restore` and `purge` (`packages/spec/src/data/api-derivation.ts`) are likewise unchanged. ADR-0049's Status line carries the dated note. **No object-level authoring key is named by this record.** v1 applies to every business object and to no `sys_*` -object by rule, not by a flag. Per-object retention is enterprise and lives in cloud; if an open per-object key is +object by rule, not by a flag — the ruling said only "business objects, not `sys_*`", and that no key carries it is the +record's choice ([D7](#d7--what-this-record-fixes-that-the-ruling-left-open)). Per-object retention is enterprise and lives in cloud; if an open per-object key is ever ruled, it arrives with its reader in the same change, as ADR-0049 requires. ### D6 — What this record does not decide @@ -326,7 +339,12 @@ Each of these is the record's choice, not the ruling's, and is marked as such wh - secret fields kept out of the snapshot in v1, of the two the ruling allowed ([D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules)); - the `restored` event's name and the grant keys declared by #30235, whose body lists neither - ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)). + ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)); +- no object-level authoring key — v1 reaches every business object and no `sys_*` object by rule, not by a flag; the + ruling said only "business objects, not `sys_*`", and #30235's body expects any object-level key the record names, + so "none" is the record's answer ([D5](#d5--the-readers-that-will-exist-and-the-keys-that-stay-retired)); +- the privacy rules as standing rules that bind v1 and everything after it — the ruling listed them among the risks + to plan for ([D4](#d4--v1-scope-forward-only-later-items-and-the-privacy-rules)). The ruling itself gives a person the restore of what they deleted and gives an admin everything (list, restore, empty); this record adds no permission beyond that.