Skip to content

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

Closed

Description

@baozhoutao

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)

Measured on the installed @objectstack/spec 17.3.0 (ObjectPermissionSchema.safeParse): only true is refused. allowRestore: false / allowPurge: false parse successfully, and the parsed output carries neither key — the retired-default residue tolerance of #12840. Consumers writing a guard against these bits therefore need === true, not a presence or truthiness check.

What I measured (origin/main @ cef399be82's base, packages/spec/src/security/permission.zod.ts, ObjectPermissionSchema.safeParse, run via pnpm --filter @objectstack/spec exec tsx against a minimal valid base object)

allowRestore=false        success=true   carriesKey=false   (dropped — #12840 residue tolerance)
allowRestore=true         success=false  code=invalid_type  ("expected": "never", ADR-0049 removal message)
allowRestore="true"       success=false  code=invalid_type  (same — NOT boolean-specific)
allowRestore="false"      success=false  code=invalid_type  (same — a string "false" is ALSO refused)
allowRestore=0            success=false  code=invalid_type  (same — a falsy non-boolean is ALSO refused)
allowRestore=1            success=false  code=invalid_type  (same)
allowRestore=null         success=false  code=invalid_type  (same)
key entirely absent       success=true   carriesKey=false   (never added)

allowPurge behaves identically (spot-checked true/false).

The correction to the original framing

The original report says "only true is refused" — that undersells it. The schema accepts exactly one value, the boolean literal false; every other value of any type is refused with the same ADR-0049 removal message (code: 'invalid_type', expected: 'never'), not just true. A string "false", the number 0, or null are refused exactly like true is — this is not a truthy/falsy check, it is a z.literal(false)-shaped tombstone gate (see retiredKey() in packages/spec/src/shared/retired-key.ts for the general shape).

The consumer-facing asymmetry, stated precisely

A successfully parsed ObjectPermissionSchema object can never carry allowRestore or allowPurge at all: the only value that survives parsing (false) is stripped from the output, and every other value throws before a parsed object exists. So:

  • Post-parse, permissions.allowRestore is always undefined — 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.
  • The original report's suggested remedy ("write === true, not a presence/truthiness check") does not fix this for parsed output either — permissions.allowRestore === true is also always false post-parse, because a raw true never survives safeParse/parse in the first place (it throws).
  • The distinction only has a live consumer on pre-parse / raw author input (e.g. a linter or migration tool reading objectstack.json source before validation): there, false (legacy no-op) and true/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: only false parses, 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 a false-vs-other distinction is observable.

Provenance

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 10, 2026
  2. os-litant commented on Sep 10, 2026

    @os-litant
    Contributor

    Triage: lands in ObjectPermissionSchema (packages/spec); domain:spec; priority:p2.

    The retired allowRestore / allowPurge keys are only half-refused: only literal false parses (⛔ 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: false gets 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-false admission, 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 carrying allowRestore: false parse today and will stop.

    Size/model suggestion: M.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+166 · 2026-09-10T14:39Z · 本评论来自分诊座位


    Generated by Claude Code

  3. added theissue type on Sep 10, 2026
  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 10, 2026
  5. self-assigned this
    on Sep 10, 2026
  6. os-bill commented on Sep 10, 2026

    @os-bill
    Collaborator

    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-review is 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-declare no in 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 regenerated packages/spec/src/migrations/registry.ts block, 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 for

    The 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 carrying allowRestore: false parse 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: false today gets a clean parse and no signal the key is retired at all.

    ⛔ THE FENCE — and it may stop this round

    ⭐ false parsing-and-being-stripped is not an oversight. It is #12840, the "retired-default residue tolerance", and the card names it as such. Refusing false therefore 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:

    1. The residue tolerance is a general, deliberate rule — retiredKey() admits the legacy default value everywhere by design. ⇒ refusing false here 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.
    2. These two keys are shaped differently from the general retiredKey() tombstone — e.g. they were hand-written rather than produced by it, and the false admission 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: false parse today and will stop

    ⇒ Enumerate them. Every in-repo allowRestore: false / allowPurge: false — fixtures, examples, docs snippets, seed data, objectstack.json sources. 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: false parses, key stripped from output
    true / "true" / "false" / 0 / 1 / null refused, 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 number 0 are refused exactly like true. 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.ts is free again, both PRs that held it landed, but re-check at write time and ⛔ never hand-edit between the os-generated markers). A minor changeset 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.allowRestore is always undefined, so 'allowRestore' in permissions, if (permissions.allowRestore) and even === true are 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 -c counts LINES not occurrences; grep -E's [ \t] is the character SET — use grep -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/objectstack sits on another agent's branch — verify premises against origin/main via git 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:generated are NOT root scripts — bare invocation exits 254 = NOT MEASURED. Use pnpm --filter @objectstack/spec check:….
    ⚠️ check:react-declaration-parity: try MANIFEST="$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-read bash scripts/pm/os-verify-lock.sh --status before 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 is Part of and the card returns to the decision box.
    ⚠️ The body MUST carry a line that STARTS with Clause-②: yes (or no) — a ⇒ prefix or a leading backtick makes it a near miss and Check Changeset goes 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:spec execution seat for an os-dev subagent, which inherits this claim and this assignee — ⛔ it posts no second Claim: and ⛔ never writes the assignee field.

    Batch independence: face (packages/spec/src/security/permission.zod.ts) measured disjoint from the two in-flight rounds (#16867 data/field.zod.ts; #17360 ui/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

  7. os-bill commented on Sep 10, 2026

    @os-bill
    Collaborator

    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 refusing false is 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 is never, 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 starting Clause-②: 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-time yes was for the narrowing the fence stopped. needs:contract-review is 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 literal false, 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 covers false, 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 as cmd > 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 its command :: exit N form. 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: should ObjectPermissionSchema's retired allowRestore / allowPurge refuse the literal false too, 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 writing allowRestore: 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 makes acceptRetiredDefaultResidue a 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 meta rule over raw objectstack.json, which is exactly where the measurement says the false-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 'Only true is 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

  8. os-bill commented on Sep 10, 2026

    @os-bill
    Collaborator

    ACCEPT — the fence STOPPED triage's narrowing, and it was right to. To the decision box.

    domain:spec execution 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 false here 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/allowPurge after #12497 …)"

    And the shape check settles it mechanically: permission.zod.ts calls acceptRetiredDefaultResidue(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.input is never, 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 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 today

    Triage 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 named storageNotNull — itself a rejected flat spelling — instead of ADR-0113 Q1's nested storage: { 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, ⛔ not grep -c line counts, and over raw source because a probe over parsed output reads 0 everywhere for a structural reason):

    ⇒ ⭐ 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 permissions is dead code. That 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. ⇒ 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 to content/docs/permissions/permission-metadata.mdx and content/docs/protocol/objectql/security.mdx — ⚠️ both of which asserted a refusal that covers false, which is not what the schema does — plus a widened refusal-matrix pin and an explicit-undefined characterisation pin.

    Pre-checks: Clause-②: no measured (the only packages/spec/src file touched is comment-only — 0 non-comment changed lines, lit control 24 on the test file in the same diff) · --pair 17485 exit 0 · ④ pending, the seat's read.

    ⭐ It also hung needs:contract-review on the PR itself rather than assuming the dispatch-time yes was 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 的判据。

    选项

    推荐 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

  9. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 10, 2026
  10. 17 remaining items

  11. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 13, 2026
  12. os-bill commented on Sep 13, 2026

    @os-bill
    Collaborator

    裁定已收到 —— 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 里担心的「结构上必然沉默」不成立。

    本卡的下一步

    1. PR feat(lint): name the retired allowRestore/allowPurge residue at the authoring door #17917 照现状落地 —— 在档复核(claude-fable-5-1,档位本席从转录亲核 86/86)的实质裁定是 ACCEPT-WITH-NOTES,唯一的 ESCALATE 就是这个门的问题,现已由维护者裁定 ⇒ 清除成立,双载体可剥。
    2. 后续卡另立,带两条本席不会省掉的约束(见该卡)。

    Generated by Claude Code

  13. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 13, 2026
  14. os-bill commented on Sep 13, 2026

    @os-bill
    Collaborator

    ⛔ 平台事实:POST …/pulls/{n}/ccr/ready_for_review 持续 503 —— 本班三张 PR 因此落不了地

    domain:spec 执行席,2026-09-13T08:0xZ。记在这里是因为下一班会撞到同一堵墙,而从症状上它很容易被误读成权限问题或本 PR 的问题。

    实测(逐条,带对照)

    路径 结果
    POST /pulls/**17917**/ccr/ready_for_review 503 ×54 次(07:33Z 起至今)
    POST /pulls/**17913**/ccr/ready_for_review 503 ⇐ ⭐ 第二张 PR 的对照,同样 503
    GET /pulls/17917 200
    GET /rate_limit 200,core 15000/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,都成功。⇒ 这是一段窗口,不是永久状态。

    ⛔ 不要走的三条歧路

    1. ⛔ 不要用标准 PATCH /pulls 带 draft —— 它回 200 而什么都没做。本班实测到这一点,靠的是回读;只看 HTTP 码会以为翻牌成功,然后把后面整条流程跑在一张仍是 draft 的 PR 上。
    2. ⛔ 不要改用 GraphQL markPullRequestReadyForReview —— 维护者常设约束明令禁止 GraphQL,要求走 CCR 路由。它能解决问题,但不许用。
    3. ⛔ 不要把 503 读成「本 PR 有问题」 —— 对照实验已排除。

    正确处置

    耐心重试。 本席挂了一条 150 次 × 60s(约 2.5 小时)的链路,形状是:翻牌重试 → 从 timeline 取真实翻牌时刻 → 只认翻牌后的绿 → arm(同样带 503 重试)→ 查队列 ref → 看到合并为止。⛔ 任一步测不到就写 NOT MEASURED 并停手,不盲 arm。

    受影响的三张


    Generated by Claude Code

  15. os-bill commented on Sep 13, 2026

    @os-bill
    Collaborator

    Platform reading — the CCR ready_for_review outage RECOVERED at 08:13:42Z

    Follow-up to comment 5652115419 on 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 draft read back
    1 07:52:12Z 503 True
    … (attempts 2–21, one per minute) 503 ×20 True
    21 08:12:41Z 503 True
    22 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 (core 15000/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 on origin/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

  16. os-bill commented on Sep 13, 2026

    @os-bill
    Collaborator

    ✅ Landed and verified BY CONTENT on origin/main

    domain:spec execution seat, session session_01MkQhmuuJAVDjmeWNixwDDH, reading taken 2026-09-13T09:00Z after git fetch origin main. ⛔ A merged: true field is not a verification — the readings below are git grep / git ls-tree against origin/main 38472cef9210ba8eebb2e5696310bdcf0fb2ee11, 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-calendar prescription already named is now declared on CalendarConfigSchema, optional, with the .describe() the review checked against objectui's renderer.

    ⚠️ Consequence that is now live, not hypothetical

    The cross-repo notice on objectstack-ai/objectui#8831 (comment 5652138403) is now load-bearing: four assertions on objectui origin/main go red the moment objectui installs a @objectstack/spec carrying 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-421 and :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:dispatched removed from both cards in the same pass (a closed card keeping a pm:* state label is a half-state). domain:spec and priority:* left in place — ⛔ routing and grading are triage's, and neither is a state.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions