Skip to content

feat(spec): the object-form runtime form field declares the grid widget's eight camelCase keys (#21768) - #21825

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21768-grid-camelcase-keys
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21768-grid-camelcase-keys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21768
Clause-②: yes (widening)

What this does

The spec half of objectstack-ai/objectui#11610, which renamed the grid widget's eight field-level keys to camelCase (merge 2abec3a96c). The .objectui-sha pin on main is 9dfaca654311, and it carries that merge: merge-base --is-ancestor 2abec3a96c 9dfaca654311 exits 0, which proves ancestry on its own.

This executes ruling B on #21704 fork 2 (record 5978663135). That ruling deferred the eight keys "until objectui camelCases them". This PR also answers objectui's two asks in 5981368783 and the at-tier review's ③ in 5986881331. It works under the claim 5987845581.

before after
runtime form field (buildObjectFormRuntimeField: object-form customFields[], and the inline arm of both forms' sections[].fields[]) minRows … sortField refused as unrecognized keys declared with the widget's value types
the eight snake_case spellings refused, with one shared prescription: "these come in once the widget reads a camelCase spelling" still refused by name; each refusal names its own camelCase key
totalField not declared described as the grid's CHILD column summed into the footer, not the PARENT field a master-detail or record:line_items sum is saved to

Census at the pin (objectui 9dfaca654311, read with git show)

key value type: GridFieldMetadata (types/src/field-types.ts:1031-1088) and the zod mirror (types/src/zod/form.zod.ts:1102-1117) grid read (fields/src/widgets/GridField.tsx, cfg = field at :669) snake_case spelling
minRows number / z.number() :762; Remove stops at it (:882, :1447) min_rows: refused
maxRows number / z.number() :763; Add, Duplicate and the blank row stop at it (:808, :850, :892, :1077, :1487) max_rows: refused
allowAdd boolean / z.boolean() :749, !== false; off when read-only or disabled allow_add: refused
allowDelete boolean / z.boolean() :750, the same allow_delete: refused
allowReorder boolean / z.boolean() :783, the same allow_reorder: refused
totalField string / z.string() :771; summed into the footer at :919-923 total_field: refused
addLabel string / z.string() :1322 (empty state) and :1491 (Add button) add_label: refused
sortField string / z.string() :778; stamped on every row by emit (:785-790) sort_field: refused
  • No snake_case read remains. git grep of the eight snake_case spellings over objectui's packages/*/src (tests and stories excluded) returns only its refusal faces:

    • the TS tombstones (field-types.ts:1106-1141, form.ts:2108-2143);
    • GRID_FIELD_RETIRED_KEYS (field-types.ts:1159);
    • the zod alias refusals (zod/form.zod.ts:1122-1129);
    • one comment in GridField.tsx:617.

    The widget refuses a field that carries any of them, and draws a refusal instead of the grid (GridField.tsx:643-650). The control grep for the camelCase spellings over the same scope has 38 hits in GridField.tsx alone.

  • FormField takes each key by reference to GridFieldMetadata (types/src/form.ts:2079-2094).

  • totalField is a homonym. The line-items panel and the master-detail form both hand the grid their amountField as its totalField (plugin-form/src/LineItemsPanel.tsx:710, MasterDetailForm.tsx:876).

Writers of either spelling in this repo (base 75ddcd1b41)

  • snake_case: none authored.
    • The only authored value is the spec pin's own refusal probe (component-form-custom-fields-sections-typed.pin.test.ts, min_rows).
    • Prose mentions: the S-forms changeset's FROM → TO row, the console pin changeset's interim note, and dated comments in conversions/registry.ts, migrations/registry.ts and two migration entries.
  • camelCase on an inline form field: none in examples/, skills/, content/docs/ or apps/.
    • The addLabel hits there (examples/app-showcase/.../project-workspace.page.ts:59, skills/objectstack-ui/SKILL.md:83) are master-detail detail entries, a different surface that is unchanged.
    • The objectui writer this PR pins is the schema catalog's fields-grid/line-items-grid field, which carries all eight keys.

Changes, packages/spec/src/ui/component.zod.ts

  • The eight members. A new builder, objectFormRuntimeFieldGridMembers(), declares them, and the runtime field spreads it into its shape. The builder's docblock carries the read points above.
  • The refusal map. OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS is now a map from each snake_case key to its camelCase key (satisfies the declared key union, so a value naming an undeclared key fails tsc).
  • One guidance set per entry, under the map's name. A set answers once per message, so this way each written spelling gets its own bullet naming its own replacement. The total_field bullet also restates the CHILD-column meaning.
  • The three comments moved:
    • the line-items guidance block, re-measured at 9dfaca654311: LineItemsPanel.tsx:696-720, :816-827, spelled addLabel / sortField;
    • the customFields fork note, which now records the rename;
    • the master-detail sortField retirement note, which keeps its dated read and adds a hop sentence: the derived field is handed to the grid as sortField at MasterDetailForm.tsx:877, and the entry key is still a tombstone.
  • One runtime string had to change, because this PR makes it false. record:line_items' sortField refusal said "No block takes an authored sortField". An inline grid field now takes one, for the rows of its own value, so the sentence now reads "No block takes an authored sortField for child records". The accept set is unchanged.

content/docs/references/ui/component.mdx was regenerated by check:generated --fix, which named only gen:docs as stale. The changeset is .changeset/21768-object-form-runtime-field-grid-camelcase-keys.md: @objectstack/spec minor, with Clause-②: yes (widening) at line start.

D3 reading

No accept set narrows. Every value that parsed at the base still parses, so no ADR-0087 D3 entry and no conversion is owed. Before this PR, the eight camelCase keys were refused (unrecognized_keys) and the eight snake_case keys were refused. After it, the camelCase keys parse and the snake_case keys are still refused. A camelCase key with the wrong value type is still refused, now as invalid_type. No step-18 file is touched.

Tests, at head bb32836dc9

component-form-custom-fields-sections-typed.pin.test.ts

  • §1: the catalog writer parses byte-identical as a customFields member. Lit controls: the keys on an object-form section's inline entry, with the three switches false, and on an object-master-detail-form section's inline entry.
  • §2: the existing min_rows row keeps its code and path. Its prescription check now asserts that the bullet names min_rows and minRows.
  • §3: the key-set pin gains the eight keys.
  • §5 (new):
    • each snake_case key gives unrecognized_keys at customFields.0, and exactly one bullet names that key and its camelCase key. The camelCase key then parses with the same value.
    • two retired keys on one field give two bullets, one each.
    • a section's inline entry refuses total_field the same way.
    • each camelCase key refuses a wrong value type as invalid_type at its own path.
    • each describe names its one reader.
    • the totalField homonym: the inline grid field's describe starts with the CHILD column and the footer, and names amountField. The record:line_items and master-detail detail-entry describes start with "Parent field to receive the rolled-up sum" and never say CHILD or footer.

The assertions check named subjects, not copy.

master-detail-detail-sort-field-retirement.test.ts

  • Its tree-scoped absence walk flagged the new pin's sortField: 'position'. The walk's own header covers this case: "A future schema that declares a sortField of its own would trip this walk: narrow the matcher to detail entries then, never exclude the new file."
  • So the matcher is narrowed, and no file is excluded. A match is skipped only when the object literal around it names type or widget grid / field:grid at its own level, which marks the inline grid field.
  • Three anti-vacuity probes stay offenders: a nested column's grid type, an inline grid field placed earlier than a detail entry in the same text, and type: 'grids'.
  • The six pre-existing probes are unchanged.

Ablation

  • Prediction, written before the run: dropping sortField from the builder turns six tests red. They are §3's key set, §1's two grid writer rows, §5's sort_field refusal row, §5's sortField value-type row, and §5's describes test. The retirement pin stays green.
  • Method: node scripts/ablation-replace.mjs --delete. The anchor hit once and went 1 → 0, and the blob went 298601db7467 → 48deefce4054.
  • Result: exactly those six went red, Tests 6 failed | 108 passed (114).
  • Restore: the blob matches HEAD 298601db7467, git diff HEAD is empty, and git status --porcelain is empty.
  • Two runs: at edbdef7bf8, and again at bb32836dc9. The subject is imported from ./component.zod (source, not dist), so no rebuild is involved.

Suites

  • pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec typecheck at bb32836dc9: exit 0.
    • check:test-typecheck says OK, and the debt ledger is unchanged.
    • tsc -p tsconfig.test.json --listFilesOnly lists both edited test files, and neither has a ledger entry.
  • The full spec suite (vitest run --project local): Test Files 615 passed (615), Tests 18383 passed | 1 todo. It ran at edbdef7bf8. The only later change is the pin file's assertion wording, re-run at bb32836dc9 with alias-integrity.test.ts and strict-object.test.ts: Tests 166 passed (166).

Gates, at head bb32836dc9

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 110 commands for this change set, and every one exits 0.
    • --ran reconciliation: 110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN.
    • Exit codes were captured before any pipe.
    • check:type-check-debt ran under the verify lock, because its first unlocked run hit the 280s per-command cap.
  • At the first head (edbdef7bf8), six gates (check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:lean-entry-closure and check:dual-build-cjs-loads) first answered exit 3, PREREQUISITE NOT MET, because their packages had no dist. After a turbo build of lint, formula, client-react and objectql (all cache hits), each answered 0. All six also answered 0 at bb32836dc9.
  • Lint, a measured narrowing. Repo-wide pnpm lint is CI's. Locally, ESLint ran over the three edited TS files at bb32836dc9, with three pieces of evidence:
    • Population: each file resolves a config through --print-config (packages/**/*.{ts,tsx,mts,cts}), so none is ignored.
    • Count: --format json reports 3 files, 0 errors and 0 warnings.
    • Invariance: the resolved parserOptions holds only ecmaVersion and sourceType, with no project, and no typed rule is on. Linting is not type-aware, so this diff cannot change the verdict on any file it did not touch.
  • Not measured locally: the path-scheduled CI jobs and type-check lanes that dispatch-gates lists outside its derived set (Test Core shards, Dogfood, Build Core, Build Docs, Temporal Conformance, and the workspace and consumer type checks). CI runs them.

Declared beyond the claim's listed surface

  • master-detail-detail-sort-field-retirement.test.ts: the claim lists no file for this edit. The new sortField member is what tripped the walk, and the walk's header says what to do, so it was narrowed, not excluded (see Tests).
  • The record:line_items sortField refusal sentence: the sentence became false with this PR, so it was corrected in the same file (see Changes).

Acceptance notes

  • main is 2 commits past this branch's base (ba57588665). They touch service-settings, the QA checklist JSON and a comment-only hunk in spec/src/contracts/crypto-provider.ts. None of that overlaps this diff, so main was not merged in. CI's merge ref covers the combination.
  • Three dated prose records still describe the pre-rename spelling or the old reach. All are accurate as dated reads, and nothing parses them. Carrier: none.
    • The replacement text of the D3 entry ui-record-line-items-props-closed says "sortField, which no block takes".
    • conversions/registry.ts (about :12488) and the retired-key entry 18.ui__ObjectMasterDetailFormProps__details.sortField say the derived field is "handed to the grid as sort_field (:874)", read at pin 89cad75d5570.
    • The S-forms D3 reason says the snake_case keys stay out "until the widget reads a camelCase" spelling.
  • The pending S-forms changeset (.changeset/21464-component-props-form-custom-fields-sections-typed.md) still says the eight keys "come in once the widget reads a camelCase spelling". If it ships in the same release as this changeset, the CHANGELOG reads as a sequence: that entry, then this one stating that they came in. It is not edited here, because it is outside this PR's surface. Carrier: none.

Generated by Claude Code

claude added 5 commits October 5, 2026 04:16
…amelCase keys

The snake_case spellings stay refused by name, each prescription naming its
camelCase key; totalField is described as the grid's CHILD column.

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…grid field's own key

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/architecture.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/getting-started/common-patterns.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/permissions/delegated-administration.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/permissions/index.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/permissions/permission-metadata.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/permissions/permission-sets.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/permissions/permissions-matrix.mdx (via allowDelete (literal, a string literal in allow_delete))
  • content/docs/protocol/objectql/security.mdx (via allowDelete (literal, a string literal in allow_delete))

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

  • content/docs/releases/v14.mdx (via allowDelete (literal, a string literal in allow_delete))

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 a3ffc4512df5d9ebc1c9e5dee70813d89bf549db → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 06:29
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 06:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 6fb7115 Oct 5, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21768-grid-camelcase-keys branch October 5, 2026 07:15
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… decision in words instead of a tracker number (stage 17) (objectstack-ai#21856)

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

Stage 17 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the third name-ordered file group directly under
`packages/spec/src/data/`: the 20 test files from
`filter-comparand-type.test.ts` to
`filter-view-operator-parity.test.ts`. They carried 95 messages and 99
tracker ids, citing 36 records. Every one of those ids now either states
what its record decided, in words (form D), or is dropped where the
title already says it. Text only: no assertion, identifier, test count
or code comment changes.

## Census at the base (`2799155678`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 16 used. A literal counts as a test title when its
folded message is argument 0 of a `describe` / `it` / `test` call,
`.each` / `.skip` / `.only` chains included. Everything else is an
"other" string.

Both instruments read **1140 messages / 1207 ids in 245 files**, the
seat's reading at `2799155678`. That is stage 16's head reading (1137 /
1204) plus the three ids that PRs objectstack-ai#21819 and objectstack-ai#21825 added in new `ui/`
test titles, which ride the `ui/` stages.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `ui/` | 84 | 396 / 419 | 378 / 401 | 18 / 18 |
| `data/` (this PR: the third 20 files) | 55 | 280 / 299 | 265 / 282 |
15 / 17 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **245** | **1140 / 1207** | **1069 / 1133** | **71 / 74**
|

The group reads **95 messages / 99 ids in 20 files**, the seat's
figures, file for file:

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `filter-comparand-type.test.ts` | 9 / 10 | 9 / 10 | 0 |
| `filter-cross-field-comparison-class.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `filter-dotted-head.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `filter-empty-operator.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `filter-equality-array-schema-door.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `filter-field-reference-lowering.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `filter-icontains-parse-door.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `filter-like-code-point.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `filter-like-nul-pattern.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `filter-like-wire-lowering.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `filter-lowering.test.ts` | 6 / 6 | 1 / 1 | 5 / 5 |
| `filter-ne-array-schema-door.test.ts` | 5 / 6 | 5 / 6 | 0 |
| `filter-number-comparand-declared-type.test.ts` | 17 / 17 | 17 / 17 |
0 |
| `filter-operator-vocabulary.test.ts` | 2 / 4 | 1 / 1 | 1 / 3 |
| `filter-preset-comparand.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `filter-save-door-face-parity.test.ts` | 14 / 14 | 7 / 7 | 7 / 7 |
| `filter-text-comparand.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `filter-text-conformance.test.ts` | 2 / 2 | 1 / 1 | 1 / 1 |
| `filter-text-operator-declared-type.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `filter-view-operator-parity.test.ts` | 2 / 2 | 2 / 2 | 0 |
| **20 files** | **95 / 99** | **81 / 83** | **14 / 16** |

Two more test files sit in the same name range and carry no id:
`filter-subtree-provenance.test.ts` and `filter-verdict.test.ts`. They
are not touched.

- **Controls.** Lit, a title: `data/filter.test.ts`, outside the group,
reads 18 / 19 at the head as at the base. Lit, "other" strings: the twin
expect messages in `system/message-queue-retirement.test.ts` (`:87`,
`:127`) still read at the head. Dark:
`data/filter-operator-vocabulary.test.ts` reads 0 / 0 at the head while
11 of its comment lines still carry a number. Planted in a scratch copy
of the head `data/filter-like-nul-pattern.test.ts`: an id put into a
title reads 1 / 1, and an id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals as the
gate pattern in all 20 files at the base, and 0 in all 20 at the head.
- **At the head:** 1045 messages / 1108 ids in 225 files. The 20 files
read 0 / 0, and no other file moved.

## How the area was chosen

`data/` has no subdirectory to split by, so its stages take name-ordered
file groups near the ~100-id bound. Stage 16's re-cut named this group
at 99, and this census reads 99, so the rule needed no re-cut.

**Named for the next stages** (re-cut from the head census, 1045 / 1108;
`data/` 185 / 200 left, the same cut stages 15 and 16 named):
- `data/` in two more stages, name-ordered:
1. `filter.test.ts` to `object.test.ts`: 17 files, 103 messages / 114
ids. `object.test.ts` alone carries 42, so no cut lands nearer the
bound;
2. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34)
with `data/driver/` (7 files, 48 / 52): 86 ids.
- `ui/` 419, about four stages. `api/` 201, two. `system/` 165, two. The
files directly in `src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

20 literals (23 ids) now state a decision in words. 3 literals (4 ids)
get their subject back in words where the number stood in for it. 72
literals (72 ids) drop a number the title already explains.

Every cited record was read with its comments through REST: 35 answer
200. objectstack-ai#14426 answers 404, and its decision was read from what landed:
commit `40a44b91b` and its CHANGELOG entry (the `undefined` comparand
refusal names the null predicate by its full spellings, so following it
never lands in another refusal). The title already says that, so the
number is dropped.

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#7872 | `filter-comparand-type.test.ts:41` | "the accepted set — one
set, defined at the shared door for every driver". The ruling put the
comparand-type set at the shared compile face, the measured superset,
instead of per-driver policy. |
| objectstack-ai#5869 | `filter-comparand-type.test.ts:255` | "does not judge a
non-array list-operator comparand — that SHAPE is refused 400 by the
engine’s shape gate". The record's 500 became an enveloped 400 at the
engine's list-shape gate. |
| objectstack-ai#20347 | `filter-cross-field-comparison-class.test.ts:44` | "the class
table — one classification, read by every judge". One comparison
classification, lifted from the SQL driver, shared by the lint arms and
the driver. |
| objectstack-ai#8371 | `filter-dotted-head.test.ts:24` | "classifyDottedFilterHead —
the FILTER axis dotted verdict, by the type of the head segment". Option
2: a type-directed verdict on the head segment, with the structured head
left unjudged. |
| objectstack-ai#19757 | `filter-empty-operator.test.ts:148` | "the empty list stays
refused in the equality slot — the ruled refusal of an array there is
untouched". Ruling 乙: an array in the equality slot is refused at the
shared face, the empty array included. |
| objectstack-ai#7597 | `filter-field-reference-lowering.test.ts:56` | "equality
triples with a `{ $field }` comparand — lowered to `$eq`, never a bare
field spec". |
| objectstack-ai#20041 | `filter-like-nul-pattern.test.ts:25` | "hasNulInLikePattern —
one predicate, so every door refuses a pattern holding U+0000". One spec
predicate beside the dangling-escape one, asked at every door. |
| objectstack-ai#7536 | `filter-like-wire-lowering.test.ts:51` | "the wire lowering of
`like` / `ilike` — its own operator, never folded onto `$contains`". The
caller binds the wildcards. |
| objectstack-ai#20116, objectstack-ai#19886 | `filter-ne-array-schema-door.test.ts:243` | "§5 — the
stored-filter carrier walk refuses $ne: [...] on save too". The save
door refuses what the query faces refuse. objectstack-ai#19886 is dropped: the title
already says the slot refuses an array. |
| objectstack-ai#20336 (2) | `filter-number-comparand-declared-type.test.ts:59`,
`:174` | "the numeric grammar — a JSON number literal naming a finite
double" and "numberComparandDoorVerdict — a numeric string narrows to
its number, a non-numeric one is refused". |
| objectstack-ai#20510 | `filter-number-comparand-declared-type.test.ts:308` |
"`aggregated` and `boundByDriver` — the refusal says only what is true
at its position". The refusal names an aggregated column as such, and
names the PostgreSQL server error only where the driver binds. |
| objectstack-ai#5701, objectstack-ai#3948, objectstack-ai#6520 | `filter-operator-vocabulary.test.ts:82`, `:89` |
One declared expect message: "(measured when $icontains was staged — a
dropped predicate WIDENS, which on an RLS read scope is an unauthorized
read)" and "Admitting $icontains with an arm on every JS face, in one
PR, is the worked example of the clearing direction." |
| objectstack-ai#4706 | `filter-operator-vocabulary.test.ts:121` | "covers the
operators retired when `$icontains` replaced `$regex`". Option B:
`$regex` retired, `$icontains` added. |
| objectstack-ai#8793 | `filter-preset-comparand.test.ts:31` | "FilterConditionSchema
— a bare preset name in an ordering comparand is refused at publish". |
| objectstack-ai#19889 (2) | `filter-save-door-face-parity.test.ts:308`, `:309` | Two
declared `it.each` labels: "an implicit list (an equality-slot refusal,
unchanged)" and "a $eq list (an equality-slot refusal, unchanged)".
Ruling A: the save door refuses an array in the equality slot. |
| objectstack-ai#18113 | `filter-text-comparand.test.ts:51` | "isRefusedTextComparand
— the one predicate every face reads, driven through every
FILTER_TEXT_CASES case". Ruling D: the predicate lives with the
contract's owner. |
| objectstack-ai#4706 | `filter-text-conformance.test.ts:162` | A declared expect
message: "a case-folding $contains returns both — the answer ruled out
when $contains was declared case-sensitive". The record's second
question, answered A. |
| objectstack-ai#15661 | `filter-text-operator-declared-type.test.ts:184` |
"textOperatorDoorVerdict — a text operator over a type that never stores
a string is refused". C-deny. |

**Subject restored (4 ids):** objectstack-ai#5222 and objectstack-ai#7597 at
`filter-comparand-type.test.ts:225` ("leaves a FieldReference alone at
every position — cross-field push-down, scalar comparison and the `$eq`
lowering own its fate"), objectstack-ai#8296 at `filter-dotted-head.test.ts:40` ("a
virtual head is refused: the unmaterializable-field verdict finally
reaching the dotted spelling") and objectstack-ai#19514 at
`filter-save-door-face-parity.test.ts:743` ("… and the text-comparand
arm stays silent").

**Dropped only (72 ids):** objectstack-ai#5146 (2), objectstack-ai#5234, objectstack-ai#5240, objectstack-ai#5298 (3), objectstack-ai#5701,
objectstack-ai#5869, objectstack-ai#7536 (2), objectstack-ai#7872 (2), objectstack-ai#8220, objectstack-ai#8793, objectstack-ai#8934, objectstack-ai#14079, objectstack-ai#14426, objectstack-ai#15661
(5), objectstack-ai#18113 (2), objectstack-ai#19514 (3), objectstack-ai#19886 (4), objectstack-ai#19889 (5), objectstack-ai#20116 (7), objectstack-ai#20143
(3), objectstack-ai#20311 (3), objectstack-ai#20336 (5), objectstack-ai#20347 (3), objectstack-ai#20446, objectstack-ai#20502 (8), objectstack-ai#20510,
objectstack-ai#21448 (4).

- Each of these titles already states the decision it pins: for example
"$ne a value: a row with no value satisfies it" for objectstack-ai#5298, "§1 — the
enumeration: the save door refuses exactly what the query faces refuse"
for objectstack-ai#20116, or "matchesLikePattern — `_` is one code point" for objectstack-ai#20143.
- **The `§1` … `§7` markers stay.** They number each file's own
sections, which its comments (`// §1 …`) and header name; only the card
number in front of them goes.
- **Files that carried a record as a file-wide tag**
(`filter-cross-field-comparison-class`,
`filter-number-comparand-declared-type`,
`filter-text-operator-declared-type`, `filter-like-wire-lowering`,
`filter-text-comparand`): the verdict's describe now states the decision
(above). The others name the contract part they pin (the judged fields,
the refusal message, the fixture, the case table), and their child
titles state what each part does. The files' header comments still name
the records; comments are not touched here.
- **Declared labels and messages:** the five `filter-lowering.test.ts`
`NULL_ROWS` names feed `it(row.name)` and the seven
`filter-save-door-face-parity.test.ts` labels feed a `%s` title; vitest
prints both whole, as the head run's names show.

## Readers

- **Test-name filters:** none. No tracked script, workflow or config
passes `-t` / `--testNamePattern`.
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`.
- **Projects:** none of the 20 files is listed in
`packages/spec/vitest.repo-tests.json`; all 20 run in the `local`
project.
- **By substring:** every old literal, plus a window around each id (260
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 7 needle hits fall on 5 lines: a code comment in
`filter-comparand-type.ts:392`, and sibling titles in `objectql`
(`engine-number-comparand-declared-type-door.test.ts:447`, `:459`,
`:484`) and `rest` (`data-number-comparand-door.test.ts:269`). None
reads a spec test title.
- **The files by name:** `scripts/check-driver-conformance.mjs` names
`filter-text-conformance.ts`, the source module, not the test file's
titles. The other references to these file names are code comments, plus
one design-doc line
(`docs/design/predicate-compilation-convergence.md:377`) that points at
`filter-lowering.test.ts`'s `INTRODUCED` constant, not at a title.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are
`filter-lowering.test.ts:81`, `:83`, `:85`, `:93` and `:95`,
`filter-operator-vocabulary.test.ts:82` and `:89`,
`filter-save-door-face-parity.test.ts:308`, `:309`, `:311` to `:314` and
`:743`, and `filter-text-conformance.test.ts:162`.

- **Result:** 20 of 20 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 96 changed string leaves in 95 literals, 81 titles and 15
declared. The diff's `+` and `-` lines are exactly the 96 planned lines,
and every file keeps its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string given a
new id VIOLATION; an undeclared `expect` message changed VIOLATION; a
title re-split into a `+` chain DIFF.

**Test counts:** the 20 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 671 tests, all passed, with the same count and status
sequence per file in 20 of 20. 543 full test names change, and each
equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was three commits
past the base (`5b2d189e28`). None of them touches `packages/spec` or
any of the 20 files, so `main` was not merged.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
20 touched files are in it, and no `*.test.ts` at all. The controls
`src/data/filter.zod.ts` and `dist/index.mjs` are in it.
- In the built `dist/`, a new phrase and an old literal each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `2ceeffd7e7`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18384 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 20 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
  - `check:generated`: all 15 generated artifacts up to date.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 to 16, and all 79 exit 0. `--ran` reconciles: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit
code recorded.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 20 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 192 changed lines.

## Acceptance notes

- **No needle in this group.** Every id was a title or a declared
string; no expected value of an assertion over a source docblock was
found. The three known needles are untouched.
- **Same-id test titles in this card's later stages** go with those
stages: 6 lines in `packages/spec/src`. Two are in the next `data/`
group (`data/filter.test.ts:576` and `:679`), and four are in `ui/`
(`component-object-grid-default-filters.pin.test.ts:56`,
`component.test.ts:3518`,
`dataset-filter-nested-relation-list.test.ts:220`,
`view-filter-rule-value-shape.test.ts:249`).
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 187 lines in 16 packages and one example app
citing ids this PR handled: `driver-sql` 32 (14 files),
`service-analytics` 32 (18), `formula` 24 (9), `lint` 21 (8), `objectql`
21 (11), `driver-memory` 13 (10), `driver-turso` 11 (10),
`plugin-security` 9 (5), `driver-mongodb` 8 (5), `driver-sqlite-wasm` 6
(5), `rest` 3 (2), `cli` 2, `metadata-protocol` 2, and one each in
`core`, `runtime` and `examples/app-todo`.
- **Code comments still carry ids** in these files and their sources,
for example the `// [objectstack-ai#21448]` line above
`filter-save-door-face-parity.test.ts:311` and the header of
`filter-operator-vocabulary.test.ts`. Comments are not this card's
share, and none is touched here.

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

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:ui size/m tests tooling

Projects

None yet

2 participants