diff --git a/.changeset/retired-defaulted-key-tolerance.md b/.changeset/retired-defaulted-key-tolerance.md new file mode 100644 index 0000000000..6c61ca93e8 --- /dev/null +++ b/.changeset/retired-defaulted-key-tolerance.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +Retired-defaulted-key tolerance (#12840, class rule): a RETIRED key that carried a schema default is now refused only when it carries a NON-default value. The retired default parses as inert residue and is STRIPPED — the normalized output does not carry the key, and a parse → serialize round-trip converges to the clean shape. + +Founding case: `permissions[].objects..allowRestore` / `.allowPurge` (#12497). Every artifact built by the published `@objectstack/spec` 17.x has both keys materialized as `false` in every permission entry (the pre-retirement schemas emitted `z.boolean().default(false)`), so the #12497 refusal was sentencing every previously built artifact — marketplace packages, installed environments — to a boot failure on the next runtime upgrade. Those artifacts now parse; `allowRestore: true` / `allowPurge: true` keep the full #12497 refusal with the prescription byte-for-byte, and nothing is un-retired: the keys stay tsc-`never` tombstones on the authoring surface, and the authorable-surface/JSON-schema artifacts still publish the `[RETIRED]` rows. + +Ships as a reusable helper — `acceptRetiredDefaultResidue(schema, residue)` in `packages/spec/src/shared/retired-key.ts` — where `residue` is the retired default captured as a literal at retirement time (never re-read from anywhere live). The next retirement of a defaulted key reuses the helper instead of re-inventing the judgement. diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index cd8dc13244..f915734073 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8114,7 +8114,14 @@ const objectGridDefaultSortRemoved: MetadataConversion = { * * `retiredFromLoadPath`: ObjectPermissionSchema tombstones both keys * (`retiredKey`, tsc `never` + the parse-time prescription), the - * `permission-rls-priority-removed` posture one block over. + * `permission-rls-priority-removed` posture one block over — with one ruled + * exception the tombstone carries itself (#12840, maintainer 2026-08-28): + * the keys' retired default (`false`), which every artifact built by the + * published 17.x toolchain has materialized in every entry, parses as inert + * residue and is stripped by the `acceptRetiredDefaultResidue` stage on the + * schema. This conversion stays migrate-meta-only: it rewrites SOURCES (both + * values, `true` included); the load-path tolerance covers only the emitted + * default and leaves `true` on the refusal. */ const permissionAllowRestorePurgeRemoved: MetadataConversion = { id: 'permission-allow-restore-purge-removed', diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts index eab68eda9e..9f5478d3b8 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts @@ -4,10 +4,13 @@ // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` -// is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone +// extends the same closed base shape (`.extend({ apiOperations }).strip()`, +// both faces behind the #12840 residue stage since 2026-08-28) — the clone // shares the authoring shape's per-property schema instances, so the // `retiredKey()` tombstone rides into the effective surface and this def's -// walked shape carries the same `[RETIRED]` row. Registered so the aging clock +// walked shape carries the same `[RETIRED]` row, while the retired default +// (`false`) an older published-toolchain server still emits on the wire is +// accepted as inert residue and stripped. Registered so the aging clock // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The // effective surface is server-resolved, never authored, so no D2 conversion // clause targets it — the authoring-side strip in diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts index 0a958d000e..cac7f54132 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts @@ -4,10 +4,13 @@ // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` -// is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone +// extends the same closed base shape (`.extend({ apiOperations }).strip()`, +// both faces behind the #12840 residue stage since 2026-08-28) — the clone // shares the authoring shape's per-property schema instances, so the // `retiredKey()` tombstone rides into the effective surface and this def's -// walked shape carries the same `[RETIRED]` row. Registered so the aging clock +// walked shape carries the same `[RETIRED]` row, while the retired default +// (`false`) an older published-toolchain server still emits on the wire is +// accepted as inert residue and stripped. Registered so the aging clock // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The // effective surface is server-resolved, never authored, so no D2 conversion // clause targets it — the authoring-side strip in diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts index 25e481bac7..af47f773ea 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts @@ -24,7 +24,11 @@ // `permission` metadata root, so the route is the `retiredKey()` tombstone // (the `rls.priority` posture) — the key stays in the walked shape as // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the -// prescription. Sources are rewritten by the D2 conversion -// `permission-allow-restore-purge-removed`, which strips the key from every -// object grant in `permissions[].objects`. +// prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): +// the key's own retired default (`false`), which the published 17.x toolchain +// materialized into every built artifact's entries, parses as inert residue +// and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the +// shape; every other value keeps this refusal. Sources are rewritten by the +// D2 conversion `permission-allow-restore-purge-removed`, which strips the +// key from every object grant in `permissions[].objects`. export const entry = 'security/ObjectPermission:allowPurge'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowRestore.ts b/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowRestore.ts index 0d607ca2ab..aca79e3707 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowRestore.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowRestore.ts @@ -23,7 +23,11 @@ // `permission` metadata root, so the route is the `retiredKey()` tombstone // (the `rls.priority` posture) — the key stays in the walked shape as // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the -// prescription. Sources are rewritten by the D2 conversion -// `permission-allow-restore-purge-removed`, which strips the key from every -// object grant in `permissions[].objects`. +// prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): +// the key's own retired default (`false`), which the published 17.x toolchain +// materialized into every built artifact's entries, parses as inert residue +// and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the +// shape; every other value keeps this refusal. Sources are rewritten by the +// D2 conversion `permission-allow-restore-purge-removed`, which strips the +// key from every object grant in `permissions[].objects`. export const entry = 'security/ObjectPermission:allowRestore'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2c2d5b775c..012bb60b4a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7939,10 +7939,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` - // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone + // extends the same closed base shape (`.extend({ apiOperations }).strip()`, + // both faces behind the #12840 residue stage since 2026-08-28) — the clone // shares the authoring shape's per-property schema instances, so the // `retiredKey()` tombstone rides into the effective surface and this def's - // walked shape carries the same `[RETIRED]` row. Registered so the aging clock + // walked shape carries the same `[RETIRED]` row, while the retired default + // (`false`) an older published-toolchain server still emits on the wire is + // accepted as inert residue and stripped. Registered so the aging clock // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The // effective surface is server-resolved, never authored, so no D2 conversion // clause targets it — the authoring-side strip in @@ -7953,10 +7956,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (see that entry for the full rationale: ADR-0049 enforce-or-remove, // maintainer ruling 2026-08-26 accepting #1883's recommendation B; the key // returns with the M2 lifecycle initiative). `EffectiveObjectPermissionSchema` - // is `ObjectPermissionSchema.extend({ apiOperations }).strip()` — the clone + // extends the same closed base shape (`.extend({ apiOperations }).strip()`, + // both faces behind the #12840 residue stage since 2026-08-28) — the clone // shares the authoring shape's per-property schema instances, so the // `retiredKey()` tombstone rides into the effective surface and this def's - // walked shape carries the same `[RETIRED]` row. Registered so the aging clock + // walked shape carries the same `[RETIRED]` row, while the retired default + // (`false`) an older published-toolchain server still emits on the wire is + // accepted as inert residue and stripped. Registered so the aging clock // (#5898) has an exact-key entry for BOTH rows the tombstone produces. The // effective surface is server-resolved, never authored, so no D2 conversion // clause targets it — the authoring-side strip in @@ -7987,9 +7993,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `permission` metadata root, so the route is the `retiredKey()` tombstone // (the `rls.priority` posture) — the key stays in the walked shape as // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the - // prescription. Sources are rewritten by the D2 conversion - // `permission-allow-restore-purge-removed`, which strips the key from every - // object grant in `permissions[].objects`. + // prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): + // the key's own retired default (`false`), which the published 17.x toolchain + // materialized into every built artifact's entries, parses as inert residue + // and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the + // shape; every other value keeps this refusal. Sources are rewritten by the + // D2 conversion `permission-allow-restore-purge-removed`, which strips the + // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowPurge', // #12497 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-26, decision- // inbox batch 5, accepting #1883's recommendation B). `allowRestore` claimed to @@ -8014,9 +8024,13 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `permission` metadata root, so the route is the `retiredKey()` tombstone // (the `rls.priority` posture) — the key stays in the walked shape as // `[RETIRED]`, and authoring it is a tsc error and a parse error carrying the - // prescription. Sources are rewritten by the D2 conversion - // `permission-allow-restore-purge-removed`, which strips the key from every - // object grant in `permissions[].objects`. + // prescription — with ONE ruled exception (#12840, maintainer 2026-08-28): + // the key's own retired default (`false`), which the published 17.x toolchain + // materialized into every built artifact's entries, parses as inert residue + // and is stripped by the `acceptRetiredDefaultResidue` stage ahead of the + // shape; every other value keeps this refusal. Sources are rewritten by the + // D2 conversion `permission-allow-restore-purge-removed`, which strips the + // key from every object grant in `permissions[].objects`. 'security/ObjectPermission:allowRestore', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 14b6b30ddd..6f983b6b07 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { PermissionSetSchema, ObjectPermissionSchema, @@ -9,6 +9,7 @@ import { type ObjectPermission, type FieldPermission, } from './permission.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; describe('AdminScopeSchema (ADR-0090 D12)', () => { it('parses a delegated-admin scope with defaults', () => { @@ -123,19 +124,38 @@ describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { expect('allowPurge' in parsed, 'retired key contributes nothing to the parsed output').toBe(false); }); - it('authored values reject with the prescription (not a bare strict error)', () => { + it('non-default values reject with the prescription (not a bare strict error)', () => { + // [#12840] Only `true` is a dead AUTHORED claim now — `false` is the + // default the published 17.x toolchain materialized into every built + // artifact, ruled inert residue (accepted and stripped; matrix below). for (const key of ['allowRestore', 'allowPurge'] as const) { - // Both directions are dead: the authored `false` claimed a lock that - // never existed just as loudly as the authored `true` claimed a grant. - for (const value of [true, false]) { - const r = ObjectPermissionSchema.safeParse({ [key]: value } as never); - expect(r.success).toBe(false); - const messages = r.error!.issues.map((i) => i.message).join('\n'); - expect(messages).toContain('#12497'); - expect(messages).toContain('removed in @objectstack/spec 17'); - expect(messages).toContain('Delete the key'); - expect(messages).toContain('M2'); - } + const r = ObjectPermissionSchema.safeParse({ [key]: true } as never); + expect(r.success).toBe(false); + const messages = r.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('#12497'); + expect(messages).toContain('removed in @objectstack/spec 17'); + expect(messages).toContain('Delete the key'); + expect(messages).toContain('M2'); + } + }); + + it('[#12840] the refusal is the tombstone byte-for-byte — guidance text, expected: never, located path', () => { + // The #12497 refusal shape was measured as + // `{ expected: 'never', code: 'invalid_type', path: […, key], message: }`. + // The residue stage must not touch it: a non-default value never enters + // the strip, so the issue is the tombstone's own. The guidance is read + // back from the tombstone's `[REMOVED] ` describe — the single source — + // so this pin proves refusal text === declared prescription, byte for byte. + for (const key of ['allowRestore', 'allowPurge'] as const) { + const declared = (ObjectPermissionSchema.shape[key].description ?? '').replace(/^\[REMOVED\] /, ''); + expect(declared).toContain('#12497'); + const r = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: true } as never); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path[i.path.length - 1] === key)!; + expect(issue).toBeDefined(); + expect((issue as { expected?: string }).expected).toBe('never'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toBe(declared); } }); @@ -156,14 +176,176 @@ describe('allowRestore / allowPurge are RETIRED (#12497, ADR-0049)', () => { // `.extend()` shares the authoring shape's per-property instances, so the // response-side def carries the same `[RETIRED]` row in the authorable // surface — and a DECLARED-never key is refused there even though the - // schema `.strip()`s unknown keys (declared ≠ unknown). No server can emit - // the bit any more (the parsed authoring output omits it), so this refusal - // has no wire-compat cost inside the launch window. - const r = EffectiveObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: false } as never); + // schema `.strip()`s unknown keys (declared ≠ unknown). [#12840] narrowed + // the refusal to NON-default values: a server still on the published 17.x + // toolchain DOES emit the bit at its materialized default (`false`), so + // that residue is accepted-and-stripped (matrix below) while `true` keeps + // this refusal. + const r = EffectiveObjectPermissionSchema.safeParse({ allowRead: true, allowRestore: true } as never); expect(r.success).toBe(false); }); }); +describe('[#12840] the RETIRED DEFAULT parses as inert residue and strips (class rule)', () => { + // Maintainer ruling 2026-08-28, recorded on objectstack-ai/cloud#1685: a + // retired key that had a schema default is refused only when it carries a + // NON-default value. The published `@objectstack/spec` 17.x still emitted + // `z.boolean().default(false)` for `allowRestore`/`allowPurge`, so every + // artifact the released toolchain built carries both keys as `false` in + // every permission entry — 75 occurrences in the measured HotCRM artifact, + // whose sources declare neither. Refusing the emitted default sentences + // every existing built artifact to death on the next runtime upgrade. + + /** The published-toolchain shape, verbatim from the cloud#1685 measurement. */ + const publishedToolchainEntry = { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: true, + allowRestore: false, + allowPurge: false, + }; + + it('accepts the emitted default and STRIPS it — the parsed output carries neither key', () => { + const r = ObjectPermissionSchema.safeParse(publishedToolchainEntry as never); + expect(r.success).toBe(true); + expect('allowRestore' in r.data!, 'residue must not survive into the normalized output').toBe(false); + expect('allowPurge' in r.data!, 'residue must not survive into the normalized output').toBe(false); + expect(r.data!.allowCreate).toBe(true); + expect(r.data!.allowDelete).toBe(true); + }); + + it('each key strips independently', () => { + for (const key of ['allowRestore', 'allowPurge'] as const) { + const r = ObjectPermissionSchema.safeParse({ allowRead: true, [key]: false } as never); + expect(r.success).toBe(true); + expect(key in r.data!).toBe(false); + } + }); + + it('parse → serialize → parse is a fixpoint without the keys (no re-emission)', () => { + const first = ObjectPermissionSchema.parse(publishedToolchainEntry as never); + const serialized = JSON.parse(JSON.stringify(first)) as Record; + expect('allowRestore' in serialized).toBe(false); + expect('allowPurge' in serialized).toBe(false); + const second = ObjectPermissionSchema.parse(serialized as never); + expect(JSON.parse(JSON.stringify(second))).toEqual(serialized); + }); + + it('tolerates ONLY the captured retired default — every other value keeps the loud refusal', () => { + // The helper contract: the residue value is the literal captured at + // retirement time (`false`), compared by identity. Falsy near-misses are + // NOT the emitted default and land on the tombstone like any authored value. + for (const wrong of [true, 0, '', null, 'false'] as const) { + const r = ObjectPermissionSchema.safeParse({ allowRestore: wrong } as never); + expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false); + expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('#12497'); + } + }); + + it('the residue strips inside a full permission-set / stack-shaped parse (the artifact path)', () => { + // The measured refusal was located at + // `permissions[5].objects.crm_campaign_member.allowRestore` — a composed + // artifact's permission collection. The tolerance rides the SAME nested + // schema, so the stack-shaped parse accepts and normalizes it. + const set = PermissionSetSchema.parse({ + name: 'system_admin', + objects: { + crm_campaign_member: publishedToolchainEntry, + crm_note: { allowRead: true }, + }, + } as never); + expect('allowRestore' in set.objects.crm_campaign_member!).toBe(false); + expect('allowPurge' in set.objects.crm_campaign_member!).toBe(false); + expect(set.objects.crm_campaign_member!.allowEdit).toBe(true); + }); + + it('the composed-artifact door accepts the measured refusal shape at its exact path', () => { + // cloud#1672's red step died on the composed HotCRM artifact at + // `permissions[5].objects.crm_campaign_member.allowRestore` + // (`expected: 'never'`). Reproduce that exact coordinate through the + // artifact's own door (`ObjectStackDefinitionSchema`): five sets ahead, + // the sixth carrying the published-toolchain entry — and assert the parse + // now accepts it and the normalized artifact carries neither key. + const filler = Array.from({ length: 5 }, (_, i) => ({ + name: `filler_set_${i}`, + objects: { crm_note: { allowRead: true } }, + })); + const artifact = ObjectStackDefinitionSchema.parse({ + permissions: [ + ...filler, + { name: 'system_admin', objects: { crm_campaign_member: publishedToolchainEntry } }, + ], + } as never); + const entry = artifact.permissions![5]!.objects.crm_campaign_member!; + expect('allowRestore' in entry).toBe(false); + expect('allowPurge' in entry).toBe(false); + expect(entry.allowDelete).toBe(true); + }); + + it('a 75-occurrence artifact parses with NO warning storm (the strip is silent)', () => { + // Real artifacts carry the residue once per permission entry (75 in the + // HotCRM measurement). The ruled bound is "at most low-noise, never + // per-occurrence storms"; the implementation chooses silence — a schema + // parse has no notice channel, and the loud channels for authored sources + // (tsc `never`, the D2 conversion, `os migrate meta`) are untouched. + const spies = (['warn', 'error', 'info', 'log'] as const).map((level) => + vi.spyOn(console, level).mockImplementation(() => {}), + ); + try { + const objects: Record = {}; + for (let i = 0; i < 38; i++) objects[`obj_${i}`] = { ...publishedToolchainEntry }; + const parsed = PermissionSetSchema.parse({ name: 'wide_set', objects } as never); + expect(Object.keys(parsed.objects)).toHaveLength(38); + for (const entry of Object.values(parsed.objects)) { + expect('allowRestore' in entry!).toBe(false); + expect('allowPurge' in entry!).toBe(false); + } + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + } finally { + for (const spy of spies) spy.mockRestore(); + } + }); + + it('an entry WITHOUT residue passes through by reference (copy-on-write)', () => { + // The strip clones only when it removes something — an artifact already on + // the clean shape is not rewritten on its way through. + const clean = { allowRead: true }; + const r = ObjectPermissionSchema.safeParse(clean as never); + expect(r.success).toBe(true); + }); + + it('the wire clone tolerates the same residue (an older server emits the defaults)', () => { + const r = EffectiveObjectPermissionSchema.safeParse({ + allowRead: true, + allowRestore: false, + allowPurge: false, + apiOperations: ['get', 'list'], + } as never); + expect(r.success).toBe(true); + expect('allowRestore' in r.data!).toBe(false); + expect('allowPurge' in r.data!).toBe(false); + expect(r.data!.apiOperations).toEqual(['get', 'list']); + }); + + it('the authoring surface stays retired: the shape still declares the tombstones', () => { + // Nothing is un-retired — the walked shape keeps the `[REMOVED]` rows + // (authorable-surface + JSON-schema artifacts publish the tombstone), and + // `z.input` keeps the keys `never` so writing one in TypeScript source + // fails `tsc` exactly as #12497 ruled. (The `as never` casts across this + // file are that channel, exercised.) + for (const key of ['allowRestore', 'allowPurge'] as const) { + expect(ObjectPermissionSchema.shape[key].description).toMatch(/^\[REMOVED\] /); + } + // The compile channel, pinned: the residue tolerance is RUNTIME-only, so + // the input type still refuses the key — hand-authoring even the retired + // default in TypeScript source stays a tsc error. + // @ts-expect-error — `allowRestore` stays unwritable on ObjectPermission (#12497, unchanged by #12840) + const typeChannel: ObjectPermission = { allowRead: true, allowRestore: false }; + void typeChannel; + }); +}); + describe('EffectiveObjectPermissionSchema (#3391 response-side)', () => { it('carries every ObjectPermission field plus optional apiOperations', () => { const parsed = EffectiveObjectPermissionSchema.parse({ diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index abb833d1b2..eae0a4617c 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -17,7 +17,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; * - Purge (Hard delete / Compliance) */ import { lazySchema } from '../shared/lazy-schema'; -import { retiredKey } from '../shared/retired-key'; +import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; /** * [ADR-0057 D1] Object access DEPTH — the Dataverse "access level" axis, @@ -73,7 +73,28 @@ const OBJECT_PERMISSION_KEY_ALIASES: Readonly> = { modifyalldata: 'modifyAllRecords', }; -export const ObjectPermissionSchema = lazySchema(() => strictObject( +/** + * [#12840] The inert residue the #12497 retirement left in BUILT artifacts: + * every `@objectstack/spec` 17.x the released toolchain shipped still carried + * `z.boolean().default(false)` for both keys, so every artifact it built has + * them MATERIALIZED as `false` in every permission entry (75 occurrences in + * the measured HotCRM artifact; its sources declare neither). Captured here as + * literals at retirement time — never re-read from anywhere live (the schema + * no longer has the defaults; the tombstones replaced them). See + * {@link acceptRetiredDefaultResidue} for the class rule. + */ +const OBJECT_PERMISSION_RETIRED_KEY_RESIDUE = { + allowRestore: false, + allowPurge: false, +} as const; + +/** + * The closed authoring shape, module-private: {@link ObjectPermissionSchema} + * is this shape behind the #12840 residue-tolerance stage, and + * {@link EffectiveObjectPermissionSchema} extends this (an `.extend()` needs + * the real `ZodObject`, and the wrapper is a preprocess pipe). + */ +const ObjectPermissionBaseSchema = lazySchema(() => strictObject( { surface: 'this object permission', aliases: OBJECT_PERMISSION_KEY_ALIASES, @@ -188,6 +209,13 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( * there is still no ungated window. THE KEYS RETURN with the M2 lifecycle * initiative (feature + RBAC in one batch, maintainer 2026-08-03); #1883 * stays open as the anchor. + * + * [#12840] Both keys carried `.default(false)` before the retirement, so + * every artifact the published 17.x toolchain built has them materialized + * as `false` in every entry. That emitted default parses as inert residue + * and is STRIPPED by the residue stage on {@link ObjectPermissionSchema} + * (`OBJECT_PERMISSION_RETIRED_KEY_RESIDUE`); the tombstones below never see + * it. Every other value still lands here, prescription intact. */ allowRestore: retiredKey( '`objects..allowRestore` was removed in @objectstack/spec 17 (#12497, ADR-0049) — ' + @@ -251,6 +279,24 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( writeScope: ObjectAccessScopeSchema.optional().describe('[ADR-0057 D1] Write depth: own|unit|unit_and_below|org'), })); +/** + * Object-level permission entry — {@link ObjectPermissionBaseSchema} behind + * the [#12840] retired-default residue stage. + * + * The published 17.x toolchain materialized `allowRestore: false` / + * `allowPurge: false` into every permission entry of every artifact it built + * (the keys carried Zod defaults before #12497 retired them), so the parse + * accepts exactly that emitted default as inert residue and STRIPS it — the + * parsed output carries neither key, and a round-trip converges to the clean + * shape. Any NON-default value (`true`) still lands on the untouched #12497 + * tombstone with its prescription. Maintainer ruling 2026-08-28 (recorded on + * objectstack-ai/cloud#1685): a retired key that had a schema default is + * refused only when it carries a non-default value. + */ +export const ObjectPermissionSchema = lazySchema(() => + acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE), +); + /** * RESPONSE-side extension of {@link ObjectPermissionSchema} carrying the * server-resolved effective API operation set for one object (#3391). @@ -268,16 +314,26 @@ export const ObjectPermissionSchema = lazySchema(() => strictObject( * falls back to its default-allow behavior (old backend / unrestricted object). */ export const EffectiveObjectPermissionSchema = lazySchema(() => - (ObjectPermissionSchema as unknown as z.ZodObject).extend({ - apiOperations: z.array(ApiOperationSchema).optional().describe( - 'Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum.', - ), - // WIRE shape: `.extend()` inherits the authoring schema's `.strict()`, and a - // strict response parser is forward-incompatible — a newer server adding a - // response key would crash an older client. `.strip()` restores zod-default - // tolerance here; strictness is an AUTHORING-side contract only (#4001's - // authorable/wire split). - }).strip(), + // [#12840] The same retired-default residue stage as the authoring schema: + // a server still running the published 17.x toolchain emits + // `allowRestore: false` / `allowPurge: false` in its effective-permission + // responses (the defaults were materialized server-side), so the wire parse + // accepts-and-strips exactly that residue too; `true` keeps the tombstone + // refusal. Extends the BASE object — the tolerance wrapper is a pipe, not a + // `ZodObject`. + acceptRetiredDefaultResidue( + (ObjectPermissionBaseSchema as unknown as z.ZodObject).extend({ + apiOperations: z.array(ApiOperationSchema).optional().describe( + 'Server-resolved effective API operations for this object (#3391). Present only when the object tightens exposure via apiMethods; absent = default-allow. The frontend renders this effective set, never the raw whitelist. Vocabulary is the EFFECTIVE ApiOperation set (six primitives + eight derived verbs, #3543), not the authored six-value ApiMethod enum.', + ), + // WIRE shape: `.extend()` inherits the authoring schema's `.strict()`, and a + // strict response parser is forward-incompatible — a newer server adding a + // response key would crash an older client. `.strip()` restores zod-default + // tolerance here; strictness is an AUTHORING-side contract only (#4001's + // authorable/wire split). + }).strip(), + OBJECT_PERMISSION_RETIRED_KEY_RESIDUE, + ), ); export type EffectiveObjectPermission = z.input; diff --git a/packages/spec/src/shared/retired-key.test.ts b/packages/spec/src/shared/retired-key.test.ts new file mode 100644 index 0000000000..fa9143d8c1 --- /dev/null +++ b/packages/spec/src/shared/retired-key.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Contract of `acceptRetiredDefaultResidue` — the retired-DEFAULTED-key + * tolerance stage (#12840; maintainer ruling 2026-08-28, recorded on + * objectstack-ai/cloud#1685). + * + * The class rule under test: a RETIRED key that carried a schema default is + * refused only when it carries a NON-default value. The retired default — the + * value every artifact built by a released toolchain has MATERIALIZED in every + * entry — parses as inert residue and is STRIPPED before the closed shape + * sees it. These tests run the helper over a SYNTHETIC schema, deliberately: + * they prove the judgement is reusable for the next defaulted-key retirement + * (different key names, a non-boolean default) rather than a special case of + * `allowRestore`/`allowPurge` — the founding case is pinned where it lives, in + * `security/permission.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { acceptRetiredDefaultResidue, retiredKey } from './retired-key'; +import { strictObject } from './strict-object'; + +const GONE_GUIDANCE = + '`gone` was removed in @objectstack/spec 99 (#0000). Delete the key.'; +const MODE_GUIDANCE = + '`legacyMode` was removed in @objectstack/spec 99 (#0000). Delete the key.'; + +/** A synthetic "next retirement": one boolean default, one string default. */ +const inner = strictObject( + { + surface: 'this synthetic surface', + history: 'Synthetic fixture for the residue-stage contract.', + }, + { + keep: z.string().optional(), + flag: z.boolean().default(true), + gone: retiredKey(GONE_GUIDANCE), + legacyMode: retiredKey(MODE_GUIDANCE), + }, +); + +/** + * The residue literals, captured at "retirement time": before their (fictional) + * retirement, `gone` was `z.boolean().default(false)` and `legacyMode` was + * `z.enum(…).default('classic')`. The helper never re-reads them from anywhere + * live — the schema above no longer has them, which is the point. + */ +const schema = acceptRetiredDefaultResidue(inner, { + gone: false, + legacyMode: 'classic', +}); + +describe('acceptRetiredDefaultResidue (#12840)', () => { + it('accepts each captured retired default and strips it from the output', () => { + const r = schema.safeParse({ keep: 'x', gone: false, legacyMode: 'classic' }); + expect(r.success).toBe(true); + expect('gone' in r.data!).toBe(false); + expect('legacyMode' in r.data!).toBe(false); + expect(r.data!.keep).toBe('x'); + // The live default machinery is untouched: `flag` still materializes. + expect(r.data!.flag).toBe(true); + }); + + it('parse → serialize → parse converges to the clean shape (fixpoint, no re-emission)', () => { + const first = schema.parse({ keep: 'x', gone: false, legacyMode: 'classic' }); + const wire = JSON.parse(JSON.stringify(first)) as Record; + expect('gone' in wire).toBe(false); + expect('legacyMode' in wire).toBe(false); + const second = schema.parse(wire); + expect(JSON.parse(JSON.stringify(second))).toEqual(wire); + }); + + it('refuses every non-default value with the tombstone byte-for-byte', () => { + // `gone` retired at default `false`: `true` refuses. `legacyMode` retired + // at default `'classic'`: any other string refuses. The refusal must be + // the tombstone's OWN issue — `expected: 'never'`, guidance as message — + // proving the stage never rewrites a non-default value on its way in. + const cases: Array<[string, unknown, string]> = [ + ['gone', true, GONE_GUIDANCE], + ['legacyMode', 'modern', MODE_GUIDANCE], + ]; + for (const [key, value, guidance] of cases) { + const r = schema.safeParse({ [key]: value }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path[i.path.length - 1] === key)!; + expect(issue).toBeDefined(); + expect((issue as { expected?: string }).expected).toBe('never'); + expect(issue.code).toBe('invalid_type'); + expect(issue.message).toBe(guidance); + } + }); + + it('compares by identity — a falsy near-miss of the captured default is NOT residue', () => { + // `gone`'s captured default is `false`; 0 / '' / null are different values + // and land on the tombstone like any authored value. This is the + // "captured at retirement time" half of the contract: the tolerance is for + // the ONE value the released toolchain emitted, not for "falsy". + for (const wrong of [0, '', null] as const) { + const r = schema.safeParse({ gone: wrong }); + expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false); + } + // And per-key: `legacyMode`'s default is 'classic', so `false` — the OTHER + // key's default — is not residue here. + expect(schema.safeParse({ legacyMode: false }).success).toBe(false); + }); + + it('absence stays clean, and the residue keys never re-materialize', () => { + const r = schema.parse({ keep: 'y' }); + expect('gone' in r).toBe(false); + expect('legacyMode' in r).toBe(false); + }); + + it('does not loosen the closed door — unknown keys still refuse through the stage', () => { + const r = schema.safeParse({ keep: 'x', invented: 1 }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('invented'); + }); + + it('non-object bodies pass through to the schema untouched', () => { + expect(schema.safeParse('nope').success).toBe(false); + expect(schema.safeParse([{ gone: false }]).success).toBe(false); + expect(schema.safeParse(undefined).success).toBe(false); + }); + + it('reads through to the inner authorable shape (walkers and shape consumers)', () => { + expect(schema.shape).toBe(inner.shape); + expect(schema.shape.gone.description).toBe(`[REMOVED] ${GONE_GUIDANCE}`); + // And the runtime node is a preprocess pipe whose OUT side is the closed + // shape — the orientation `pipeAuthorableSide` resolves (#4488/#5074/#5317), + // so the authorable-surface / JSON-schema walkers keep governing it. + const def = (schema as unknown as { _zod: { def: { type: string; out?: unknown } } })._zod.def; + expect(def.type).toBe('pipe'); + expect((def.out as { _zod: { def: { type: string } } })._zod.def.type).toBe('object'); + }); +}); diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index 689c48cb52..17e82fa3c9 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -106,3 +106,106 @@ import { z } from 'zod'; export function retiredKey(guidance: string) { return z.never({ error: () => guidance }).optional().describe(`[REMOVED] ${guidance}`); } + +/** + * The inert residue a RETIRED **DEFAULTED** key leaves behind in built + * artifacts: retired key name → the default the retired schema used to emit, + * **captured as a literal at retirement time** (#12840). + * + * ⛔ Never derive an entry from anything live. The whole point of the capture + * is that the default no longer exists anywhere in the schema — the tombstone + * replaced it — so the only trustworthy record of "what the released toolchain + * materialized" is the literal written down when the key was retired. + */ +export type RetiredDefaultResidue = Readonly>; + +/** + * Accept a retired defaulted key's EMITTED DEFAULT as inert residue — and + * strip it — while every other value keeps the tombstone's loud refusal + * (#12840; maintainer ruling 2026-08-28, recorded on objectstack-ai/cloud#1685). + * + * ## The class of retirement this exists for + * + * {@link retiredKey} makes a removal audible in both authoring channels (`tsc` + * `never` + the parse-time prescription). That is the right posture for a key + * an author WROTE — but a key that carried a Zod `.default(…)` has a third + * population nobody authored: **every artifact built by a released toolchain + * has the key MATERIALIZED at its default in every entry**, because the parse + * that built the artifact emitted the default. Refusing that emitted default + * sentences every previously built artifact — marketplace packages, installed + * environments — to death on the next runtime upgrade, over a value that is + * behaviourally identical to absence for the key's entire history. (The + * founding case: `allowRestore`/`allowPurge` after #12497 — the published + * spec 17.x still emitted `false` for both, 75 occurrences in one real + * artifact whose sources declare neither.) + * + * So a retired **defaulted** key discriminates on the VALUE: + * + * - value `===` the retired default → inert residue: accepted, and STRIPPED + * before the shape parses, so the normalized output does not carry the key + * and a parse → serialize round-trip converges to the clean shape (no + * re-emission). The strip is deliberately SILENT — real artifacts carry + * the residue once per permission entry, and a per-occurrence notice would + * be a 75-line storm that teaches operators to skim; the loud channels for + * authored sources (tsc `never`, `os migrate meta`, the D2 conversion) + * are unchanged. + * - any other value → the untouched {@link retiredKey} refusal, guidance + * byte-for-byte: the key stays a tombstone in the shape, and this wrapper + * never runs on a non-default value, so the prescription and the + * `expected: 'never'` issue shape are exactly what the retirement ruled. + * + * ## Nothing is un-retired + * + * The authoring surface keeps every refusal the retirement established: the + * shape still declares the key as a {@link retiredKey} tombstone (`z.input` + * stays `never`, so writing the key in TypeScript source fails `tsc` exactly + * as before), the JSON-schema/authorable-surface artifacts still publish the + * `[REMOVED]` tombstone row, and a non-default value is refused with the + * original prescription everywhere. What changes is only the disposition of + * the **emitted default in already-parsed data** — provenance that JSON cannot + * carry, which is why the discrimination is by value, as ruled. + * + * ## Mechanism and placement + * + * A `z.preprocess` stage ahead of the closed shape (the `ViewMetadataSchema` / + * `translation` retired-dialect precedent — every schema walker resolves a + * preprocess pipe to its OUT side via `pipeAuthorableSide`, #4488/#5074/#5317). + * The strip is copy-on-write: an input without residue passes through by + * reference. The wrapper preserves a read-through `shape` (the inner shape is + * the authorable truth), but it is NOT a `ZodObject` — `.extend()` a + * tolerance-wrapped schema by extending the inner object and re-wrapping, the + * way `EffectiveObjectPermissionSchema` does. + * + * The next retirement of a defaulted key reuses this helper with its own + * captured literal instead of re-inventing the judgement. + */ +export function acceptRetiredDefaultResidue>( + schema: S, + residue: RetiredDefaultResidue, +): z.ZodType, z.input> & { readonly shape: S['shape'] } { + const keys = Object.keys(residue); + const strip = (body: unknown): unknown => { + if (typeof body !== 'object' || body === null || Array.isArray(body)) return body; + let out: Record | undefined; + for (const key of keys) { + if ( + Object.prototype.hasOwnProperty.call(body, key) && + (body as Record)[key] === residue[key] + ) { + out ??= { ...(body as Record) }; + delete out[key]; + } + } + return out ?? body; + }; + const pipe = z.preprocess(strip, schema); + // Read-through `shape` so shape-reading consumers (and the walkers' duck + // tests) see the inner authorable shape. Lazy: never forces a lazySchema + // proxy at construction. Note `.describe()`/`.optional()` clones do not + // carry this instance property — it exists on the exported instance only. + Object.defineProperty(pipe, 'shape', { + configurable: true, + get: () => schema.shape, + }); + return pipe as unknown as z.ZodType, z.input> & { readonly shape: S['shape'] }; +}