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
15 changes: 15 additions & 0 deletions .changeset/retired-permission-bits-parse-time-accept-set.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) nothing authorable changes shape: no spec key, no Zod schema and no exported symbol is added, removed, renamed or narrowed, and no accepted value moves in either direction, so `os migrate meta` has no edit to make and no ledger id to carry. The retirement this prose describes was registered by its own change; this one only describes it accurately. -->
18 changes: 18 additions & 0 deletions content/docs/permissions/permission-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
38 changes: 34 additions & 4 deletions packages/spec/src/security/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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
Expand Down
35 changes: 35 additions & 0 deletions packages/spec/src/security/permission.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — ' +
Expand Down Expand Up @@ -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),
Expand Down
Loading