diff --git a/.changeset/retired-permission-bits-parse-time-accept-set.md b/.changeset/retired-permission-bits-parse-time-accept-set.md new file mode 100644 index 0000000000..e787cf9bb4 --- /dev/null +++ b/.changeset/retired-permission-bits-parse-time-accept-set.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425) + +Documentation only — no schema, no key, no exported symbol and no accepted value moves. What changes is what the tombstone's own prose claims about itself, in the three places a consumer reads it: the `permission.zod.ts` docblocks (published in the tarball, both as `dist/*.d.ts` and as the `src/**/*.zod.ts` sources this package ships), and the two hand-written permission docs pages. + +The prose said the retired bits are refused, and separately that "every other value" lands on the tombstone. Read together those two sentences describe a truthy/falsy split, and that is not what the schema does. Measured on this tree, `ObjectPermissionSchema` tolerates exactly ONE value: the boolean literal `false` the published 17.x toolchain materialized into every permission entry of every artifact it built, accepted as inert residue and silently stripped under the retired-defaulted-key class rule. Every other value of any type — including the string `"false"`, the number `0` and `null` — is refused exactly like `true`, with `code: 'invalid_type'`, `expected: 'never'` and the same guidance string, at the key's own path. + +The consequence consumers were missing is now stated with it: a successfully parsed permission entry can carry neither key on any input that came from JSON, so a post-parse guard against either bit is dead code — presence, truthiness and `=== true` alike can never be true on validated data. A `false`-versus-other distinction is observable only to pre-parse tooling reading raw sources, where the retired default is inert legacy residue and any other value is a hard ADR-0049 violation. + +One measured exception is documented and pinned, because it is the only post-parse observation that survives: an in-memory TypeScript input carrying an explicit `undefined` for either key parses and keeps the key as an own property whose value is `undefined`, so a presence check can be true there. JSON cannot spell it, and a serialize round-trip drops it again. + + diff --git a/content/docs/permissions/permission-metadata.mdx b/content/docs/permissions/permission-metadata.mdx index 689e8c5f5e..9dd3b6b74d 100644 --- a/content/docs/permissions/permission-metadata.mdx +++ b/content/docs/permissions/permission-metadata.mdx @@ -101,6 +101,24 @@ objects: { > now a loud publish-time error carrying this prescription. The keys return > with the M2 lifecycle initiative (feature + RBAC in one batch, #1883); > until then a dispatched `restore` / `purge` is denied unconditionally. +> +> **Exactly one value is still accepted, and it is not an author's to write:** +> the literal `false` that the published 17.x toolchain materialized into every +> permission entry of every artifact it built. That emitted default parses as +> inert residue and is silently **stripped** (#12840, so upgrading a runtime +> does not kill artifacts nobody re-published) — a source that still writes +> `allowRestore: false` therefore gets **no error and no key**. Every other +> value is refused: `"false"`, `0`, `1`, `"true"` and `null` all fail exactly +> like `true`, with the same message. **This is not a truthy/falsy split** — +> only the boolean literal `false` is tolerated. +> +> **Consumers of validated data never see either key**, so a post-parse guard +> is dead code: `permissions.allowRestore` is always `undefined`, which makes +> `'allowRestore' in permissions`, `if (permissions.allowRestore)` and +> `permissions.allowRestore === true` all unable to fire. Only **pre-parse** +> tooling reading raw source (a linter or migration tool over +> `objectstack.json`) can tell legacy `false` residue from an ADR-0049 +> violation — and it should say something different about each. ## Field Permissions diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index c323f276a8..86e0d479ac 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -109,6 +109,14 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra > a migration prescription. A dispatched `restore` / `purge` is denied > unconditionally (fail-closed destructive-operation backstop). The flags > return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883). +> +> One exception, by ruling: the boolean literal `false` — the default the +> published 17.x toolchain emitted into every built artifact — parses as inert +> residue and is silently stripped (#12840), so it raises no error and reaches +> no parsed output. Every other value, `"false"` and `0` included, is refused +> exactly like `true`; this is not a truthy/falsy split. Since neither key can +> reach validated data, guards belong in pre-parse tooling over raw sources, +> never in code reading a parsed permission set. > Permission sets are **additive-only**: a user's effective capability is the union of every set they hold — directly, via positions, or via the built-in `everyone` baseline (ADR-0090 D5). A `true` anywhere wins; there are no subtraction sets — to withhold, don't grant. diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index deb060711b..8e08348639 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -236,13 +236,43 @@ describe('[#12840] the RETIRED DEFAULT parses as inert residue and strips (class // 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('ObjectQL operation it claimed'); + // [#17425] The full matrix, re-measured on this tree: NOT a truthy/falsy + // split — the string `'true'` and the number `1` are refused exactly like + // the string `'false'` and the number `0`, all with the same issue shape. + for (const wrong of [true, 0, 1, '', 'true', 'false', null] as const) { + for (const key of ['allowRestore', 'allowPurge'] as const) { + const r = ObjectPermissionSchema.safeParse({ [key]: wrong } as never); + expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === key)!; + expect(issue, `${key}=${JSON.stringify(wrong)} must be refused AT ITS OWN PATH`).toBeDefined(); + expect(issue.code).toBe('invalid_type'); + expect((issue as unknown as { expected?: string }).expected).toBe('never'); + expect(issue.message).toContain('ObjectQL operation it claimed'); + } } }); + it('[#17425] the only post-parse observation left: an EXPLICIT `undefined` survives as an own key', () => { + // The consumer-facing claim this pins (prose on `ObjectPermissionSchema`): + // on data that came from JSON no post-parse guard can ever fire — `false` + // strips and every other JSON value throws, so the key is always + // `undefined`. But an in-memory TS/JS input carrying an explicit + // `undefined` — what spreading an object that once had the key produces — + // parses AND keeps the OWN key, so a presence check can still be true. + // ⛔ If a zod upgrade moves this, re-measure and rewrite the prose; do not + // relax the pin, because the prose is what consumers act on. + const parsed = ObjectPermissionSchema.parse({ allowRead: true, allowRestore: undefined } as never) as Record; + expect(Object.prototype.hasOwnProperty.call(parsed, 'allowRestore')).toBe(true); + expect('allowRestore' in parsed).toBe(true); + expect(parsed.allowRestore).toBeUndefined(); + // …and the two guards consumers were told to write stay dead regardless. + expect(parsed.allowRestore === true).toBe(false); + expect(Boolean(parsed.allowRestore)).toBe(false); + // JSON cannot spell it: a serialize round-trip drops the key again, which + // is why raw JSON sources can never reach this branch. + expect('allowRestore' in (JSON.parse(JSON.stringify(parsed)) as object)).toBe(false); + }); + 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 diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index fd6a64e59f..c61f48c4bd 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -217,6 +217,15 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * 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. + * + * [#17425] The accept set that leaves behind, stated exactly — the residue + * stage tolerates ONE value, the boolean literal `false`, compared by + * identity against the captured literal. **This is not a truthy/falsy + * split**: `"false"`, `0`, `''` and `null` are refused exactly like `true` + * is, with the same `code: 'invalid_type'` / `expected: 'never'` issue at + * the key's own path and the same guidance string. Anything that is not the + * captured literal is an authored claim, and authored claims are what the + * tombstone exists to refuse. The matrix is pinned in `permission.test.ts`. */ allowRestore: retiredKey( '`objects..allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — ' + @@ -293,6 +302,32 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject( * 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. + * + * ## [#17425] What a consumer of PARSED output can observe: effectively nothing + * + * Every spelling reachable from JSON is gone by the time you hold parsed data: + * `false` is stripped, and every other JSON-expressible value (`true`, + * `"true"`, `"false"`, `0`, `1`, `null`) throws before a parsed object exists. + * So on validated data `permissions.allowRestore` is always `undefined`, which + * makes `if (permissions.allowRestore)` and `permissions.allowRestore === true` + * dead code — a post-parse guard against either bit can never fire, and + * `=== true` is no fix for a truthiness check because a raw `true` never + * survives the parse either. + * + * ONE observation survives, and it is not reachable from JSON: an in-memory + * TS/JS input carrying an EXPLICIT `undefined` (`{ allowRestore: undefined }` + * — the shape a spread of an object that once carried the key produces) parses, + * and the key survives as an OWN property whose value is `undefined`. So + * `'allowRestore' in parsed` can be `true` while the value is still undefined; + * `JSON.parse(JSON.stringify(parsed))` drops it again. Measured and pinned in + * `permission.test.ts`. + * + * ⇒ A `false`-versus-other distinction has a live consumer only in PRE-PARSE / + * raw-source tooling — a linter or migration tool reading `objectstack.json` + * (or a `.ts` source) before validation, where `false` is inert legacy residue + * and any other value is a hard ADR-0049 violation. Those two facts deserve + * different messages; a presence or truthiness check on raw input conflates + * them, and the same check on parsed output measures nothing at all. */ export const ObjectPermissionSchema = lazySchema(() => acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE),