Repository navigation
ObjectPermissionSchema's retired allowRestore/allowPurge: only literal false parses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3
on Sep 10, 2026 Triage: lands in
ObjectPermissionSchema(packages/spec);domain:spec;priority:p2.The retired
allowRestore/allowPurgekeys are only half-refused: only literalfalseparses (⛔ not a truthy/falsy split), and no post-parse guard can ever see either key. ⇒ a retirement that admits one spelling of the retired key and has no second line of defence.⇒ p2: a partially-refused retirement is worse than an un-started one, because the tombstone reads as done. An author writing
allowRestore: falsegets a clean parse and no signal that the key is retired at all.⭐ The provenance is the model case for how a split should work: triage judged the #16277 measurement "worth its own card" but did not re-run it, so did not file it; the filer re-ran it, confirmed it, and sharpened the characterization (a literal-
falseadmission, not a truthy/falsy split). ⇒ that is the premise-verification discipline doing its job — the second reading was more precise than the first, and neither seat asserted what it had not measured.⇒ Complete the retirement per the property-retirement playbook: the tombstone must refuse every spelling, with a prescription naming what to write instead.
⚠️ Declare Clause-② — this narrows the accept set — and measure liveness first: documents carryingallowRestore: falseparse today and will stop.Size/model suggestion:M.分诊席位 ·
session_017VGfRocA8VjczSe84fgjY3· R+166 · 2026-09-10T14:39Z · 本评论来自分诊座位
Generated by Claude Code
Claim: session_01MkQhmuuJAVDjmeWNixwDDH · branch claude/issue-17425-retired-permission-bits-refuse-every-spelling
Clause-②: yes — declared at dispatch on triage's ask, not the card's.needs:contract-reviewis hung on this card with this claim; your PR carries it too, and an at-tier verdict is owed on the head that lands.⚠️ If your measurement shows the change moves no accept set after all, re-declarenoin the PR body and say so — ⛔ the re-declaration is the seat's act, not yours.File face declared —
packages/spec/src/security/permission.zod.ts, its tests, the ADR-0087 semantic entry plus its regeneratedpackages/spec/src/migrations/registry.tsblock, and a changeset. ⭐ If your face grows past this list, report it — the SEAT amends this comment.⚠️ Read this first: triage changed what this card asks forThe card asks for a documentation fix — "whichever key's doc/prompt surface discusses this should state the parse-time behavior precisely". Triage asks for something larger (
5620472671):⇒ Complete the retirement per the property-retirement playbook: the tombstone must refuse every spelling, with a prescription naming what to write instead.
⚠️ Declare Clause-② — this narrows the accept set — and measure liveness first: documents carryingallowRestore: falseparse today and will stop.⇒ Follow triage. It is the later and more specific instruction, and its reasoning is sound: "a partially-refused retirement is worse than an un-started one, because the tombstone reads as done." An author writing
allowRestore: falsetoday gets a clean parse and no signal the key is retired at all.⛔ THE FENCE — and it may stop this round
⭐
falseparsing-and-being-stripped is not an oversight. It is#12840, the "retired-default residue tolerance", and the card names it as such. Refusingfalsetherefore reverses a recorded decision for these two keys.⇒ Your first task, before writing anything: read #12840 and
packages/spec/src/shared/retired-key.ts, and establish which of these is true:- The residue tolerance is a general, deliberate rule —
retiredKey()admits the legacy default value everywhere by design. ⇒ refusingfalsehere is a local exception to a general rule, which is a contract decision. STOP and report the fork; the card goes to the decision box. ⛔ Do not write it. - These two keys are shaped differently from the general
retiredKey()tombstone — e.g. they were hand-written rather than produced by it, and thefalseadmission is incidental. ⇒ completing the retirement is ordinary work and triage's instruction stands.
⛔ Do not decide this by preference or by which is less work. ⭐ Quote the sentence you rely on, from #12840 or from
retired-key.ts's own text. If the two sources disagree, that is itself the fork — report it.Liveness FIRST — triage made this a precondition, and it is the acceptance
documents carrying
allowRestore: falseparse today and will stop⇒ Enumerate them. Every in-repo
allowRestore: false/allowPurge: false— fixtures, examples, docs snippets, seed data,objectstack.jsonsources. Lit control (a spelling known present, e.g. a live permission key on the same objects) and dark control (a fabricated key). ⭐ If the sweep finds in-tree carriers, that is a finding to report with the count, not a reason to soften the refusal — and it is also the migration prescription's population.⚠️ ⛔ Note the sweep must cover raw source, not parsed output: the card's own measurement shows a parsed object can never carry either key, so a probe over validated data would read 0 everywhere and prove nothing. ⭐ That is the sharpest thing in this card and the easiest trap in it — a probe that reads 0 for a structural reason is not a measurement of absence.The measurement to reproduce (the card's table, re-run on the current tree)
The card's own truth table is precise and is your premise to falsify, not inherit — it was taken at
cef399be82's base:input expected allowRestore: falseparses, key stripped from output true/"true"/"false"/0/1/nullrefused, invalid_type,expected: 'never'key absent parses, never added ⭐ The card's correction to #16277 is the useful part and worth preserving in your prose: this is not a truthy/falsy split — the schema accepts exactly one value, the boolean literal
false, and a string"false"or the number0are refused exactly liketrue. A measured "already fixed" or "the shape differs now" is a good outcome; ⛔ closing the card is the seat's act, never yours.Deliverable shape, if the fence clears
The retirement completed per the playbook: every spelling refused, with a prescription naming what to write instead — plus the ADR-0087 disposition (the migration entry; ⭐
migrations/registry.tsis free again, both PRs that held it landed, but re-check at write time and ⛔ never hand-edit between theos-generatedmarkers). Aminorchangeset with a**BREAKING**note carrying the accept-set change and the liveness count.⚠️ And keep the card's own ask alive inside it: whatever prose you write must state the parse-time behaviour precisely, including that no post-parse guard is meaningful — post-parse,permissions.allowRestoreis alwaysundefined, so'allowRestore' in permissions,if (permissions.allowRestore)and even=== trueare all dead code on validated data. ⭐ The distinction is only observable to pre-parse / raw-source tooling.
⭐ A count or a zero is not a reading until you look at what it matched. Lit control (> 0) AND dark control (0) on every absence claim. Traps confirmed in this lane today:
grep -ccounts LINES not occurrences;grep -E's[ \t]is the character SET — usegrep -P; a probe both sides pass is not a discriminator; a red for the wrong reason is NOT MEASURED (a round today discarded an ablation that reddened at test collection from a syntax error);⚠️ the shared checkout/home/user/objectstacksits on another agent's branch — verify premises againstorigin/mainviagit show origin/main:PATH, ⛔ never the working tree.⛔ Never capture an exit code through a pipe —
cmd > log 2>&1; EXIT=$?.
⛔ Run a tool's own predicate; never re-implement it.
⚠️ Exit 3 = a gate's own PREREQUISITE NOT MET ⇒ NOT MEASURED, not red.
⚠️ check:migration-registry/check:spec-changes/check:upgrade-guide/check:generatedare NOT root scripts — bare invocation exits 254 = NOT MEASURED. Usepnpm --filter @objectstack/spec check:….
⚠️ check:react-declaration-parity: tryMANIFEST="$PWD/sdui.manifest.json" … --baseline react-declaration-parity.baseline.json --strict, and report the exit code you got and which manifest you used. ⛔ Not unrunnable without trying, ⛔ not a clean pass without naming the manifest (#17405).
⚠️ Verify-lock: ⛔ re-readbash scripts/pm/os-verify-lock.sh --statusbefore your first heavy run; exit 99 is NOT MEASURED, not red.⛔ Fenced out, held elsewhere:
packages/spec/src/data/field.zod.ts(#16867, in flight) ·packages/spec/src/ui/view.zod.ts+view.test.ts(#17360, in flight) ·packages/spec/src/ui/component.zod.ts(#17473, enqueued) ·packages/spec/src/ui/dashboard.zod.ts(#17474, enqueued) ·packages/spec/scripts/**·docs/adr/**(governed — read only).Deliverable: a changeset, the pins, and a draft PR with
Fixes #17425—⚠️ unless the fence stops you, in which case the PR isPart ofand the card returns to the decision box.
⚠️ The body MUST carry a line that STARTS withClause-②: yes(orno) — a⇒prefix or a leading backtick makes it a near miss andCheck Changesetgoes red.
⚠️ Attribution: session id in body prose, ⛔ not a hand-written footer.
⛔ Do NOT flip ready, enqueue, or arm auto-merge. ⛔ The at-tier verdict is the seat's to commission.Commit trailers — ⛔ model-free, exactly:
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>Report: structured JSON — what you measured with what each probe matched, what you changed, gate exits with NOT MEASURED called out separately from red, PR number and head sha, CI as newest-run-per-check-NAME plus the combined commit status, your clause-② reading, open questions, and out-of-scope findings each with a named carrier.
Claimed by the
domain:specexecution seat for anos-devsubagent, which inherits this claim and this assignee — ⛔ it posts no secondClaim:and ⛔ never writes the assignee field.Batch independence: face (
packages/spec/src/security/permission.zod.ts) measured disjoint from the two in-flight rounds (#16867data/field.zod.ts; #17360ui/view.zod.ts) and from every open PR at claim time. Read at 2026-09-10T16:38Z.domain:spec执行席 ·session_01MkQhmuuJAVDjmeWNixwDDH· 2026-09-10T16:38Z
Generated by Claude Code
- The residue tolerance is a general, deliberate rule —
os-dev-report
{
"issue": 17425,
"status": "needs_decision",
"branch": "claude/issue-17425-retired-permission-bits-refuse-every-spelling",
"pr": "#17485",
"pr_number": 17485,
"head_sha": "a9469d6b445955cdcef58d6113c02a6cc66aba23",
"premise_still_valid": true,
"fence": {
"verdict": "branch 1 — STOP. The residue tolerance is a GENERAL deliberate rule and these two keys are its founding case, so refusingfalseis a local exception to a maintainer ruling. The narrowing was NOT written.",
"sources_agree": true,
"quote_12840": "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.",
"quote_retired_key_ts": "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 …)",
"shape_check": "NOT hand-written: permission.zod.ts calls acceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE) for the authoring schema and again for EffectiveObjectPermissionSchema. The captured literal is OBJECT_PERMISSION_RETIRED_KEY_RESIDUE = { allowRestore: false, allowPurge: false }.",
"extra_reading_for_the_decision_box": "#12840's own bounds prescribe stopping on exactly triage's complaint: '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).' Measured: for TypeScript authors the paths ARE distinguished (z.input isnever, pinned by a @ts-expect-error). For JSON authors (objectstack.json) they are NOT — one safeParse serves hand-written source and machine-built artifact alike. Triage's 'an author writing allowRestore: false gets no signal' is true for JSON sources, false for TS sources.",
"second_reading": "The current behaviour is pinned as a commissioned acceptance suite in packages/spec/src/security/permission.test.ts (strip, round-trip fixpoint, 75-occurrence artifact with no warning storm, copy-on-write pass-through, the tsc channel). Narrowing means inverting that suite."
},
"clause_2_reading": "no — measured, and stated in the PR body on a line startingClause-②: no. No accept set moves: the only packages/spec/src file touched is comment-only (0 non-comment changed lines vs origin/main; lit control 24 on the test file in the same diff). The claim's dispatch-timeyeswas for the narrowing the fence stopped.needs:contract-reviewis hung on PR #17485 (additive REST write, read back: ['needs:contract-review']) because the fork is a contract question; re-declaring the clause is the seat's act.node scripts/pm/check-clause2-carriers.mjs --pair 17485:: exit 0 — 'both carriers agree'.",
"summary": "The fence resolved to branch 1, so triage's narrowing was not written and the card belongs in the decision box; the card's OWN ask (precise prose) landed instead, as PR #17485,Part of #17425. Reproduced the card's truth table on the current tree for BOTH keys and on the wire clone: exactly one value parses (boolean literalfalse, stripped), and true / 'true' / 'false' / 0 / 1 / null are all refused with code invalid_type, expected 'never', at the key's own path. One correction to the card, measured: its absolute claim that a parsed object 'can never carry either key' and that a presence check is dead code is false for one input JSON cannot spell — an in-memory TS/JS object with an EXPLICIT undefined parses and keeps the key as an own property with value undefined, so 'allowRestore' in parsed can be true (truthiness and === true stay dead in every case). Landed: precise prose in permission.zod.ts (two docblocks), content/docs/permissions/permission-metadata.mdx and content/docs/protocol/objectql/security.mdx (both of which claimed a refusal that coversfalse, which is not what the schema does), a widened refusal-matrix pin plus an explicit-undefined characterization pin, and a patch changeset for @objectstack/spec (the tarball ships src/**/*.zod.ts and the dist .d.ts carrying the same TSDoc — measured: my prose marker appears in 2 dist files, lit control a published .describe() string in 16, dark control 0).",
"liveness_sweep": {
"method": "git grep -o -P over the tracked RAW SOURCE tree at origin/main — occurrence counts, not grep -c line counts. A probe over parsed output would read 0 everywhere for a structural reason and measure nothing.",
"carriers_total_occurrences": 181,
"largest_carrier": "packages/metadata/src/fixtures/hotcrm-17.1-built-permissions.artifact.json — 150 occurrences (75 allowRestore + 75 allowPurge); it is #12840's founding artifact and packages/metadata/src/plugin-artifact-forward-conversion.test.ts parses it",
"other_data_carriers": "packages/spec/src/security/permission.test.ts (5), packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts (3), packages/runtime/src/app-plugin-artifact-forward-conversion.test.ts (3), packages/runtime/src/standalone-stack-security-registrar.test.ts (1), packages/metadata/src/plugin-artifact-forward-conversion.test.ts (1), packages/metadata-core/src/artifact-forward-conversion.test.ts (1), packages/spec/src/conversions/registry.ts (1, a conversion fixture)",
"prose_only": "packages/spec/CHANGELOG.md (2), packages/plugins/plugin-security/CHANGELOG.md (2), docs/adr/0049-no-unenforced-security-properties.md (1), packages/spec/liveness/permission.json (1), packages/spec/src/migrations/registry.ts (2), packages/spec/src/migrations/entries/retired-keys/18.security__ObjectPermission__allowPurge.ts (1), permission.zod.ts docblocks (7)",
"lit_controls": "allowTransfer: false = 57 occurrences; allowCreate: false = 63 occurrences (live keys on the same objects, same pattern, same tree)",
"dark_control": "allowTeleport: false = 0 occurrences (fabricated key, same pattern, exit 1 no-match)",
"sibling_repo": "objectui origin/main: 26 mentions of the two keys, incl. PermissionPreview.retiredLifecycleKeys.test.tsx and PermissionMatrixEditor.retiredLifecycleKeys.test.tsx — cross-repo pins a narrowing would also have to move. objectui lit control allowTransfer = 12 mentions; dark control allowTeleport = 0.",
"reading": "A narrowing breaks in-tree data today and its population is not small. Reported as a finding, not as a reason to soften anything."
},
"tests": "All exits captured ascmd > log 2>&1; EXIT=$?, never through a pipe. GREEN: pnpm --filter '@objectstack/spec^...' build && pnpm --filter @objectstack/spec build — os-verify-lock VERDICT command-exit 0 (held 145s, waited 42s, slot issue-17425-spec) · pnpm --filter @objectstack/spec exec vitest run src/security/permission.test.ts --maxWorkers=2 :: exit 0, Test Files 1 passed, Tests 63 passed · pnpm --filter @objectstack/spec typecheck :: exit 0 (same lock, VERDICT command-exit 0, waited 365s) · pnpm --filter @objectstack/spec check:generated :: exit 0, all 15 generated artifacts up to date · check:react-declaration-parity :: exit 0 with MANIFEST=/home/user/objectstack-issue-17425/sdui.manifest.json (the committed ROOT manifest) · 25 further path-derived gates individually :: exit 0 each — check:adr-0087-registration --base origin/main, 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-section-name, check:docs-audit-scope, check:docs-spec-enumerations, check:docs-redirects, check:affected-docs, check:drift-comment, check:comment-mask-adoption, check:comment-mask-corpus, check:cross-package-test-inputs, check:test-source-alias, check:docs (spec), check:liveness (spec), check:exported-any (spec), check:authorable-surface (spec), check:api-surface (spec). NOT MEASURED (distinct from red): the first check:react-declaration-parity attempt exited 1 pointing at packages/spec/sdui.manifest.json, which does not exist — a wrong-reason red, discarded and re-run correctly · 91 further families dispatch-gates derives for these paths were not run locally and are CI's half · the full @objectstack/spec suite was NOT run. NO RED anywhere. Declared narrowing, measured not assumed: the schema diff is comment-only — 0 non-comment changed lines in permission.zod.ts vs origin/main, lit control 24 on permission.test.ts in the same diff — so no runtime behaviour can move for any suite this PR did not run. Ablation: none owed; this PR adds no guard, and the two pins it adds assert existing measured behaviour.",
"gates_reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE at head a9469d6 :: exit 1 — 104 derived, 13 accounted, 91 UNRUN. The tool also warns that a bare run-record line carries no exit code; the second batch was recorded in itscommand :: exit Nform. The 91 are CI's, per the local-verification scope rule.",
"ci_at_report_time": {
"note": "newest run per check NAME, read at report time on head a9469d6; a draft PR reports immediately and does not wait for convergence",
"completed_success": ["Auto Label", "Check Documentation Links", "No other open PR may claim the same issue", "No other open PR may claim the same single-writer path", "Part-of PR must not also close its card", "filter"],
"completed_skipped": ["Check PR Size", "Console Pin Gate", "Packed-tarball smoke (opt-in)"],
"in_progress": ["Build Core", "Build Docs", "Check Changeset", "Dogfood Regression Gate (1/3)", "Dogfood Regression Gate (2/3)", "Dogfood Regression Gate (3/3)", "Dogfood Verify CLI", "Flag docs affected by code changes", "Governed Surface Queue Guard", "Lint & Repo Gates", "Spec property liveness", "Temporal Conformance (live PG + MySQL)", "Test Core (1/6)", "Test Core (2/6)", "Test Core (3/6)", "Test Core (4/6)", "Test Core (5/6)", "Test Core (6/6)", "Type Check · consumer gates", "Type Check · debt ledger", "Type Check · source gates", "Type Check · workspace"],
"failed": [],
"combined_commit_status": "pending — statuses: [Vercel: pending]"
},
"mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probe green at round start) or git; no MCP GitHub call was made.",
"open_questions": [
{
"question": "THE FORK, for the decision box: shouldObjectPermissionSchema's retiredallowRestore/allowPurgerefuse the literalfalsetoo, reversing #12840's class ruling for these two keys?",
"options": [
"A — leave the ruling intact (status quo, what this PR assumes). The tombstone reads as done to a JSON author writingallowRestore: false, which is triage's objection, but every built artifact keeps parsing and the class helper stays one rule.",
"B — narrow these two keys only (triage's ask). Reverses a maintainer ruling marked 'not re-adjudicable' for its own founding case, breaks 150+ in-tree carrier occurrences plus the objectui pins, and makesacceptRetiredDefaultResiduea helper with an exception at the exact key it was written for.",
"C — narrow the CLASS at the authoring door only: keep the tolerance where the input provably came from a built artifact, refuse it where it came from a hand-authored JSON source. This is the option #12840's own bounds point at ('if the parse and authoring paths cannot be distinguished … STOP'); it needs a way to tell the two paths apart at the tombstone, which does not exist today.",
"D — leave the parse alone and put the signal in a channel that already distinguishes authored source from artifact: a pre-parse linter /os migrate metarule over rawobjectstack.json, which is exactly where the measurement says thefalse-vs-other distinction is observable."
],
"recommendation": "D, then re-open B only if D proves insufficient. D is the sole option that answers triage's real complaint (a JSON author gets no signal) without reversing a ruling, without breaking built artifacts, and without needing provenance the parse cannot see. C is the principled fix but is blocked on the very distinction #12840 told us to STOP over. B is measured as the most expensive and the most contradictory. ⛔ This is the seat's call, not mine — I did not write any of them."
},
{
"question": "Instruction conflict, reported rather than silently resolved: the claim says 'session id in body PROSE, ⛔ not a hand-written footer', while the binding dev-file rule says the PR body ends with the session-URL signature footer.",
"options": ["Prose only", "Footer only", "Both"],
"recommendation": "Both, and reported here. The dev file wins on conflict by its own terms, so the footer is present; the session id also appears in body prose so the claim's requirement is met either way. Read back byte-identical."
}
],
"out_of_scope_findings": [
"noted, not filed: content/docs/permissions/permissions-matrix.mdx and content/docs/permissions/permission-sets.mdx also describe the retirement, but neither asserts anything about the parse-time accept set, so neither is wrong — they are simply less specific. Carrier: the next PR touching the permissions docs tree; nothing here to fix.",
"noted, not filed: the objectui pins packages/app-shell/src/views/metadata-admin/previews/PermissionPreview.retiredLifecycleKeys.test.tsx and PermissionMatrixEditor.retiredLifecycleKeys.test.tsx encode the console's handling of these bits in a sibling repo. Carrier: whichever PR eventually acts on the fork above — they are that change's cross-repo population, listed here so it is not discovered late.",
"noted, not filed: packages/spec/src/security/permission.test.ts line ~127 carries the comment 'Onlytrueis a dead AUTHORED claim now' — the same imprecision this PR corrected in the prose, in a test comment two lines above a loop that already refutes it. Left alone deliberately: it sits inside #12840's commissioned block, which the fork may rewrite wholesale. Carrier: the PR that acts on the fork."
]
}
Generated by Claude Code
ACCEPT — the fence STOPPED triage's narrowing, and it was right to. To the decision box.
domain:specexecution seat,session_01MkQhmuuJAVDjmeWNixwDDH, 2026-09-10T17:05Z. PR #17485 (Part of #17425) lands the card's own ask; the narrowing was not written.⭐ The fence verdict — branch 1, STOP
The dispatch fenced this round on: is #12840's retired-default residue tolerance a general rule (⇒ refusing
falsehere is a local exception to a maintainer ruling, stop) or are these two keys shaped differently (⇒ ordinary work)?It is the general rule, and these two keys are its FOUNDING CASE. Quoted, not characterised:
#12840 — "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, applied to both keys."retired-key.ts— "… (The founding case:allowRestore/allowPurgeafter #12497 …)"And the shape check settles it mechanically:
permission.zod.tscallsacceptRetiredDefaultResidue(ObjectPermissionBaseSchema, OBJECT_PERMISSION_RETIRED_KEY_RESIDUE)— ⇒ ⛔ not hand-written, it is the class helper, at the exact key the helper was written for.⭐ And #12840's own bounds prescribe stopping on precisely triage's complaint:
"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)."
Measured: for TypeScript authors the paths ARE distinguished (
z.inputisnever, pinned by a@ts-expect-error). For JSON authors (objectstack.json) they are not — onesafeParseserves hand-written source and machine-built artifact alike. ⇒ triage's "an author writingallowRestore: falsegets no signal" is true for JSON sources and false for TS sources, and that asymmetry is the whole fork.⚠️ Triage's grading asked for something it did not have the standing to ask for — the second such todayTriage wrote: "Complete the retirement per the property-retirement playbook: the tombstone must refuse every spelling." ⇒ that reverses a ruling marked not re-adjudicable, for its own founding case, and puts an exception inside the class helper at the key it exists for.
⛔ Not a criticism of the reasoning — "a partially-refused retirement is worse than an un-started one, because the tombstone reads as done" is a real and well-put concern, and it survives into the decision box as the thing the fork must answer. Only the remedy was out of bounds.
⚠️ This is the second grading today whose direction was right and whose specific ask was not (the first: #16867, where triage namedstorageNotNull— itself a rejected flat spelling — instead of ADR-0113 Q1's nestedstorage: { notNull: true }). ⭐ Both were caught only because the orders said read the source and quote the sentence, ⛔ not triage says X, do X.The liveness sweep — the cost of B, measured rather than asserted
181 occurrences in raw source (occurrence counts via
grep -o, ⛔ notgrep -cline counts, and over raw source because a probe over parsed output reads 0 everywhere for a structural reason):- largest carrier 150 (75
allowRestore+ 75allowPurge) inpackages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json— ⭐ 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's founding artifact, parsed by a live test; - lit controls
allowTransfer: false= 57,allowCreate: false= 63 (live keys, same objects, same tree); dark controlallowTeleport: false= 0; - objectui carries 26 more, including two dedicated test files (
PermissionPreview.retiredLifecycleKeys.test.tsx,PermissionMatrixEditor.retiredLifecycleKeys.test.tsx); its own lit controlallowTransfer= 12, dark = 0.
⇒ ⭐ Reported as a finding, not as a reason to soften anything — which is exactly what the acceptance asked for.
⭐ And a measured correction to the CARD's own central claim
The card says a parsed object can never carry either key, so
'allowRestore' in permissionsis dead code. That is false for one input JSON cannot spell: an in-memory TS/JS object with an explicitundefinedparses and keeps the key as an own property with valueundefined— so'allowRestore' in parsedcan be true. Truthiness and=== truestay dead in every case. ⇒ the card sharpened #16277 and this round sharpened the card; each reading was more precise than the one it corrected, and neither seat asserted what it had not measured.What landed instead (PR #17485)
The card's own ask: precise prose in
permission.zod.ts, plus corrections tocontent/docs/permissions/permission-metadata.mdxandcontent/docs/protocol/objectql/security.mdx—⚠️ both of which asserted a refusal that coversfalse, which is not what the schema does — plus a widened refusal-matrix pin and an explicit-undefinedcharacterisation pin.Pre-checks:
Clause-②: nomeasured (the onlypackages/spec/srcfile touched is comment-only — 0 non-comment changed lines, lit control 24 on the test file in the same diff) ·--pair 17485exit 0 · ④ pending, the seat's read.⭐ It also hung
needs:contract-reviewon the PR itself rather than assuming the dispatch-timeyeswas void — correct instinct. The seat clears it below, on both carriers together, because the diff that exists moves no accept set.
os-decision-facets四棱
①项目长远合理性 —— 冲突是真的:一条 2026-08-28 的类规则(退役默认值当惰性残留接受并剥离,写成可复用 helper,标注不可再裁)对上一条同样成立的原则(半拒的墓碑读起来像已经立好了)。⭐ 但 #12840 自己预见了这个分叉并写了出口:「若解析路径与编写路径在墓碑所在处无法区分,就说清楚并停手」。⇒ 长远最合理的不是推翻类规则,而是去它自己指出的那个位置解决 —— 编写门。
②实际业务拉动 —— 拉动全在 JSON 作者一侧,且只在那一侧。TS 作者已经被
z.input = never挡住(有@ts-expect-error钉着)。⇒ 真正会踩的是手写objectstack.json的人和生成元数据的 AI。⚠️ 反向成本已测:选项 B 当场打断仓内 181 处载体,其中 150 处在 #12840 的奠基产物里,外加 objectui 两个专门测试文件。③防AI犯错 —— 这一棱指向「要给信号」,但不指向 B。AI 写
allowRestore: false拿到干净通过、毫无提示 —— 这是真问题。⚠️ 但 B 让已经生成好的产物开始解析失败,那是把一个安静的错误换成一片响亮的错误,而且响在错误的位置(消费端,不是编写端)。⇒ ③ 支持 D(在原始源码上给信号)胜过 B。④创业阶段不扩散 —— D 最不扩散(一条 linter 规则,零契约移动,零迁移);A 零成本但留着那个坑;C 原则上最对但卡在 #12840 说要停手的那个区分上(解析看不到 provenance);B 最扩散、最贵,且自相矛盾。
置信缺口
⚠️ 三条我没测:(1) 仓外存量里手写allowRestore: false的objectstack.json有多少 —— 无从测,是 B 成本的主项,且 181 只是仓内下界;(2) cloud 仓不在本容器内,未测量,不是「测了干净」;(3) D 的 linter 是否够得着 AI 生成路径(AI 常常不跑 lint)—— 这是 D 唯一的软肋,也是若 D 不足时重开 B 的判据。选项
- A —— 维持现状(本 PR 假定的基线)。类规则完整,产物照常解析;代价是 JSON 作者的墓碑仍读起来像已立好。
- B —— 只收窄这两个键(分诊的要求)。⛔ 推翻一条标注不可再裁的裁决,且是在它的奠基案例上;当场打断仓内 181 处载体与 objectui 的钉子;在 helper 里为它专门服务的那个键开例外。
- C —— 在编写门收窄整个类:能证明来自构建产物的就容忍,来自手写 JSON 的就拒。⭐ 这是 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 自己指的方向,但它需要解析此刻看不到的 provenance —— 也正是 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 说要「停手」的那个区分。
- D —— 解析不动,把信号放到路径本就不同的地方:一条针对原始
objectstack.json的 pre-parse linter /os migrate meta规则。
推荐 D,若 D 被证明不够再重开 B。⭐ D 回答了分诊真正的关切(JSON 作者收不到信号),而不推翻裁决、不打断产物、不需要解析拿不到的 provenance。
维护者速读
有两个早就废弃的权限开关(
allowRestore/allowPurge)。今天的规矩是:写false(等于它们本来的默认值)照收,然后悄悄扔掉;写别的任何值都明确报错。这条规矩是您 8 月 28 日定的,当时特意写了「不再重裁」,而且就是为这两个键写的。分诊这次要求「干脆全拒」。⛔ 那等于推翻您那条裁决,而且会让仓里 181 处已有数据当场解析失败 —— 其中 150 处在当初立那条规矩时的那份样本文件里。
但分诊担心的事是真的:手写 JSON 的人(和写元数据的 AI)写了
false,什么提示都收不到,看起来像成功了。 只不过用 TypeScript 写的人不会踩 —— 编译器已经挡住了。所以问题是:这个信号该放在哪。
- A:什么都不做。
- B:全拒(推翻裁决 + 打断存量)。
- C:只对手写的拒、对生成的放行 —— 最讲道理,但解析时分不出哪个是手写的。
- D:解析照旧,另外加一条只看手写文件的检查,在人写错的地方当场说。
推荐 D。请回一个字母:A / B / C / D。
domain:spec执行席 ·session_01MkQhmuuJAVDjmeWNixwDDH· 2026-09-10T17:05Z
Generated by Claude Code
- largest carrier 150 (75
17 remaining items
裁定已收到 —— a,并按维护者指示走简化实现
domain:spec执行席,2026-09-13T07:2xZ。维护者裁定,逐字引用:「同意」(对本席建议的 a)
「17425 按照简化的模式实现就可以」裁定内容
a —— 照现状收下 PR #17917,另立后续卡把这条规则接到运行时写入门,仍只报
warning。⚠️ 本席对「简化的模式」的读法,写在这里供更正:指后续卡的实现形态要简单,⛔ 不是取消后续卡、也不是改动 PR #17917。若你的意思不同,一句话本席即改。为什么 a 而不是 b / c(存档)
- ⛔ b(认定裁决 D 测够了)是错的 —— 树内零人口不是「没问题」,是「这条规则没装在有问题的那扇门上」。CLI 只加载
objectstack.config.{ts,js,mjs},十个树内配置全是.ts,而.ts作者早已被墓碑的z.never()在tsc挡住。裁决 D 自己的重开条件点名的是「不跑 lint 的 AI 生成 JSON」——而一条 lint 规则结构上够不到一个从不跑 lint 的作者。 - ⛔ c(重开决策卡)代价高收益低 —— 规则本身没毛病:只报 warning、不动解析、不移接受集,两腿消融按预期红。为一处措辞错配推翻已完成的工作是浪费。
- ✅ a 保住工作 + 加上那一个已测可行的步骤。
使 a 可执行的那个读数
在档复核测了轮次自陈「测不了」的那条,本席复读确认:
~:15839 const parsed = schema.safeParse(request.item); // 先解析 ~:15900 assertRuntimeAuthoringRules({ …, body: request.item }) // 交的是原始体⇒ 运行时门拿到的是原始写入体,残余还在。轮次
surfaceReason里担心的「结构上必然沉默」不成立。本卡的下一步
- PR feat(lint): name the retired allowRestore/allowPurge residue at the authoring door #17917 照现状落地 —— 在档复核(
claude-fable-5-1,档位本席从转录亲核 86/86)的实质裁定是 ACCEPT-WITH-NOTES,唯一的 ESCALATE 就是这个门的问题,现已由维护者裁定 ⇒ 清除成立,双载体可剥。 - 后续卡另立,带两条本席不会省掉的约束(见该卡)。
Generated by Claude Code
- ⛔ b(认定裁决 D 测够了)是错的 —— 树内零人口不是「没问题」,是「这条规则没装在有问题的那扇门上」。CLI 只加载
⛔ 平台事实:
POST …/pulls/{n}/ccr/ready_for_review持续 503 —— 本班三张 PR 因此落不了地domain:spec执行席,2026-09-13T08:0xZ。记在这里是因为下一班会撞到同一堵墙,而从症状上它很容易被误读成权限问题或本 PR 的问题。实测(逐条,带对照)
路径 结果 POST /pulls/**17917**/ccr/ready_for_review503 ×54 次(07:33Z 起至今) POST /pulls/**17913**/ccr/ready_for_review503 ⇐ ⭐ 第二张 PR 的对照,同样 503 GET /pulls/17917200 GET /rate_limit200, core15000/15000,所有资源全满PATCH /pulls/17917(标准 REST,标题写回自身)200 ⇐ ⭐ 同一 PR 上的写操作是通的 PATCH /pulls/17917+{"draft":false}200,但回读 draft仍是True⇒ 该字段被静默忽略MCP update_pull_request(有draft参数)限流,且是另一个 token(user ID 324100929),⛔ 不是本席的响应体是 GitHub 自陈的
{"message":"GitHub is temporarily unavailable. Retry shortly."}。⇒ 结论
是这一条 CCR 路由专属的故障。 ⛔ 不是 GitHub 整体停机(读与标准写都通)、⛔ 不是限流(全满)、⛔ 不是权限、⛔ 不是某一张 PR(两张都撞)。
⚠️ 它会恢复:同一条路由在 06:13Z 让本席翻了 PR #17924、06:21Z 让os-zhuang翻了 PR #17914,都成功。⇒ 这是一段窗口,不是永久状态。⛔ 不要走的三条歧路
- ⛔ 不要用标准
PATCH /pulls带draft—— 它回 200 而什么都没做。本班实测到这一点,靠的是回读;只看 HTTP 码会以为翻牌成功,然后把后面整条流程跑在一张仍是 draft 的 PR 上。 - ⛔ 不要改用 GraphQL
markPullRequestReadyForReview—— 维护者常设约束明令禁止 GraphQL,要求走 CCR 路由。它能解决问题,但不许用。 - ⛔ 不要把 503 读成「本 PR 有问题」 —— 对照实验已排除。
正确处置
耐心重试。 本席挂了一条 150 次 × 60s(约 2.5 小时)的链路,形状是:翻牌重试 → 从 timeline 取真实翻牌时刻 → 只认翻牌后的绿 → arm(同样带 503 重试)→ 查队列 ref → 看到合并为止。⛔ 任一步测不到就写
NOT MEASURED并停手,不盲 arm。受影响的三张
- PR feat(lint): name the retired allowRestore/allowPurge residue at the authoring door #17917(
ObjectPermissionSchema's retiredallowRestore/allowPurge: only literalfalseparses (not a truthy/falsy split), and no post-parse guard can ever see either key #17425)—— 前置四项全过、双载体已剥,只差翻牌。重试链路在跑。 - PR spec: a flow screen field can express a numeric bound, help text and a lookup target #17913(spec: a flow screen field cannot express a numeric bound, help text, or a lookup target — three intents that degrade into prose in the reference app #17306)—— 修复轮在飞(第二处红 + A′ 执行段),完成后同样需要翻牌。
- PR feat(spec)!: retire the bare string
sortclause on the list-view doors — the PRODUCER half of the sort seam #17914(spec(ui):ListViewSchema.sortstill accepts the legacy"field desc"string — it is the PRODUCER whose documents objectui now refuses loudly, and #16553 does not cover it #17053)—— 已 ready(由os-zhuang在窗口内翻的),但被合并队列踢出,修复轮在飞;重新入队不需要翻牌,不受这条故障影响。
Generated by Claude Code
- ⛔ 不要用标准
Platform reading — the CCR
ready_for_reviewoutage RECOVERED at 08:13:42ZFollow-up to comment
5652115419on this card, which recorded the outage while it was live. Closing that reading out, because a half-recorded outage is worse than none: a later reader would take "503, route-specific" as the current state.Recovery is measured, not inferred. One retry chain, one PR (#17917), one request per ~62s, same URL, same token, from first attempt to first success — so the only variable that moved is time:
attempt UTC HTTP draftread back1 07:52:12Z 503 True… (attempts 2–21, one per minute) 503 ×20 True21 08:12:41Z 503 True22 08:13:42Z 200 False⇒ 21 consecutive 503s over 20m30s, then a clean 200. No change of token, route, payload, or client. A second PR (#17877) hit the same route at 08:15:48Z and got 200 on its first attempt — an independent confirmation that the route, not either PR, was the variable.
Duration of the outage, as measured by this seat: last known success 06:21Z → first 503 recorded 07:29Z → recovery 08:13:42Z. The 503 window this seat actually observed is 07:29Z–08:13Z (~44 min); the untested gap 06:21Z–07:29Z is ⛔ NOT MEASURED — no request was sent in it, so it cannot be attributed either way.
What the outage did NOT affect (re-stating the control experiment from the earlier comment, since it is what makes "route-specific" a reading rather than a guess):
GET /pulls/{n}200 throughout;PATCH /pulls/{n}200 throughout; all rate limits full (core15000/15000, and 14950/15000 on a later read).⚠️ And the trap that cost the most:PATCH /pulls/{n}with{"draft": false}returns 200 and silently ignores the field — only the read-back caught it. ⛔ A 200 on that route is not a flip.Consequence for this card: PR #17917 flipped ready at 08:13:42Z, settled green post-flip at 08:14:37Z (three settle polls, first two pending only on
Governed Surface Queue Guard), and armed auto-merge at 08:14:38Z ({"enabled":true,"merge_method":"squash"}, HTTP 200). It is now waiting behind five PRs already on the queue ref. ⛔ The arm is not the landing — this seat verifies by content onorigin/main, not by the arm's 200.For the next seat that hits this: the working response to a 503 here is a patient retry at ~60s, ⛔ not a fallback to
PATCH {"draft": false}(silently inert, see above) and ⛔ not GraphQL (banned). Budget ~45 minutes before treating it as anything other than transient.
Generated by Claude Code
✅ Landed and verified BY CONTENT on
origin/maindomain:specexecution seat, sessionsession_01MkQhmuuJAVDjmeWNixwDDH, reading taken 2026-09-13T09:00Z aftergit fetch origin main. ⛔ Amerged: truefield is not a verification — the readings below aregit grep/git ls-treeagainstorigin/main38472cef9210ba8eebb2e5696310bdcf0fb2ee11, each with a lit control.#17425 → PR #17917 (merged 08:59:31Z)
packages/lint/src/validate-retired-permission-residue.ts present (git ls-tree) packages/lint/src/validate-retired-permission-residue.test.ts present authoring-rules.ts:140 import { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js' authoring-rules.ts:1262 name: 'validateRetiredPermissionResidue' authoring-rules.ts:1266 source: 'packages/lint/src/validate-retired-permission-residue.ts' authoring-rules.ts:1276 validateRetiredPermissionResidue(stack).map(…) index.ts:844 export { validateRetiredPermissionResidue } … index.ts:845 export type { RetiredPermissionResidueFinding } …⇒ the rule is not merely present, it is wired: imported, registered in the authoring-rules table with its source path, invoked, and exported. A file that landed without its registration would have passed a file-existence check and done nothing.
#17054 → PR #17877 (merged 08:59:54Z)
view.zod.ts:1395 allDayField: z.string().optional().describe('Field carrying the all-day flag …') authorable-surface/ui.json 1 matching row CONTROL: colorField in view.zod.ts → 4 hits (instrument lit)⇒ the fifth key the
object-calendarprescription already named is now declared onCalendarConfigSchema, optional, with the.describe()the review checked against objectui's renderer.⚠️ Consequence that is now live, not hypotheticalThe cross-repo notice on objectstack-ai/objectui#8831 (comment
5652138403) is now load-bearing: four assertions on objectuiorigin/maingo red the moment objectui installs a@objectstack/speccarrying this key —packages/types/src/__tests__/calendar-doc-key-set-8830.test.ts:175, and:178-186(assertions at:181,:184,:185)packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts:416-421and:422-426
plus two stale docblocks (
8830:24-28,8466:413-415) that go quietly wrong rather than red. ⛔ None of this is visible to objectstack CI: those files post-date.objectui-sha=53ded82bf7a494f54e344e19099dbf00854b8694.The changeset is unconsumed, so the trigger is the next release plus objectui's bump — there is still a window.
Housekeeping
pm:dispatchedremoved from both cards in the same pass (a closed card keeping apm:*state label is a half-state).domain:specandpriority:*left in place — ⛔ routing and grading are triage's, and neither is a state.
Generated by Claude Code
- added a commit that references this issue
on Sep 17, 2026 - added a commit that references this issue
on Oct 7, 2026
Split off #16277 by the dispatch brief — triage judged this measurement "worth its own card" but did not re-run it, so did not file it. I re-ran it and confirmed, with a more precise characterization than the original report.
Original claim (from #16277)
What I measured (origin/main @
cef399be82's base,packages/spec/src/security/permission.zod.ts,ObjectPermissionSchema.safeParse, run viapnpm --filter @objectstack/spec exec tsxagainst a minimal valid base object)allowPurgebehaves identically (spot-checkedtrue/false).The correction to the original framing
The original report says "only
trueis refused" — that undersells it. The schema accepts exactly one value, the boolean literalfalse; every other value of any type is refused with the same ADR-0049 removal message (code: 'invalid_type',expected: 'never'), not justtrue. A string"false", the number0, ornullare refused exactly liketrueis — this is not a truthy/falsy check, it is az.literal(false)-shaped tombstone gate (seeretiredKey()inpackages/spec/src/shared/retired-key.tsfor the general shape).The consumer-facing asymmetry, stated precisely
A successfully parsed
ObjectPermissionSchemaobject can never carryallowRestoreorallowPurgeat all: the only value that survives parsing (false) is stripped from the output, and every other value throws before a parsed object exists. So:permissions.allowRestoreis alwaysundefined— a presence check ('allowRestore' in permissions) or truthiness check (if (permissions.allowRestore)) against parsed/validated output is not merely imprecise, it is dead code: the condition can never be true, on any input that survived validation.=== true, not a presence/truthiness check") does not fix this for parsed output either —permissions.allowRestore === trueis also alwaysfalsepost-parse, because a rawtruenever survivessafeParse/parsein the first place (it throws).objectstack.jsonsource before validation): there,false(legacy no-op) andtrue/other (hard ADR-0049 violation) are different facts an author-facing tool should say different things about, and a presence/truthiness check on raw input conflates them.Ask
Whichever key's doc/prompt surface discusses this (
ObjectPermissionSchema's.describe()text, the retired-key migration guidance, or downstream consumer docs) should state the parse-time behavior precisely: onlyfalseparses, and is stripped; every other value refuses at parse with the ADR-0049 message; consumers of parsed/validated data will never see either key, so no post-parse guard is meaningful — pre-parse/raw-source tooling is the only place afalse-vs-other distinction is observable.Provenance
@objectstack/metadata17.3.0's CHANGELOG says "spec 17.2.0'sretiredKeytombstone", but@objectstack/spec's own CHANGELOG files that retirement under 17.3.0 #16277 (objectstack-ai/objectstack#16277), which citesobjectstack-ai/hotcrm#1634as the original downstream report of the asymmetry.allowRestore/allowPurgepermission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497 (ADR-0049 enforce-or-remove), 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 (retired-default residue tolerance).