fix(lint)!: object-field-ref-unknown judges a field's relatedListColumns, lookupColumns, lookupFilters and dependsOn, and indexes[].fields - #20479
Conversation
…and indexes[].fields Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
…runtime door Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
… at authoring Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 758ffae000b6fecf95f81aacebf3b4caad8de3b9 && git checkout 758ffae000b6fecf95f81aacebf3b4caad8de3b9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 75b2169243911f54dbc967679489b7cd01125093 af444b4cd12f2bb8252ce319d94a9b676382310b && git checkout -B drift-repro 75b2169243911f54dbc967679489b7cd01125093 && git merge --no-ff af444b4cd12f2bb8252ce319d94a9b676382310b
node scripts/docs-audit/affected-docs.mjs --json 75b2169243911f54dbc967679489b7cd01125093
|
Contract reviewServed-tier: Inputs: card #20432 (body; comments 5870812245, 5871296642, 5872067468, 5874560373), PR #20479 (body, comment 5874529668, file list, the net diff of 5 files against merge-base 3cf6449), the objectui readers at the pin dd3f7e1b ( Check-runs on the head at judgment time: 34 total, 31 success, 3 skipped by path condition (Build Docs, Console Pin Gate, Packed-tarball smoke), 0 failed, 0 in progress. The nine that were in progress when the brief was written (Lint & Repo Gates, Test Core 1/2/3/5/6 and the rollup, Type Check · workspace, TypeScript Type Check) all concluded success. ① Derived judgmentsAccept-set change: the rule
False refusals, each a skip I verified in the code:
Public surface: ② Semver level
The claim about already-stored objects is honest: because the baseline excludes the same-named stored copy, an object republished with an unchanged misspelling is judged as new and refused; a draft save is not judged (D1); the changeset states both. #4463 D4 still keeps a sibling's stored violation off a clean write (test
③ Boundary flagsDev flags (
Implemented-by: VERDICT: PASS Generated by Claude Code |
…dded Since object-field-ref-unknown judges indexes[].fields, publishing and os validate refuse an index column that names no field of the object; a draft save (the schema parse) still does not check. A real field that is not a stored column (a formula) is still skipped whole by the SQL driver with a warning. The help text, its comment and its four catalogue leaves now say exactly that. Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH Co-authored-by: Claude <noreply@anthropic.com>
…d inlineColumns form rows (objectstack-ai#19332, flight G2b) (objectstack-ai#20485) Part of objectstack-ai#19332 Flight G2b of ruling 5861442317. Clause-②: no ## Status: draft, no open gap The first round stopped at one red test outside the claim's surface, `packages/lint/src/validate-predicate-path-refs.test.ts`, the lint census of every predicate the shipped metadata forms carry. The claim both admitted mechanically moved population pins and forbade `packages/lint/**`. The seat answered A and amended claim `5873857698` in place (its "Amended 2026-09-28T17:27Z" line): that one file joined the surface for its census pin only. The patch round landed it in `16037890` (**Pins moved** below), and the file reads 54 of 54. Under the claim's second amendment ("Amended 2026-09-28T17:51Z"), `2bcad436` corrects one G2a text: the `indexes.fields` sub-row's help text (and its code comment and four catalogue leaves) now reads "Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log." Each clause was measured on this tree after `4b2d9041` (objectstack-ai#20479): `ObjectSchema.safeParse` accepts a misspelt column; `os validate`'s rules and the runtime publish gate (`runRuntimeAuthoringRules`, type `object`) both return `object-field-ref-unknown` at `error` on it; and an in-memory SQLite `SqlDriver` sync skips an index on a formula field (and one on a misspelt name) with `[sql-driver] skipping declared index … column(s) not materialized` at `warn`, while the index on a stored column is created. ## What Four live keys had no form row, so an author could reach them only through the Source tab. Each is now a row with hand-written sub-rows, as the ruling says: 「**G2 (…) — hand-written curated sub-rows**, plus … one nested `subset` row for `inlineColumns`」. The four-locale catalogue rows are in this PR. | key | form, section | row, sub-rows (face) | the row each copies | |:--|:--|:--|:--| | `object.activityMilestones` | `object.form.ts`, Advanced, after `validations` | `type: 'repeater'`: `field` (`widget: 'text'`, required), `value`, `summary` (text, required), `type` (text) | the `fieldGroups` repeater face (declared, labelled sub-rows); the text sub-rows copy the plain text rows in Basics | | `object.publicSharing` | `object.form.ts`, Advanced, after `requiredPermissions` | `type: 'composite'`: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` with inline options), `maxExpiryDays` (number, `min: 1`), `redactFields` (`widget: 'string-tags'`), `eligibility` (`type: 'code'`, `language: 'expression'`) | the `access` / `lifecycle` composite face; `enabled` the `enable` toggles; the two lists the multiselect objectui derives for an array of enum (the derived `appearance.allowedVisualizations` on the view and page forms); `redactFields` the `highlightFields` row; `eligibility` the `fields.visibleWhen` predicate rows | | `object.userActions` | `object.form.ts`, Advanced, under `managedBy` | `type: 'composite'`: `create`, `import`, `edit`, `delete` (`widget: 'json'`), `exportCsv` (switch) | the composite face; the four union keys the G1a `requiredPermissions` row (`json` on a union); `exportCsv` the `enable` toggles | | `field.inlineColumns` | `field.form.ts`, Configuration, between `inlineTitle` and `inlineAmountField`, gated `data.type == 'master_detail'` like both | `type: 'repeater'` over a curated subset: `name` (text, required), `label` (text), `width` (number), `defaultHidden` (switch) | the `fieldGroups` repeater face; the gate copies its two sibling rows | **Shapes (dispatch assumption 2), confirmed on this base:** `activityMilestones` is `z.array(strictObject(…))` at `object.zod.ts:2093`, four keys; `publicSharing` is a `strictObject` at `:2286`, six keys; `userActions` a `strictObject` at `:1769`, five keys; `inlineColumns` is `z.array(InlineGridColumnSchema)` at `field.zod.ts:1460`, the item schema at `:890`, twenty keys. The gate's own `keysOf` read the same sets. **Ledger:** one nested `subset` row at `field` / `inlineColumns` in `metadata-form-zod-reconciliation.test.ts`. Its shape is the `object` / `fields` subset row at the top of the ledger (and its two depth-two children `fields.options`, `fields.summaryOperations`), which is the ledger's `subset` precedent. The other three keys need no row: every key they declare is offered. **Row titles:** the two new repeaters' row schemas carry a JSON Schema `title` on every property (**Row titles** below). ## Faces that needed a reason - **`activityMilestones.field` pins `widget: 'text'`.** Read at the `.objectui-sha` pin on `main`, `dd3f7e1b`: with no `widget`, `SchemaForm`'s `resolveFieldWidget` runs its name conventions, and `detectFieldRefWidget` turns a string property named `field` into the `field-ref` picker whenever `widgetContext.objectFields` is present. `ResourceEditPage` always hands that over as a load state, and on an object draft it is `idle` (the draft names no `object` / `objectName` / `data.object` / `interfaceConfig.source`). `FieldRefWidget` then renders a select offering only "None", so a new milestone could not name its field. An explicit `widget` skips the conventions, and `text` is a passthrough hint, so the face is a plain input. - **`redactFields` pins `widget: 'string-tags'`** for the same reason: a string list named `...Fields` becomes `field-multi` by the same convention. - **`userActions.create` / `import` / `edit` / `delete` take `widget: 'json'`**, the ruling's union rule (「Union-typed values take `json`」). Each is a boolean or a strict `{ enabled, visibleWhen, disabledWhen }` object. At the pin, `json` is a passthrough hint: `resolveFieldFace` picks the stored value's union branch. A new entry or a stored boolean renders the switch (the first arm), and a stored object renders its three keys as a nested form, whose `setField` merges each edit into it. No face writes one arm over the other. The object arm is written in source and edited here once stored. - **`allowedAudiences` / `allowedPermissions` take `widget: 'multiselect'`** with inline options. Every member is a spellable option value. `MultiSelectWidget` writes `undefined` when every choice is cleared, so the form cannot store the empty list `getPolicy` reads as "any audience". - **The objectui faces re-checked here are the ones G2a did not use**, read at `dd3f7e1b` (G2a read the repeater face at `f8a9d0fb`): the declared composite (`CompositeField`, `pickSubSchema` reading `properties[NAME]` after `inlineSchemaRefs`), the multiselect widget, the switch and number branches of the scalar chain, and the `json` hint on a union sub-row. Code readings only, no browser run. ## `inlineColumns`: the curated subset - **Offered:** `name`, plus the three keys that apply to a column of any type, `label`, `width` and `defaultHidden`. An entry that names only a field is what the key's own describe recommends, because objectui's `hydrateColumns` completes it from the child field. - **Deliberately not offered (recorded in the `subset` row):** - `type`: declaring it opts the column out of that hydration. - `options`, `reference`, `displayField`, `idField`, `autofill`, `multiple`, `accept`, `prefix`, `step`, `scale`, `computed`, `expr`: each applies to one cell type only. A column takes its type from the child field at render, and no sub-row `visibleWhen` here can see it, so each would be offered on every column. - `required`, `readonlyWhen`, `requiredWhen`: hydration copies them from the child field, where the rule the server enforces lives. - The nested reading below shows the row is load-bearing: without it, the gate's `zodOnly` for `field.inlineColumns` is exactly those sixteen keys. ## Where a misspelt field name is refused, read from the code The ruling's 「a misspelling is refused loudly at parse」 does not hold for three of this flight's four name positions. Each help text claims only what is measured. | position | parse | publish door / `os validate` | runtime with a miss | help text claims | |:--|:--|:--|:--|:--| | `publicSharing.redactFields[]` | accepts | **refused**: `validate-object-field-refs` owns it at `error` (`runtimeTypes` includes `object`); probe below | the redaction never binds (fails open) | "refused at publish" | | `activityMilestones[].field` | accepts (probe) | not judged: `validate-object-field-refs` leaves it out by name; probe below | `matchMilestone` compares `after[field] === value`, so the milestone never fires | "Nothing checks it when you save or publish … never fires" | | a `{token}` in `activityMilestones[].summary` | accepts | not judged | `renderMilestoneSummary` renders it empty | "a token that names no field renders empty" | | `inlineColumns[].name` | accepts (probe) | not judged; probe below | `hydrateColumns` leaves an unknown name unhydrated, a plain text column | "Nothing checks it when you save or publish … renders a plain text column" | Seat 2's objectstack-ai#20479 (for objectstack-ai#20432), which landed on `main` as `4b2d9041` during this flight, extends `validate-object-field-refs` to four field-level lists and `indexes[].fields`. Read on `origin/main`, its list positions still do not include `activityMilestones[].field` or `inlineColumns[].name`, so these texts stay true (**Out-of-scope finding** below). ## Other help-text claims, each read from its consumer - `activityMilestones`: an update that moves the field into the value writes the summary in place of the field-change entry, and the first match wins (`audit-writers.ts` `matchMilestone`). The comparison is strict, and `value` is a string, so a milestone on a number or boolean field never fires. A lookup, master-detail or user token shows the referenced title (`REFERENCE_FIELD_TYPES`). An unset `type` is `updated`: the update branch starts from `activityTypeFor('update')` and a milestone replaces it only when it names one. (The schema's describe says the default is "completed"; see Acceptance notes.) - `publicSharing` (`share-link-service.ts`): `enabled` is re-read on every redemption; unset audiences default to `['link_only']` and permissions to `['view']`; `createLink` refuses any other with 422. Every audience still needs the token: `resolveToken` adds a signed-in check for `signed_in` and an allowlist check for `email`. `maxExpiryDays` defaults to 365, and a link created without an expiry is stored with none (`expiresAt ?? null`), so the cap does not force one. `eligibility` binds `record`, is checked at mint and at every redemption, and a predicate that does not compile or faults refuses. - `userActions` (`resolveCrudAffordances`): the per-bucket defaults in the help text are `CRUD_AFFORDANCE_DEFAULTS` verbatim. On an `engine-owned` or `append-only` object, turning a verb on also passes plugin-security's `assertEngineOwnedWriteAllowed`, so users can make that write through the data API. - `inlineColumns`: read only when the field sets `inlineEdit` (`attachInlineSubforms`). Unset, `deriveColumns` curates past six columns into the column chooser. `defaultHidden` never hides a required column (`GridField`: `c.defaultHidden && !c.required`). ## Row titles (admitted by the claim from the start) - **The guard.** `repeater-item-titles.test.ts` (objectstack-ai#17232) requires a JSON Schema `title` on every authorable property of every repeater's row schema, and forbids a ledger entry. Both new repeaters are new carriers: `object:activityMilestones` and `field:inlineColumns`. - **The change.** 24 `.meta({ title })` calls, and nothing else in either file: - `object.zod.ts`, the `activityMilestones` entry: `field` 'Field', `value` 'Value', `summary` 'Summary', `type` 'Type'. - `field.zod.ts`, `InlineGridColumnSchema`, all twenty properties: Name, Label, Type, Width, Required, Options, Prefix, Step, Reference, Display Field, ID Field, Multiple, Accept, Default Hidden, Computed, Expression, Scale, Autofill, Read-only When, Required When. - The four offered sub-rows' titles equal their declared labels. - **Byte proof.** Stripping exactly the added calls line by line gives each file's base blob byte for byte: `object.zod.ts` sha256 prefix `8979b5feea7ed0ff` both ways (4 removed), `field.zod.ts` `24713de3b50d5f71` both ways (20 removed). - **Reverse verification.** Run through `scripts/ablation-replace.mjs` in wrap mode, on the committed state. Deleting the `Summary` title reads `object:activityMilestones … expected [ 'summary' ] to deeply equal []`, 1 failed of 29. Deleting the `Default Hidden` title reads the same for `field:inlineColumns` with `[ 'defaultHidden' ]`. The tool proved each mutation landed (anchor 1 → 0, blob changed) and each restore (blob equals HEAD, `git diff HEAD` empty). - **No accept set moves.** `check:generated` reads all 15 artifacts up to date on this head, `check:authorable-surface` and `check:api-surface` included. ## Residue of the reconciliation gate (dispatch assumption 1) The test file's own helper block was copied verbatim into a probe that was never committed, and run with the gate's own functions. At base it is lines 1-838, sha256 prefix `5e04d44fc5c5edb3`, the prefix G2a read. On this branch it is lines 1-851, and it differs from the base block only by the 13 inserted ledger lines. Residue = offerable root keys − offered − root `omit` rows, per type, with `view` apart. Controls, asserted inside the probe: lit, `name` is offered by 17 of 17 forms; dark, `object.zzFabricated19332G2b` and `object.name` are in no residue. | tree | residue | per type | view | |:--|:--|:--|:--| | base `e956924e` | **4** | object 3, field 1 | 42 | | this branch (`82c5b111`, forms and ledger as on the head) | **0** | none | 42 | Removed: `object.activityMilestones`, `object.publicSharing`, `object.userActions`, `field.inlineColumns`. Added: none. Nested reading on the branch, through the gate's own `reconcileNestedLists`: - `field.inlineColumns` reads `zodOnly = []` with the `subset` row, and without it `zodOnly` = `accept, autofill, computed, displayField, expr, idField, multiple, options, prefix, readonlyWhen, reference, required, requiredWhen, scale, step, type`. - `object.activityMilestones`, `object.publicSharing` and `object.userActions` read `formOnly = [] · retired = [] · zodOnly = []` with or without any row of their own. ## Pins moved (measured, mechanical) | file | pin | from → to | why | |:--|:--|:--|:--| | `object-collapsed-sections-echo-decisions.test.ts` | collapsed-section leaves / `advanced` | 69 → 105 / 60 → 96 | three new Advanced rows with fifteen sub-rows: 18 rows, 36 leaves | | `object-lifecycle-panel-echo-decisions.test.ts` | translated `.label` control, per locale | 634 → 657 | 23 new row labels | | `field-panel-echo-decisions.test.ts` | the field form's repeater row properties / walked parents | 6 → 10 / `['options']` → `['options', 'inlineColumns']` | the new `inlineColumns` repeater and its four children, all translated | | `packages/lint/src/validate-predicate-path-refs.test.ts` (admitted by the claim's 17:27Z amendment) | predicates / literal comparisons | 81 → 82 / 56 → 57 | the one new predicate, `field :: inlineColumns` on `data.type == 'master_detail'`. Measured, not inferred: the shipped corpus, keyed `FORM::FIELD::SOURCE`, was enumerated at the merge base `e956924e` (81 predicates, 56 comparisons) and on this branch (82, 57), and the difference is exactly that one entry added and none removed | ## Verification Test runs went through `scripts/pm/os-verify-lock.sh`. The table is the first round's, at `66b73be5`, and the lint row is the patch round's, at `16037890`. After the merge and the G2a text correction, these re-ran at the final head `2bcad436`: `pnpm --filter @objectstack/spec test` `Test Files 572 passed (572)` · `Tests 16791 passed \| 1 todo (16792)`; `pnpm --filter @objectstack/platform-objects test` `Test Files 55 passed (55)` · `Tests 911 passed (911)` (the text change moves no pin); lint `validate-predicate-path-refs.test.ts` + `validate-object-field-refs.test.ts` 2 files, 114 passed; `pnpm check:i18n` OK (9 packages in sync) after the three translated leaves were authored and a second `--write` left no source-hash row; `pnpm --filter @objectstack/spec check:generated` `All 15 generated artifacts are up to date`. | run | result | |:--|:--| | `pnpm --filter @objectstack/spec test` | `Test Files 569 passed (569)` · `Tests 16698 passed \| 1 todo (16699)` | | `pnpm --filter @objectstack/spec test:repo` | `Test Files 38 passed (38)` · `Tests 690 passed (690)` | | `repeater-item-titles.test.ts` + `metadata-form-zod-reconciliation.test.ts` | `repeater-item-titles.test.ts` 29 + `metadata-form-zod-reconciliation.test.ts` 57: `Test Files 2 passed (2)` · `Tests 86 passed (86)` | | `pnpm --filter @objectstack/platform-objects test` | `Test Files 55 passed (55)` · `Tests 911 passed (911)` (before the pin moves: 4 failed, the three pins above) | | `pnpm --filter @objectstack/spec typecheck` / platform-objects `typecheck` | exit 0 both; `check:test-typecheck: OK` (53 file(s) / 251 error(s) / 138 pinned; 1 / 3 / 2) | | `pnpm check:i18n` | `check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)` | | `pnpm --filter @objectstack/spec check:generated` | `All 15 generated artifacts are up to date` | | metadata-protocol `src/protocol.meta-types-*.test.ts` | 4 files, 58 passed | | cli unit `test/i18n-coverage.test.ts`, `test/i18n-duplicate-demand.test.ts` | 2 files, 27 passed | | lint `src/validate-predicate-path-refs.test.ts` at `16037890` | `Test Files 1 passed (1)` · `Tests 54 passed (54)` (2 failed before the pin move, the two pins above) | Catalogues: `node scripts/check-i18n-bundles.mjs --write` regenerated the 46 `en` leaves (23 rows, a label and a help text each). The 138 translated leaves were then authored in zh-CN, ja-JP and es-ES, with no `en` echo. A second `--write` kept every translated value and left no source-hash row. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 87 commands at the final head `2bcad436` (change set vs merge base `9801da12`, after the merge `e809f0bd`: the 14 files of this diff). That is the first round's 86 plus `check:docs-transcript-drift`, which the lint test file brings in. All 87 ran on that head, and each exit code went to disk before it was read. In every round `pnpm check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: nine packages had no `dist/` in the fresh worktree); later gates in the same run built them, and the rerun exited 0 (`104 published require entry point(s) across 66 package(s) load`), which is the code `ran.list` records. `--ran` reports: `87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED (a DERIVED zero — all 87 recorded an exit code and none of them is 3)`. Reach probe (built `@objectstack/spec` and `@objectstack/lint` of this tree, never committed): an object with `activityMilestones: [{ field: 'statsu', … summary: 'Done: {titel}' }]` and `publicSharing.redactFields: ['titel']`, and a child `master_detail` field with `inlineColumns: [{ name: 'quantiy' }]`. `ObjectSchema.safeParse` and `FieldSchema.safeParse` both succeed. The reference-integrity suite, which the publish door and `os validate` run, returns exactly one finding, `object-field-ref-unknown @ objects[0].publicSharing.redactFields[0]` (the lit control), and none for the milestone field, the token or the column. ## Acceptance notes - **`activityMilestones[].type`'s describe says the default is "completed".** The runtime writes `updated`: the update branch's `activityTypeFor('update')`, replaced only by a milestone that names a type. The help text states the runtime. The showcase milestone names `type: 'completed'` explicitly, so no measured author relies on the describe. Carrier: none. - **The `public` audience's TSDoc (`object.zod.ts`) says "search engines may index; no token check".** `resolveToken` has no branch for `public`: it redeems like `link_only`, token required. The option label says only "Public", and the help text says every audience needs the link. Carrier: none. - **`maxExpiryDays` does not force an expiry.** A link created without one never expires. That matches the key's describe ("Reject links with expiry beyond this many days"), and the help text says it outright. Whether a capped object should require an expiry is a product question. Carrier: none. - **An untouched `userActions` switch reads off** even where the `managedBy` default offers the entry, a switch having no unset state. The composite's help text names the defaults. Carrier: none. - **Existing object-form rows named `field` meet the same `field-ref` convention.** `lifecycle.ttl.field` has `type: 'text'` and no `widget`, so by the reading above it renders the "None"-only picker on an object draft. This is a code reading at `dd3f7e1b`, not browser-run, and it is outside this flight's rows. (`fields.summaryOperations.field` sits inside the `fields` row, which the Studio object page hides as canvas-owned.) Carrier: none. - **Concurrency.** `origin/main` was merged once, with `scripts/pm/os-regen-merge.sh`, at `9801da12` (`e809f0bd`), because objectstack-ai#20456's `e967cbd2` edited three `view` `why` texts in the reconciliation ledger. It merged without conflict, `main`'s side was taken for every generated artifact it moved, and `check:generated` then read all 15 up to date. `origin/main` has moved since (to `3062e500`), not onto a file of this diff. Seat 2's objectstack-ai#20475 regenerates `en.metadata-forms.generated.ts` too and is not on `main` yet: ordinary concurrency. - **G2a's `indexes.fields` help text went stale when objectstack-ai#20479 landed, and is corrected here** (Status, second paragraph), under the claim's 17:51Z amendment. No other G2a row changes. ## Out-of-scope finding (folded into objectstack-ai#20432 by the seat; not filed by this run) - **class c · reach: the save door and the publish door, measured (probe above).** `activityMilestones[].field` and `inlineColumns[].name` name fields of the owning object, and no authoring door judges them. A misspelt milestone field silently never fires, and a misspelt column renders as plain text. `validate-object-field-refs` leaves the first out by name. Its extension objectstack-ai#20479, landed as `4b2d9041`, reaches four field-level lists and `indexes[].fields`, but neither of these. - Dedupe words: `activityMilestones field unknown` · `inlineColumns name unknown field` · `milestone never fires misspelt field` · `inline grid column reference integrity`. --- _Generated by [Claude Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…t error and reported in drift (objectstack-ai#20519) Fixes objectstack-ai#20432 Clause-②: yes (widening) Step 2 of 2 on this card, and the step that completes it. Step 1, the lint refusal, landed as PR objectstack-ai#20479. ## What was wrong (measured on `origin/main` 9bf5e67, SQLite) Take an object whose only column is `status` and that declares `indexes: [{ fields: ['statsu'], unique: true }]`: - `initObjects` skipped the whole index and logged one **warn**: `[sql-driver] skipping declared index on "t_probe" — column(s) not materialized: statsu`. - `detectManagedDrift`, which is what `os migrate plan` renders, returned `[]`, because `expectedIndexes` drops the index from the expected set. - A virtual `formula` field named in an index was skipped the same way, also at warn. So the declared uniqueness was not enforced, and every instrument said nothing was wrong. AGENTS.md "Degradation log levels" asks: does the system still look normal from the outside while something it claims is persisted has not actually landed? Yes, so the level is `error`. The duplicate-row arms of the same loop already answered their version at `error` through `logDurabilityFailure`. ## What changed **`sql-driver.ts`, `syncDeclaredIndexes`.** The skip now goes through `logDurabilityFailure`, at **error**, as triage's direction says. One line per skipped index per sync names: - the object and the index; - each missing column with its reason: `not a field of the object`, `a formula field: computed on read, never stored`, or `a declared field whose column the table does not have`; - whether the index is `UNIQUE`. The line also states the consequence and the fix. A unique index gets "The uniqueness it declares is NOT enforced: duplicate rows are accepted…". A plain index gets "The index does not exist…". The structured meta carries `{ tableName, index, fields, missing, unique }`. The object's `fields` are threaded in from `syncTableIndexes` and from the shard path; the drift-op apply paths pass none and get the bare column names. H5: a skipped non-unique index is also at error, because it is DDL that was supposed to run and did not. The message tells the two apart by the `UNIQUE` word and the consequence sentence, and the meta tells them apart by `unique`. **`schema-drift.ts`, drift.** A new pure differ, `diffUnbuildableIndexes`, reports the half of the declared set that `expectedIndexes` leaves out. It shares `declaredIndexSet` with `expectedIndexes`, so the two cannot disagree about which indexes metadata asks for. An entry is emitted only when a missing key column will NEVER materialize: not a declared field, or a declared virtual `formula`. A declared, column-materializing field that the table merely lacks is pending additive work (`add_columns`), and is not reported. The entry: - `kind: 'index_mismatch'` (an existing `SchemaDiffEntryKind`, so spec is unchanged), `actual: '(absent)'`; - `category: 'needs_confirm'` (the report-only precedent of `manual_column_type_change`); - `severity: 'error'` when unique, `'warning'` otherwise (the `recreate_index` convention); - op: the new **report-only** `DriftOp` member, `{ type: 'unbuildable_index'; table; column?; indexName; unique; missingColumns }`. It sits in `INDEX_DRIFT_OPS`. `applyIndexDriftOp` answers `false` for it before any read, so apply reports it `skipped` on every dialect and it never triggers a SQLite rebuild. **Public surface (H3).** This is the one widening, and the seat pre-cleared it before the build: amended claim 5877196132, `Clause-②: yes (widening)`. The changeset is `@objectstack/driver-sql` **minor**. No CLI source changes. **The object form's help text (deviation, see below).** In `packages/spec/src/data/object.form.ts`, the `indexes` → Fields help said the skip leaves "a warning in the server log". This change made that false, so it now says an error. The en bundle is regenerated with `node scripts/check-i18n-bundles.mjs --write`. The zh-CN, ja-JP and es-ES values are edited by hand, one word each. The changeset adds `@objectstack/spec` and `@objectstack/platform-objects` as patch, and (patch round 2) `@objectstack/lint` as patch for the corrected rule message. ## Consumer census: every in-repo reader of `DriftOp`, `DriftOp['type']` and `INDEX_DRIFT_OPS` I searched with `git grep` (outside `dist/`) for `DriftOp`, `INDEX_DRIFT_OPS`, `isIndexDriftOp`, `ColumnDriftOp`, `IndexDriftOp`, `op.type` and `ManagedDriftEntry`. No `never`-exhaustiveness check over `op.type` exists anywhere, and no `Record` is keyed by `DriftOp['type']`, so the new member breaks no typecheck. Measured: `driver-sql`, `driver-turso` and `cli` typecheck exit 0 at 7ec990a. | Consumer | What it does with `unbuildable_index` | |:---|:---| | `driver-sql` `applyMigrationEntries` | `isIndexDriftOp` is true, so it takes the index path, never the SQLite rebuild. **Pinned.** | | `driver-sql` `applyIndexDriftOp` / `applyDriftOpInPlace` | Explicit early `return false`, so the entry is reported `skipped`. **Pinned.** | | `driver-sql` `applyNullSafeUniquePreflight` | Probes `create_index` / `recreate_index` only, so this entry is untouched. | | `driver-sql` `reconcileAndWarnDrift` (boot) | Auto-reconciles only `safe` entries. This one is `needs_confirm`, so it is warned once per process through the existing `[schema-drift]` line. `driftKey` includes `indexName`. | | CLI `schema-migrate.ts` `renderPlan` / `driftTarget` / `groupByCategory` / `summarize` | Generic: `category`, `op.indexName`, `op.type`, `message`. `os migrate plan` lists the entry under "Needs confirmation" as `table [indexName] [unbuildable_index]`. **Pinned.** | | CLI `migrate/plan.ts` | Renders through the above, and `--json` emits the entry as-is. The exit code does not depend on drift. | | CLI `migrate/apply.ts` | The entry counts like any `needs_confirm` entry (non-TTY without `--yes`: "Confirmation required"), then it is skipped, and the summary prints "Skipped N change(s)". The only `op.type` read (line 348) is a `drop_column` / `sys_account` filter, which this entry does not match. | | CLI `multi-value-columns.ts` | Selects only `manual_column_type_change`. Unaffected. | | CLI `artifact-boot-migration.ts` (the artifact-pinned boot gate) | Refuses only `category === 'destructive'`. This entry is handed to the driver, reported `skipped`, and **warned** ("schema change not applied by the driver"), and the **boot continues**. **Pinned.** | | `driver-turso` | Extends `SqlDriver`, so the local face inherits the fix. The remote face already refuses `detectManagedDrift` / `applyMigrationEntries`. Suite: 76 files, 2037 passed. | ## The two folded boundaries - **A virtual `formula` column in an index: pinned.** It is not materialized, and it is skipped. The error names it `'doubled' (a formula field: computed on read, never stored)`, is not marked `UNIQUE`, and has meta `unique: false`. Drift reports it at `severity: 'warning'`. A field-level `unique` on a formula field takes the same route (pure-differ pin). - **`objectExtensions` fields: not reached.** Measured at BASE with the real `SchemaRegistry` (a throwaway probe, not committed): - Register a base object whose index names an extension's field, plus two extensions, one of whose indexes names the other's field. - `getAllObjects()` returns the merged `fields` (base and both extensions) and the merged `indexes`, and `SqlDriver.syncSchema` of that merged object built both indexes (`uniq_h4b_account_ext_code`, `idx_h4b_account_ext_tier`), with no skip line and empty drift. - `ObjectQLPlugin` syncs from `registry.getAllObjects()`, so the driver only ever sees merged objects. The lint-graph gap is step 1's boundary, and sync does not reproduce it. ## Tests (at 7ec990a unless stated) - New `packages/drivers/driver-sql/src/sql-driver-20432-unbuildable-declared-index.test.ts`: - the dialect matrix through `declareDialectCell`: misspelt UNIQUE (log + drift + apply-skips), formula plain index (log + drift), and a buildable control that is enforced, with no error line; - 5 pure-differ cases: pending column not reported, a mixed index names only the never-materializing column, field-level unique on a formula, the split against `expectedIndexes`, and reason kinds. - SQLite plus live PostgreSQL 16 (local server, `Asia/Shanghai` server zone, `TZ=America/New_York`): 15 passed, 1 skipped. **Live MySQL: NOT MEASURED locally** (no server in this container); declared to CI's `Temporal Conformance (live PG + MySQL)`. - New `packages/cli/src/utils/artifact-boot-migration.unbuildable-index.test.ts` (integration tier, real `SqlDriver` from `dist`, real gate and renderer): 3 passed. - `driver-sql` full suite at 2f21af6, the head before the merges (the merges bring nothing under `packages/drivers`): SQLite plus live PostgreSQL gave 206 files passed / 3 skipped and 3968 tests passed / 93 skipped. SQLite only gave 198 / 11 and 3198 / 184. - `cli` unit tier at 2062104: 233 files, 3336 passed. `driver-turso`: 76 files, 2037 passed / 18 skipped. - `spec` `--project local`, 3 shards, at 797da30: 6070 + 5193 (+1 todo) + 5531 passed. `platform-objects` at 7ec990a: 56 files, 921 passed. - Typecheck exit 0: `driver-sql`, `driver-turso`, `cli` (including `check:test-typecheck`) at 7ec990a; `spec`, `platform-objects` at 797da30. - **Cross-package reverse check.** A throwaway CLI file typed `{ type: 'unbuildable_index', … }` without `missingColumns` got `TS2322 … Property 'missingColumns' is missing … required in type '{ type: "unbuildable_index"; …'`, so the CLI reads the rebuilt `.d.ts`. The file was removed and the tree is clean. - **Ablations**, from committed state through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed), trap-restored: - A, the skip back to `logger.warn`: 4 failed (2 per cell, SQLite and PG). - B, the drift push deleted: 4 failed. - C, `columnEverMaterializes` forced false: 3 failed (pure). - Restore: `git diff HEAD` 0 bytes, blobs equal to HEAD (`1ede8d9d…`, `50ae8546…`). - D, a dist ablation for the CLI pin: category mutated to `destructive`, `driver-sql` rebuilt, and `ablation-dist-preflight` confirmed the marker in 2 built files (exit 0). CLI test 3/3 failed. After restore and rebuild, `--absent` exit 0 with a whole-tree clean state, and CLI test 3/3 passed. - ESLint, a proven narrowing: - The population is from `eslint.config.mjs` (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`). - `--format json` read 4 files with 0 errors and 0 warnings. - There is no `parserOptions.project` (type-aware linting is off), so no untouched file's verdict can move. The repo-wide `pnpm lint` is CI's. ## Gates (measured head 7ec990a, after merging `origin/main` 9449512) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 90 commands. All 90 were run, and each exit code was captured before any pipe. - `--ran`: `90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero (all 90 recorded exit 0). - Roster gates flagged for this diff's directories, all exit 0: `check-changeset-fixed`, spec `check:meta-url-spelling`, spec `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`. Plus `check:durability-log-level`, exit 0. - `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt before and after. The dialect axis is unchanged (8 suites, 0 in the DIALECT ledger). The ledger did not rise. ## Patch round 1: the g2a note's index-door sentence (deliberate correction) Admitted by the seat in the amended claim 5879543809. Text only: this round changes no code, test, help text or bundle. **The note:** `.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` (spec lane, unreleased). One sentence on line 13 carried two clauses that are false in the same release. Nothing else in the file changes (1 line out, 1 line in). **Old:** > `indexes[].fields` is free text, and no authoring door judges its names: not the schema parse, not the publish door, not `os validate`. A name that is not a stored column makes the SQL driver skip the whole index at sync with a warning in the server log, and the help text says exactly that. **New:** > `indexes[].fields` is free text, and the schema parse, so a draft save, does not judge its names; `os validate`, `os build`, `os lint` and the publish door refuse a name that is not a field of the object (`object-field-ref-unknown`, objectstack-ai#20479, in the same release). A name that is not a stored column, a `formula` field say, makes the SQL driver skip the whole index at sync with an error in the server log, and the help text says exactly that; `os migrate plan` reports the skipped index too (objectstack-ai#20432, in the same release). **The doors, measured at the patch-round base 7ec990a** on the built CLI, against a one-object fixture whose UNIQUE index names `statsu` (bad) or `status` (clean): - `os validate`: bad exits 1 with `object-field-ref-unknown` at `objects[0].indexes[0].fields[0]`, clean exits 0. - `os build`: bad 1 (same rule and path), clean 0. - `os lint`: bad 1 (same), clean 0. - The runtime publish door (`publishPackageDrafts`, throwaway probe on the package's own stub engine): the bad draft is refused, `INVALID_METADATA` with the same rule at `objects.idx_ticket.indexes[0].fields[0]`. The clean draft is `published`. - The draft save staged the bad object without complaint (the schema parse does not judge names), which is what the new sentence says. **`Check Changeset` stays red by design on that one name.** `node scripts/check-empty-changeset.mjs --base origin/main` exits **1** at cef89b8. It names only `.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` ("present on the merge base and CHANGED by this PR") and prints the DELIBERATE CORRECTION class, whose remedy is "do NOT restore it -- say so on the PR and get it confirmed". This section is that statement, and the at-tier review is its written confirmation. ⛔ No `skip-changeset`. **Gates re-run at cef89b8** (after merging `origin/main` 0bbe400 with a true merge commit): - `dispatch-gates --commands` derived 90. All 90 were run with exit codes captured before any pipe: 89 exited 0, and 1 exited 1 (`check-empty-changeset`, the by-design red above). - `--ran`: 90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN. - Exit 0 on the roster gates flagged for this diff's directories (`check-changeset-fixed`, spec `check:meta-url-spelling`, spec `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), and on `check:durability-log-level`, `check-adr-0087-registration`, `check-changeset-no-major` and `check:nul-bytes`. ## Patch round 2: the `object-field-ref-unknown` index message (text only) The seat answered patch round 1's open question with **A**. Amended claim 5879937791 admits `packages/lint/src/validate-object-field-refs.ts`, text only. No rule logic, severity, id, code path or test assertion moved. **The message tail** (the `INDEX_POSITION.consequence` string). The prescription is unchanged: the hint line reads byte-identically before and after on the fixture. > **Old:** The SQL driver skips the WHOLE index at sync with only a warning, and drift drops it too, so `os migrate plan` never reports it: a `unique` index is then silently unenforced while everything looks normal. > > **New:** The SQL driver skips the WHOLE index at sync. It logs the skip at error and `os migrate plan` reports the index as unbuildable, but the object keeps serving: a `unique` index is then unenforced until the name is fixed. **The docblock sentence** is put in the past tense ("the sync's skip was a `warn` and drift dropped the index") and followed by a parenthesis. It says that since objectstack-ai#20432 step 2 the skip is logged at `error` through `logDurabilityFailure` and `os migrate plan` reports the unbuildable index, both still after the authoring doors. **The test pin:** measured with `git grep` across `packages/**`, `examples/**`, `content/**` and `skills/**`. Exactly one test pins this text beyond the subject: `validate-object-field-refs.test.ts` asserted ``'`unique` index is then silently unenforced'``. Only that pinned string changed, to ``'`unique` index is then unenforced'``. This is a test-text change, not an assertion change: the same `toContain` on the same finding. No other test, doc or skill quotes the message. **Changeset:** `.changeset/20432-skipped-index-durability.md` gains `'@objectstack/lint': patch` and one paragraph naming the corrected message. **Measured at d159ac9** (`origin/main` has not moved since cef89b8, so no merge was needed): - The rule's test file passed 60 of 60. The full `@objectstack/lint` suite passed 115 files and 5363 tests. `pnpm --filter @objectstack/lint typecheck` (with `check:test-typecheck`) exits 0. - `check-changeset-fixed` exits 0, and `check-changeset-no-major` exits 0. `check-empty-changeset` exits **1**, still on exactly `.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` (the DELIBERATE CORRECTION class, by design). - `os validate` on the misspelt-index fixture (built CLI) exits 1, and the clean fixture exits 0. The new tail, quoted from its output: > …Did you mean "status"? The SQL driver skips the WHOLE index at sync. It logs the skip at error and `os migrate plan` reports the index as unbuildable, but the object keeps serving: a `unique` index is then unenforced until the name is fixed. - `dispatch-gates --commands` derived 91 (the new family is `check:docs-transcript-drift`). All 91 were run after a full `packages/*` and `examples/*` build: 90 exited 0 and 1 exited 1 (`check-empty-changeset`, the by-design red). `--ran`: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. - The roster gates (`check-changeset-fixed`, spec `check:meta-url-spelling`, spec `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) and `check:durability-log-level` all exit 0. ## Deviations - **File surface.** This PR also edits `packages/spec/src/data/object.form.ts` (one help-text word and its comment) and the four `metadata-forms` translation bundles, beyond the claim's original surface. The os-dev contract makes a published text that this change turns false a must-fix in the same PR. The breach was named in the first report instead of chosen silently, and **the seat admitted it in the amended claim 5879543809** (text only, no logic). That claim also admits the deliberate correction in patch round 1 below. - MySQL cells: not measured locally (see Tests). ## Acceptance notes - **`driver-memory` mirror** (measured, not touched; under its freeze). `syncSchema` of `indexes: [{ fields: ['statsu'], unique: true }]` logs nothing at any level (only `Created in-memory table` at info). The index then constrains only rows that write the undeclared key `statsu`. Rows duplicating `status` are accepted silently. - **`driver-turso` remote transport** (`remote-transport.ts`, about line 2410) keeps its own copy of the skip line through `diagnosticSink`. It is not in this file surface. - **Release text.** The pending `.changeset/19332-g2a-fieldgroups-indexes-form-rows.md` said that no authoring door judges `indexes[].fields`, and that the skip happens "with a warning in the server log". Both were false in the same release. Corrected in patch round 1 below, as the seat admitted. - **A second published text this PR makes false: fixed in patch round 2.** The `object-field-ref-unknown` message tail on an `indexes[].fields` position, and the rule's docblock sentence, said the skip was a warning that drift drops. The seat admitted the fix in 5879937791 (see Patch round 2). - The CLI's `os migrate apply` skip summary reads "(destructive without --allow-destructive, or unsupported on this dialect)". Neither reason is this op's. The plan message states the real one. The wording is CLI source, outside this file surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20432
Clause-②: no
What this does
This is step 1 of the triage direction on the card (
5871296642). The object-level field-reference ruleobject-field-ref-unknown(packages/lint/src/validate-object-field-refs.ts,error, already onos validate/os build/os lintand the runtime publish door for object writes) now judges five more field-name lists. Before, it judgedhighlightFieldsandpublicSharing.redactFieldsonly:relatedListColumns,lookupColumns(both arms),lookupFilters[].field,dependsOn(both arms);indexes[].fields(the fold5870812245).A name that is not a field of the object the list addresses is refused at the exact path. Examples:
objects[i].fields.FIELD.lookupColumns[j].fieldandobjects[i].indexes[j].fields[k]. The message has the family's shape: the string that was written, the object it was judged against, a "Did you mean" when a name is close, then the consequence. The hint ends with that object's field list (Fields on "X": a, b, c.). The rule id, severity and suite wiring are unchanged. No new rule file and no new gate.Step 2 is not in this PR:
SqlDriver.syncDeclaredIndexeslogging a skipped declared index aterrorthroughlogDurabilityFailure, and showing it in drift. That is thedomain:engineseat's second PR on this card, and #20432 remains open for it.Which object a name is judged against (measured on each key's reader)
The PM's mechanism hypothesis 3 asked for this per key. The readers were read at the objectui pin
.objectui-shadd3f7e1b:relatedListColumns[i]app-shell/src/utils/deriveRelatedLists.ts:291, where the related list ischildObject: child.namelookupColumns[i]/.fieldfields/src/widgets/LookupField.tsx:337,484, picker columns overrefObjectSchemaofreferenceTolookupFilters[i].fieldLookupField.tsx:339,661lookupFiltersToRecord, which feeds the query onreferenceTo.validate-preset-comparands.tsalready binds the same key this way.dependsOn[i]/.fieldLookupField.tsx:451-458, where the gate reads the host record by this keydependsOn[i].param, or the bare name on a pickerLookupField.tsx:371-380,647:paramdefaults tofield, anddependentFilter[param]indexes[i].fields[j]SqlDriver.syncDeclaredIndexesreads names verbatim againstphysicalColumnsThe referenced-object positions are judged only on types that render the picker.
lookupandmaster_detailrenderLookupField(FieldEditWidget.tsx:105-106).userrendersUserField, which delegates toLookupFieldwithsys_userfixed (UserField.tsx:48). The target is the graph'sreferenceTargetOfanswer. On any other type those keys have no reader and are not judged.lookupColumns,dependsOnand index columns are read verbatim by their readers, so a dotted name there is judged as one name.relatedListColumnsandlookupFilters[].fieldkeep the family's path resolution. OndependsOn, a name that misses on the owner is reported once, not a second time against the referenced object. The field form rows that landed with #19332 G1b (field.form.ts:232-245) describe the same addresses, and none of them claims a refusal, so no published text changes.Severity
error, the family's existing tier.os validateandos buildturn author-timeerrorfindings into exit 1 (validate.tsandcompile.ts,splitBySeveritythenthis.exit(1)). This was measured on the real built CLI over a two-object fixture (lookupinvoice.accounttoaccount):os validate: clean variant exit 0; misspelt variant exit 1 with 5 findings, one per position (index, relatedListColumns, lookupColumns.field, lookupFilters.field, dependsOn).os build: clean variant exit 0; misspelt variant exit 1 with the same 5 findings.objects.proj_task.indexes[0].fields[1]and a lookup resolved against a context object.Producers census
Measured before landing, with the rule run from source (
tsx) over every exported object:packages/**/*.object.tsataf444b4cd1examples/app-showcase(+ platform: 108)examples/app-crm(+ platform: 90)examples/app-todo(+ platform: 85)src/**/*.object.tsat5bec6eb0The hotcrm carriers match the card's reading exactly:
relatedListColumnsin 3 files,dependsOnin 4. There is no real misspelling in any producer, so nothing is fixed at a producer and nothing is handed to the hotcrm lane.The controls are lit:
lookupColumnsarms) gave 5 findings;sys_metadata_audit) gave 1 finding;examples/app-multi-packagedeclares 2 inline objects that carry none of these keys (population 0).Tests (all at
af444b4cd1)@objectstack/lint:vitest run, 115 files, 5355 passed / 5 skipped.typecheck, includingcheck:test-typecheckover the test layer, exit 0. The rule file went from 31 to 60 tests: per list, a misspelling is refused with its code and path, a correct name passes, and a name from the OTHER object is still refused. Both object arms are covered, plususerresolving tosys_user, the three skips, verbatim dotted names, and junk inertness.@objectstack/cli--project unit: the first run gave 231 files passed, 3306 tests passed and 29 skipped. The other 2 files refused with the prerequisite "packages/cli is not built", which is not a red gate. Afterpnpm --filter @objectstack/cli buildthose 2 files passed, 29/29 tests. Integration tier is declared to CI.examples/app-showcase: 29 files / 385 tests passed.metadata-protocolprotocol-publish-drafts-object-field-refsandbuild-probes-rule-failure(12 tests), plusplatform-objectssys-email.highlight-fields-resolve(2 tests), all passed.@objectstack/lint) and named consumers were run. No...@objectstack/lintsweep was run, because the public surface (exports, types) is byte-unchanged; the behaviour narrowing is what the consumer runs above cover.Ablation (one-shot, restored, not kept)
The changes were committed first. Each leg was applied through
scripts/ablation-replace.mjs, whose anchor must hit, with the landing proven by anchor count 1 to 0 and a blob change. A shell trap restoredHEADon exit. The test file imports the rule's source, so no build was involved.Restore was proven by blob equal to HEAD blob
ac79635fand an emptygit diff HEAD.Gates
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsataf444b4cd1gave 60 commands, reconciled with--ran: 59 exited 0 and 1 is NOT MEASURED. The unmeasured one ispnpm check:dual-build-cjs-loads, which exited 3 with PREREQUISITE NOT MET because it reads every package's dist and 9 unrelated packages are unbuilt in this worktree. The four artifact-roster gates flagged under this diff's directories also exited 0:check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity. ESLint, as a proven narrowing, ran on the 4 changed TS files (--format json: 4 files, 0 errors, 0 warnings). The config enables no type-aware linting (the printed config hasparserOptionsequal toecmaVersionandsourceTypeonly), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Acceptance notes
carrier: #20432: step 2, the sync half, belongs to thedomain:engineseat's second PR on this card.SqlDriver.syncDeclaredIndexeslogs a skipped declared index (uniqueabove all) atwarn. Its duplicate-row sibling in the same function logs aterrorthroughlogDurabilityFailure.expectedIndexesdrops the index from drift. The Studio save door (ObjectSchema.safeParse) still admits a misspelt name, so that half still matters after this PR.formula) passes here and is still skipped at sync. Materialization stays the driver's question. It is written into the rule's docblock, which replaces the old "indexes[].fields[]is a storage question" paragraph (reason: that exclusion left a misspelling with no door at all).metadata-protocol/src/build-probes.ts) runs this rule over a one-object universe. The referenced-object positions are therefore unknowable, and silent, there. Its owner-addressed positions are judged. The door-time gate judges both.objectExtensionsentry are not merged into the graph. A list naming only such a field would be refused as a false finding. The census gave 0 findings in every producer measured, so no such case arose. Recorded here, not filed.reference-integrity-suite.ts's member note said the rule resolves names against the object's OWN field map only, which is now false for the picker keys, so it was rewritten. Theindex.tsexport comment lists the new positions.Clause-②: no (narrowing)with a BREAKING banner andminor, following the ADR-0087 gate (not-required (no-migration-prescription)) and the precedent of thesecurity-role-wordfield-groups changeset. This body carries the claim'sClause-②: noverbatim.origin/mainmoved during the run (dc0ab6a2ed). This branch was not re-merged. CI validates the merge ref. The gate derivation flagged one stale family file (scripts/cross-package-test-inputs.mjs), whose gate ran green on this tree.Generated by Claude Code