diff --git a/.changeset/object-enable-trash-mru-migration-surface.md b/.changeset/object-enable-trash-mru-migration-surface.md new file mode 100644 index 0000000000..27deb2d099 --- /dev/null +++ b/.changeset/object-enable-trash-mru-migration-surface.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): register the missing ADR-0087 migration surface for the `enable.trash` / `enable.mru` removal, and repoint its tombstones at the parked soft-delete issue (#3207) + +The 16.x removal of the dead object capability flags (PR #3414, +`remove-enable-trash-mru`) tombstoned both keys in the `.strict()` +capabilities block but registered no D2 conversion. Two consequences this +closes: + +- **Stored 16.x rows flagged forever.** A `sys_metadata` object row written + before the removal still carries `enable.trash`/`enable.mru`; with no + conversion to own that history, every rehydration re-flagged it + `metadata_spec_invalid` — mislabelling chain-owned history as a + current-contract violation (#3903's invariant). The new + `object-enable-trash-mru-removed` conversion (protocol 17, + `retiredFromLoadPath`) strips both keys on the stored-row pass, and + `os migrate meta --from 16` now rewrites authored sources. +- **Tombstones pointed at a closed issue.** The prescriptions named #1893 + (closed 2026-07-24) as where a real recycle bin returns. Per the #3207 + ruling (2026-08-02), soft delete is parked at #3146 — the `trash` + tombstone, the `restore` legacy-apiMethod guidance and the api-derivation + note now point there, and both tombstones name the + `os migrate meta --from 16` rewrite. + +FROM → TO: `enable.trash` / `enable.mru` → *(removed)* — delete the key; +the flags never gated behavior, so the rewrite is lossless. diff --git a/.changeset/remove-enable-trash-mru.md b/.changeset/remove-enable-trash-mru.md index addb55f249..a9dcc684f4 100644 --- a/.changeset/remove-enable-trash-mru.md +++ b/.changeset/remove-enable-trash-mru.md @@ -1,5 +1,5 @@ --- -"@objectstack/spec": minor +"@objectstack/spec": major --- feat(spec)!: remove the dead `object.enable.trash` / `enable.mru` capability flags (#2377, ADR-0049 enforce-or-remove — close-out) @@ -27,7 +27,10 @@ soft-delete existed to opt out of. `lifecycle` policy for recoverability). **Migration**: delete any `enable.trash` / `enable.mru` keys from object -metadata — they never changed behavior. `ObjectSchema.create()` / -`ObjectCapabilities.parse()` now reject them with this prescription. A real +metadata — they never changed behavior, so the removal is drop-the-key only. +`ObjectSchema.create()` / `ObjectCapabilities.parse()` now reject them with +this prescription; `os migrate meta --from 16` rewrites authored sources +automatically, and stored 16.x rows replay clean through the ADR-0087 chain +(`object-enable-trash-mru-removed`). Soft delete is parked at #3146; a real recycle bin or MRU feature, if built, returns as a live enforced flag -(#1893 prune-or-build). +(ADR-0049 prune-or-build). diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 415c884a42..0bbe5c889f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -176,6 +176,8 @@ The subtle half is the defaults, and it is worth stating because no gate can see The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` drops `data.field.changed` (#4673). It had no producer anywhere — the engine emits `data.record.{created,updated,deleted}` and, since #4639, `data.records.{updated,deleted}` — so a subscriber switching on it held a branch that could never run, and the `switch` still compiled, which is why an empty member could sit in a public enum this long. It could not have been implemented against this contract as written: `DataEventSchema` is record-shaped and has no `field` / `oldValue` / `newValue` slot, so the member advertised a granularity the payload has no room for. Nothing is lost — per-field detail already rides on `data.record.updated` as `changes` (with `before` / `after`), one event per write instead of N on a wide table. Like the driver contract above it is a runtime surface, never stored in stack metadata, so it is one semantic TODO for event consumers rather than a source rewrite, and it carries no tombstone: a removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` retirement noted. Should a real per-field stream ever be wanted, it earns its own contract on the #4639 precedent rather than reclaiming this slot. +The object capability block closes out the same ADR-0049 pass: `enable.trash` and `enable.mru` left the schema in the 16.x line (#3207, the #2377 close-out — every delete has always been a hard delete and MRU tracking was never implemented, so both default-true flags gated nothing), and the `.strict()` capabilities block rejects them with the prescription. This step registers the migration surface that removal was missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, and `os migrate meta --from 16` rewrites authored sources. Soft delete stays parked at #3146; if built it returns as a live enforced flag rather than by reviving these keys. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -215,6 +217,7 @@ The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` dr | `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only | | `retry-policy-converged` | `flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier` | retry policy unified across job.retryPolicy and try_catch retry: base delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1 (#4661) | live — protocol 17 loader accepts the old shape | | `object-managed-by-system-to-system-data` | `object.managedBy` | object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data) | retired — `migrate meta` only | +| `object-enable-trash-mru-removed` | `object.enable.trash / object.enable.mru` | object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index e0e084b1b4..de368e728e 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -277,6 +277,12 @@ "to": "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data)", "conversionId": "object-managed-by-system-to-system-data", "toMajor": 17 + }, + { + "surface": "object.enable.trash / object.enable.mru", + "to": "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)", + "conversionId": "object-enable-trash-mru-removed", + "toMajor": 17 } ], "migrated": [ @@ -860,6 +866,12 @@ "to": "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data)", "conversionId": "object-managed-by-system-to-system-data", "toMajor": 17 + }, + { + "surface": "object.enable.trash / object.enable.mru", + "to": "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)", + "conversionId": "object-enable-trash-mru-removed", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index e128e2a594..14a232ad14 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3198,6 +3198,79 @@ const objectManagedBySystemToSystemData: MetadataConversion = { }, }; +/** + * The dead object capability flags leave the surface (protocol 17, #3207 — + * the #2377 ADR-0049 close-out slice PR #3414 removed from the schema). + * + * `enable.trash` promised a recycle bin and `enable.mru` promised + * Most-Recently-Used tracking; neither ever had a behavior-changing reader — + * every delete has always been a hard delete, and no MRU state was ever + * written. Both defaulted `true`, the ADR-0078 silent-failure shape: authors + * wrote `trash: false // never soft-delete audit logs` believing they were + * opting out of a soft-delete that never ran. Soft delete stays parked at + * #3146; if built it returns as a live enforced flag (ADR-0049). + * + * `retiredFromLoadPath`: the capabilities block is `.strict()` and rejects + * both keys with the prescription (`CAPABILITIES_RETIRED_KEY_GUIDANCE`), so a + * live author is taught at parse; this entry exists so stored 16.x rows + * replay clean (`applyConversionsToStoredItem` — without it a pre-removal row + * flags `metadata_spec_invalid` forever, mislabelling chain-owned history as + * a current-contract violation) and so `os migrate meta --from 16` rewrites + * sources. + */ +const objectEnableTrashMruRemoved: MetadataConversion = { + id: 'object-enable-trash-mru-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'object.enable.trash / object.enable.mru', + summary: + "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no " + + 'recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)', + apply(stack, emit) { + return mapCollection(stack, 'objects', (obj, path) => { + // `enable.*` sits one level down, so stripKeys (top-level only) cannot + // reach it — drill in, and copy-on-write so an untouched object keeps + // its identity (pattern of `datasource-inert-blocks-removed`). + const enable = obj.enable; + if (!enable || typeof enable !== 'object' || Array.isArray(enable)) return obj; + const stripped = stripKeys( + enable as Record, + ['trash', 'mru'], + emit, + `${path}.enable`, + ); + if (stripped === enable) return obj; + return { ...obj, enable: stripped }; + }); + }, + fixture: { + before: { + objects: [ + { + name: 'task', + label: 'Task', + enable: { trash: false, mru: true, searchable: true }, + }, + // an object without the retired keys passes through untouched + { name: 'crm_account', label: 'Account', enable: { searchable: true } }, + ], + }, + // Two notices: one per removed key; the surviving `searchable` proves the + // strip is surgical, not a block-level delete. + after: { + objects: [ + { + name: 'task', + label: 'Task', + enable: { searchable: true }, + }, + { name: 'crm_account', label: 'Account', enable: { searchable: true } }, + ], + }, + expectedNotices: 2, + }, +}; + /** * The retry policy converges to one declaration (protocol 17, #4661 — the * #4535 C8 dual-source cluster). @@ -3394,6 +3467,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly expect(out.sharingModel).toBe('public_read'); }); + // #3207 — a 16.x row still carrying the retired `enable.trash`/`enable.mru` + // flags replays clean instead of tripping the registry's spec-conformance + // diagnostic forever (`.strict()` capabilities would flag it + // `metadata_spec_invalid` on every rehydration). + it('strips the retired enable.trash/mru flags from a stored object row', () => { + const row = { + name: 'sys_audit_log', + label: 'Audit Log', + enable: { trash: false, mru: false, searchable: true }, + }; + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as { + enable: Record; + }; + expect(out.enable).toEqual({ searchable: true }); + expect(notices.filter((n) => n.conversionId === 'object-enable-trash-mru-removed')).toHaveLength(2); + }); + it('is idempotent — a canonical row passes through by reference', () => { const row = { name: 'crm_task', diff --git a/packages/spec/src/data/api-derivation.ts b/packages/spec/src/data/api-derivation.ts index d392b96cff..2b3f193d7d 100644 --- a/packages/spec/src/data/api-derivation.ts +++ b/packages/spec/src/data/api-derivation.ts @@ -131,7 +131,7 @@ interface DerivationRule { * - `restore`/`purge` map to `delete` but their flag is permanently `false`: * `enable.trash` was retired (#2377/ADR-0049) with no runtime consumer, so * there is no soft-delete state to restore/purge. They return as live derived - * verbs only if/when a real recycle bin ships (#1893). + * verbs only if/when a real recycle bin ships (#3146, parked). */ export const API_METHOD_DERIVATION: Record = { upsert: { all: ['create', 'update'] }, diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 0f79531af8..30151d9f4d 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -31,8 +31,8 @@ describe('ObjectCapabilities', () => { expect(result).toEqual(capabilities); }); - // #2377 (ADR-0049): `trash`/`mru` parsed-but-did-nothing for years — the - // retired keys must fail loudly with the upgrade prescription, not strip + // #2377/#3207 (ADR-0049): `trash`/`mru` parsed-but-did-nothing for years — + // the retired keys must fail loudly with the upgrade prescription, not strip // silently (#1535; pattern of the tenancy tombstones, #2763). it('rejects the retired trash/mru flags with upgrade guidance', () => { for (const key of ['trash', 'mru'] as const) { @@ -41,7 +41,17 @@ describe('ObjectCapabilities', () => { const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); expect(message).toContain(`\`${key}\``); expect(message).toContain('#2377'); + // The prescription names the source rewrite (the #3207 conversion). + expect(message).toContain('os migrate meta --from 16'); } + // `trash` additionally points at the parked soft-delete issue — the + // parking spot the 2026-08-02 #3207 ruling designates (#1893, the old + // pointer, closed 2026-07-24). + const trash = ObjectCapabilities.safeParse({ trash: false }); + expect(trash.success).toBe(false); + const trashMsg = trash.success ? '' : trash.error.issues.map((i) => i.message).join('\n'); + expect(trashMsg).toContain('#3146'); + expect(trashMsg).not.toContain('#1893'); }); it('rejects unknown capability keys instead of stripping them', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index a8b9b74006..4171ff9ba8 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -46,7 +46,7 @@ export const LEGACY_API_METHOD_GUIDANCE: Record = { aggregate: "declare ['list'] — `aggregate` derives from list", history: "declare ['get'] with `enable.trackHistory: true` — `history` derives from get ∧ trackHistory", search: "declare ['list'] (with `searchable` not false) — `search` derives from list ∧ searchable", - restore: "delete the value — `restore` never derives (`enable.trash` retired, #2377); it returns only with a real recycle bin (#1893)", + restore: "delete the value — `restore` never derives (`enable.trash` retired, #2377); it returns only with a real recycle bin (#3146, parked)", purge: "delete the value — `purge` never derives (`enable.trash` retired, #2377)", import: "declare ['create'] and/or ['update'] — `import` derives from create ∨ update (writeMode-precise at the gate)", export: "declare ['list'] — `export` derives from list", @@ -133,18 +133,20 @@ export function stripLegacyApiMethods( */ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record = { trash: - '`enable.trash` was removed from @objectstack/spec in the 16.x line (#2377, ' + + '`enable.trash` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' + 'ADR-0049) — it never had a runtime consumer: every delete has always been a ' + 'hard delete, and 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). Delete the key. For recoverability use per-field ' + - '`trackHistory` (audit trail) or a `lifecycle` policy; a real recycle bin, if ' + - 'built, returns as a live enforced flag (#1893 prune-or-build).', + '`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked at ' + + '#3146 and, if built, returns as a live enforced flag (ADR-0049 prune-or-build). ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.', mru: - '`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377, ' + + '`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' + 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' + 'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' + - 'built it returns as a live enforced flag (#1893 prune-or-build).', + 'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' + + 'Run `os migrate meta --from 16` to rewrite it automatically.', }; /** diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 81a7df25f8..8f3cb7c7c5 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -694,7 +694,15 @@ const step17: MigrationStep = { + 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a ' + 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` ' + 'retirement noted. Should a real per-field stream ever be wanted, it earns its own ' - + 'contract on the #4639 precedent rather than reclaiming this slot.', + + 'contract on the #4639 precedent rather than reclaiming this slot.\n\n' + + 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and ' + + '`enable.mru` left the schema in the 16.x line (#3207, the #2377 close-out — every ' + + 'delete has always been a hard delete and MRU tracking was never implemented, so both ' + + 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them ' + + 'with the prescription. This step registers the migration surface that removal was ' + + 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, ' + + 'and `os migrate meta --from 16` rewrites authored sources. Soft delete stays parked at ' + + '#3146; if built it returns as a live enforced flag rather than by reviving these keys.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -731,6 +739,7 @@ const step17: MigrationStep = { 'flow-node-script-branch-keys-removed', 'object-managed-by-system-to-system-data', 'retry-policy-converged', + 'object-enable-trash-mru-removed', ], semantic: [ {