Skip to content

feat(spec): retire the allowRestore / allowPurge object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049) - #12619

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12497-retire-allowrestore-allowpurge
Aug 27, 2026
Merged

feat(spec): retire the allowRestore / allowPurge object-permission bits — declared gates on operations that do not exist (#12497, ADR-0049)#12619
os-zhuang merged 2 commits into
mainfrom
claude/issue-12497-retire-allowrestore-allowpurge

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12497

Retires the two declared-but-unenforceable object-permission props allowRestore and allowPurge from packages/spec, per the maintainer ruling of 2026-08-26 (decision-inbox batch 5, verbatim 「12452 不处理,其他接受」, accepting #1883's presented recommendation B). The keys return with the M2 lifecycle initiative (feature + RBAC in one batch, maintainer 2026-08-03); anchor card #1883 stays open and is deliberately not referenced by any closing keyword here — #1883 remains open, and is out of scope for closure.

Clause-②: yes; dispatched at review tier (published-surface accepting-set change; the dispatch ran at the contract-review tier).

allowTransfer is untouched — enforced (#3004), stays.

Day-of re-measurement (obligation 1)

Measured on origin/main @ 7a25e7d60 (2026-08-26):

  • Declarations: packages/spec/src/security/permission.zod.ts:157-158as premised.
  • Operation→key alias rows: permission.zod.ts:61-62as premised.
  • Reader: packages/spec/src/security/high-privilege.ts:65 reads p.allowPurgeas premised.
  • Evaluator pre-wiring: the PM's recorded path is stale — the rows are NOT at packages/objectql/.../permission-evaluator.ts:14-24; the evaluator lives at packages/plugins/plugin-security/src/permission-evaluator.ts (rows at :23-24, CRUD folding at :100-102). The pre-wiring itself exists exactly as premised — same map, same fail-closed backstop — so the premise stands; only its address moved.
  • No restore/purge ObjectQL operation exists (packages/objectql/src non-test grep: zero; the test(objectql): prove purge/transfer/restore never reach the security AST path, and pin it (#7809) #8106 vocabulary pin still pins the 7-verb union).
  • Readers that appeared since the anchor: security-plugin.test.ts (fixtures + gating tests), controlled-by-parent-detail-write-authority.test.ts (CRUD fixture + by-id op loop), audience-anchors.test.ts:60, qa/dogfood/test/authz-conformance.matrix.ts:329 (prose note), liveness ledger entries (status live, verifiedAt 2026-07-30, citing the evaluator rows). All addressed below.
  • objectui has authoring-surface consumers (out of this card's repo scope — filed separately, see "Out-of-scope findings"): PermissionMatrixEditor.tsx renders authoring columns for both keys, permission-slice.ts types them, PermissionPreview.tsx reads them.

Route choice (playbook §2)

ObjectPermissionSchema is a strictObject, but the def is reachable from the permission metadata root, so the strict-deletion route (the data/Metric:filters precedent) is unavailable: gate (c) of build-schemas.ts refuses deletion of a LIVE baseline line on a reachable def. The route is the retiredKey() tombstone — the rls.priority precedent, same module family:

Ratchet readings (route-relative expectations, playbook §2)

Tombstone route ⇒ surface rows move to [RETIRED], never vanish: authorable-surface/security.json 4 rows marked, authorable-defaults/security.json −4 rows, api-surface/ unchanged (no export changed), json-schema.manifest/ unchanged (def still emitted; these property names were not inlined in the security shard before either — verified by control grep at BASE). spec-changes.json / upgrade guide byte-identical by design: the projection aggregates to the current protocol major (17) and major-18 conversions join it at the version bump — verified with the landed metric-filters-removed control, which is likewise absent.

Liveness-ledger verdict on the pre-wired evaluator rows (obligation 2)

The ledger's 2026-07-30 live verdicts for both keys cited only the evaluator pre-mapping rows as evidence. Measured today: those rows were the keys' sole non-test readers, and with the keys tombstoned the rows' grant condition became unsatisfiable through any parse — a mapping onto an unwritable bit is a claim about a surface that rejects authoring. Verdict: the rows retire in the same batch (not the dormant-pre-wiring posture), because:

  1. the test(objectql): prove purge/transfer/restore never reach the security AST path, and pin it (#7809) #8106 posture's load-bearing half is preserved and pinned: a dispatched restore/purge stays denied fail-closed via DESTRUCTIVE_OPERATIONS (now unconditionally — the modifyAllRecords bypass no longer reaches an unmapped destructive op; it re-covers them when the M2 batch re-adds the rows, the Salesforce semantics confirmed in the [security][立项位] M2 权限生命周期(undelete/purge)功能与 allowRestore/allowPurge RBAC 同批建设(evaluator 已 fail-closed,allowTransfer 已 enforced) #1883 disposition);
  2. keeping the rows would have let a legacy stored 17-era allowRestore/allowPurge: true row grant a future operation without re-adjudication;
  3. behaviour is deny-before and deny-after for every production-reachable input (the vocabulary pin proves the verbs cannot be dispatched today).

Ledger entries flip to dead with the retiredKey evidence and stay (tombstone route keeps the keys in the walked shape — the rls.priority note pattern); ledger README + generated state-counts.md updated. The CLI liveness advisory lint is ledger-driven and opt-in (authorWarn), which these entries don't set — no lint change needed.

high-privilege.ts:65 (obligation 3)

The p.allowPurge read is removed. Reasoning from measurement: the predicate deliberately accepts legacy ROW shapes, but a stored legacy allowPurge: true now grants nothing (no operation, no evaluator row, unwritable bit), so flagging it guarded nothing real. The D5 class message delete/purge/transfer is kept (the class returns with M2); the code comments instruct restoring the read in the same PR that restores the bit. Anchor bindings are re-checked at boot, so a legacy value regains no privilege silently at M2. Pinned in audience-anchors.test.ts (a legacy allowPurge: true no longer flags; allowDelete/allowTransfer still do).

Verification

(commands run through scripts/pm/os-verify-lock.sh; exit codes captured before any pipe; readings at final head below; full pre-merge run at 0e9cee239, post-merge union at head 6f88d6900)

  • pnpm --filter @objectstack/spec build — VERDICT command-exit 0 (includes gen:schema gates a0/a/b/b2/c: the 4 tombstones registered and accepted)
  • pnpm --filter @objectstack/spec check:generated — "✓ All 14 generated artifacts are up to date" (VERDICT command-exit 0)
  • full pnpm --filter @objectstack/spec test at 0e9cee239: "Test Files 435 passed (435) / Tests 11529 passed (11529)" (VERDICT command-exit 0)
  • pnpm --filter @objectstack/spec typecheck, pnpm --filter @objectstack/plugin-security typecheck — VERDICT command-exit 0
  • full pnpm --filter @objectstack/plugin-security test: "Test Files 85 passed (85) / Tests 1554 passed (1554)" (VERDICT command-exit 0) — after two suite amendments this retirement forces: the crudBucketForOperation pins (restore/purge now resolve null, pinned as such) and store-fault-fail-closed.test.ts (its probe verb moved purge to transfer — the only lifecycle verb a caller can still be GRANTED, so the two-doors "admitted" leg stays measurable; the fail-closed purge posture is pinned in security-plugin.test.ts instead, including the legacy-stored-grant and modifyAllRecords directions)
  • dogfood conformance (test/authz-conformance.test.ts, test/expression-conformance.test.ts): "Test Files 2 passed (2) / Tests 30 passed (30)"
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths — script derived the changeset itself, stderr line names this repo at 0e9cee239): every named runnable family run per-gate with individually captured exit codes — doc gates (check:doc-anchors, check:doc-authoring, check:docs-audit-scope, check:docs-redirects, check:docs-single-h1, check:role-word, check:quick-reference-counts, lint check:doc-formula-expressions, lint check:doc-security-posture) all PASS; test-code walkers (check:engine-double-contract, check:where-matcher, check:cross-package-test-inputs, check:test-source-alias, check:type-source-resolution, check:spec-parsed-alias, check:query-options-erasure, check:objectql-double-limit) all PASS; misc (check:i18n, check:i18n-stale-fill, check:changeset-gate-self-tests, check:merge-driver, check:driver-memory-census, check:objectui-changeset, check:page-declaration-shape, check:published-files, check:published-readme-links, check:react-page-adapter-contract, check:slot-lookup) all PASS; spec dist-audits (check:dual-source-exports, check:entry-nameability, check:exported-any) PASS after a rebuild (first attempt refused on a stale-mtime dist — NOT MEASURED, not red)
  • check:nul-bytes — OK (6982 files scanned)
  • Consciously left to CI (named per the lane standard): check:type-check-coverage / check:type-check-debt (needs the whole-workspace turbo build + per-ledger tsc re-measure — a repo-wide farm run; my new test code already typechecks inside its packages via spec's check:test-typecheck artifact gate and plugin-security's typecheck), repo-wide pnpm lint (CI-owned run), check:react-declaration-parity (EXTERNAL_INPUT_REQUIRED — objectui manifest, pin-bump-triggered by settled ruling)
  • Landing-watch: PR feat(spec): declare record:details section headerColor as a closed six-token enum #12616 merged to main mid-flight; merged into this branch (merge, not rebase — commit 6f88d6900) and the union re-run on the merged head: pnpm --filter @objectstack/spec build (gen gates) + check:generated ("All 14 generated artifacts are up to date", zero drift from the merge) + spec src/security/ suite (178 passed) + full plugin-security suite (1554 passed) — VERDICT command-exit 0 at 6f88d6900

Reverse verification

Fix committed first (0e9cee239), then the schema alone reverted to the pre-retirement BASE (git checkout 7a25e7d60 -- packages/spec/src/security/permission.zod.ts) with the new pins kept:

  • RED leg (direction observed: turned red, as predicted):src/security/permission.test.ts — "51 tests | 4 failed", and the four are EXACTLY the new retirement pins ("absent parses clean", "authored values reject with the prescription", "the bare verbs carry the prescription", "the tombstone rides into the EffectiveObjectPermission clone"); the 47 pre-existing tests stayed green.
  • Restore leg:git checkout HEAD -- packages/spec/src/security/permission.zod.ts; restoration proven by observation, not exit code — git diff HEAD EMPTY and git status clean (0 lines each); re-run: "Tests 51 passed (51)" (VERDICT command-exit 0).

No ablation of built artifacts was involved in this leg — the pin file imports ./permission.zod source directly (in-package relative import, no dist resolution), so the red/green readings are of the reverted/restored source itself; the dist-reading audits were separately re-run after a full rebuild (see above).

Out-of-scope findings

  • metadata-admin permission matrix still authors the retired allowRestore / allowPurge bits — spec now rejects them at publish objectui#6595 (filed this run, unassigned): the metadata-admin permission matrix still AUTHORS the retired bits — PermissionMatrixEditor.tsx:180-181 renders allowRestore/allowPurge columns whose save the spec now refuses at publish; permission-slice.ts:27-28 types them; PermissionPreview.tsx:59-60,86 renders and lints over them. Out of this card's repo scope; lands with the next spec bump. Dedup: repo-scoped REST search is blocked on this seat (channel probe: repo-scoped REST reads answer 200, /search/issues refuses with "sessions are bound to their configured repositories"), so the dedup ran as ONE targeted MCP search_issues over objectstack-ai/objectui — 3 hits, all PermissionMatrixEditor write-tier cards, none about the retired keys.

Remaining allowRestore|allowPurge mentions (acceptance sweep)

git grep -E "allowRestore|allowPurge" at head returns only:

  • the tombstones, guidance, conversion, registry entries, ledger + pin tests this PR adds (the ADR-0087 tombstone/registry class);
  • immutable history: CHANGELOG.md files, content/docs/releases/v12.mdx (⛔ release-owned, never touched in a code PR);
  • dated audit records: docs/audits/2026-06-*, 2026-07-*, docs/audits/README.md, packages/spec/ZOD_SCHEMA_AUDIT_REPORT.md (historical measurements, the feat(spec): retire the orphan CLICommandContributionSchema export after the contributes.commands tombstone (#12007, ADR-0049) #12613 precedent leaves these standing);
  • ADR prose: ADR-0049 / ADR-0090 (historical decision records);
  • runtime prose/comments that now DESCRIBE the retirement (evaluator, security-plugin step-2.7 comment, dogfood matrix note).

All live teaching surfaces (content/docs/permissions/*, protocol/objectql/security.mdx, generated references/*) are updated.


Generated by Claude Code

…ts (#12497, ADR-0049)
Maintainer ruling 2026-08-26 (decision-inbox batch 5) accepting #1883's
recommendation B. The restore/purge ObjectQL operations the bits claimed to
gate have never existed (#8106 vocabulary pin), so authoring them granted
nothing. retiredKey() tombstone route (def reachable from the permission
root — the rls.priority posture); the evaluator's pre-mapping rows retired in
the same batch, with DESTRUCTIVE_OPERATIONS keeping restore/purge denied
fail-closed. The keys return with the M2 lifecycle initiative; #1883 stays
open as the anchor.
- retiredKey tombstones + former-alias guidance in permission.zod.ts
- ADR-0087: 4 retired-key entries under major 18, D2 conversion
permission-allow-restore-purge-removed, step-18 chain wiring
- liveness ledger entries flipped dead (tombstone route keeps them);
README + state-counts regenerated
- evaluator rows retired; high-privilege allowPurge read removed
- pin tests: spec refusal pins, plugin-security fail-closed pins,
audience-anchor predicate pin; store-fault suite probe verb purge→transfer
- regenerated authorable-surface/, authorable-defaults/, reference docs;
hand-written permission docs updated
- changeset: spec minor (launch-window convention), plugin-security patch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xGvxcwPRTJfA7RfjXEYA4
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-security, @objectstack/dogfood, @objectstack/spec, touching 17 documentable anchor(s). ⚠️9 changed file(s) yielded no anchor (packages/spec/authorable-defaults/security.json, packages/spec/authorable-surface/security.json, packages/spec/liveness/README.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/concepts/architecture.mdx(via allowDelete (literal))
  • content/docs/getting-started/common-patterns.mdx(via allowDelete (literal), support_agent (literal))
  • content/docs/permissions/authorization.mdx(via allowTransfer (literal))
  • content/docs/permissions/delegated-administration.mdx(via allowDelete (literal))
  • content/docs/permissions/index.mdx(via allowDelete (literal))
  • content/docs/permissions/permission-metadata.mdx(via allowDelete (literal), allowPurge (literal), allowRestore (literal), allowTransfer (literal))
  • content/docs/permissions/permission-sets.mdx(via allowDelete (literal), allowPurge (literal), allowRestore (literal), allowTransfer (literal))
  • content/docs/permissions/permissions-matrix.mdx(via allowDelete (literal), allowPurge (literal), allowRestore (literal), allowTransfer (literal))
  • content/docs/protocol/objectql/security.mdx(via ObjectPermissionSchema (symbol), allowDelete (literal), allowPurge (literal), allowRestore (literal), allowTransfer (literal))

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

  • content/docs/releases/v12.mdx(via allowPurge (literal), allowRestore (literal), allowTransfer (literal))
  • content/docs/releases/v14.mdx(via describeHighPrivilegeBits (symbol), allowDelete (literal))
  • content/docs/releases/v15.mdx(via allowTransfer (literal))
  • content/docs/releases/v17.mdx(via ObjectPermissionSchema (symbol))

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
  • 9 changed file(s) yielded no anchor (packages/spec/authorable-defaults/security.json, packages/spec/authorable-surface/security.json, packages/spec/liveness/README.md, …) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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 — 130 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 527e0505d8729ae022121a992b62326035953a82packageMentionDocs.

Which tree this was computed on

This run read content/docs from d7f2bb20da6a74ab475495f93e08f7a83b84d8c3 — the merge of head 6f88d6900445dced778c039fb292d5b147cc4e45 into base 527e0505d8729ae022121a992b62326035953a82, 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 d7f2bb20da6a74ab475495f93e08f7a83b84d8c3 && git checkout d7f2bb20da6a74ab475495f93e08f7a83b84d8c3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 527e0505d8729ae022121a992b62326035953a82 6f88d6900445dced778c039fb292d5b147cc4e45 && git checkout -B drift-repro 527e0505d8729ae022121a992b62326035953a82 && git merge --no-ff 6f88d6900445dced778c039fb292d5b147cc4e45
node scripts/docs-audit/affected-docs.mjs --json 527e0505d8729ae022121a992b62326035953a82

⚠️ 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 527e0505d8729ae022121a992b62326035953a82 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: retire the allowRestore / allowPurge permission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2)

2 participants

@os-zhuang@claude