Repository navigation
fix(types,components,plugin-form,console,core): a faulted or blank visibleWhen refuses the submit, naming the field and the rule; a blank field rule is refused at authoring; blank gates are diagnosed (objectui#8069) - #11233
objectstack-fleet[bot] merged 11 commits into
Conversation
…nk-gate silencers are diagnosed (objectui#8069) ADR-0137 D2 needs a submit path to know which field rule could not be evaluated. resolveFieldRuleState now returns `faults` beside the three verdicts, filled from the same evaluation (the onFault passback), so a refusal never re-evaluates. The verdicts are unchanged (D3). A blank predicate stays out of the report: objectui's form wire admits it, so a refusal on it would be a trap; it keeps its [blank] warning. ADR-0137 D4: evaluateCelCondition's blank guard and hasDeclaredPredicate's blank fold now report through evalFieldPredicate's [blank] channel. Both verdicts (objectui#3850 / #3960) are unchanged, throwOnError included. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…e submit, naming the field and the rule (objectui#8069) ADR-0137 D2 as ruled (Q1 = B, one judge per rule): the form renderer, the console's FormPage and the wizard's cross-step gate refuse a submit whose field visibleWhen could not be evaluated, with the new form.visibleWhenFaulted message naming the field and the rule. Each reads the fault from the resolveFieldRuleState call that already drew the verdict. requiredWhen / readonlyWhen are unchanged on the client: the server refuses their faults and form.tsx shows that inline. A blank visibleWhen stays "no gate" with its [blank] warning, and a visibleWhen reading previous on a create form is refused (the accepted residual). Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…lyWhen faults are the server's (objectui#8069) Two sentences said a broken field predicate never blocks a submit; that is no longer true for visibleWhen (ADR-0137 D2 as ruled). Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…i18n provider; the objectui#6958 boundary pin states D2 (objectui#8069) The form renderer and the wizard translate through createSafeTranslation tables; both now carry form.visibleWhenFaulted (and the wizard the list joiner) byte-identical to the en pack. The #6958 BOUNDARY pin asserted a broken visibleWhen writes the value as it stood; under ADR-0137 D2 the write is refused, so it now pins both halves of "a broken predicate never silently nulls a stored column": nothing cleared, nothing written. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…sis (objectui#8069) ADR-0137 D4 (Q2 = B as ruled): a blank gate folded to "no gate" is diagnosed, never silent. The verdicts these two pins hold (objectui#3862 enabled, objectui#3850 / #3960 kept) are unchanged; the "and silent" half now reads "and reported once per blank spelling, through core's [blank] channel, with no fault report". Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…onfig Callback parameters over mock.calls are annotated (TS7006), and the console pin reads the last toast by index (its lib has no Array#at). Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs: card #8069 (body and all 23 comments, the ruling Check-runs on the head: 43 runs, all completed at 2026-09-30T16:26Z — 40 success, 3 skipped ( ① Derived judgmentsCore — The three paths (RIGHT).
Q2 — the two gate silencers (RIGHT). Silencer 1: The The residual pin (RIGHT, with one residual broader than the ruled one — finding 2). Restated pins (each is what the ruling implies, none a weakening). Docs (RIGHT, one sentence contested). Ablation legs (accepted on the report; not re-run — read-only). A (fault report records nothing) 13 red by name across all six pin files with every control, blank, Overlap with #11208 (disjoint, verified). #11208 merged at 2026-09-30T13:43Z (merge ② Semver levelChangeset Clause-②: the PR body reads ③ Boundary flagsUnder-listed surface (all within the ruling's scope). Open question — a stored blank Residual broader than the ruled one (finding 2, non-blocking; the seat names it on the card). The ruling accepted "a The skill sentence (confirmed false at this head). Two more blank silencers (filing class: ONE successor card under ADR-0137 D4, not an acceptance note). At the head, PR #11208 overlap. Verified disjoint in ① above; the PR is Findings, numbered:
Implemented-by: VERDICT: FAIL Generated by Claude Code |
… refused — at parse by the form wire (ADR-0137 D1), at submit when stored (D2) (objectui#8069) Contract review 5915380177 (finding 1), as the seat applied it: ADR-0137 refuses "blank means no rule" for the field-rule triad. FormFieldSchema's visibleWhen / readonlyWhen / requiredWhen now refuse a predicate that is blank after trimming, with the spec's EVALUATED_EXPRESSION_SOURCE_REQUIRED sentence, as a refinement OVER ExpressionWireSchema: the same two option schemas by reference, so the one wire shape of objectui#7530 holds and only the blank value is taken out. Gate keys keep the plain wire. resolveFieldRuleState now reports a blank rule as a fault ([blank]), so a stored blank visibleWhen is refused at submit on form.tsx, FormPage and the wizard gate. The objectui#7530 pin is re-pinned to one wire SHAPE plus the triad's blank refusal; the edit-wizard previous residual (finding 2) is named; changeset and metadata-diagnostics.md follow. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
…nused binding (objectui#8069) Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…onInputSchema, not a restated rule (objectui#8069) The form field-rule triad's check now hands the predicate TEXT to the spec's own evaluated-slot schema (through the objectui#8317 import boundary) and refuses with its verdict and sentence, so the rule and the message are the spec's by reference. The import-boundary census lists the new crossing; the objectui#11073 census rebuilds its OPEN twin from the union's own def so a union-level check stays on both sides and only the arms are measured. Two view-level controls pin "no gate, no refusal". Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…e thank-you screen replaces the form (objectui#8069) Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Round 2, after the FAIL record Check-runs on the head: 43 runs, every one completed between 2026-09-30T18:09:48Z and 18:24:11Z, all on ① Derived judgmentsD1 on objectui's own form wire — ONE wire SHAPE, a blank VALUE taken out; not a second wire type (RIGHT; the fork-stop was right not to fire). D2 on all three submit paths, a stored blank included (RIGHT). The rest of the round-1 record stands at this head. The Finding 2 of round 1 (the edit-wizard The two instrument corrections — honest fixes, not gates bent. (a) Ablation legs D and E (accepted on the report; not re-run — read-only). D (the triad's blank refusal made unreachable): 12 red, exactly the twelve blank cases, every accept, identity, junk and gate control green. E (a stored blank taken out of the report): 7 red, exactly the seven stored-blank cases. Each with anchor count, blob before and after, and a hash-equal restore; the first D attempt was refused by the tool as an anchor miss before anything ran. Every relayed body sentence and the title, checked at this head — TRUE. The ten replacements are in the body as the dev wrote them; each was checked against the head files: the ② Semver levelChangeset:
③ Boundary flagsThe seat's option-B decision and its fork-stop — recorded in the dev report and the PR body, not on the card. The card's thread ends with the dev's follow-up report; the decision under which Card the seat must file, A — the governed skill sentence. Card the seat must file, B — the ADR-0137 D4 successor, now with THREE silent blank-gate paths. D4 says a blank or faulting GATE predicate is "diagnosed, never a silent Landing condition, not a finding. Round-1 items closed this round. Finding 1 (the blank at submit): resolved as option B, judged RIGHT in ① — a stored blank is refused at submit (D2) and a new one at authoring (D1), a blank gate stays diagnosed-not-refused (D4), and ADR-0137's Alternatives are now honoured for the triad. Finding 2: accepted and named. The under-listed claim surface (five items) still needs appending to the claim by the seat; No numbered findings: none of the items above blocks this head. Implemented-by: VERDICT: PASS Generated by Claude Code |
…age and two console pins name reference PR #11233 has landed, so the remaining tail is in scope: the form-field zod comment, FormPage's metadata comment, plugin-form.mdx's field-slot row and the shared-field-resolver pin now spell the target reference. deriveRelatedLists' docblock drops the reference_to it has not read since objectui#6837 half 2. Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude <noreply@anthropic.com>
…ational-target spelling objectui writes or reads; reference_to retires (objectui#11070, round 4) (objectstack-ai#11264) Refs objectstack-ai#11070 Clause-②: yes (narrowing). `LookupFieldMetadata`, `MasterDetailFieldMetadata`, and `DetailViewField` retire `reference_to` and declare `reference`, and the ingestion pass no longer stamps `reference_to` onto served defs, under the startup no-gradualism rule. It is priced in the changeset. Round 4 of objectui#11070, the `reference_to` round: the seat's answer A to Q1 (`5915992755`), under the maintainer ruling on objectui#6837 (`5475017957`, 2026-08-31, verbatim: 「objectui不是前端的项目吗?后端的元数据只要对,前端按协议执行就行了呀」, with the earlier question 「按 spec 拒绝面直接删不是更合理吗」). Claim `5916640555`. `reference` is now the only spelling ObjectUI writes or reads for a relational field's target object. The one fold that remains is the ruling's choke point: `normalizeFieldReferenceKeys` folds a foreign `reference_to` / `referenceTo` onto `reference` when `reference` is absent, keeps its dev warning, and never drops a key. ## What changed - **Types (`@object-ui/types`).** `LookupFieldMetadata` and `MasterDetailFieldMetadata` declare `reference` typed by reference to `@objectstack/spec`'s `FieldSchema.reference`. `DetailViewField` and its zod mirror `DetailViewFieldSchema` move from `reference_to` to `reference` in the same commit, so the two faces keep equal key sets. The `form.ts` comments and the form-field mirror's comment now say that the lookup and user widgets read `reference` only. `strict-face-read-keys-11070.test.ts` moves `reference_to` from the pending list to the retired list, and gains a type-level pin: both field metadata types carry the spec member, and none of the three contracts has a `reference_to` key. - **Ingestion (`@object-ui/core`, `reference-keys.ts`).** The pass folds onto `reference` and no longer stamps `reference_to`. The header prose that described a stamp of "BOTH snake_case keys" now describes the fold, and it records why the stamp protected nothing once both ends moved. The leave-arm rule stands. - **Readers read `reference` only:** `LookupField`, `UserField`, `LookupCellRenderer` and `UserCellRenderer` (only those two reads in `fields/src/index.tsx`), `InlineFieldInput`'s reference fallback, `schemaDefaults`' `current_user` seed, `sectionFields`' view override, and `MetadataProvider`'s inline-subform parent. - **Emitters write `reference`:** `paramToField`, `RecordDetailView`, `FlowReferenceField` (its one target line), `RecordDetailPanel`, `RecordMetaFooter`, `RelatedList`, `fieldEnrichment`, `buildDefaultPageSchema`, `sectionFields`, `bulkParamToField` and `ObjectGallery`. - **Copy sets and ledgers.** `plugin-grid`'s `RELATIONAL_META_READ_SET` loses its `reference_to` row and the `adapter-stamped` verdict that row alone held. `plugin-dashboard`'s `CELL_RELATIONAL_META_KEYS` and `FieldMeta` drop `reference_to`. `resolveActionParams`' unread `RuntimeField.reference_to` member goes. `FieldDefaultsSchemaLike` pins three members. - **Docs and fixtures.** `lookup.mdx` (typed fences, plaintext fences, prose, plus one paragraph on the retirement), `record-edit-modes.md`, `plugin-form.mdx`'s field-slot row and `plugin-form`'s README. The tests that fed `reference_to` to a widget, a cell or a served-def stub now feed `reference`. The pins whose job is the fold, or a refusal, keep `reference_to` as their input and assert the fold to `reference` with no stamp back, or the refusal. - **One changeset**, `.changeset/11070-reference-to-round4.md`, minor with the break stated (AGENTS.md §9: never `major`). Fifteen pending changesets that this round made false get an appended dated note, in the append-only shape: `6837-reference-to-arm-deletion`, `6837-gantt-tree-referenceto-arms`, `6837-recorddrawer-invented-target-arms`, `6694-dashboard-lookup-reference-meta`, `6597-retire-fieldmeta-referenceto`, `7166-retire-inert-fieldmeta-copies`, `paramtofield-reference-rule-derives-from-core-5312`, `6711-retire-reference-to-field`, `6874-retire-titleformat`, `7155-converge-lookup-dialect-camelcase`, `6528-resolve-reference-to-census`, `7324-plugin-form-nameable-parameter-types`, `11070-field-metadata-spec-spellings`, `10535-person-name-read-gate` and `6875-grid-relational-meta-derive`. ### Pins that flipped, and why These pins asserted the stamp. Each now asserts that no stamp happens, and that the fold still does: - `reference-keys.test.ts`: a `reference` def gains no `reference_to`; `referenceTo` folds to `reference` only; idempotence; the map and array containers. - `reference-keys.legacyWarning-6837.test.ts`, case 5: "the STAMP is unchanged" becomes "the FOLD still runs". The `referenceTo` case asserts no `reference_to`; the `reference_to` case keeps the input key, because the pass never drops one. - `MetadataProvider.itemReferenceKeys-7650.test.tsx`: the first pin asserted the stamp and now asserts its absence. The rest feed a legacy spelling, because a `reference`-only def comes back unchanged and cannot show that the pass ran. - `getObjectSchema.test.ts` (data-objectstack). - The emit-side pins that read `reference_to` off an emitted bag: `referenceArms-6837.divergent.test.ts` (plugin-detail), `RecordDetailDrawer.referenceArms-6837.test.tsx`, `HeaderHighlight.editable.test.tsx`, `relationalMetaCopySet-6711` / `-6874`, `lookupRelationalMeta-6694`, `sectionFields*`, `paramToField.test.ts`, `resolveActionParams.test.ts`, `bulkParamToField.test.ts`. - New refusal pins: the lookup cell, the user cell and `LookupField` each read no target from a `reference_to`-only def. Pins that assert something else were not "fixed green". Three copy-set floors, which required more than two keys in `RELATIONAL_META_KEYS`, now require more than one, because the set is now `reference` and `displayField`, and each floor now also names `reference`. ### Beyond the claim's file surface, stated - `ObjectDataTable.tsx` gains `ObjectDataTableRetiredReferenceToSnakeTombstone`, and both ObjectDataTable test files gain a directive and a counter-control for it. Retiring `FieldMeta.reference_to` removed the key from the derived refusal bands' pool, and `recordFields.tsx`'s own `FieldMeta` docblock requires that a retired member be re-refused by hand at that seam. This is the third tombstone, beside `decimals` and `referenceTo`. - Comment-only corrections to statements this round made false: `auditHistoryDisplay.ts`, `deriveRelatedLists.ts`, `useDatasetFields.ts`, `object-fields-io.ts`, `components/custom/field.tsx`, core `ActionRunner.ts` / `chart-series.ts` / `expand-fields.ts`, `data-objectstack/src/index.ts`, `RecordPickerDialog.tsx`, `lookupColumnDisplay.tsx`, `HeaderHighlight.tsx` and `ObjectGantt.tsx`, plus the stamp prose in the gantt / tree / drawer 6837 pins. - The named tail is folded. PR objectstack-ai#11243 landed, so the three `ObjectGrid.tsx` relational-metadata comments now name `reference` and `displayField` (seat message, 18:53Z). `ListView.tsx`'s two lines were left as they are, because both are true after this round: one says `FieldSchema` refuses `reference_to`, the other that a list column declares neither spelling. PR objectstack-ai#11233 landed, so the `form.zod.ts` comment, the `FormPage.tsx` comment, the `plugin-form.mdx` row and the `FormPage.sharedFieldResolver-10179` pin were folded as well (seat message, 19:54Z; the comment only in `form.zod.ts`, with no new declaration). `FormPage.sharedWidgets-10179`'s lookup fixture moved first, because that test went red on the change. ## Census, before and after Base: merge base `615346d` for the round's own diff. Code lines are non-comment lines in `packages/*/src`, `apps/console/src` and `examples/*/src`, excluding tests. The typed half comes from a TypeScript LanguageService `findReferences` over 5568 source files, with workspace packages resolved to source. | kind | before | after | | --- | --: | --: | | emitter sites writing `reference_to` onto a def or widget `field` | 11 | 0 | | ingestion stamp of `reference_to` | 1 | 0 | | copy sets carrying `reference_to` (grid ledger row, dashboard cell keys) | 2 | 0 | | reader lines reading `reference_to` (widgets, cells, inline editor, seeding, section override, subform parent) | 10 | 0 | | the ingestion fold's read of the legacy key (the ruling's choke point) | 1 | 1 | | declared `reference_to` members (field-types ×2, views, views.zod, RuntimeField, FieldMeta, schemaDefaults ×2) | 8 | 0 | | `reference_to?: never` refusal (tombstone) | 0 | 1 | | typed references to a `reference_to` member: production writes / test writes | 2 / 7 | no member left to reference | The eleven emitters are the PM census's eleven. The type-checker census found two of them typed (`FlowReferenceField`, `RelatedList`); the other nine write through `any` or `Record` bags. It found no typed reader: every reader was an untyped read. ## Runtime probe — the user-visible proof A scratch vitest file, never committed, ran against the source at merge base `615346d` and at head (`1972d01`, re-run at `338b19d` with identical readings). `ObjectForm` in create mode opens its lookup picker; `ObjectGrid` renders a lookup column with a primitive id; the referenced record is `{ id: 'a1', name: 'Acme Corp' }`. | case | base | head | | --- | --- | --- | | (a) served def with `reference` only, through `ObjectStackAdapter` | picker queries `probe_account`; cell shows `Acme Corp`; served keys `label, reference, reference_to, type` | picker queries `probe_account`; cell shows `Acme Corp`; served keys `label, reference, type` | | (a) the same def from a BYO `DataSource` | works | works | | (b) action param `referenceTo` through `paramToField` into `LookupField` | field carries `reference_to`; picker queries `probe_account` | field carries `reference`; picker queries `probe_account` | | (c1) `reference_to`-only def served through `ObjectStackAdapter` | folded (`reference` added); dev warning fires; picker and cell work | the same: folded, warned, picker and cell work | | (c2) `reference_to`-only def from a BYO `DataSource` | picker queries `probe_account`; cell shows `Acme Corp` | **no `find` call at all; the cell shows the raw id `a1`** | (c2) is the break, and the changeset's BREAKING paragraph names that host. ## Hypotheses - **H1 holds.** Read at objectstack `origin/main` `165c1d49e`. The example apps write `reference_to` zero times, against a control of 30 `Field.lookup` / `Field.masterDetail` calls in 19 files; both helpers write `reference`. `FieldSchema` refuses `reference_to` by name (installed 17.5.0: `unrecognized_keys`, "Did you mean `reference_to` → `reference`?"), and so does the spec's form-field schema. The `field-reference-to-alias` conversion (`packages/spec/src/conversions/registry.ts`) rewrites stored rows on rehydration and in `os migrate meta`. The only other hits are refusals (the SQL and Mongo doors, verify, lint), a reader of three spellings in `plugin-security`, and a comment in `plugin-approvals` that describes this repo's widget key (noted below). - **H2 holds.** Every production write is one of the eleven emitters, the ingestion stamp, or one of the two copy sets the brief listed as readers. No twelfth writer turned up, typed or untyped. - **H3 holds.** On the ObjectStack path the cells receive folded defs, as probe cases (a) and (c1) show. `RELATIONAL_META_KEYS` is derived to `reference, displayField`, and the grid's derivation gate re-extracts the cell's read set with `reference` in it. - **H4:** **false as posed.** The `reference_to` pair was not in M3's class (ii) at this base. Round 1 (PR objectstack-ai#11115) moved both `fields-lookup` fixtures to `reference`, and round 3 reported class (ii) without it. Measured with `scripts/measure-strict-authoring-face.mjs --json` at merge base `af9e957` and at `338b19d`: every refusal figure is identical (2224 nodes, 105 strict-refused, 66 strict-only, 39 red today, 599 documents, 98 refused whole-tree), and `reference_to` occurs in zero refusals at both. The only differences are the renderer-source word-mention counts, which the comment edits moved, and the error text of one authored module that fails to load under either build state. So this round moves M3 by zero in every class. The absolute class (ii) figure at this base is NOT MEASURED: round 3's shipped-face port of PR objectstack-ai#11069's `findUndeclaredKeys` was a scratch script and was never committed. ## Checks Heavy runs went through `os-verify-lock` (slot `issue-11070`). Exit codes were captured before any pipe. - **Build closure** (`check-doc-snippet-types --build-filter`, turbo `--concurrency=2`): `35 successful, 35 total`, before and after the first merge. - **type-check** of 21 packages, the ones this diff touches plus the console and the schema catalog (the type-checker census found no other typed reader): `Tasks: 56 successful, 56 total`, before and after the first merge. After the second merge, `@object-ui/types` was rebuilt and type-checked again, exit 0. - **Tests, full affected set** (the 21 packages' paths, 3536 files), before the first merge: `4 failed | 3527 passed | 5 skipped (3536)`. The four were pins that asserted the old shape: three copy-set floors of more than two keys, and the console shared-widget lookup fixture. They were fixed in `c684de4` and re-run: `Test Files 4 passed (4)`, `Tests 30 passed (30)`. - **Tests after the first merge** (`338b19d`), narrowed to the files both sides touched plus this round's relational surface (types, fields tests and widgets, the Flow inspectors, console components, plugin-grid, plugin-detail and plugin-dashboard tests, plugin-form, core reference-keys, the adapter, and app-shell providers and utils): `Test Files 1137 passed | 3 skipped (1140)`, `Tests 16884 passed | 31 skipped (16915)`. After the second merge (`16b43a7`, no file overlap): `packages/types/` `295 passed (295)`, `7375 passed (7375)`, including `zod-mirror-parity.test.ts`, whose totals did not move. - **Doc gates** at `338b19d`: `check:doc-snippets` `696 of 696 block(s) judged, 0 failed`; `check:doc-examples`, `check:doc-types`, `check:spec-symbols`, `check:doc-example-readers` and `check:doc-example-ids` all exit 0. - **At `16b43a7`**, all exit 0: `check:doc-fences`, `docs:check-links`, `check:control-bytes`, `check:pending-changeset-literals`, `check-changeset-presence` (`174 source file(s) of 20 released package(s) changed, and this change declares 1 changeset(s)`), `changeset:check` (no major), `check:new-line-citations` (`0 new citation(s)`), `check:test-path-roots`, `check:changeset-claims` (report-only; every named changeset was read), and `check-governed-queue-guard --test` over the 194 paths (`NOT GOVERNED`). - **Lint, a measured narrowing.** The population is `eslint.config.js`'s `**/*.{ts,tsx}` files entry; there are no `parserOptions.project` or `projectService`, so type-aware linting is off and this diff cannot move an untouched file's verdict. The 174 changed `.ts` / `.tsx` files were linted at `16b43a7` and at merge base `54a7830`: 0 errors at both, 1909 warnings at both, and no file is worse than base. - **Ablations**, each run on a committed, clean tree through objectstack's `ablation-replace.mjs`. In every case the anchor hit once, the blob moved, the blob was restored equal to HEAD, and `git diff HEAD` was empty afterwards. - A1: `lookup.mdx`'s fence writes `reference_to`. `check:doc-snippets` exited 1 with `696 of 696 block(s) judged, 1 failed`, TS2561 "'reference_to' does not exist in type 'LookupFieldMetadata'. Did you mean to write 'reference'?", which proves the gate read the rebuilt `.d.ts`. - A2: the fold stamps `reference_to` again. 6 red / 83 green, against 5 predicted: the three core stamp pins, the adapter pin and the provider pin, plus the empty-target pin, because an empty `reference` also gained a stamp. - A3: `LookupCellRenderer` reads `reference || reference_to` again. 2 red / 14 green, as predicted: the new cell refusal pin, and the grid derivation gate's unclassified-spelling check. - **Type-checker census** at `338b19d`: the seven contracts carry no `reference_to` member. The control run of the same instrument on `reference` finds 7 declarations, the same two typed production writes that moved, 1 production read, 3 test reads and 10 test writes. - **Diff size:** 194 files, +1022 / −647 against merge base `54a7830`. That is under the 5000-line human-merge threshold: 62 non-test files (16 of them changesets) and 132 test files. NOT MEASURED: - The repository-wide lint and the test shards outside the sets above. CI runs those. - Tests after the second merge outside `packages/types/`. Its three incoming commits share no file with this branch, and none contains `reference_to`. - A browser dogfood against a live objectstack app. The probe used `ObjectForm` / `ObjectGrid` with a stub or adapter-wrapped `DataSource`. - The absolute M3 class (ii) figure (see H4). - CI convergence. It was not waited on, per the dispatch contract. ## Acceptance notes - `docs/adr/0059-action-params-shared-field-widgets.md`'s decision section still records `referenceTo` → `reference_to` as `paramToField`'s mapping. It was left as written: it is the accepted decision's record (2026-07-19), and the ruling kept the historical record. Editing `docs/adr/**` would also make this PR governed. The seat may want a dated amendment line in a docs-only PR. - `ROADMAP.md`'s two hits are completed items and changelog history, not current behaviour, so they were not changed. - `.changeset/audit-field-def-reference-narrow.md` says the choke point "stamps both snake_case spellings". It has empty front matter, so nothing publishes it, and no note was appended. - objectstack `plugin-approvals`' `sys-approval-request.object.ts` has a comment saying the console resolves a lookup config as `reference_to: sys_user`. That spelling is now `reference`. It is a comment in the sister repo, not a producer; noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #8069
Clause-②: yes (narrowing)
A field
visibleWhenthat cannot be evaluated now refuses the submit, naming the field and the rule, on the three submit paths the ruling names: the record form renderer (form.tsx, so everyObjectFormlayout), the console'sFormPage(/forms/:nameand/f/:slug), and the wizard's cross-step gate at final submit.requiredWhen/readonlyWhenare unchanged on the client; the server's ADR-0137 D2 refusal of them lands beside the input. Both blank-gate silencers are diagnosed, with no verdict changed.Ruling: card comment 5910115531 (batch #256 item 4) — Q1 = B, one judge per rule; Q2 = B as seat 1 answered it in 5908497249. Dispatch claim: 5912071659.
Narrowing
visibleWhenfaulted (typo in a column, syntax error, unbound root) or was stored BLANK drew the field (fail-open) and submitted as if the rule had said "show"; and objectui'sFormFieldSchemaaccepted a blankvisibleWhen/readonlyWhen/requiredWhenat parse.form.visibleWhenFaultednaming the field(s) and the rule — a stored BLANKvisibleWhenincluded ("A blank predicate takes this path too, wherever one is already stored"). The field is still drawn (D3 unchanged). Nothing is written.@object-ui/types(minor):FormFieldSchema's field-rule triad refuses a predicate whose text is blank after trimming, with the spec's own sentence (EVALUATED_EXPRESSION_SOURCE_REQUIRED), as the spec already does onFieldSchema. The accepted SHAPE is unchanged (see theExpressionWireSchemaparagraph); gate keys keep the plain wire, and a blank gate stays "no gate" plus a one-time diagnostic.requiredWhen/readonlyWhenfaults — a stored blank one included — are refused by objectql's D2 and stay the server's. ADR-0137 refuses "blank means no rule" for the triad (Alternatives), so a blank is refused at the first place that can see it: authoring (D1, objectui's own wire handled in the same round, as the ruling's execution text asks) or submit (D2). Applied after contract review 5915380177, finding 1.visibleWhenreadingpreviouscannot be evaluated on a CREATE form, so such a form is refused on every submit (ruled); and the wizard's cross-step gate binds nopreviousin either mode, so the same rule is refused at an EDIT wizard's final submit too (accepted by the seat under the ruling's refusal of option D;persistedRecordis deliberately not bound).@object-ui/core(minor):resolveFieldRuleStatereturns a newfaultsmember beside its three verdicts, and its typeFieldRuleFaultsis exported from the package root.@object-ui/i18n(minor): the new keyform.visibleWhenFaultedin all ten packs, so the exportedTranslationKeystype gains a member.What changed
Core — the report a submit path reads.
resolveFieldRuleStatefillsfaultsfrom theonFaultpassback of the veryevalFieldPredicatecalls that draw the verdicts, so no refusal evaluates a predicate a second time. It reports all three rule kinds; which ones a path refuses on is the path's ruling. Left out, by construction: a rule the verdict did not need (short-circuited, never ran). A stored BLANK rule is IN it, with its[blank]reason — ADR-0137 D2's third state, not a spelling of "no rule". The stale "open question" prose infieldRules.tsnow points at ADR-0137 D2 / D3 / D5. Edited regions stay clear of PR #11208's two hunks in that file (its import line and theevalFieldPredicateevaluate call);git merge-treeof this branch with #11208's head947b8d38bis clean.The three submit paths, each reading
faults.visibleWhenoff the call it already makes:form.tsx: theconditionallyHiddenFieldNamesmemo (every declared field'svisibleWhen, the6a449fc49clear-on-hide input) now also returns the faulted names.handleSubmitrefuses BEFORE react-hook-form validation (nothing the user types clears a broken rule), on the in-form banner and the outcome toast, and marks the tabs holding the fields. Every declared field is judged, not only drawn ones: a faultedvisibleWhennever clears on hide, so its value reaches the payload whatever hides it. Asection-dividerrow'svisibleWhen(its section's layout gate) is excluded.FormPage:findMissingRequiredbecamefindSubmitRefusals, oneresolveRowStatepass returning both the faulted rows and the missing ones; the fault is refused first. Hidden sections stay skipped, as the required check already skipped them: on this page visibility decides only what is drawn, so a row a hidden section keeps off screen cannot be shown by its own broken rule.WizardForm:missingRequiredByStepbecamegateFinalSubmit, the same pass returning both; a fault lands the wizard on the first step holding such a field.Q2 — the two blank-gate silencers, diagnosed (ADR-0137 D4), verdicts unchanged:
ExpressionEvaluator.evaluateCelConditionansweredtruefor a blank CEL source before anything could say so. It now reports throughevalFieldPredicate's[blank]channel (the caller'sonFaultwhen given, else the deduped built-in warning) and still answerstruein every mode,throwOnErrorincluded.hasDeclaredPredicatefolded blank text to "no gate". It still does (objectui#3850 / 「空谓词」的第四种拼法:{ dialect: 'cel', source: ' ' }(source 只有空白)仍被判成已声明门 → disabled 侧仍永久置灰、执行入口仍拒执行(#3850 裁决枚举未覆盖) #3960), and now reports once through the same channel. Its locator is fixed (the question is key-neutral), so the dedupe is per blank spelling across the app — stated in its docblock.Two existing pins asserted the silence and now pin the diagnosis instead, verdicts untouched:
SchemaRenderer.disabledGateFaultDiagnostic.test.tsx(objectui#3862 empty shapes) andActionParamDialog.test.tsx(blank paramvisible).ExpressionWireSchema— D1 on objectui's own form wire, and no second wire type.FormFieldSchema'svisibleWhen/readonlyWhen/requiredWhenareExpressionWireSchema.superRefine(...)(module-privateFieldRulePredicateWireSchemainform.zod.ts): the check hands the predicate's TEXT (the string, or an envelope'ssource) tostripImportedDefaults(EvaluatedExpressionInputSchema)from@objectstack/spec/sharedand refuses with that schema's verdict and sentence, so neither the blank rule nor its message is restated. Only the text is judged, never the envelope, whose specdialectenum is narrower than the wire. The refined union's arms AREExpressionWireSchema.options, by reference, and its parse output is the authored value unchanged — so the accepted SHAPE is the one wire type objectui#7530 ruled, and only a blank VALUE is taken out. That closes the submit-refusal trap: a blank cannot be authored, and one already stored is refused at submit.BaseSchema'svisible/hidden/disabled, a field's view-levelvisibleOnand an option'svisibleWhenkeep the plain const. Two test instruments followed:imported-defaults-8317.test.tslists the new spec crossing, andterminal-unknown-key-refusal-11073.test.tsrebuilds its OPEN twin from the union's own def (itsz.union(options)dropped the union's check and read the triad's blank refusal as an accept-set move).Docs:
content/docs/guide/metadata-diagnostics.mdandcontent/docs/plugins/plugin-form.mdxsaid a broken predicate never blocks a submit; both now describe the refusal, andmetadata-diagnostics.mdadds that a blank field rule is refused (at authoring by the form schema, at submit when stored) while a blank gate stays "no gate".Pins
packages/core/src/evaluator/__tests__/fieldRuleFaults-8069.test.ts— the report per rule kind, the create-formpreviousresidual with its edit-form control, short-circuited rules, a stored blank rule reported as[blank]for each rule kind with an absent-rule control, and both silencers with their controls (fresh module graph per case, since the unit project runsisolate: false).packages/components/src/renderers/form/__tests__/visibleWhen-fault-refuses-submit-8069.test.tsx— the refusal, its ordering before validation, the healthy control, a stored blankvisibleWhenrefused, a blank view-levelvisibleOnNOT refused (the gate control), the divider exclusion, the residual and its edit control, and the end-to-end server pin: a faultedrequiredWhen/readonlyWhenis let through by the client and the server's D2 envelope (VALIDATION_FAILED,code: 'rule_violation',constraint.reason: 'unevaluable', transcribed from objectql at the spec 17.5.0 tag0f6dcac5e9) lands inside that field'sdata-fieldwrapper.apps/console/src/components/FormPage.visibleWhenFault-8069.test.tsxandpackages/plugin-form/src/wizardVisibleWhenFault-8069.test.tsx— the same cases on the page (read off the stubbed write) and on the gate (a skipped step, so only the gate can see the field).visiblewhen-clear-on-hide-6958.test.tsxBOUNDARY pin restated for D2: a broken predicate clears nothing AND writes nothing — the verbatim constraint "a broken predicate must NEVER silently null a stored column" now holds in both halves.packages/types/src/__tests__/base-schema-predicate-envelope-7530.test.ts— re-pinned to what objectui#7530 protects, ONE wire shape:BaseSchema's gates and the form's gate legs still carryExpressionWireSchemaitself; the triad carries a refined clone whose arms areExpressionWireSchema.optionsby reference; each triad key refuses'', whitespace,{ source: '' }and a whitespace CEL envelope at the key withEVALUATED_EXPRESSION_SOURCE_REQUIRED, accepts a non-blank string, a CEL envelope and a dialect-less envelope with the value unchanged, and still refuses junk; the control: a blank gate onBaseSchema,visibleOnand an option'svisibleWhenstill parses. The comment cites ADR-0137 D1 and this ruling.Verification
All heavy runs went through
os-verify-lock.sh(slotissue-8069); verdicts read from itsVERDICTline and from the pass counts, never a bare exit code. Base0389650f3.Rework round (contract review 5915380177: finding 1 as option B, finding 2 accepted), final head
277476aaf:277476aaf:apps/console/src/components/36 files, 437 passed; the twelve pin files (the four new ones,fieldRules.test.ts, the The Console submits the value of a field its ownvisibleWhenhas hidden, and clears nothing — sovisibleWhenon a populated lookup is a dead end, not a hint #6958 file,wizardPredicateScope,ActionParamDialog,SchemaRenderer.disabledGateFaultDiagnostic, and the types pinsbase-schema-predicate-envelope-7530,imported-defaults-8317,terminal-unknown-key-refusal-11073) 12 files, 469 passed;@object-ui/consoletype-check EXIT 0.90d406183(the one later commit changes only the console pin file, re-run above):turbo run build --filter='@object-ui/console^...' --concurrency=2EXIT 0;type-checkEXIT 0 for@object-ui/types,@object-ui/core,@object-ui/i18n,@object-ui/components,@object-ui/plugin-form,@object-ui/console, 0error TS;packages/types/289 files, 6915 passed;packages/core/191 files, 3768 passed, 27 skipped;packages/components/src/renderers/form/69 files, 475 passed, 17 skipped;packages/plugin-form/in three chunks, 50 + 47 + 57 files, 310 + 514 + 953 passed, 1 skipped; rootscripts/__tests__/177 files, 5371 passed.eslinton the 12 code files this round changed: 0 errors, per-file warning counts unchanged (0 on the new and changed pins). Light gates at90d406183, all EXIT 0: the list below pluscheck:spec-symbolsandcheck:phantom-deps;check-governed-queue-guard --testover 34 paths NOT GOVERNED.277476aaf, same discipline as below: baseline 5 files, 112 passed. D — drop the triad's blank refusal (thesuperRefine's issue branch made unreachable): 12 failed / 50 passed, exactly the twelve blank cases (three keys, four spellings); every accept, shape-identity, junk and gate control green; blob53a98c5btoc748e2baand back. E — take a stored blank out of the fault report again: 7 failed / 43 passed, exactly the seven stored-blank cases (core four, form, FormPage, wizard); blob4d2bb278to01b3a2c2and back. The first D attempt, at90d406183, was an anchor miss the tool refused (exit 3, nothing ran): its anchor was the pre-superRefinespelling.First round, at
ff6d7775b:type-check(each afterturbo run build --filter='@object-ui/console^...' --concurrency=2, EXIT 0):@object-ui/core,@object-ui/i18n,@object-ui/components,@object-ui/plugin-form,@object-ui/console— all EXIT 0, 0error TS. Each package's script also compiles its tests (tsconfig.test.json, and the console's own config), which is where the first round found twelve TS7006 and one TS2550 in the new pins, all fixed.pnpm exec vitest run packages/components/src/renderers/form/: 69 files, 474 passed, 17 skipped.fieldRules.test.ts, the The Console submits the value of a field its ownvisibleWhenhas hidden, and clears nothing — sovisibleWhenon a populated lookup is a dead end, not a hint #6958 file,ActionParamDialog.test.tsx,SchemaRenderer.disabledGateFaultDiagnostic.test.tsx): 8 files, 189 passed.eslinton the 27 changed code files: 0 errors; every file's warning count equals its count at base (new files 0) — population is the changed-file list, counts from--format json, and type-aware linting is not enabled ineslint.config.js(noproject/projectService), so the diff cannot move a verdict on an untouched file.check-changeset-presence,check-control-bytes,check:new-line-citations(0 new),check:i18n-keys,check:i18n-drift,check:i18n-dead-keys,check-changeset-claims,check-changeset-no-major,check:test-path-roots,check-vi-mock-specifiers,check-unreferenced-sources,markdown-test-inputs.mjs --audit. A control-byte scan of every changed file finds none.check-readme-exports: NOT MEASURED in full — it exits 1 on two packages outside this diff that the build closure does not include (@object-ui/cli,@object-ui/plugin-ai, "run pnpm build first"); over the 35 built packages it judged 545 self-imports real, 0 wrong-path, 0 fabricated.Earlier heads (the later commits only touched test typing, two pin files re-run above, and one
(field as any).labelbecomingfield.label):packages/core/191 files, 3767 passed, 27 skipped ·packages/i18n/74 files, 1216 passed ·apps/console/src/components/36 files, 436 passed (all at5800ae995).packages/plugin-form/in three chunks: 50 + 47 + 57 files, 310 + 514 + 952 passed, 1 skipped ·packages/react/104 files ·packages/components/src/renderers/action/29 files, 475 passed · 18 consumer files ofhasDeclaredPredicate/resolveFieldRuleStatein app-shell, plugin-detail, plugin-grid and components · rootscripts/__tests__/177 files, 5371 passed (all at2dc06d4e2). The react and consumer runs each had ONE red, both a pin asserting a blank gate stays SILENT — the behaviour Q2 = B reverses; both pins now assert the diagnosis and are green in the final run.console.warnnear a blank value, found by grep across other packages: 22 files, 773 passed (atcfd6676a9).Ablations (at
cfd6676a9, each leg throughablation-replace.mjs: the anchor hit exactly once, the write proven on disk by anchor count and blob hash, the restore proven by blob hash equal toHEADand an emptygit diff HEAD; the package is aliased tosrcin the vitest config, so nodistleg applies):visibleWhenrefusal at its one source (the fault report records nothing): 13 failed / 91 passed, red by name on every refusal and report pin across all six files (form, The Console submits the value of a field its ownvisibleWhenhas hidden, and clears nothing — sovisibleWhenon a populated lookup is a dead end, not a hint #6958 boundary, wizard, FormPage, core); every CONTROL, blank,requiredWhen/readonlyWhenand edit-form case stayed green. Blob2e74e4cctodc5beddaband back to2e74e4cc.hasDeclaredPredicatewithout its report): 4 failed / 20 passed, exactly the four silencer-2 diagnosis cases; its controls stayed green. Blob748c4f17to0fb13924and back.evaluateCelConditionguard: the first attempt was a no-op the tool refused (its replacement contained the anchor, so the anchor count did not drop; exit 1 before anything ran). Re-run with a different anchor: 2 failed / 22 passed, exactly the two silencer-1 diagnosis cases; thethrowOnError-no-throw case and the control stayed green. Blobbcf875eeto24a1178dand back.Acceptance notes
ObjectFormand its drawer, modal, split and tabbed layouts, each wizard step, andMasterDetailForm's headerObjectFormall render atype: 'form'node, so they submit throughform.tsxand inherit the refusal; a wizard step whose own field faulted therefore refuses its Next as well.RecordFormPagedelegates toObjectForm. Not judged:ScreenView(flow screens — not a record write),GridField(grid-column rules are presentation-only in the spec) andplugin-kanban'srequiredWhenPrompt(requiredWhenonly, which the server refuses).FormPageshows a server D2 refusal as page-level text, not inline — it has noextractFieldErrorspath. The server's message still names the field and the rule. No change here.visibleOn, and a form view's ownvisibleWhen, whichsectionFieldsroutes there) and SECTION predicates are layout gates, not field rules; they stay fail-open and warned.examples/: a fieldvisibleWhenreadingparentoutside a grid, and one reading a column the create form does not render or seed, are unevaluable there and so refused.visibleWhenreadingpreviousis refused on a CREATE form (ruled), and at the final submit of an EDIT wizard, whose cross-step gate binds nopreviousin either mode (accepted by the seat under the ruling's refusal of option D;persistedRecordis deliberately not bound).evaluateCondition's legacy path returnstruefor a bare''/ whitespace string, andevalRowPredicatereturns its fallback for a bare blank string, both without a diagnostic. A third silent path, also a gate and also untouched: a blank VIEW-level field predicate is dropped by section normalization (sectionFields'attachVisibility) before any evaluator sees it, on both form chains — "no gate", never refused, but not diagnosed either.skills/objectui/guides/schema-expressions.mdsays field-rule evaluation "never blocks submit". It is not edited here, so this PR stays off the governed surface; it needs its own PR.Overlap
PR #11208 (objectui#4421) edits
fieldRules.ts(its import block and theevalFieldPredicateevaluate call) andRecordFormPage.tsx's evaluator memo. This PR touches neither hunk and does not touchRecordFormPage.tsx; the textual merge is clean. PR #11208 landed first (9cebfca5a); the merge queue's merge group runs this PR's CI overmain, which carries it.Session:
https://claude.ai/code/session_011p7ikEivgXefNDaE5S5UecGenerated by Claude Code