Skip to content

Commit 396eae3

Browse files
os-billclaude
andauthored
docs(spec): state the retired allowRestore / allowPurge parse-time accept set exactly (#17485)
Part of #17425. Clause-②: no — measured. No accept set moves in either direction: the only source file this PR touches under `packages/spec/src` changes comments and nothing else (0 non-comment changed lines vs `origin/main`, against a lit control of 24 on the test file in the same diff). The claim on the card declared `Clause-②: yes` for the narrowing triage asked for; **the fence below stopped that narrowing**, so what lands here is the card's own ask only. `needs:contract-review` stays hung, because the fork this PR reports is a contract question — re-declaring the clause is the seat's act, not this PR's. ## The fence stopped the narrowing — the card belongs in the decision box Triage asked for the retirement to be *completed*: refuse every spelling of `allowRestore` / `allowPurge`, not just the non-`false` ones. The dispatch fenced that behind a question: is the `false` admission a general deliberate rule, or an incidental hand-written shape? **It is the general rule, and these two keys are its founding case.** Both sources agree; there is no disagreement fork. From #12840, the issue that commissioned the admission: > Ruled semantics (maintainer, 2026-08-28 — not re-adjudicable): > - Value **equals the retired default** (`false`) ⇒ accepted as inert residue and **STRIPPED on parse** … > - Implement as a REUSABLE helper for the class ("retired defaulted key" tombstone shape), applied to both keys — the next defaulted-key retirement uses the same helper rather than reinventing the judgement. From `packages/spec/src/shared/retired-key.ts`, on the helper itself: > 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 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.) And the shape is not hand-written: `permission.zod.ts` builds both `ObjectPermissionSchema` and `EffectiveObjectPermissionSchema` by calling that shared helper with a captured literal (`acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE)`). Refusing `false` for these two keys is therefore a **local exception to a general, maintainer-ruled, explicitly non-re-adjudicable rule** — a contract decision, not ordinary work. Fence branch 1. Not written here. Two further readings the decision needs: 1. **#12840 anticipated exactly triage's complaint and prescribed stopping on it.** Its own bounds say: *"If the parse and authoring paths cannot be distinguished where the tombstone sits, say so precisely and STOP (that would change the card's shape)."* That is the live tension. For a **TypeScript** author the paths *are* distinguished — `z.input` types the key `never`, so writing `allowRestore: false` in `.ts` source fails `tsc` before anything runs, and that channel is pinned. For a **JSON** author (`objectstack.json`) there is no such channel: the same `safeParse` serves the hand-written source and the machine-built artifact, so a JSON source writing `allowRestore: false` gets the artifact's silent acceptance. Triage's "an author writing `allowRestore: false` gets no signal at all" is true for JSON sources and false for TypeScript ones. 2. **The current behaviour is not merely tolerated, it is pinned as an acceptance suite.** `permission.test.ts` carries a whole `[#12840]` block asserting the strip, the round-trip fixpoint, a 75-occurrence artifact parsing with no warning storm, copy-on-write pass-through, and the `@ts-expect-error` compile channel. Narrowing the accept set means deleting or inverting that commissioned suite — which is the seat's call to commission, not a dev's to take. ## Liveness, measured on raw source (the precondition) Raw-source sweep over the tracked tree at `origin/main` (`git grep -o -P`, occurrence counts, not line counts — the parsed-output probe the card warns about would read 0 everywhere for a structural reason and prove nothing): | population | occurrences | |:--|--:| | `allowRestore: false` / `allowPurge: false` — every spelling, whole repo | **181** | | of which data carriers in one built-artifact fixture (`packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json`) | 150 (75 + 75) | | of which data carriers in test/fixture literals across `runtime`, `metadata`, `metadata-core`, `spec` | ~17 | | of which prose/comments (CHANGELOGs, ADR-0049, liveness ledger, migration guidance, the schema's own docblocks) | the remainder | | lit control `allowTransfer: false` (a live key on the same objects) | 57 | | lit control `allowCreate: false` | 63 | | dark control `allowTeleport: false` (fabricated) | 0 | The 150-occurrence fixture is the #12840 founding artifact itself, and `packages/metadata/src/plugin-artifact-forward-conversion.test.ts` parses it. Sibling repo `objectui` (`origin/main`) carries 26 mentions, including two test files named `*.retiredLifecycleKeys.test.*` that pin the console's handling of these bits — cross-repo carriers a narrowing would also have to move. So: refusing `false` breaks in-tree data today, and the population is not small. ## What this PR does land: the card's own ask The card asked that whichever surface discusses this state the parse-time behaviour precisely. Three of them were imprecise in the same direction — they described a refusal that covers `false`, which is not what the schema does: - `content/docs/permissions/permission-metadata.mdx`: *"Authoring either key is now a loud publish-time error"* — true of every value except the one an upgrading author is most likely to still have. - `content/docs/protocol/objectql/security.mdx`: *"the schema now refuses them"* — same. - `packages/spec/src/security/permission.zod.ts`: *"Every other value still lands here"* — accurate but reads as a truthy/falsy split when paired with the sentence above it. All three now state: exactly one value is tolerated, the boolean literal `false`, compared by identity; `"false"`, `0`, `1`, `"true"` and `null` are refused exactly like `true`, same `code` / `expected` / message; and no post-parse guard is meaningful, because on JSON-derived validated data the key is always `undefined` — presence, truthiness and `=== true` are all dead code — so the `false`-versus-other distinction has a live consumer only in pre-parse tooling over raw sources. ## Truth table, re-run on this tree (not inherited) The card's table was taken at `cef399be82`'s base. Re-measured at merge base `65ad77d5a` via `pnpm --filter @objectstack/spec exec tsx`, for **both** keys, on `ObjectPermissionSchema` and on the wire clone: `false` → parses, key stripped; `true`, `"true"`, `"false"`, `0`, `1`, `null` → refused, `code: 'invalid_type'`, `expected: 'never'`, path `["allowRestore"]`; key absent → parses, never added. Confirmed byte-for-byte. **One correction to the card, measured.** The card states a parsed object *"can never carry `allowRestore` or `allowPurge` at all"* and that `'allowRestore' in permissions` is dead code. That is true for every input reachable from JSON, and false for one input that is not: an in-memory TS/JS object carrying an **explicit `undefined`** (`{ allowRestore: undefined }` — what spreading an object that once held the key produces) parses, and the key survives as an **own property** with value `undefined`. So `'allowRestore' in parsed` can be `true`; `parsed.allowRestore === true` and the truthiness check stay dead in every case, and `JSON.parse(JSON.stringify(parsed))` drops the key again. This is now pinned, and the prose says it. Pins added: the existing refusal matrix grows `'true'` and `1`, runs over **both** keys, and now asserts the issue shape (`invalid_type` / `expected: 'never'`) at the key's own path rather than only the message; plus the explicit-`undefined` characterization above. ## Verification Merge base `65ad77d5a`; head as pushed. Everything below captured as `cmd > log 2>&1; EXIT=$?`, never through a pipe. - `pnpm --filter '@objectstack/spec^...' build` + `pnpm --filter @objectstack/spec build` — VERDICT command-exit 0 (under `scripts/pm/os-verify-lock.sh`). - `pnpm --filter @objectstack/spec exec vitest run src/security/permission.test.ts` — exit 0, 63 tests passed. `pnpm --filter @objectstack/spec typecheck` — exit 0. - `pnpm --filter @objectstack/spec check:generated` — exit 0, all 15 generated artifacts up to date (`check:docs`, `check:authorable-surface`, `check:api-surface`, `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide` among them). `migrations/registry.ts` is untouched: no ADR-0087 conversion is owed, because nothing authorable changes shape. - `check:react-declaration-parity` — exit 0, `MANIFEST=/…/objectstack-issue-17425/sdui.manifest.json` (the committed root manifest; the first attempt pointed at `packages/spec/sdui.manifest.json`, which does not exist, and its exit 1 was a wrong-reason red, discarded). - 25 further path-derived gates run individually, all exit 0, including `check:adr-0087-registration`, `check:empty-changeset`, `check:changeset-no-major`, `check:changeset-fixed`, `check:closing-keyword-parity`, `check:nul-bytes`, `check:spec-docblock-symbol-anchors`, `check:doc-frontmatter`, `check:doc-anchors`, `check:doc-authoring`, `check:docs-single-h1`, `check:docs-audit-scope`, `check:affected-docs`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:liveness`, `check:exported-any`. - **Declared narrowing:** the full `@objectstack/spec` suite and the repo-wide gate farm are left to CI. The narrowing is measured, not assumed — the schema diff is comment-only (0 non-comment changed lines, lit control 24 on the test file), so no runtime behaviour can move for any other suite. ## Scope note The file face the claim declared was `permission.zod.ts`, its tests, an ADR-0087 entry with its regenerated registry block, and a changeset. **The face moved in both directions and the seat should amend it:** it *shrank* (no ADR-0087 entry and no `migrations/registry.ts` regeneration — the fence removed the change that would have owed them) and it *grew* by two hand-written docs pages, `content/docs/permissions/permission-metadata.mdx` and `content/docs/protocol/objectql/security.mdx`, which are the "downstream consumer docs" the card's ask names and which carried the same imprecision. Both were measured free of every open PR's branch at write time. Written by an `os-dev` subagent under the `domain:spec` execution seat's claim, in session `session_01MkQhmuuJAVDjmeWNixwDDH`; it posted no second claim and did not write the assignee. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d414e2b commit 396eae3

5 files changed

Lines changed: 110 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): state the retired `allowRestore` / `allowPurge` parse-time accept set exactly (#17425)
6+
7+
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.
8+
9+
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.
10+
11+
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.
12+
13+
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.
14+
15+
<!-- 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. -->

content/docs/permissions/permission-metadata.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,24 @@ objects: {
101101
> now a loud publish-time error carrying this prescription. The keys return
102102
> with the M2 lifecycle initiative (feature + RBAC in one batch, #1883);
103103
> until then a dispatched `restore` / `purge` is denied unconditionally.
104+
>
105+
> **Exactly one value is still accepted, and it is not an author's to write:**
106+
> the literal `false` that the published 17.x toolchain materialized into every
107+
> permission entry of every artifact it built. That emitted default parses as
108+
> inert residue and is silently **stripped** (#12840, so upgrading a runtime
109+
> does not kill artifacts nobody re-published) — a source that still writes
110+
> `allowRestore: false` therefore gets **no error and no key**. Every other
111+
> value is refused: `"false"`, `0`, `1`, `"true"` and `null` all fail exactly
112+
> like `true`, with the same message. **This is not a truthy/falsy split**
113+
> only the boolean literal `false` is tolerated.
114+
>
115+
> **Consumers of validated data never see either key**, so a post-parse guard
116+
> is dead code: `permissions.allowRestore` is always `undefined`, which makes
117+
> `'allowRestore' in permissions`, `if (permissions.allowRestore)` and
118+
> `permissions.allowRestore === true` all unable to fire. Only **pre-parse**
119+
> tooling reading raw source (a linter or migration tool over
120+
> `objectstack.json`) can tell legacy `false` residue from an ADR-0049
121+
> violation — and it should say something different about each.
104122
105123
## Field Permissions
106124

content/docs/protocol/objectql/security.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ Beyond the four CRUD flags, the schema also exposes lifecycle and super-user gra
109109
> a migration prescription. A dispatched `restore` / `purge` is denied
110110
> unconditionally (fail-closed destructive-operation backstop). The flags
111111
> return with the M2 lifecycle initiative (feature + RBAC in one batch, #1883).
112+
>
113+
> One exception, by ruling: the boolean literal `false` — the default the
114+
> published 17.x toolchain emitted into every built artifact — parses as inert
115+
> residue and is silently stripped (#12840), so it raises no error and reaches
116+
> no parsed output. Every other value, `"false"` and `0` included, is refused
117+
> exactly like `true`; this is not a truthy/falsy split. Since neither key can
118+
> reach validated data, guards belong in pre-parse tooling over raw sources,
119+
> never in code reading a parsed permission set.
112120

113121
> 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.
114122

packages/spec/src/security/permission.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,43 @@ describe('[#12840] the RETIRED DEFAULT parses as inert residue and strips (class
236236
// The helper contract: the residue value is the literal captured at
237237
// retirement time (`false`), compared by identity. Falsy near-misses are
238238
// NOT the emitted default and land on the tombstone like any authored value.
239-
for (const wrong of [true, 0, '', null, 'false'] as const) {
240-
const r = ObjectPermissionSchema.safeParse({ allowRestore: wrong } as never);
241-
expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false);
242-
expect(r.error!.issues.map((i) => i.message).join('\n')).toContain('ObjectQL operation it claimed');
239+
// [#17425] The full matrix, re-measured on this tree: NOT a truthy/falsy
240+
// split — the string `'true'` and the number `1` are refused exactly like
241+
// the string `'false'` and the number `0`, all with the same issue shape.
242+
for (const wrong of [true, 0, 1, '', 'true', 'false', null] as const) {
243+
for (const key of ['allowRestore', 'allowPurge'] as const) {
244+
const r = ObjectPermissionSchema.safeParse({ [key]: wrong } as never);
245+
expect(r.success, `value ${JSON.stringify(wrong)} must NOT be tolerated`).toBe(false);
246+
const issue = r.error!.issues.find((i) => i.path.join('.') === key)!;
247+
expect(issue, `${key}=${JSON.stringify(wrong)} must be refused AT ITS OWN PATH`).toBeDefined();
248+
expect(issue.code).toBe('invalid_type');
249+
expect((issue as unknown as { expected?: string }).expected).toBe('never');
250+
expect(issue.message).toContain('ObjectQL operation it claimed');
251+
}
243252
}
244253
});
245254

255+
it('[#17425] the only post-parse observation left: an EXPLICIT `undefined` survives as an own key', () => {
256+
// The consumer-facing claim this pins (prose on `ObjectPermissionSchema`):
257+
// on data that came from JSON no post-parse guard can ever fire — `false`
258+
// strips and every other JSON value throws, so the key is always
259+
// `undefined`. But an in-memory TS/JS input carrying an explicit
260+
// `undefined` — what spreading an object that once had the key produces —
261+
// parses AND keeps the OWN key, so a presence check can still be true.
262+
// ⛔ If a zod upgrade moves this, re-measure and rewrite the prose; do not
263+
// relax the pin, because the prose is what consumers act on.
264+
const parsed = ObjectPermissionSchema.parse({ allowRead: true, allowRestore: undefined } as never) as Record<string, unknown>;
265+
expect(Object.prototype.hasOwnProperty.call(parsed, 'allowRestore')).toBe(true);
266+
expect('allowRestore' in parsed).toBe(true);
267+
expect(parsed.allowRestore).toBeUndefined();
268+
// …and the two guards consumers were told to write stay dead regardless.
269+
expect(parsed.allowRestore === true).toBe(false);
270+
expect(Boolean(parsed.allowRestore)).toBe(false);
271+
// JSON cannot spell it: a serialize round-trip drops the key again, which
272+
// is why raw JSON sources can never reach this branch.
273+
expect('allowRestore' in (JSON.parse(JSON.stringify(parsed)) as object)).toBe(false);
274+
});
275+
246276
it('the residue strips inside a full permission-set / stack-shaped parse (the artifact path)', () => {
247277
// The measured refusal was located at
248278
// `permissions[5].objects.crm_campaign_member.allowRestore` — a composed

packages/spec/src/security/permission.zod.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject(
217217
* and is STRIPPED by the residue stage on {@link ObjectPermissionSchema}
218218
* (`OBJECT_PERMISSION_RETIRED_KEY_RESIDUE`); the tombstones below never see
219219
* it. Every other value still lands here, prescription intact.
220+
*
221+
* [#17425] The accept set that leaves behind, stated exactly — the residue
222+
* stage tolerates ONE value, the boolean literal `false`, compared by
223+
* identity against the captured literal. **This is not a truthy/falsy
224+
* split**: `"false"`, `0`, `''` and `null` are refused exactly like `true`
225+
* is, with the same `code: 'invalid_type'` / `expected: 'never'` issue at
226+
* the key's own path and the same guidance string. Anything that is not the
227+
* captured literal is an authored claim, and authored claims are what the
228+
* tombstone exists to refuse. The matrix is pinned in `permission.test.ts`.
220229
*/
221230
allowRestore: retiredKey(
222231
'`objects.<object>.allowRestore` was removed in @objectstack/spec 17 (ADR-0049) — ' +
@@ -293,6 +302,32 @@ const ObjectPermissionBaseSchema = lazySchema(() => strictObject(
293302
* tombstone with its prescription. Maintainer ruling 2026-08-28 (recorded on
294303
* objectstack-ai/cloud#1685): a retired key that had a schema default is
295304
* refused only when it carries a non-default value.
305+
*
306+
* ## [#17425] What a consumer of PARSED output can observe: effectively nothing
307+
*
308+
* Every spelling reachable from JSON is gone by the time you hold parsed data:
309+
* `false` is stripped, and every other JSON-expressible value (`true`,
310+
* `"true"`, `"false"`, `0`, `1`, `null`) throws before a parsed object exists.
311+
* So on validated data `permissions.allowRestore` is always `undefined`, which
312+
* makes `if (permissions.allowRestore)` and `permissions.allowRestore === true`
313+
* dead code — a post-parse guard against either bit can never fire, and
314+
* `=== true` is no fix for a truthiness check because a raw `true` never
315+
* survives the parse either.
316+
*
317+
* ONE observation survives, and it is not reachable from JSON: an in-memory
318+
* TS/JS input carrying an EXPLICIT `undefined` (`{ allowRestore: undefined }`
319+
* — the shape a spread of an object that once carried the key produces) parses,
320+
* and the key survives as an OWN property whose value is `undefined`. So
321+
* `'allowRestore' in parsed` can be `true` while the value is still undefined;
322+
* `JSON.parse(JSON.stringify(parsed))` drops it again. Measured and pinned in
323+
* `permission.test.ts`.
324+
*
325+
* ⇒ A `false`-versus-other distinction has a live consumer only in PRE-PARSE /
326+
* raw-source tooling — a linter or migration tool reading `objectstack.json`
327+
* (or a `.ts` source) before validation, where `false` is inert legacy residue
328+
* and any other value is a hard ADR-0049 violation. Those two facts deserve
329+
* different messages; a presence or truthiness check on raw input conflates
330+
* them, and the same check on parsed output measures nothing at all.
296331
*/
297332
export const ObjectPermissionSchema = lazySchema(() =>
298333
acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE),

0 commit comments

Comments
 (0)