Commit 396eae3
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
- .changeset
- content/docs
- permissions
- protocol/objectql
- packages/spec/src/security
Lines changed: 15 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
104 | 122 | | |
105 | 123 | | |
106 | 124 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
109 | 109 | | |
110 | 110 | | |
111 | 111 | | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
112 | 120 | | |
113 | 121 | | |
114 | 122 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
236 | 236 | | |
237 | 237 | | |
238 | 238 | | |
239 | | - | |
240 | | - | |
241 | | - | |
242 | | - | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
243 | 252 | | |
244 | 253 | | |
245 | 254 | | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
246 | 276 | | |
247 | 277 | | |
248 | 278 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
220 | 229 | | |
221 | 230 | | |
222 | 231 | | |
| |||
293 | 302 | | |
294 | 303 | | |
295 | 304 | | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
296 | 331 | | |
297 | 332 | | |
298 | 333 | | |
| |||
0 commit comments