Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/object-enable-trash-mru-migration-surface.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 7 additions & 4 deletions .changeset/remove-enable-trash-mru.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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).
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions packages/spec/spec-changes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
74 changes: 74 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
['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).
Expand Down Expand Up @@ -3394,6 +3467,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
flowNodeScriptBranchKeysRemoved,
retryPolicyConverged,
objectManagedBySystemToSystemData,
objectEnableTrashMruRemoved,
],
};

Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/conversions/stored.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () =>
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<string, unknown>;
};
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',
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/api-derivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LegacyApiMethod, DerivationRule> = {
upsert: { all: ['create', 'update'] },
Expand Down
14 changes: 12 additions & 2 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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', () => {
Expand Down
14 changes: 8 additions & 6 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const LEGACY_API_METHOD_GUIDANCE: Record<LegacyApiMethod, string> = {
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",
Expand Down Expand Up @@ -133,18 +133,20 @@ export function stripLegacyApiMethods(
*/
const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record<string, string> = {
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.',
};

/**
Expand Down
11 changes: 10 additions & 1 deletion packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
{
Expand Down
Loading