Skip to content

feat(spec)!: FieldSchema refuses a select / radio with neither options nor picklist - #21390

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20827-choice-door
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20827-choice-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20827
Clause-②: yes

Ruling A (record 5910124148), step two: FieldSchema refuses a select / radio field with neither options nor picklist at parse, on the lookup-without-reference precedent (0fb8760bec). Step one (5917187437) measured the census and the Studio order; the objectui half landed and .objectui-sha 31971ff1e28f carries it (5947557233).

The door

  • packages/spec/src/data/field.zod.ts, FieldSchema's superRefine, beside the reference check: a custom issue on the options path whose message names the field type and both remedies (options: [{ label, value }], or picklist: 'NAME' for a shared list), says what the hole costs (an empty control, and no server-side value validation), and offers text when any value is meant to be allowed. No tracker number in the runtime string.
  • One predicate, not a second one. The door applies the ADR-0078 completeness predicate itself: checkFieldCompleteness(field) has a field/choice-without-options finding at error severity. That module imports nothing at runtime, so the new edge (data to kernel) closes no cycle, and no export is added. Three facts follow from it rather than being restated: options: [] is the same hole as a missing key; a picklist reference is a source; the types are exactly select / radio (checkboxes stays a warning, multiselect / tags stay free-form). The same reading is what objectui's guard derivation test uses (deriveChoiceTypesRequiringOptions).
  • The exclusivity block's comment no longer claims "neither" is only the completeness finding. The options and picklist TSDoc and .describe() texts state the rule; the reference pages were regenerated through check:generated --fix (only check:docs was stale).
  • ADR-0078's author-time rule and the registration warning are not edited. The door is one more gate.

Fixture census, measured with the door on

Re-measured at base 5fd4855a9a with the full @objectstack/spec suite (6 red in 5 files) and the full @objectstack/metadata-protocol suite (9 red in 2 files). That matches step one exactly.

Red with the door on Why it went red Disposition
field.test.ts:2155 (radio + multiple: false) oversight: pins multiple, not choices one option added
field-autonumber-default-unique.test.ts:45 (minimalField) oversight: "minimal valid input per type" select / radio get one option
filter-number-comparand-declared-type.ts fixture (fixtureFieldFor) oversight: "each is a legal FieldSchema input" single-choice types get one option (SINGLE_OPTION_TYPES); the fixture interface gains an optional options member
filter-text-operator-declared-type.ts fixture (fixtureFieldFor) same same
picklist.test.ts:129, 2 tests pinned the old acceptance by design ("neither is the completeness gate's error, not a parse refusal") flipped into a refusal pin; its checkFieldCompleteness assertions are kept unchanged
metadata-protocol: 9 tests from legacyObjectRow's status: { type: 'select' } (protocol.stored-conversions.test.ts, protocol.stored-migration.test.ts) oversight: the row exists to test conditionalRequired one option added (sent, the value its requiredWhen reads)

One fixture beyond the census: canonicalObjectRow in protocol.stored-migration.test.ts carries the same optionless select. It caused no red (the pass never validates a row that needs no conversion), but its doc says "already canonical — the shape every row ends up in", which the door makes false, so it got the same option.

Stored rows: the disposition

No migration can invent the options an author meant, so a stored row is read and named, never rewritten. That was step one's reading, and it is now pinned in @objectstack/metadata-protocol:

  • getMetaItem still serves such a row, with _diagnostics.valid: false naming fields.status.options;
  • loadMetaFromDb counts it invalid: 1 and still registers it;
  • migrateStoredMetadata({ apply: true }) on a legacy row that also carries an optionless select reports it failed (spec validation), writes no history row, and leaves the stored bytes as they were.

The changeset tells an operator to find such rows through GET /api/v1/meta/diagnostics or the boot log's field/choice-without-options lines, and says the os migrate meta --stored preview does not find them, because it does not validate.

ADR-0087 marker: not-required (no-migration-prescription). Two existing keys are narrowed in validity, and none is removed, renamed or re-shaped. No conversion entry can supply the missing intent, and the gate's other categories are closed on facts (the marker says why). That is the precedent's category. Production sys_metadata cannot be measured from here, so the marker text takes the ruling's "some rows exist" arm and names the read-and-name path, not a migration.

Semver

@objectstack/spec: minor with a BREAKING header, as the precedent shipped. The changeset is .changeset/20827-choice-door-select-radio-needs-options.md. Gate lines:

  • check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition · [BREAKING+bang] not-required (no-migration-prescription)
  • check-changeset-no-major: This diff introduces no major bump
  • check-empty-changeset: No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)

No other package publishes a change: the metadata-protocol edits are tests only, and content/docs does not publish.

Reverse verification (ablation)

The door's predicate was mutated through scripts/ablation-replace.mjs (FIELD_CHOICE_WITHOUT_OPTIONS to the literal 'ablation-20827-never'; anchor 1 to 0, blob ea9313c768 to 9654443823). Then @objectstack/spec was rebuilt, and ablation-dist-preflight found the marker in 24 built files.

  • Mutate leg: red. 8 spec pins red: the 6 refusal pins in field.test.ts and the 2 rewritten pins in picklist.test.ts. 3 metadata-protocol pins red: the two stored-conversions pins and the stored-migration failed pin. The positive controls stayed green (one option, picklist only, and multiselect / checkboxes / tags with neither).
  • Restore leg: green. Restored with git checkout HEAD --: blob equal to HEAD and git diff HEAD empty. After a rebuild, --absent reported the marker in 0 of 230 built files and the tree clean. Then 325/325 spec tests and 56/56 metadata-protocol tests passed.

Tests (head ee5b089bbe, after merging origin/main)

  • @objectstack/spec full suite: 646 files, 18391 passed, 1 todo.
  • @objectstack/spec typecheck: green, including check:test-typecheck.
  • @objectstack/spec check:generated: all 15 artifacts up to date.
  • @objectstack/metadata-protocol typecheck: green.
  • These consumer suites ran with the door on, before the merge, at a9473b8d91. The merge brought no change to these packages' sources.
    • @objectstack/metadata-protocol: 201 files, 2983 passed, 19 skipped.
    • @objectstack/objectql: 363 files, 7277 passed. This includes the two engine door suites that consume the changed spec fixtures.
    • @objectstack/lint: 119 files, 5575 passed.
    • @objectstack/metadata: 56 files, 836 passed.
    • @objectstack/runtime: 306 files, 5081 passed, 11 skipped.
  • dispatch-gates --ran: 110 derived families, 109 run, 1 NOT MEASURED, 0 unrun. The one: check:dual-build-cjs-loads exits 3 because it needs every package's dist. As a declared narrowing, all 19 require entries of @objectstack/spec load under CJS, and the door is live through dist/data/index.js.
  • check:doc-authoring and check:nul-bytes are green.

Acceptance notes

  • Blueprint (A6). packages/spec/src/ai/solution-blueprint.zod.ts, BlueprintFieldSchema.options (optional) and StrictField.options (.nullable()), still let a blueprint select / radio carry no options. Inside objectstack and objectui, nothing expands a blueprint into FieldSchema input. apply_blueprint lives outside both repositories; objectui only renders its progress and plan cards. The module's own header says that the expansion validates against the per-type schema at write time. So once this door ships, an optionless blueprint select is refused loudly at that write, not silently stored. The gap is that the blueprint accepts what the door refuses, so the refusal lands one step late, on the expanded artifact, not on the AI's structured output. Not edited here. For the PM to file: align BlueprintFieldSchema / StrictField options with the door for select / radio.
  • objectui pin (A8). packages/data-objectstack/src/object-metadata-write-guard.derivation.test.ts, the describe "the installed server still ACCEPTS a choice with no options", goes red when objectui next bumps @objectstack/spec to a release carrying this door. Its own comment prescribes the follow-up: rewrite the guard's docblock paragraph into the relationship form and turn the block into a refusal pin at options. That is the objectui lane's follow-up. No objectui edit here.
  • skills/** hit, not edited (governed surface). skills/objectstack-upgrade/references/examples-upgrade.md:105 teaches status: { type: 'select', required: true, storage: { notNull: true } } inside ObjectSchema.create(...). With this door that example is refused at parse. It needs one options entry, in a skills-lane PR.
  • Docs. content/docs/deployment/troubleshooting.mdx had an entry quoting an error message no code emits ("Required property missing: options") and listing multiselect / checkboxes as refused. It now quotes the real refusal, the right two types and the stored-row reading. validation-rules.mdx called options "Required" on all four option types; multiselect / checkboxes now read optional, as the runtime treats them. field-types.mdx names picklist as the alternative. The formulas.mdx ObjectSchema.create example got options. No content page said an optionless select parses or is only a lint finding.
  • The Clause-② line is copied from the claim (yes). The diff narrows FieldSchema's accept set, which is BREAKING and is carried by the changeset banner. It also widens one published type: the two exported door-fixture interfaces gain an optional options member. Read together, yes (narrowing) is the fuller spelling. The claim's spelling is kept, and the two gates pass on it.

Generated by Claude Code

claude added 7 commits October 2, 2026 07:52
… nor picklist

The choice door beside the reference check: the ADR-0078 completeness
predicate (checkFieldCompleteness, its error-severity
field/choice-without-options finding) is applied at parse, so options: []
is the same hole, a picklist reference is a source, and the types are
exactly select / radio. The options and picklist TSDoc and describes
state the rule.

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
…n becomes a refusal pin

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
… the choice door's stored-row disposition

Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-driven.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx (via FieldSchema (symbol, a top-level const))

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

  • content/docs/releases/v17/17-0.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via FieldSchema (symbol, a top-level const))

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

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 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 ee75aae1a8ebc2625e57074527d446151535634b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0969ed145600f546d4d07c37b1993a4ee833f3c9 — the merge of head ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a into base ee75aae1a8ebc2625e57074527d446151535634b, 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 0969ed145600f546d4d07c37b1993a4ee833f3c9 && git checkout 0969ed145600f546d4d07c37b1993a4ee833f3c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee75aae1a8ebc2625e57074527d446151535634b ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a && git checkout -B drift-repro ee75aae1a8ebc2625e57074527d446151535634b && git merge --no-ff ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a

node scripts/docs-audit/affected-docs.mjs --json ee75aae1a8ebc2625e57074527d446151535634b

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ee5b089bbe2533d9d3044b93f4b64d6d57b5bb3a
Local-runs: none

Inputs read: card #20827 (body and all 11 comments, the ruling 5910124148, step one 5917187437, the dispatch claim 5947645436, the dev report 5950037486), PR #21390 (body, 16-file list, net diff against main, its one comment), the check-runs on the head, and files at the head sha through git show only. Nothing built, run or re-run.

① Derived judgments

Every accept-set and public-surface change the diff implies, each judged:

  1. FieldSchema narrows — a select / radio whose options is absent or [] and whose picklist is absent is refused at parse with a custom issue on the options path. RIGHT: ruling A orders exactly this on the 0fb8760bec precedent, and the door sits beside the reference check in the same superRefine. The predicate is read, not restated: checkFieldCompleteness filtered to FIELD_CHOICE_WITHOUT_OPTIONS at error. At this head that module has DEAD_WITHOUT_OPTIONS_ERROR = {select, radio}, hasOptionSource = hasEntries(options) || typeof picklist === 'string', hasEntries = non-empty array, and zero import statements, so the new data-to-kernel edge closes no cycle and no export is added (the dev's claim holds). The three consequences the shared predicate fixes — options: [] is the same hole, picklist is a source, checkboxes (warning) and multiselect / tags (non-rule) keep parsing — are each pinned per type in field.test.ts, plus a document-level pin at fields.status.options.
  2. Reach of the narrowing — every schema embedding FieldSchema at this head: ObjectSchema.fields, ObjectExtensionSchema.fields, the standalone field metadata type (metadata-type-schemas.ts), system/migration.zod.ts addField.field, ui/widget.zod.ts field. All RIGHT. The one that could have refused a legitimate shape is ObjectExtensionSchema (an override of an existing select relying on the base's options), but objectql/src/registry.ts mergeObjectDefinitions merges fields by per-key replacement ({ ...base.fields, ...extension.fields }), so an optionless select override was already a dead field at runtime; refusing it at parse is the ADR-0049 move. The only in-tree extension with a select (showcase account.extension.ts) carries options.
  3. Served picklist shape — PicklistServedFieldSchema is a separate looseObject with options.min(1), and PicklistSchema.options is .min(1), so a picklist-bound field is never re-parsed into the hole. RIGHT, no served-shape regression.
  4. Stored rows — not rewritten; getMetaItem serves with _diagnostics.valid: false at fields.status.options; loadMetaFromDb counts invalid: 1 and still registers; migrateStoredMetadata({ apply: true }) on a legacy row reports failed, writes no history row, leaves the bytes. RIGHT: this is the ruling's "some rows exist" arm (read and named, since no migration can invent options), and all three are new pins in @objectstack/metadata-protocol.
  5. Public type surface widens — NumberComparandDoorFixtureField and TextOperatorDoorFixtureField (both reach the public entry through export * in data/index.ts) gain an optional readonly options member. RIGHT, additive; check:api-surface records that an export exists, not its shape, so its "unchanged" is the expected reading, not a miss.
  6. Describe texts on options / picklist changed, so content/docs/references/data/field.mdx, data/object.mdx, system/migration.mdx regenerated. RIGHT: Type Check · source gates, the job that runs check:docs, is success on this head.
  7. Hand-written docs — troubleshooting.mdx heading renamed from a message no code emits ("Required property missing: options") to the real refusal; no inbound link to the old anchor exists at this head (grep over content, skills, packages). validation-rules.mdx now grades multiselect optional and checkboxes warning, matching the predicate. field-types.mdx select row rewritten; its radio / multiselect / checkboxes sections say "Same options property as select", and the refusal clause in that row names select and radio explicitly, so the multi types are not over-claimed. RIGHT. formulas.mdx example gained options. No content/docs/releases/ or CHANGELOG.md touched.
  8. Field.select([]) emits options: [] and now reaches the refusal. RIGHT: stated in the changeset and pinned; it is the ruling's "every door" reading, and Field.select({ picklist }) and Field.select(['Open', 'Closed']) still pass (pinned).
  9. Fixture census — 4 oversight fixtures plus the intended pin at picklist.test.ts:129 (flipped into a refusal pin, its checkFieldCompleteness assertions kept) in spec; one legacyObjectRow row in two metadata-protocol files; canonicalObjectRow beyond the census for doc consistency. Matches step one exactly. RIGHT. Every helper the rewritten tests rely on (issuesByPath, Field, ObjectSchema, SINGLE_OPTION_TYPES) is already imported at this head.
  10. ADR-0078 relationship — the ruling says "one more gate, not a replacement": the author-time rule and the registry.ts warn-never-throw twin are untouched (file list), and the registration seam still registers a stored optionless row (pinned). RIGHT against the ruling; see ③ for the ADR's own text.
  11. ADR anchors — the one anchor on field.zod.ts (ADR-0120, UniqueScopeSchema) is symbol-based and the system-context.mdx census row anchors field.zod.ts#readonly by symbol, so the inserted lines move nothing. RIGHT.

Governed surfaces: none in the file list (.changeset/, content/docs/, packages/), and Governed Surface Queue Guard is success. This record is the dispatch's owed at-tier review, not a Tier S landing record. Size 344 changed lines over 16 files, well under the 5,000 human-merge threshold.

② Semver level

  • Changeset .changeset/20827-choice-door-select-radio-needs-options.md: "@objectstack/spec": minor, summary fix(spec)!: with the bang, a **BREAKING** header, the migration in prose (FROM a select / radio with neither key TO options: [{ label, value }] or picklist: 'NAME', or a text field when any value is meant to be allowed), the unchanged types named, and the stored-row prescription naming GET /api/v1/meta/diagnostics and the boot log's field/choice-without-options lines while saying the os migrate meta --stored preview does not find them. RIGHT: it is the 0fb8760bec changeset's shape (minor plus BREAKING, same marker category), and @objectstack/spec is the only package that publishes a change — the metadata-protocol edits are tests only, content/docs does not publish. skip-changeset does not apply.
  • ADR-0087 marker: one HTML-comment marker reading not-required (no-migration-prescription) …; the category is in CATEGORIES of check-adr-0087-registration.mjs, and the "why" closes the other four categories on facts (publishes, no id covers the rule, a schema narrowing not a runtime-interface-only or type-surface-only change). RIGHT. The gate verdicts: Check Changeset (pr-automation.yml, which runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major against the merge base) is success on this head.
  • Clause-②: yes (PR body, changeset, and the dispatch claim 5947645436). Valid: yes takes at least minor and the diff grades minor; the arm is optional by rule (clause2-line.mjs: "The arm is OPTIONAL, and that is a measurement"). The dev's own flag is right that yes (narrowing) is the fuller spelling — clause2-line.mjs defines it as "a diff that widens one surface and narrows another", which is this diff (two fixture interfaces widen, FieldSchema narrows), and the step-one claim 5916083155 spelled it so. The breaking signal the arm exists to carry is already carried where the gate reads it (**BREAKING** plus bang, [BREAKING+bang]), so the bare yes loses nothing at any gate. Judged right as written; the owning seat may amend the PR-body line to yes (narrowing) before landing. Optional, not a condition.
  • Cosmetic: PR title feat(spec)!: while the changeset says fix(spec)!: (the precedent used fix). No gate reads the pair together. Not a condition.

③ Boundary flags

open_questions: [] in the dev report — nothing to answer. The eight deviations and three out-of-scope findings, each answered or escalated:

  1. Clause-② spelling — ANSWERED above: right as written, fuller spelling optional.
  2. skills/objectstack-upgrade/references/examples-upgrade.md:105 (status: { type: 'select', required: true, storage: { notNull: true } } inside ObjectSchema.create, in an os:check block whose intro says it "cannot rot into teaching a shape that no longer compiles") — VERIFIED at this head: the block is there, and check-skill-examples.ts type-checks with tsc --noEmit only, no safeParse, so no gate sees the parse refusal. ESCALATED: skills/** is Tier H and PD feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14 says to split the governed file off, which the dev did. The fix is one options entry, valid before and after the door, so there is no ordering dependency; the owning seat should file the skills-lane PR now so the published skill does not teach a refused shape across a release. Not a condition on this PR.
  3. Blueprint (solution-blueprint.zod.ts:88 options optional; :320 .nullable()) — VERIFIED at this head, and the module header (lines 15–16) says apply_blueprint stages the expansion as a draft so the per-type schema validates it, so the door reaches the expanded artifact one step after the AI emitted it. ESCALATED to the PM to file: align the blueprint options with the door for select / radio. Not a condition.
  4. objectui object-metadata-write-guard.derivation.test.ts ("the installed server still ACCEPTS a choice with no options") — ANSWERED: not a main-red hazard from this landing. The Console Pin Gate is skipped on this head because its console path filter names .objectui-sha, scripts/build-console.sh, packages/spec/package.json, tsup.config.ts and the probe scripts, none touched; when it runs it builds the SPA at the pin, bundling this tree's spec, and runs check:console-sha / check:console-injection — no objectui tests, and a runtime superRefine cannot break a bundle. .objectui-sha reads 31971ff1e28f… at this head and carries objectui 1563d3e10b (unlock scan 5947557233), so the console this repo ships already has the guard the ruling ordered first. The red the dev names arrives in objectui's own CI on its next @objectstack/spec bump — the objectui lane's follow-up, as noted.
  5. canonicalObjectRow beyond the census — ANSWERED: right; doc consistency, no behaviour.
  6. origin/main merged before the PR; consumer suites at a9473b8d91 — ANSWERED: procedural; the head's check-runs are the authority (below), not the dev's local runs.
  7. Full spec suite as a background job; labels; model-free commit trailers — ANSWERED: procedural and consistent with AGENTS.md.
  8. A4 precision (a row whose only defect is the missing source counts canonical in the preview; failed only when the row also carries an older spelling) — ANSWERED: right, stated in the changeset, pinned.

New flag, not raised by the dev — ADR-0078's text. §4 of docs/adr/0078-no-silently-inert-metadata.md says "No hard Zod .refine() that rejects existing metadata at registration (back-compat)", and its Non-goals list "Hard Zod refinement that rejects existing metadata … not a registration-time crash — existing apps with benign-incomplete instances must keep loading." The door is a hard Zod refinement. It leaves the registration seam that text protects exactly as it was (warn, never throw; a stored row still registers — pinned here), but a code-declared optionless select now throws at ObjectSchema.create(), which is the BREAKING arm the changeset declares. The maintainer's ruling A positions the door against ADR-0078 explicitly ("one more gate, not a replacement") on a precedent (0fb8760bec, the relationship-without-reference rule) that shipped the same way without an ADR edit, so the ruling governs here. What is owed is a Tier H landing note on ADR-0078 naming the two completeness rules that are now also parse-door refusals, so the ADR does not read against the schema it governs. ESCALATED for the owning seat to carry to the maintainer. Not a condition on this PR.

Check-runs on the head at 2026-10-02T10:24Z: 27 success, 2 skipped by design (Console Pin Gate — path filter; Packed-tarball smoke — opt-in), 4 in_progress: Test Core (1/6), Test Core (3/6), Test Core (6/6), Type Check · workspace. Green so far includes Lint & Repo Gates, Check Changeset, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Dogfood Regression Gate 1–3, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Build Core, Spec property liveness. An in_progress gate is an honest reading, not a pass: the landing still waits for Test Core and the TypeScript Type Check aggregate to conclude success, and this record does not stand in for them.

Implemented-by: claude/issue-20827-choice-door
Reviewed-by: session_01YDt3PzwfrkuFzUBF89WPmM

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 10:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 10:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit c52c49d Oct 2, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20827-choice-door branch October 2, 2026 10:55
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…hat decided them (stage 6 of objectstack-ai#20595) (objectstack-ai#21396)

Part of objectstack-ai#20595
Clause-②: no

## What changed

Stage 6 of the `domain:engine` lane of the dead-citation sweep:
`packages/metadata/**`, comment and docblock prose only, per the claim
(`5949772544`). Stages 1 to 5 landed as `a7d9768ec`, `d150c3039`,
`4bf4e7e70`, `13a24ece2` and `db0cf2231`; objectstack-ai#20595 stays open for the next
stage.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR when one records the decision,
otherwise the commit in this repository's history that made it. That is
**75 sites on 74 lines in 24 files, covering 18 numbers**:

- **41 census sites** (41 lines, 12 files under `src/`): the whole
`allocated-but-absent` population of the gate's own census in this
package at the base, the slash-joined `objectstack-ai#5108/objectstack-ai#14423` at
`ambiguous-metadata-stem.ts:40` from the post-landing census
(`5923084795`) included;
- **2 sites on 1 line outside the census glob, inside the claimed
surface**: `tsup.config.ts:17` (`objectstack-ai#14399/objectstack-ai#14680` in a `//` comment),
stage 1's `tsup.config.ts` precedent;
- **32 test-comment sites** (32 lines, 11 test files), which the census
defers. They carry 13 numbers: 10 the census itself reads as dead in
this package's `src`, 1 it reads as dead elsewhere in the repository
(objectstack-ai#12914, in `metadata-core`), and 2 it never judges on this tree because
they stand only in test files (objectstack-ai#13072, objectstack-ai#16657), which the board and a
single read each settle.

No comment-id citation stands in the package (see Census).

**Anchors: 17 numbers by commit, 0 by ADR, 0 by words alone, and 1
respelled with its repository.** `objectstack-ai#6111` never meant this repository's
issue: both sites read `objectui#6110 + objectstack-ai#6111`, a pair of objectui
issues whose second number the same test file spells `objectui#6111` at
`:229`, so they now read `objectui#6110 + objectui#6111`, the exact
respelling the spec lane gave the same pair (`2123fcca3`). 11 numbers
reuse the anchor (or respelling) another lane or stage already used for
them; 7 were measured here. 17 distinct shas. No ADR or ruling record
names any of the 18 (`git grep` over `docs/adr` and
`scripts/adr-anchors` finds none of them; control: objectstack-ai#15041 finds
ADR-0104). `ADR-0049`, already beside objectstack-ai#10485 and objectstack-ai#13135, stays.

Only comments changed. Every file keeps its line count (74 lines out, 74
in, plus the changeset), so no line citation into any of them moves. No
code token moves (the guard below). **No citation number is added**: on
every changed line, the numbers on the new text are a subset of those on
the old (for `objectstack-ai#6111` the same number, now qualified), and the
diff-scoped gate judged the citations left on changed lines (see Gates).

**A `patch` changeset**: 23 of the 42 rewritten non-test lines are in
the published `dist` (the `.d.ts` keeps JSDoc on exported members, and
esbuild keeps some comments in the JS), and `dist` is not byte-identical
with the base text (see Changeset).

## H0: the largest remaining `domain:engine` package

The gate's own `node scripts/check-issue-citations.mjs --census --json`
at base `ee75aae1a` (the before run below), `allocated-but-absent` per
package over every `domain:engine` family in
`.claude/skills/pm-dispatch/SKILL.md`'s lane table:

| package | sites |
|---|---|
| `metadata` | **41** |
| `core` | 40 |
| `platform-objects` | 27 |
| `metadata-core` | 19 |
| `drivers/driver-turso` | 14 |
| `drivers/driver-mongodb` | 9 |
| `formula` | 4 |
| `metadata-fs` | 2 |
| `metadata-protocol`, `objectql`, `plugins/plugin-pinyin-search`,
`drivers/driver-memory`, `drivers/driver-sql`,
`drivers/driver-sqlite-wasm` | 0 each |

`metadata` is still the largest (lane total 156), so this stage went
ahead. The margin is one site: `core` reads 40.

## Census: `metadata`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count is its `allocated-but-absent` findings under
`packages/metadata/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `ee75aae1a`, run 10:00:40Z to 10:04:24Z | enumerated,
193 pages, frontier objectstack-ai#21388, 19,209 records (newest number read before
and after the run: objectstack-ai#21388) | 271 | **41** | 41 | 12 | 15 |
| after | `7f9836c0e`, run 10:17:35Z to 10:20:58Z | enumerated, 193
pages, frontier objectstack-ai#21394, 19,215 records (newest before: objectstack-ai#21392, after:
objectstack-ai#21394) | 230 | **0** | 0 | 0 | 0 |

The whole-repo drop is 41, and the two finding sets differ by exactly
the 41 rows of this package, removed; none was added. `resolves`
(34,962) and `resolves-as-pull-request` (2,380) did not move;
`cross-repo-unjudged` went 1,169 to 1,170, which is `plugin.ts:916`'s
`objectui#6111`, now read as objectui's. The card's 41-site figure for
`metadata` was stage 5's re-measure at `5fd4855a9`; the base here reads
the same 41. The head's later commits are the changeset (outside the
census surface) and one merge of `main` that touches no file under
`packages/metadata`.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (100:
91 `.ts`, `package.json`, `tsconfig.json`, three `__fixtures__` artifact
`.json` files, `README.md`, `ROADMAP.md`, `CHANGELOG.md` and `LICENSE`),
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (193 pages, frontier
objectstack-ai#21390, 19,211 records, 10:05:02Z to 10:08:30Z). Every one of the 18
numbers in the population was then read on its own over the issues
endpoint (10:09Z): **all 18 answer 404**, and the lit controls `objectstack-ai#5286`
and `objectstack-ai#12624` answer 200.

| reading | citations | dead | src comment | test comment | test string
| `ROADMAP.md` | changelog |
|---|---|---|---|---|---|---|---|
| before, `ee75aae1a` | 1,102 | **119** | 43 | 32 | 22 | 2 | 20 |
| after, `7f9836c0e` | 1,029 | **44** | 0 | 0 | 22 | 2 | 20 |

`src comment` here is the 41 census sites plus the 2 in
`tsup.config.ts`. The citation count drops by 73: the 75 rewritten sites
less the 2 `objectui#6111` respellings, which are still citations and
now classify as cross-repo (src comment 3 to 4, test comment 7 to 8).
The live counts did not move (src comment: 277 resolve, 7 as pull
requests; test comment: 275 and 13). A third, raw reading (every `#`
followed by 2 to 6 digits, whatever surrounds it) counts 1,111 before
and 1,038 after: a drop of 73, for the same reason.

**Comment ids.** No comment-id citation stands in the package: `git grep
-E '[0-9]{10}'` over `packages/metadata` (its `CHANGELOG.md` aside)
finds no line (exit 1); the same grep over `packages/metadata-protocol`
finds 13 lines, 5 of them `comment 5…` citations (control). So no
comment id is in the population.

## Per-number table

`src` counts the census sites plus the two in `tsup.config.ts`; `test`
counts test-comment sites. Every sha below matches exactly one commit
(`git rev-parse --disambiguate`, count 1) and is an ancestor of the base
`ee75aae1a` (`git merge-base --is-ancestor`, exit 0 for all 17 shas; the
clone is not shallow, `--is-shallow-repository` false). The `+` lines
carry exactly these 17 nine-hex spans and no other: 16 are new, and
`3ecb7dc1a` was already on both of its lines. The message or the diff of
each one names the number it replaces: 9 in the message and the diff
(`35ad101bc`, `6a180e42d`, `2e471e362`, `3c1bbd2a8`, `a56baa2bd`,
`4b4d5a331`, `3bd9b3498`, `c1d274de7`, `5a95b0e93`), 5 in the diff alone
(`f4e7ae5c7` 11 added lines, `200d255e7` 2, `9e0ba21a1` 80, `4cda78c9b`
11, `efc5447ee` 5), 1 in the message alone (`f887e5249`, the squash of
PR objectstack-ai#13074, whose two commits each name objectstack-ai#12914), and 2 only in the
subject's squash suffix (`3ecb7dc1a` and `ac8ed476f`, where the dead
number was that pull request's own). `git blame` at the base puts 49 of
the 75 sites on their anchor. The other 26 were written by a later
commit that cites the number as an earlier change (for example
`c1d274de7` citing objectstack-ai#14486 and objectstack-ai#14423, `efc5447ee` citing objectstack-ai#14486 and
objectstack-ai#14341, `4b4d5a331` citing objectstack-ai#14341 and objectstack-ai#14627, `3e7ef9c23` citing
objectstack-ai#14409), or are the two `objectstack-ai#6111` sites `200d255e7` wrote. In each case
the anchor is the commit that made the change the sentence credits to
the number. `source` says whether another lane or stage already used
this anchor for this number (`reused`) or it was measured here
(`measured`).

| number | src | test | anchor | kind | source | what it decided |
|---|---|---|---|---|---|---|
| `objectstack-ai#6111` | 1 | 1 | `objectui#6111` | repository qualifier | reused
(spec lane stage 5, `2123fcca3`, which read objectui's issues endpoint
for 6111 at 200) | objectui's own issue: evaluate a form section's
`visibleWhen` with the host predicate scope bound (`200d255e7`'s
message), paired with `objectui#6110` in the same sentence |
| `objectstack-ai#10485` | 1 | 0 | `35ad101bc` | commit | reused (stages 1 and 3; the
spec, qa, cli, rest and runtime lanes) | retire the `themes` carrier key
and `ThemeSchema` (ADR-0049) |
| `objectstack-ai#12140` | 4 | 1 | `f4e7ae5c7` | commit | measured | gate the metadata
HMR door on an explicit `NODE_ENV=development` posture; the route
ledger's row moves with it |
| `objectstack-ai#12914` | 0 | 1 | `f887e5249` | commit | measured | a form SECTION
`visibleWhen` binds `current_user` too: re-measure the stale contract
text |
| `objectstack-ai#13072` | 0 | 1 | `200d255e7` | commit | measured | the unbound-root
notice stops flagging a section-level `current_user` predicate (it
inverted this very case in place) |
| `objectstack-ai#13135` | 1 | 2 | `9e0ba21a1` | commit | reused (the spec lane) |
retire the paper metadata-customization protocol, the manager's overlay
limb included (ADR-0049) |
| `objectstack-ai#13279` | 3 | 0 | `6a180e42d` | commit | reused (stage 4; the types,
rest, runtime and services lanes) | move `isMissingTableError` /
`isSchemaAlreadyExistsError` to `@objectstack/types`; its message
records the maintainer's 2026-08-30 ruling |
| `objectstack-ai#13324` | 1 | 0 | `4cda78c9b` | commit | reused (stages 1 and 4; the
types lane) | require a missing-table error to name the table that was
read |
| `objectstack-ai#14341` | 3 | 5 | `2e471e362` | commit | measured |
`FilesystemLoader` keys loader-held items by the name it can resolve
(the squash of PR objectstack-ai#14497) |
| `objectstack-ai#14399` | 3 | 0 | `3c1bbd2a8` | commit | reused (stage 3) | derive a
view container's object through the shared `deriveViewContainerObject` |
| `objectstack-ai#14409` | 1 | 1 | `3ecb7dc1a` | commit | reused (stage 1; the rest
lane), already on both lines | measure what each dialect materialises
for a datetime JS cannot hold (that pull request's squash) |
| `objectstack-ai#14423` | 6 | 3 | `a56baa2bd` | commit | reused (stage 3; the runtime
lane) | `listNames` gains `loadMany`'s per-loader fault parity, a keyed
plural read lands beside `loadMany`, and the action audit reads the
store key |
| `objectstack-ai#14486` | 6 | 11 | `4b4d5a331` | commit | measured |
`FilesystemLoader.list()` reports only names `findFile()` can resolve;
its message records the maintainer's 2026-09-02 ruling (option A) and is
「Part of objectstack-ai#14486」 |
| `objectstack-ai#14627` | 1 | 2 | `ac8ed476f` | commit | measured | that pull
request's squash (repair the package's hidden test-type errors and wire
`typecheck`), the concurrent claim the sentences name |
| `objectstack-ai#14680` | 3 | 0 | `3bd9b3498` | commit | reused (stage 3) | the leaf
`/view-container` subpath, measured on the built artifacts |
| `objectstack-ai#14921` | 8 | 2 | `c1d274de7` | commit | reused (the runtime lane) |
refuse an ambiguous metadata stem with both paths named |
| `objectstack-ai#15037` | 1 | 1 | `efc5447ee` | commit | measured |
`RemoteLoader.list()` no longer reports a nameless body as a literal
`undefined` |
| `objectstack-ai#16657` | 0 | 1 | `5a95b0e93` | commit | reused (stages 1 and 4; the
types lane) | read the dialect text out of `cause` for operator-facing
records |

## Wordings to check

Most rewrites swap a tag in place (`[#N]` to `[commit SHA]`, `(#N)` to
`(commit SHA)`, `#N's X` to `commit SHA's X`), the form the landed
stages use. These say more than the tag:

- **`objectstack-ai#6111`** (`plugin.ts:916`,
`plugin-unbound-form-predicate-roots.test.ts:94`): 「objectui#6110 +
objectstack-ai#6111」 became 「objectui#6110 + objectui#6111」. `200d255e7`'s message,
which wrote both sites, says 「objectui#6110 threads the host shell's
predicate scope into `isSectionVisible`, and objectui#6111 evaluates the
authored section `visibleWhen`」.
- **`objectstack-ai#14409`** (`database-loader.ts:82`,
`database-loader-14078-invalid-date-total-arm.test.ts:19`): 「(objectstack-ai#14409,
landed `3ecb7dc1a`)」 became 「(commit `3ecb7dc1a`)」 and 「PR objectstack-ai#14409
(landed `3ecb7dc1a`):」 became 「Commit `3ecb7dc1a`:」, stage 1's spelling
for the same sentence: the dead pull-request number goes and the squash
already beside it stays.
- **`objectstack-ai#14486` where it was written while that card was open**
(`filesystem-loader-keyed-items.test.ts:42`, `:180`): `2e471e362` wrote
them a day before `4b4d5a331`, which is 「Part of objectstack-ai#14486」 and left this
file's RECORD cases standing (the file was under a concurrent claim). A
bare swap would credit `4b4d5a331` with inverting them, so they read
「the card commit 4b4d5a3 took in part」.
- **Rulings the anchor's message does not carry, so the date stays and
the site reads 「landed as」** (stage 1's `objectstack-ai#9741` precedent): `objectstack-ai#14341`
(`2e471e362`'s message states the rule, not the 2026-09-02 PM ruling)
became 「PM ruling of 2026-09-02 — option D, landed as commit 2e471e3」
(`filesystem-loader-keyed-items.test.ts:18`) and 「PM ruling of
2026-09-02, landed as commit 2e471e3」 (`filesystem-loader.ts:181`);
`objectstack-ai#14921` (`c1d274de7`'s message states the refusal, not the 2026-09-05
ruling) became 「(maintainer, via the director seat, 2026-09-05; landed
as commit c1d274d)」 (`ambiguous-metadata-stem.ts:24`) and 「(maintainer
ruling, landed as commit c1d274d, via the director seat,」
(`filesystem-loader-ambiguous-stem.test.ts:28`), whose next line keeps
the date and the verbatim 「同意」.
- **Rulings the anchor's message does carry**: `objectstack-ai#14486`'s (`4b4d5a331`:
「Ruled by the maintainer via the director seat (2026-09-02) as option A
over the reverse-unify」) reads 「recorded in commit 4b4d5a3」 on four
sites (`filesystem-loader-list-reachability.test.ts:26`,
`filesystem-loader.ts:368`, `remote-loader-list-nameless.test.ts:42`,
`remote-loader.ts:130`), and 「objectstack-ai#14486 ruling (…)」 became 「ruling in
commit 4b4d5a3 (…)」 (`filesystem-loader.ts:203`); `objectstack-ai#13279`'s
(`6a180e42d`: 「Maintainer ruling 2026-08-30, verbatim: 第一批其余同意」) reads
「the maintainer's 2026-08-30 ruling (commit 6a180e4)」
(`errors.ts:27`).
- **The concurrent claim** (`filesystem-loader.ts:207`,
`filesystem-loader-list-reachability.test.ts:51`, `:238`): 「(PR objectstack-ai#14627)」
became 「(squashed as commit ac8ed47)」. `ac8ed476f` landed at 16:18Z on
2026-09-03, after `4b4d5a331` (14:30Z the same day), so the claim was
open when this landed. Its final diff does not touch
`filesystem-loader-keyed-items.test.ts`; the sentence says only that the
file was under its claim.
- **Where the number named the defect, not the change**: the title
「objectstack-ai#15037 — `RemoteLoader.list()` declares … and maps a nameless body
straight through」 became 「The defect commit efc5447 fixed — …」
(`remote-loader-list-nameless.test.ts:4`); 「that axis is the whole of
objectstack-ai#14423」 became 「the whole of the defect commit a56baa2 fixed」
(`metadata-manager.ts:2841`).
- **Item references**: 「objectstack-ai#14423 items 1 and 2」 became 「Items 1 and 2 of
the card commit a56baa2 answered」
(`metadata-manager-keyed-plural-read.test.ts:4`), and 「the objectstack-ai#14423 cost
question (the ruling's item 6)」 became 「the cost question commit
a56baa2 answered (the ruling's item 6)」 (`:356`); `a56baa2bd`'s
commits include 「pin the by-name rung's per-name cost — one findOne per
probe」.
- **Pins and RECORDs**: 「landed objectstack-ai#14341 pin」 became 「landed pin of commit
2e471e3」, and 「three landed objectstack-ai#14341 pins」 / 「the three landed objectstack-ai#14341
pins」 became 「three pins commit 2e471e3 landed」 / 「the three pins
commit 2e471e3 landed」; 「(objectstack-ai#14486 RECORD)」 became 「(commit 4b4d5a3's
RECORD)」, the RECORD cases that commit pinned.
- **Slash pairs**: 「(objectstack-ai#5108/objectstack-ai#14423)」 became 「(objectstack-ai#5108 and commit
a56baa2)」; 「(objectstack-ai#14341/objectstack-ai#14205 fixed items going MISSING」 became 「(commit
2e471e3 and objectstack-ai#14205 fixed …」; 「(objectstack-ai#14399/objectstack-ai#14680)」 became 「(commits
3c1bbd2 and 3bd9b34)」 (`tsup.config.ts:17`). objectstack-ai#5108 and objectstack-ai#14205 are
live and stay.
- **Capitalisation**: where the number opened a sentence, the new text
opens with 「Commit」 (five sites), or with 「Items」, 「The defect」 or 「The
card」 (one each).
- No line was reflowed, so some are longer than their block's wrap
(`eslint.config.mjs` declares no line-length rule, and a reflow would
move neighbouring lines and every line citation into the file).

## Sites left

- **In comments (src, test, `tsup.config.ts`): none.**
- **String literals: 22 test-string sites, 7 numbers, 7 files**
(`describe` and `it` titles, assertion arguments): `objectstack-ai#14921` 5, `objectstack-ai#14486`
4, `objectstack-ai#12140` 3, `objectstack-ai#15037` 3, `objectstack-ai#16657` 3, `objectstack-ai#14341` 2, `objectstack-ai#14423` 2. Every one
of the 7 is in this stage's population, so the table above holds an
anchor for each. Non-test strings carry none. Strings are outside this
stage's surface.
- **`ROADMAP.md`: 2 sites (`objectstack-ai#13135`, at `:32` and `:173`).** Markdown
prose, not a comment or docblock, and not in the package's `files`
(`dist`, `README.md`, `CHANGELOG.md`), so outside this stage's surface;
counted, not edited. Its anchor is in the table (`9e0ba21a1`).
- **Outside `src`:** the release-owned `CHANGELOG.md` names dead numbers
on 20 sites (16 numbers); left. `package.json`, `tsconfig.json`, the
three fixture artifacts, `README.md` and `LICENSE` cite no dead number.

## Mechanical guard: no code token moves

The guard (stages 2 to 5's) compares base `ee75aae1a` against the
working tree at `7f9836c0e` over all 24 touched files, with TypeScript
6.0.3:

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, JSDoc nodes skipped. String, template and numeric
literals are compared in full on both readings.

Results:

- Real run: 56,628 base tokens, **0 files with a token change** (exit
0).
- Comment control (「Matched first, read second.」 to 「Matched FIRST」,
`filesystem-loader.ts`): 0 files changed (exit 0).
- Positive control, an identifier (`deriveViewContainerObject` to
`deriveViewContainerObjectX`, `view-container.ts`): DIFFER on both
readings (exit 1).
- Positive control, a string literal (`'AMBIGUOUS_METADATA_STEM'` to
`…X`, `ambiguous-metadata-stem.ts`): DIFFER on both readings (exit 1).
- Positive control, a numeric literal (`err.status = 409` to `410`,
`metadata-manager.ts`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode,
anchor hit 1 to 0, replacement 0 to 1) under a shell trap that restores
by absolute path from `HEAD`. Each restore was proven equal to its
`HEAD` blob (`9cb56cd6daac`, `1479bc09334e`, `7f0d7600dadb`,
`8cf73463b771`), with `git diff HEAD` empty and a clean tree afterwards.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. In one script under the shared verify lock (VERDICT
command-exit 0), at `7f9836c0e`: the dependency closure was built first
(`turbo run build --filter='@objectstack/metadata^...'`, 9 of 9 tasks),
then the package's own `build` (tsup plus `check-dts-emitted`) ran three
times:

- **Leg 1**, the head text: 30 `dist` files hashed. Of the 42 rewritten
non-test lines, 23 appear verbatim in `dist`: 10 from
`filesystem-loader.ts`, 5 from `metadata-manager.ts`, 3 from
`ambiguous-metadata-stem.ts`, 2 each from `remote-loader.ts` and
`view-container.ts`, 1 from `plugin.ts`; in `index` / `node` `.d.ts` /
`.d.cts` and `.js` / `.cjs`, and `view-container.d.ts` / `.d.cts`.
- **Leg 2**, the base text put back in the 13 non-test touched files (13
of 13 proven equal to their base blob): 10 of the 30 files differ from
leg 1 (`index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`, `node.js`,
`node.cjs`, `node.d.ts`, `node.d.cts`, `view-container.d.ts`,
`view-container.d.cts`; no sourcemap moves), and
`scripts/ablation-dist-preflight.mjs` finds the base marker 「[objectstack-ai#14921]
The refusal a metadata source tree earns by naming one item twice.」 in 2
built files (exit 0).
- **Leg 3**, after the proven restore (13 of 13 equal to their `HEAD`
blob, `git diff HEAD` empty, porcelain empty): all 30 files are
byte-identical to leg 1, and the preflight's `--absent` reading exits 0,
so the build is deterministic and the difference is the rewrite.

So the rewrite ships, and
`.changeset/20595-metadata-provenance-anchors.md` declares a `patch` for
`@objectstack/metadata`, comment text only, with the claim's `Clause-②:
no` line. The later changeset commit and the merge of `main` touch no
file under `packages/metadata`.

## Gates (head `61a589d48`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `61a589d48` (25 paths against
merge base `51550933d`, 165 changed lines) derived 65 commands. All 65
ran, each exit code captured before any pipe: 65 exit 0. `--ran` reports
「65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits
0.
- **Roster families the derivation flags under a touched directory**,
also run, each exit 0: `node scripts/check-changeset-fixed.mjs`, `pnpm
check:authz-resolver`, `pnpm check:error-code-casing`, `pnpm
check:filter-alias-parity`.
- **Named readings:** `node scripts/check-issue-citations.mjs` exits 0
(「every citation this change adds resolves (or is a declared cross-repo
reference)」: 3 judged across 12 files, 1 resolves, 2 cross-repo, the
`objectui#6110` / `objectui#6111` pair on `plugin.ts:916`); `pnpm
check:issue-citations` exits 0 (self-test, 173 cases, 9 batteries);
`pnpm check:doc-authoring` exits 0 (the sibling-package prose-id
baseline holds, no growth); `pnpm check:nul-bytes` exits 0 (9,684 files,
no raw control bytes), and a control-byte grep over the 25 changed files
finds none (exit 1).
- **Build before the gates:** `turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2` at `61a589d48`, 71 of 71
tasks, VERDICT command-exit 0.
- **Tests and typecheck, under the verify lock, at `61a589d48`** (one
command joined by `&&`, VERDICT command-exit 0): `pnpm --filter
@objectstack/metadata test`: 56 test files pass (56), 836 tests pass
(836); `pnpm --filter @objectstack/metadata typecheck` (`tsc --noEmit`)
exits 0. `tsc --listFilesOnly` puts all 56 tracked test files and all 23
changed `src` files in that program; `tsup.config.ts` is outside it, and
the token guard covers it.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 24 touched `.ts` files plus `dist/index.js` as the control: 25
results, 0 errors and 1 warning, the control's ignore notice; none of
the 24 is reported ignored. `eslint.config.mjs` never enables type-aware
linting (its lines 327 and 328 say so), so a comment edit cannot move
the verdict on an untouched file. The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base.** The branch was cut at `ee75aae1a` and merged with `main`
once, at `51550933d`, before the gates (merge `61a589d48`, no conflict;
`main` brought one `packages/cli` commit and its changeset, and neither
`check-issue-citations.mjs` nor `dispatch-gates.mjs`). `617f25f8a` (the
last change under `packages/metadata` before this stage) is in the base
(`merge-base --is-ancestor` exit 0; control `db0cf2231`, exit 0). The
net diff against `main` is the 24 rewritten files (+74/−74) and the
changeset (+17).
- **`objectui#6111` was not read in this session.** This session's
GitHub access does not reach `objectstack-ai/objectui` (its proxy
answers 403 for the issues and pulls endpoints there, and the web page
answers 403 for a control number too, so neither reading means
anything). The 200 reading is the spec lane's, recorded in `2123fcca3`'s
message for the same pair; `git ls-remote` on objectui shows no
`refs/pull/6111/head` (control: `refs/pull/6112/head` is there), which
fits an issue rather than a pull request.
- **The margin over `core` is one site** (41 against 40 at the base).
- **The same dead numbers stand in `packages/metadata-core`**: `objectstack-ai#6111`
on 3 census sites and `objectstack-ai#12914` on 1, in its own stage of this card; this
stage's `objectui#6111` respelling and `f887e5249` are there to reuse
where those sentences say the same thing.
- **Wording only:** no line without a number was changed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ect options (objectstack-ai#21402)

Fixes objectstack-ai#21392
Clause-②: no

## What changed

`skills/objectstack-upgrade/references/examples-upgrade.md`, § The
v17-canonical shapes, compiled — two edits, nothing else in the file:

1. The `status` field in the block carrying the `os:check` marker gains
a two-entry `options` list (`New` / `Qualified`, CRM-lead vocabulary),
in the shape `skills/objectstack-data/references/examples-objects.md:73`
already teaches. Every other line of the block is unchanged.
2. The section's lead sentence is narrowed to what
`check:skill-examples` actually does — a type check. The clause claiming
the block "cannot rot into teaching a shape that no longer compiles" is
removed: PR objectstack-ai#21390 is the counter-example (the block type-checked green
and parsed red at the same time), and the deletion is what pays for the
`options` list under the file's token ceiling.

The section title is untouched (`SKILL.md:461` references it by text).
No parse limb is added to `check:skill-examples` — triage 5950866103:
not this card, new gates default to no.

## Why

PR objectstack-ai#21390 (ruling A on objectstack-ai#20827, record 5910124148) is on `main` at
`c52c49d9`: `FieldSchema` refuses at parse a `select` / `radio` with
neither a non-empty `options` list nor a `picklist` reference. The
published block was teaching exactly that shape to every AI author who
reads the skill, while the gate watching the block (`tsc --noEmit`)
stayed green — `options` is optional in the type, and the refusal is a
Zod refine. `ObjectSchema.create()` runs that same parse, so the block
as published would also throw at module evaluation.

## Measured

Tree: `05733de0` (this PR's head, the branch's only commit). Base:
`ecb6ca02`.

**Parse probe (one-shot script in the scratchpad, not committed, no gate
added)** — the `Lead` literal extracted from the block and handed to
`ObjectSchema.safeParse` from `packages/spec/dist` built at this head:

- before (the block at `ecb6ca02`): `success=false`, one issue,
`code=custom path=fields.status.options`, message begins "A `select`
field needs its choices: declare a non-empty `options` list …"
- after (this head): `success=true`

**Token ratchet** (`node scripts/check-skills-token-ratchet.mjs`, unit =
ceil(utf8 bytes / 4)):

| | bytes | tokens | ceiling | headroom | lines |
|---|---|---|---|---|---|
| before | 4751 | 1188 | 1197 | 9 | 119 |
| after | 4780 | 1195 | 1197 | 2 | 119 |

Net +29 bytes / +7 tokens / 0 lines: the `options` list costs +94 bytes,
the lead-sentence narrowing pays −65. The ceiling is not moved, and no
re-wrap is used as currency.

**Gates** — the 24 families `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derives at `05733de0` (set-equal
to the dispatch's list), each exit captured before any pipe, then
`--ran` reconciled: 0 unrun, every family exit 0. Two refused on first
run with a printed prerequisite (exit 3 — not a verdict):
`check:skill-examples` until `packages/client-react/dist` existed,
`check:doc-formula-expressions` until `@objectstack/formula` and
`@objectstack/lint` were built. After `turbo run build
--filter=@objectstack/client-react --filter=@objectstack/lint` (34
tasks, under the shared verify lock) both ran green:
`check:skill-examples` — "259 prose examples type-check across 3
surface(s) — every marked block parsed, so tsc ran the SEMANTIC pass on
all of them"; `check:doc-formula-expressions` — 22 record-scoped
examples and 14 field-level `*When` predicates judged clean.

Not run locally, declared to CI: repo-wide `pnpm lint`; package test
suites (the diff touches no package, and `skills/**` is in no package's
`files[]`, hence `skip-changeset`).

## Risk and rollback

A two-line example-and-prose change in a published skill reference; no
runtime, package or gate code moves. Rollback is a revert of `05733de0`.
Governed surface (`skills/**`, Tier H): this PR stays draft until an
authorized APPROVED review; no seat readies, queues or arms it.

## Acceptance notes

- The lead sentence overclaimed what the gate checks; the
mechanism-level remedy (a parse limb on `check:skill-examples`) is a new
gate and explicitly not this card. Noted, not filed; carrier: none.
- `check:skill-examples` and `check:doc-formula-expressions` each exit 3
with a printed remedy on an unbuilt tree — a prerequisite refusal by
design, not a finding.
- `ObjectSchema.create()` runs the full parse at module evaluation, so
the block before this PR would have thrown when executed, not only under
`os validate`. Observation only; the gate that reads the block never
executes it.

## 维护者速读(草稿)

**改了什么**:升级技能(`skills/objectstack-upgrade`)参考文件里「v17 规范形状」示例中的 `status`
单选字段补上两项 `options`(New /
Qualified);该节引导句从「经类型检查所以不会腐烂」收窄为「经类型检查」。文件其余部分、节标题、门禁脚本均未动。

**为什么改**:PR objectstack-ai#21390 已合入,spec 现在在 parse 时拒收没有选项的 `select` 字段;这段示例正是在教 AI
写一个会被拒收的形状,而守着它的门禁只做类型检查,看不见这一点。写给 AI 的技能说错一句等于产品缺陷(NORTH-STAR 优先规则 4)。

**风险与代价(含回滚)**:纯文档示例,两处改动,不影响任何运行时与发布包;token 账本余量从 9 降到 2,未抬上限。回滚即
revert 本 PR 的单个 commit。

**席位意见**:(留空)

**你要做的**:审阅通过即在本 PR 上 APPROVE,席位随后落地;本 PR 应与 objectstack-ai#21390 同随下一个 Version
Packages 发布。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ainst a boolean field is refused like a non-boolean string (objectstack-ai#21382) (objectstack-ai#21404)

Fixes objectstack-ai#21382
Clause-②: yes (narrowing)

## What this changes

One verdict, widened, as triage directed on the card (grade
`5949762416`, inheriting the number door's direction `5877498426`): the
published boolean-comparand verdict now refuses any comparand against a
declared `boolean` / `toggle` field (or a `formula` returning `boolean`)
that is outside its accepted set. The engine's boolean arm consumes that
verdict and nothing else; it carries no second rule. This is the boolean
twin of PR objectstack-ai#20545 (`b05743433b`) on the number door, and it follows that
PR's shape and words where the two contracts have the same parts.

- **The verdict**,
`packages/spec/src/data/filter-boolean-comparand-declared-type.ts`.
`booleanComparandDoorVerdict(field, comparand)` answers `door-refusal`
(`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`,
`0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list
member. The string rule is byte-for-byte what PR objectstack-ai#21372 published. The
accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`,
`"false"`, `"1"`, `"0"`; `null` is still the null test.
- **A `bigint` is read as the number it names**: `1n` / `0n` narrow like
`1` / `0`, and any other `bigint` is refused as a number. See *The
bigint reading* below for why this is the only reading that gives one
answer at every position.
- **What still passes**: a boolean, `null`, a `{ $field }` reference,
and every value outside the comparand-type door's accepted set
(`undefined`, a plain object, a `Map`). That door already refuses those
on every field, in its own words (see *Objects* below).
- Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`,
`array`) and the types `NonBooleanValueForm` /
`NonBooleanComparandForm`. The refusal's `form` widens from
`NonBooleanStringForm` to `NonBooleanComparandForm`, on
`BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and
`BooleanComparandDoorRefusalCase`. The site's `value` was already
`unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s
signature is unchanged (relevant to objectstack-ai#21376, which will consume it).
- Three refusal clauses. None names a backend, because PostgreSQL's
server error was measured at `where` only. The engine evaluates the
per-aggregation `filter` and `having` itself, and this contract has no
driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite
number by name, not as JSON (`NaN` would otherwise print as `null`, the
null test).
- The derived case table gains a `value` group: `-1` at `$ne` on every
judged field, and `2` and a `Date` at every judged position of
`f_boolean`. An array sits at every judged position except the equality
slots, where the comparand-shape door refuses one first. Beside them are
four passing rows. The `2` / `-1` reading rows, and a new `0.5` row, now
derive refusals. `freshComparand` copies a `Date` and an array per
filter, as the number table does. No `bigint` row is in either table, so
every filter a suite builds survives `JSON.stringify`.
- **The arm**,
`packages/objectql/src/boolean-comparand-declared-type-door.ts`:
comments only. The walk already routed every comparand to the verdict
and carried the refused value as written.
`number-comparand-declared-type-door.ts` (the shared walk) is untouched.
- **Tests**: the spec contract suite, the objectql arm suite, and a new
REST three-dialect cell
`packages/rest/src/data-boolean-comparand-door.test.ts`, beside
`data-number-comparand-door.test.ts`. In that cell SQLite always runs,
and PostgreSQL / MySQL are named skips without their URL.
- **Generated**: `packages/spec/api-surface/data.json` and
`export-origins/data.json`, three added lines each, regenerated by
`gen:api-surface` / `gen:export-origins` after `check:generated` named
exactly those two.
- **Changesets**, one per published package moved, both BREAKING `minor`
with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker
(`not-required (no-migration-prescription)`):
`.changeset/21382-spec-boolean-comparand-non-string.md` and
`.changeset/21382-objectql-boolean-comparand-non-string.md`.

## Clause-②, measured

`node scripts/pm/check-widening-tells.mjs` on `git diff
69a12a0...HEAD`:

- `--declaration no`: exit 4, three T3 tells. These are the three new
rows in `packages/spec/api-surface/data.json` (lines 469, 505, 507).
- `--declaration yes`: exit 0.

So this PR declares `Clause-②: yes (narrowing)`. It adds an export
listing row, and what it changes in behaviour is a narrowing. No T1 / T2
/ T4 tell fired.

## Before and after

A declared `boolean` field, two rows (`rt` true, `rf` false), through
`engine.find` / `engine.aggregate`. The probe is a scratch script
against freshly built dists, not a committed test. A `where` cell reads
implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base
`69a12a0952`. After: this branch, spec and objectql source as at
`196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in
this container, started for the run and stopped after.

| position | comparand | before: memory · SQLite · PostgreSQL 16 |
after, all three |
|:--|:--|:--|:--|
| `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row,
both, the false row · the same · **500 `DATABASE_ERROR` at every slot**
| 400 `INVALID_FILTER` at every slot |
| per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three |
400 |
| `having` on a groupBy of the field | the same | no group, no group,
both, the false group on all three | 400 |
| `where` | an array `[true]` as a `$in` member (the card) | **the false
row (200)** · a driver 400 · a driver 400 | 400, in the contract's words
|
| per-aggregation `filter` / `having` | the same | count 1 / the false
group on all three: the member silently dropped | 400 |
| all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the
comparand-shape door) | the same |
| `where` | `2n` (in-process) | no row · no row · 500 | 400 |
| `where` | `1n` (in-process) | **no row · the true row · the true row**
(`$ne 1n`: both rows on memory) | the true row on all three |
| all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same,
same words |
| all three | `true`, `1`, `"true"` (the controls) | the true row, count
1, the true group | the same |

## Premise check, and the dispatch's hypotheses

- **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null`
for every non-string except `1` / `0`, the verdict answered `passes`,
and the reading rows read `unread(2)` / `unread(-1)`. The card's table
reproduces on memory, SQLite and PostgreSQL (rows above). The number
contract's non-string branch was the template.
- **H2 holds: no walk change.** `judgeBooleanComparand` already passed
`value: comparand` and `form: verdict.form` straight through, so
widening the verdict reaches `where` (both spellings), the
per-aggregation `filter` and `having` with no code change in objectql.
The shared walk file is not in the diff.
- **H3**: the after-column is re-measured on memory, SQLite and
PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server
is available in this container. The REST cell's MySQL leg is a named
skip.
- **H4 holds.** A `formula` returning `boolean` is still refused one
door earlier, by the unmaterializable-field door, with `INVALID_FIELD` /
400. The arm suite's NAMED DIVERGENCE test drives every formula row,
including the new `value` row on `f_formula_boolean`.

### The bigint reading

The ruling does not name `bigint`. Measured on the base, `1n` against
the boolean field gave different answers per driver at `where`: no row
on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave
PostgreSQL's 500. That is the card's defect class, through an in-process
caller. The comparand-type door rewrites a `bigint` to its number. It
runs AFTER this door on the object spelling of `where` and on a
per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling
and at `having`. So:

- passing a `bigint` leaves the divergence;
- refusing every `bigint` would refuse `1n` on one spelling and narrow
it on the other;
- reading it as the number it names gives one answer at every position.

This is not a widening. A `bigint` passed the verdict before, so it was
accepted, and now it is either narrowed correctly or refused. The arm
suite pins it on both spellings and at all three positions.

### Objects: served by the comparand-type door, deliberately

Triage's list names objects. On the base, an object comparand against
the boolean field is already refused with `INVALID_FILTER` / 400 by the
comparand-type door, on all three drivers and at all three positions
(table above). The widened verdict passes such a value rather than
refusing it a second time. The reason is the one PR objectstack-ai#20545 measured: the
engine runs the two doors in a different order per position, so a second
refusal would answer one mistake with two sets of words. The arm suite
pins it: for a plain object, `undefined` and a `Map`, the engine's
message equals the comparand-type door's own message.

## Tests (at `196afd75cd`, the final head)

Each heavy run went through `scripts/pm/os-verify-lock.sh` with exit
codes from the lock's `VERDICT command-exit` lines.

- **`@objectstack/spec`**:
`filter-boolean-comparand-declared-type.test.ts` 34/34. `--project
local`, run as two `--shard` halves to fit the container's foreground
cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599
files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck`
holds its ledger (52 files / 246 errors, none added).
- **`@objectstack/objectql`**:
`engine-boolean-comparand-declared-type-door.test.ts` 34/34, and
`engine-number-comparand-declared-type-door.test.ts` still green (its
boolean-arm partition reads the widened verdict). `--project local` as
two shards: 182 files / 3583 passed, and 182 files / 3777 passed.
`typecheck` exit 0 (ledger 40 / 234, none added).
- **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with
`OS_TEST_POSTGRES_URL` set: 6 passed, 3 skipped (the MySQL leg). The
SQLite and PostgreSQL legs both ran. `--project local` (no live URL):
255 files, 4808 passed, 322 skipped. `typecheck` exit 0 (test layer 0 /
0).
- **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's):
1. Population, from `eslint.config.mjs` itself: all 5 touched lintable
files answer `isPathIgnored` false. The other 4 changed files are JSON /
Markdown, outside its globs.
2. `eslint --no-inline-config --format json` over them: 5 files, 0
errors, 0 warnings.
3. The config never enables type-aware linting (no
`parserOptions.project`, no `projectService`), so this diff cannot move
any untouched file's verdict.
- **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran`
reconciles 90 derived / 90 run / 0 NOT-MEASURED.
`check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET,
because the workspace was not fully built. It was re-run after a full
`turbo run build`, and exited 0.

## Ablation (reverse verification)

From the committed head `196afd75cd`, with
`scripts/ablation-replace.mjs` in wrap mode. The mutation lives only
while the tool's trap is armed. It removes the widening at its one
routing line in the verdict: `if (form === null) return { verdict:
'passes' };` gains `|| (globalThis as Record[string,
unknown]).ABLATION_21382 === undefined` (angle brackets spelled as
square brackets here), so every non-string passes again. The string rule
is untouched.

- **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After
`pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found
the marker in 4 built files (exit 0). Results:
- spec suite: **3 failed** / 31 passed (the non-string verdict, the
bigint reading, the `value` group);
- objectql arm suite: **5 failed** / 29 passed (the GUARD's form set,
and the `where`, per-aggregation `filter`, `having` and bigint tests);
- REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on
both the SQLite and PostgreSQL legs).
- The control tests stayed green on every suite. The direction is the
expected one: red.
- **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff
HEAD` empty, and `git status --porcelain` empty for the whole tree.
After a rebuild, `ablation-dist-preflight --absent` found the marker
absent from all 230 built files (exit 0). Results: spec 34/34, objectql
34/34, REST 6 passed / 3 skipped.

## Acceptance notes

- `comparandPreview` is a private copy in the boolean module. The number
module's copy is private, and PR objectstack-ai#21390 holds that file, so this PR does
not touch it. Moving both copies into `filter-comparand-refusal-text.ts`
is a consolidation for whichever PR next touches both. No carrier is
named.
- Two comments outside this claim's file surface still describe the
string half only: `engine.ts` around the `narrowNumberComparands` call
("any other string is refused"), and the shared walk's `[objectstack-ai#21333]` header
section. Both are incomplete, not false. No carrier is named.
- The REST cell's live legs are red-capable and un-run in CI (no job
provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the
number cell. This PR carries their local PostgreSQL run.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants