Skip to content

Commit 155507e

Browse files
os-zhuangclaude
andauthored
fix(spec): register the missing migration surface for the enable.trash/mru removal + repoint tombstones at #3146 (#3207) (#4734)
The 16.x removal of the dead object capability flags (PR #3414) tombstoned enable.trash / enable.mru in the .strict() capabilities block but registered no ADR-0087 D2 conversion, and its prescriptions pointed at #1893 — closed 2026-07-24. Per the #3207 ruling (2026-08-02): soft delete is parked at #3146, and the retirement follows the full spec-property-retirement kit. - New conversion `object-enable-trash-mru-removed` (protocol 17, retiredFromLoadPath): stored 16.x rows replay clean through applyConversionsToStoredItem instead of flagging metadata_spec_invalid on every rehydration (#3903 invariant — chain-owned history is not a current-contract violation), and `os migrate meta --from 16` rewrites authored sources. Wired into MIGRATIONS_BY_MAJOR[17] with rationale; fixture proves the strip is surgical (surviving `searchable` untouched). - Tombstones now name #3146 (trash) and the `os migrate meta --from 16` rewrite (both); the restore legacy-apiMethod guidance and the api-derivation note drop the dead #1893 pointer. - remove-enable-trash-mru changeset flipped minor -> major: v17 is the breaking window and the guard stands aside in pre-mode; a breaking removal listed under "Minor Changes" in the compiled CHANGELOG would mislead upgrading agents. - Pin tests: prescription must name #3146 + the migrate command and must not resurrect #1893; stored-row replay strips exactly the two keys. Gates: spec build green; check:generated all 8 green after gen:spec-changes/gen:upgrade-guide; liveness/empty-state/strictness-ledger/ skill gates green; spec 7400, cli 581, metadata-protocol 231 tests pass; check:i18n 9/9 in sync (zero drift). Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 072806a commit 155507e

10 files changed

Lines changed: 173 additions & 14 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
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)
6+
7+
The 16.x removal of the dead object capability flags (PR #3414,
8+
`remove-enable-trash-mru`) tombstoned both keys in the `.strict()`
9+
capabilities block but registered no D2 conversion. Two consequences this
10+
closes:
11+
12+
- **Stored 16.x rows flagged forever.** A `sys_metadata` object row written
13+
before the removal still carries `enable.trash`/`enable.mru`; with no
14+
conversion to own that history, every rehydration re-flagged it
15+
`metadata_spec_invalid` — mislabelling chain-owned history as a
16+
current-contract violation (#3903's invariant). The new
17+
`object-enable-trash-mru-removed` conversion (protocol 17,
18+
`retiredFromLoadPath`) strips both keys on the stored-row pass, and
19+
`os migrate meta --from 16` now rewrites authored sources.
20+
- **Tombstones pointed at a closed issue.** The prescriptions named #1893
21+
(closed 2026-07-24) as where a real recycle bin returns. Per the #3207
22+
ruling (2026-08-02), soft delete is parked at #3146 — the `trash`
23+
tombstone, the `restore` legacy-apiMethod guidance and the api-derivation
24+
note now point there, and both tombstones name the
25+
`os migrate meta --from 16` rewrite.
26+
27+
FROM → TO: `enable.trash` / `enable.mru`*(removed)* — delete the key;
28+
the flags never gated behavior, so the rewrite is lossless.

.changeset/remove-enable-trash-mru.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
"@objectstack/spec": minor
2+
"@objectstack/spec": major
33
---
44

55
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.
2727
`lifecycle` policy for recoverability).
2828

2929
**Migration**: delete any `enable.trash` / `enable.mru` keys from object
30-
metadata — they never changed behavior. `ObjectSchema.create()` /
31-
`ObjectCapabilities.parse()` now reject them with this prescription. A real
30+
metadata — they never changed behavior, so the removal is drop-the-key only.
31+
`ObjectSchema.create()` / `ObjectCapabilities.parse()` now reject them with
32+
this prescription; `os migrate meta --from 16` rewrites authored sources
33+
automatically, and stored 16.x rows replay clean through the ADR-0087 chain
34+
(`object-enable-trash-mru-removed`). Soft delete is parked at #3146; a real
3235
recycle bin or MRU feature, if built, returns as a live enforced flag
33-
(#1893 prune-or-build).
36+
(ADR-0049 prune-or-build).

docs/protocol-upgrade-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ The subtle half is the defaults, and it is worth stating because no gate can see
176176

177177
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.
178178

179+
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.
180+
179181
### Mechanical (applied for you)
180182

181183
| Conversion | Surface | Change | Load window |
@@ -215,6 +217,7 @@ The same enforce-or-remove pass reaches the event vocabulary: `DataEventType` dr
215217
| `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 |
216218
| `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 |
217219
| `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 |
220+
| `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 |
218221

219222
### Semantic (delegated to you, with acceptance criteria)
220223

packages/spec/spec-changes.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,12 @@
277277
"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)",
278278
"conversionId": "object-managed-by-system-to-system-data",
279279
"toMajor": 17
280+
},
281+
{
282+
"surface": "object.enable.trash / object.enable.mru",
283+
"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)",
284+
"conversionId": "object-enable-trash-mru-removed",
285+
"toMajor": 17
280286
}
281287
],
282288
"migrated": [
@@ -860,6 +866,12 @@
860866
"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)",
861867
"conversionId": "object-managed-by-system-to-system-data",
862868
"toMajor": 17
869+
},
870+
{
871+
"surface": "object.enable.trash / object.enable.mru",
872+
"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)",
873+
"conversionId": "object-enable-trash-mru-removed",
874+
"toMajor": 17
863875
}
864876
],
865877
"migrated": [

packages/spec/src/conversions/registry.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3198,6 +3198,79 @@ const objectManagedBySystemToSystemData: MetadataConversion = {
31983198
},
31993199
};
32003200

3201+
/**
3202+
* The dead object capability flags leave the surface (protocol 17, #3207 —
3203+
* the #2377 ADR-0049 close-out slice PR #3414 removed from the schema).
3204+
*
3205+
* `enable.trash` promised a recycle bin and `enable.mru` promised
3206+
* Most-Recently-Used tracking; neither ever had a behavior-changing reader —
3207+
* every delete has always been a hard delete, and no MRU state was ever
3208+
* written. Both defaulted `true`, the ADR-0078 silent-failure shape: authors
3209+
* wrote `trash: false // never soft-delete audit logs` believing they were
3210+
* opting out of a soft-delete that never ran. Soft delete stays parked at
3211+
* #3146; if built it returns as a live enforced flag (ADR-0049).
3212+
*
3213+
* `retiredFromLoadPath`: the capabilities block is `.strict()` and rejects
3214+
* both keys with the prescription (`CAPABILITIES_RETIRED_KEY_GUIDANCE`), so a
3215+
* live author is taught at parse; this entry exists so stored 16.x rows
3216+
* replay clean (`applyConversionsToStoredItem` — without it a pre-removal row
3217+
* flags `metadata_spec_invalid` forever, mislabelling chain-owned history as
3218+
* a current-contract violation) and so `os migrate meta --from 16` rewrites
3219+
* sources.
3220+
*/
3221+
const objectEnableTrashMruRemoved: MetadataConversion = {
3222+
id: 'object-enable-trash-mru-removed',
3223+
toMajor: 17,
3224+
retiredFromLoadPath: true,
3225+
surface: 'object.enable.trash / object.enable.mru',
3226+
summary:
3227+
"object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no "
3228+
+ 'recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)',
3229+
apply(stack, emit) {
3230+
return mapCollection(stack, 'objects', (obj, path) => {
3231+
// `enable.*` sits one level down, so stripKeys (top-level only) cannot
3232+
// reach it — drill in, and copy-on-write so an untouched object keeps
3233+
// its identity (pattern of `datasource-inert-blocks-removed`).
3234+
const enable = obj.enable;
3235+
if (!enable || typeof enable !== 'object' || Array.isArray(enable)) return obj;
3236+
const stripped = stripKeys(
3237+
enable as Record<string, unknown>,
3238+
['trash', 'mru'],
3239+
emit,
3240+
`${path}.enable`,
3241+
);
3242+
if (stripped === enable) return obj;
3243+
return { ...obj, enable: stripped };
3244+
});
3245+
},
3246+
fixture: {
3247+
before: {
3248+
objects: [
3249+
{
3250+
name: 'task',
3251+
label: 'Task',
3252+
enable: { trash: false, mru: true, searchable: true },
3253+
},
3254+
// an object without the retired keys passes through untouched
3255+
{ name: 'crm_account', label: 'Account', enable: { searchable: true } },
3256+
],
3257+
},
3258+
// Two notices: one per removed key; the surviving `searchable` proves the
3259+
// strip is surgical, not a block-level delete.
3260+
after: {
3261+
objects: [
3262+
{
3263+
name: 'task',
3264+
label: 'Task',
3265+
enable: { searchable: true },
3266+
},
3267+
{ name: 'crm_account', label: 'Account', enable: { searchable: true } },
3268+
],
3269+
},
3270+
expectedNotices: 2,
3271+
},
3272+
};
3273+
32013274
/**
32023275
* The retry policy converges to one declaration (protocol 17, #4661 — the
32033276
* #4535 C8 dual-source cluster).
@@ -3394,6 +3467,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly<Record<number, readonly MetadataConv
33943467
flowNodeScriptBranchKeysRemoved,
33953468
retryPolicyConverged,
33963469
objectManagedBySystemToSystemData,
3470+
objectEnableTrashMruRemoved,
33973471
],
33983472
};
33993473

packages/spec/src/conversions/stored.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () =>
4646
expect(out.sharingModel).toBe('public_read');
4747
});
4848

49+
// #3207 — a 16.x row still carrying the retired `enable.trash`/`enable.mru`
50+
// flags replays clean instead of tripping the registry's spec-conformance
51+
// diagnostic forever (`.strict()` capabilities would flag it
52+
// `metadata_spec_invalid` on every rehydration).
53+
it('strips the retired enable.trash/mru flags from a stored object row', () => {
54+
const row = {
55+
name: 'sys_audit_log',
56+
label: 'Audit Log',
57+
enable: { trash: false, mru: false, searchable: true },
58+
};
59+
const notices: ConversionNotice[] = [];
60+
const out = applyConversionsToStoredItem('object', row, { onNotice: (n) => notices.push(n) }) as {
61+
enable: Record<string, unknown>;
62+
};
63+
expect(out.enable).toEqual({ searchable: true });
64+
expect(notices.filter((n) => n.conversionId === 'object-enable-trash-mru-removed')).toHaveLength(2);
65+
});
66+
4967
it('is idempotent — a canonical row passes through by reference', () => {
5068
const row = {
5169
name: 'crm_task',

packages/spec/src/data/api-derivation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ interface DerivationRule {
131131
* - `restore`/`purge` map to `delete` but their flag is permanently `false`:
132132
* `enable.trash` was retired (#2377/ADR-0049) with no runtime consumer, so
133133
* there is no soft-delete state to restore/purge. They return as live derived
134-
* verbs only if/when a real recycle bin ships (#1893).
134+
* verbs only if/when a real recycle bin ships (#3146, parked).
135135
*/
136136
export const API_METHOD_DERIVATION: Record<LegacyApiMethod, DerivationRule> = {
137137
upsert: { all: ['create', 'update'] },

packages/spec/src/data/object.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ describe('ObjectCapabilities', () => {
3131
expect(result).toEqual(capabilities);
3232
});
3333

34-
// #2377 (ADR-0049): `trash`/`mru` parsed-but-did-nothing for years — the
35-
// retired keys must fail loudly with the upgrade prescription, not strip
34+
// #2377/#3207 (ADR-0049): `trash`/`mru` parsed-but-did-nothing for years —
35+
// the retired keys must fail loudly with the upgrade prescription, not strip
3636
// silently (#1535; pattern of the tenancy tombstones, #2763).
3737
it('rejects the retired trash/mru flags with upgrade guidance', () => {
3838
for (const key of ['trash', 'mru'] as const) {
@@ -41,7 +41,17 @@ describe('ObjectCapabilities', () => {
4141
const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n');
4242
expect(message).toContain(`\`${key}\``);
4343
expect(message).toContain('#2377');
44+
// The prescription names the source rewrite (the #3207 conversion).
45+
expect(message).toContain('os migrate meta --from 16');
4446
}
47+
// `trash` additionally points at the parked soft-delete issue — the
48+
// parking spot the 2026-08-02 #3207 ruling designates (#1893, the old
49+
// pointer, closed 2026-07-24).
50+
const trash = ObjectCapabilities.safeParse({ trash: false });
51+
expect(trash.success).toBe(false);
52+
const trashMsg = trash.success ? '' : trash.error.issues.map((i) => i.message).join('\n');
53+
expect(trashMsg).toContain('#3146');
54+
expect(trashMsg).not.toContain('#1893');
4555
});
4656

4757
it('rejects unknown capability keys instead of stripping them', () => {

packages/spec/src/data/object.zod.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const LEGACY_API_METHOD_GUIDANCE: Record<LegacyApiMethod, string> = {
4646
aggregate: "declare ['list'] — `aggregate` derives from list",
4747
history: "declare ['get'] with `enable.trackHistory: true` — `history` derives from get ∧ trackHistory",
4848
search: "declare ['list'] (with `searchable` not false) — `search` derives from list ∧ searchable",
49-
restore: "delete the value — `restore` never derives (`enable.trash` retired, #2377); it returns only with a real recycle bin (#1893)",
49+
restore: "delete the value — `restore` never derives (`enable.trash` retired, #2377); it returns only with a real recycle bin (#3146, parked)",
5050
purge: "delete the value — `purge` never derives (`enable.trash` retired, #2377)",
5151
import: "declare ['create'] and/or ['update'] — `import` derives from create ∨ update (writeMode-precise at the gate)",
5252
export: "declare ['list'] — `export` derives from list",
@@ -133,18 +133,20 @@ export function stripLegacyApiMethods(
133133
*/
134134
const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record<string, string> = {
135135
trash:
136-
'`enable.trash` was removed from @objectstack/spec in the 16.x line (#2377, ' +
136+
'`enable.trash` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' +
137137
'ADR-0049) — it never had a runtime consumer: every delete has always been a ' +
138138
'hard delete, and a default-true flag promising a recycle bin was a false ' +
139139
'affordance (authors wrote `trash: false` believing they were opting out of a ' +
140140
'soft-delete that never ran). Delete the key. For recoverability use per-field ' +
141-
'`trackHistory` (audit trail) or a `lifecycle` policy; a real recycle bin, if ' +
142-
'built, returns as a live enforced flag (#1893 prune-or-build).',
141+
'`trackHistory` (audit trail) or a `lifecycle` policy; soft delete is parked at ' +
142+
'#3146 and, if built, returns as a live enforced flag (ADR-0049 prune-or-build). ' +
143+
'Run `os migrate meta --from 16` to rewrite it automatically.',
143144
mru:
144-
'`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377, ' +
145+
'`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, ' +
145146
'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader ' +
146147
'existed, so the flag changed nothing. Delete the key. If MRU tracking is ' +
147-
'built it returns as a live enforced flag (#1893 prune-or-build).',
148+
'built it returns as a live enforced flag (ADR-0049 prune-or-build). ' +
149+
'Run `os migrate meta --from 16` to rewrite it automatically.',
148150
};
149151

150152
/**

packages/spec/src/migrations/registry.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,15 @@ const step17: MigrationStep = {
694694
+ 'TODO for event consumers rather than a source rewrite, and it carries no tombstone: a '
695695
+ 'removed enum VALUE cannot hold a fix-it error, exactly as the sharing-rule `full` '
696696
+ 'retirement noted. Should a real per-field stream ever be wanted, it earns its own '
697-
+ 'contract on the #4639 precedent rather than reclaiming this slot.',
697+
+ 'contract on the #4639 precedent rather than reclaiming this slot.\n\n'
698+
+ 'The object capability block closes out the same ADR-0049 pass: `enable.trash` and '
699+
+ '`enable.mru` left the schema in the 16.x line (#3207, the #2377 close-out — every '
700+
+ 'delete has always been a hard delete and MRU tracking was never implemented, so both '
701+
+ 'default-true flags gated nothing), and the `.strict()` capabilities block rejects them '
702+
+ 'with the prescription. This step registers the migration surface that removal was '
703+
+ 'missing: stored 16.x rows replay clean instead of flagging `metadata_spec_invalid`, '
704+
+ 'and `os migrate meta --from 16` rewrites authored sources. Soft delete stays parked at '
705+
+ '#3146; if built it returns as a live enforced flag rather than by reviving these keys.',
698706
conversionIds: [
699707
'action-execute-to-target',
700708
'field-conditionalRequired-to-requiredWhen',
@@ -731,6 +739,7 @@ const step17: MigrationStep = {
731739
'flow-node-script-branch-keys-removed',
732740
'object-managed-by-system-to-system-data',
733741
'retry-policy-converged',
742+
'object-enable-trash-mru-removed',
734743
],
735744
semantic: [
736745
{

0 commit comments

Comments
 (0)