Skip to content

docs(spec): state the retired allowRestore / allowPurge parse-time accept set exactly - #17485

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-17425-retired-permission-bits-refuse-every-spelling
Sep 12, 2026
Merged

docs(spec): state the retired allowRestore / allowPurge parse-time accept set exactly#17485
os-bill merged 2 commits into
mainfrom
claude/issue-17425-retired-permission-bits-refuse-every-spelling

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

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. feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips; non-default values keep the loud refusal (#12497 class rule) #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

…exactly

`ObjectPermissionSchema`'s retired `allowRestore` / `allowPurge` tolerate
exactly ONE value — the boolean literal `false` the published 17.x toolchain
materialized into every built artifact, accepted as inert residue and stripped
(#12840). The prose around them said only "every other value" and "a loud
publish-time error", which reads as a truthy/falsy split and as a refusal that
covers `false` too. Neither is what the schema does.

State it precisely in the three places a consumer reads — the tombstone
docblock, the exported schema's docblock, and the two hand-written permission
docs — including the consequence: on validated data neither key can ever
appear, so `'allowRestore' in permissions`, `if (permissions.allowRestore)`
and `=== true` are all dead code, and only pre-parse tooling over raw sources
can tell legacy `false` residue from an ADR-0049 violation.

Pins: extend the refusal matrix to the full measured set (`'true'` and `1`
join `true` / `0` / `''` / `'false'` / `null`), assert the issue shape
(`invalid_type`, `expected: 'never'`) at the key's own path for both keys, and
record the one post-parse observation that does survive — an explicit
`undefined` in an in-memory input keeps the own key, which JSON can never
spell.

No accept set moves.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
…correction

The tarball ships `src/**/*.zod.ts` and the `dist/*.d.ts` that carries the
same TSDoc, so the corrected prose is published bytes even though no value,
key or exported symbol moves.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in ObjectPermissionBaseSchema))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in ObjectPermissionBaseSchema))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in ObjectPermissionBaseSchema))
  • content/docs/permissions/permission-metadata.mdx (via allowRestore (literal, a string literal on a changed line))
  • content/docs/permissions/permission-sets.mdx (via allowRestore (literal, a string literal on a changed line))
  • content/docs/permissions/permissions-matrix.mdx (via allowRestore (literal, a string literal on a changed line))
  • content/docs/protocol/objectql/security.mdx (via allowRestore (literal, a string literal on a changed line))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in ObjectPermissionBaseSchema))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via allowRestore (literal, a string literal on a changed line))
  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in ObjectPermissionBaseSchema))
  • content/docs/releases/v17/17-3.mdx (via allowRestore (literal, a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 010c48a09d0ccfb75be2106e98b09558aa2e542apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 4803c24787890b6f062be379aeb0845782dfeac0 — the merge of head a9469d6b445955cdcef58d6113c02a6cc66aba23 into base 010c48a09d0ccfb75be2106e98b09558aa2e542a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4803c24787890b6f062be379aeb0845782dfeac0 && git checkout 4803c24787890b6f062be379aeb0845782dfeac0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 010c48a09d0ccfb75be2106e98b09558aa2e542a a9469d6b445955cdcef58d6113c02a6cc66aba23 && git checkout -B drift-repro 010c48a09d0ccfb75be2106e98b09558aa2e542a && git merge --no-ff a9469d6b445955cdcef58d6113c02a6cc66aba23

node scripts/docs-audit/affected-docs.mjs --json 010c48a09d0ccfb75be2106e98b09558aa2e542a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 010c48a09d0ccfb75be2106e98b09558aa2e542a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

This PR has been green and accepted for ~32 hours and was not landed. That is this seat's miss, not a gate's.

Read 2026-09-12T00:55Z on head a9469d6b (unmoved since 2026-09-10T17:01Z). The ACCEPT verdict was posted on the card at 2026-09-10T17:06Z. ⛔ Nothing has been red at any point since; the PR simply was not picked back up.

All four landing pre-checks, measured now on THIS head

# check reading
at-tier verdict not owed — both carriers now declare Clause-②: no and agree
check-clause2-carriers --pair 17485 exit 0 — 「the clause-② declaration is readable in the fixed spelling and both carriers agree」
check-governed-merges --test over the FINAL 5-path diff 0 of 5 ⇒ not governed. ⚠️ First control I picked (.github/CODEOWNERS) did NOT light — it returns exit 0 too, so that run proved nothing and I discarded it. Re-read the register at scripts/pm/check-governed-merges.mjs:793: it is exactly five surfaces (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) — CODEOWNERS is genuinely not among them, so my control choice was wrong, ⛔ not the instrument. Re-run with AGENTS.md: 1 of 1, exit 3. ⇒ the reading stands
checks on a9469d6b, newest-run-per-check-NAME 34 names, 30 success, 4 skipped (Auto Label, Check PR Size, Packed-tarball smoke (opt-in), Console Pin Gate). 0 failures, 0 pending. mergeable_state: clean against today's main

Why it is still not landed right now

⛔ Not a gate and ⛔ not a review question — the draft→ready flip is failing route-wide:

POST /repos/objectstack-ai/objectstack/pulls/17485/ccr/ready_for_review  → 503
POST /repos/objectstack-ai/objectstack/pulls/17711/ccr/ready_for_review  → 503   (8 attempts, 00:1x–00:53Z)
MCP update_pull_request(draft:false)                                     → rate limit, user 324100929

⭐ The #17485 attempt is the control for that claim: the 503 reproduces on a second, unrelated PR, so it is the route and ⛔ not something about #17711. Read back after every attempt: draft is still true on both, so ⛔ nothing half-landed.

⚠️ What is NOT affected by the director's ruling

#17425 was ruled D by the director seat on 2026-09-11T11:53Z (os-tesla, class-one self-adjudication): the parse stays exactly as #12840 ruled, and the missing authoring signal moves to a pre-parse lint rule over raw objectstack.json. This PR is unaffected by that and must not be reworked for it: it lands the card's own ask — stating the parse-time behaviour precisely in prose plus tests — and the fence in its round is what stopped the narrowing that D also rejects. It carries Part of #17425, ⛔ not a closing keyword, so merging it leaves the card open for D's lint-rule ask. ⇒ the card correctly sits at pm:queue with no assignee.

Disposition: queued to land on the next successful flip, with ④ re-read at that moment (the ready flip re-triggers Check Changeset, so the table above goes stale the instant the flip succeeds). ⛔ Not closed, ⛔ not reworked, ⛔ not waiting on anyone.

domain:spec execution seat · session_01MkQhmuuJAVDjmeWNixwDDH · readings taken 2026-09-12T00:55Z


Generated by Claude Code

@os-bill
os-bill marked this pull request as ready for review September 12, 2026 01:24
@os-bill
os-bill added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 396eae3 Sep 12, 2026
45 of 46 checks passed
@os-bill
os-bill deleted the claude/issue-17425-retired-permission-bits-refuse-every-spelling branch September 12, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants