Conversation
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…ed shape Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…m close Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 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 09fecea25bac1df8ce6474824afa35c3bc68baa3 && git checkout 09fecea25bac1df8ce6474824afa35c3bc68baa3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 362035cc079c62ce7f5c21844d5095fb5855fa29 837234d86b8b991ef0959457cfccf1ba2006b210 && git checkout -B drift-repro 362035cc079c62ce7f5c21844d5095fb5855fa29 && git merge --no-ff 837234d86b8b991ef0959457cfccf1ba2006b210
node scripts/docs-audit/affected-docs.mjs --json 362035cc079c62ce7f5c21844d5095fb5855fa29
|
Contract reviewServed-tier: Reviewed against merge-base Ruling executed: batch #146 item 4, letter A. Its operative sentence, stated twice: «declares every key the renderer measurably honours, (1) Derived judgments1. Step 1a — the dev's reading holds in full, and the control fires. 2. ⭐ The declare set — ⛔ The dev's reason (a declared key costs a retirement kit, an over-strict refusal costs one card) is a product judgment on a contract-shape question — the class the filing seat, the triage seat and the ruling all placed on the maintainer's floor. A seat may not narrow a ruling and file the difference as a card afterwards, which is what PR body §2 and acceptance note 1 do. ⭐ A material fact the ruling did not have: of the 21 honoured keys, 15 are declared on Exits, either of which clears this: (a) declare the measured set — the 15 with their 3. The curated refusal text is wrong for six of its 21 keys — owed in the same revision. The prescription says the family «are keys of a FIELD ( 4. Step 1b — census: zero on all three reachable legs; ⭐ the hotcrm leg is now MEASURED. Instrument brackets every inline 5. The replaced fixture and the two control legs are non-vacuous — shown by reverse verification. With the base blob of 6. Twin-parity pin — the anti-vacuity assertion exists and fires. 7. Union top-level messages. The only union in this PR's assertions is the 8. One test is mis-titled and duplicates another — owed in the same revision. «the twin refuses the same nonsense key» parses 9. Generated artifacts. Producer: direct package build in the head worktree (tsup 8.5.1 / typescript 6.0.3, not turbo); 10. Consumer coordination — re-measured: one of the two named tests actually reds. objectui's 11. Not closed, correctly. (2) Semver level
(3) Boundary flagsThe dev raised no open questions. Its four out-of-scope findings, answered: (1) «should the widget-config family become declared» — ⛔ not residual: the ruling already answers it; this is finding 2, BLOCKING. (2) objectui tests — accepted, corrected to one test. (3) the union-message pin table — accepted; the file's own header leaves the standing re-scan to its own card. (4) the module-header CI on Implemented-by: VERDICT: FAIL — finding 2 BLOCKING; findings 3, 4, 8, 10 owed in the same revision. This record names head Generated by Claude Code Generated by Claude Code |
|
Heads-up from the Seat: domain:spec#3
The overlap, measuredThe half-state sweep this seat ran at 2026-09-18T20:09Z rows the pair under H36 (cross-lane same-file), and the open-PR file map built at 2026-09-18T19:26Z (29 open PRs, 362 file rows, instrument lit) names the three:
PR #19095 (card #19046, this seat's) was armed and is in the queue as of 2026-09-18T21:16Z — Why the first two matter more than the thirdBoth ⭐ The order that holds on those paths, and the only one this seat has seen survive both sides: resolve → commit the merge → regenerate with the repo's own command → let the regeneration diff certify it. ⛔ Never hand-resolve a generated artefact into a shape you chose, and ⛔ never trust the combined diffstat as the reading.
What this seat is and is not doing
Generated by Claude Code |
Fixes #18177
Clause-②: yes (narrowing)
Executes decision batch #146 item 4, letter A — maintainer 「146 同意」 2026-09-17T13:16Z.
BulkActionParamSchemabecomes strict like its twin and declares the key measured live on the surface; route B is not built, route C is not kept.Changeset carrier:
.changeset/18177-bulk-action-param-strict.md—@objectstack/specminor, body carrying 「Breaking for authored metadata」.ADR-0087 disposition:
registered ui-bulk-action-param-unknown-keys-refused— a D3 structured TODO, not a D2 conversion, for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target.1 — Measure first (the ruling's step 1)
1a. Which keys the bulk dialog reads off a bulk param after the spread
Instrument.
bulkParamToFielddestructures the eleven declared keys out and spreads the rest onto the field metadata handed togetLazyFieldWidget, so the question is: which keys does a widget read off that bag? Enumerated by scanning every form-widget modulegetLazyFieldWidgetcan return —objectui@3e4f6324f7,packages/fields/src/widgets/**(74 non-test modules) — for property reads off thefieldprop, following the local aliases those modules assign it (const config = field as any, and the chainedlookupField→fieldMeta→cascadeMetaunwrap inLookupField).Firing control (so a zero would be a reading). The positive control is
dependsOn: the scan returns it at 6 sites across 5 modules, and each was read by hand to confirm it is live code and not a comment. The negative probe (zzz_nonsense_key_that_no_producer_emits_8755) returns nothing.Instrument's reachable radius, and a known target outside it. The radius is
packages/fields/src/widgets/**in objectui. It does not reachpackages/fields/src/index.tsx, and a known target lives there:buildValidationRulesreadsfield.min/field.max/field.pattern/field.required_messageand more. That function is excluded deliberately, not by accident — it is the react-hook-form path the object FORM uses, and the bulk dialog does not go through it (BulkActionDialogrenders the widget directly). Its keys are therefore not evidence about this surface. A first pass of the scan that did includeindex.tsxalso over-reportedstartsWith(a string method, not a field key), which is why the alias-following pass was read by hand rather than trusted.Result —
dependsOnis live on BOTH widget families reachable from the dialog:SelectField,MultiSelectField,RadioField,CheckboxesFieldfield?.dependsOnuseCascadingOptionsLookupField, andUserFieldthrough itcascadeMeta?.dependsOnAnd a long tail of widget-config keys is read off the same bag —
min/max/step(NumberField, SliderField, CurrencyField, PercentField, RatingField),accept/maxSize/crop/capture(FileField, ImageField),rows(TextAreaField, RichTextField),precision/scale,dimensions(VectorField),defaultName(AvatarField), and the picker knobsdescriptionField/idField/allowCreate/lookupColumns/lookupPageSize/lookupFilters/picker/subtitle/avatarField(LookupField). ⭐formatis not among them, although the module header used to name it beside min/max/step: no form widget reads it. That discrimination is what makes the list a measurement rather than a transcription of the header.1b. Census of authored bulk params for keys the schema does not declare
Instrument. Bracket-matches every
bulkActionDefsarray in a tree, extracts eachparams[]object literal and lists its top-level keys. Firing control: a planted fixture carryingdependsOnand the nonsense key — the instrument reports both and leaves the eleven declared keys unflagged.objectstack-ai/objectstack@176b03582eobjectstack-ai/objectui@3e4f6324f7objectstack-ai/hotcrmInstrument's radius here too: it finds params written as object literals inside a
bulkActionDefsarray. A param assembled in a variable and spread in would be outside it. No such site was seen, but that is an absence the instrument cannot certify.⇒ No authored unknown key was found, so no ADR-0087 conversion entry is owed and there is no stop-and-report. The registered entry is the D3 structured TODO for the narrowing itself.
2 — What the change is
BulkActionParamSchemamoves fromz.object({…}).passthrough()tostrictObject({…}), and declaresdependsOn. The rejection is curated rather than bare:helpText→help,description→help,defaultValue→default,reference→object,referenceTo→object,displayField→labelField,title→label. These are the same three mappingstoBulkParamperforms when it promotes an ACTION param, so the authored and promoted directions now agree.field,objectOverride,visible,visibleWhen,carryOver,defaultFromRow,requiresFeature, each answered with the layer that really owns it. ⛔ None of them promises the field-backed route, because the bulk surface does not have one — that would be the confidently-wrong prescription this campaign has shipped before.BULK_PARAM_WIDGET_CONFIG_KEYS— one prescription for the whole measured widget-config family, namingFieldSchemaas the shape those keys are real on, and saying in as many words that declaring the key on the object's FIELD does not reach this dialog either.Why the declare set is
dependsOnand not the whole measured tailThe tail is measurably READ, so declaring it would be defensible on that half alone. It is not declared because the other half is missing: the census found no author writing one, and a declared key is published contract whose removal costs a full retirement kit, while an over-strict refusal costs one card. The asymmetry decides it. The measured tail is written into the file beside the guidanceSet so the next reader has the evidence without re-deriving it, and the residual question is filed rather than guessed — see Acceptance notes.
What is deliberately NOT closed
params[].options[]stays.passthrough(). Its openness rests on its own 2026-08-03 measurement (the option entries are spread verbatim into the field metadata, where the widgets readcolor/icon/disabled/visibleWhen), which this change does not disturb. Closing it by symmetry with its parent would delete widget config the renderer honours — the same defect this PR closes one level up. The declared{ label, value }pair is still type-checked.3 — A brief premise corrected on measurement
The dispatch named
packages/spec/src/ui/action.zod.tsas «the twin whose shape and.describe()text you must match». Measured:ActionParamSchemadeclares nodependsOnat all. The single-record dialog reaches the key through the field-backed route (resolveActionParamsresolves the object's field definitions), so the spec's only declaration of this key isFieldSchema.dependsOn(packages/spec/src/data/field.zod.ts) — which is also the spelling the card itself names as the one objectui was ruled to honour.So
action.zod.tsis the twin for strictness, andFieldSchemais the twin for this key's shape and description. Both halves are honoured: the member is byte-for-byte the field-level union (stringor a strict{ field, param }entry, same alias table), and the description is the field-level text with ONE sentence appended — a bulk run holds a selection and not a row, so «other field(s) on the same record» had to say what the record is here (the dialog's own in-progress param values, i.e. a sibling param of the same def). ⛔action.zod.tsis not edited.A parity pin (
accepts exactly what the FieldSchema twin accepts, and refuses exactly what it refuses, 8 cases, asserted equal as a vector and asserted to contain both verdicts) is what stops the two doors drifting into dialects.4 — Verification
Run against
837234d86b, this branch's final commit.pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec check:generatednode scripts/check-adr-0087-registration.mjs --base origin/main[BREAKING+clause-②-narrowing] registered ui-bulk-action-param-unknown-keys-refused (new here)pnpm lint(=eslint . --no-inline-config)grep -naPover all 14 changed paths, pluspnpm check:nul-bytesnode scripts/pm/dispatch-gates.mjsover the real change set, reconciled with--rancarrying exit codesThe 7 NOT MEASURED, every one a
PREREQUISITE NOT METrefusal that needs a repo-wide build this lane does not own (exit 3, except the last which exits 1 and says the same thing in words — recorded here rather than counted as a failure):check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt,check:skill-examples. ⛔ None of them read anything about this diff; they are declared to CI, not skipped quietly.Regenerated, never hand-edited:
authorable-surface/ui.json(gainsui/BulkActionParam:dependsOn),api-surface-declarations/*.txt,content/docs/references/ui/{bulk-action,view}.mdx, the strictness-ledger counts, andmigrations/registry.ts(from the new one-file entry, viagen:migration-registry). ⛔ Nothing was typed between the generated markers.authorable-surface.base.jsonis unchanged, as expected — onlygen:authorable-surface-basewrites it.Strictness ledger moves the right way:
ui/passthrough 3 → 2, strict 165 → 167; repo total strict 318 → 320, passthrough 4 → 3.No in-repo consumer of the narrowed type.
BulkActionParamloses its index signature when the shape closes. Measured: no file outsidepackages/spec/srcimports that type (packages/cliandpackages/spec/scriptsmention it in prose only), so no consumer typecheck is owed.packages/cli typecheckwas attempted and refuses on unbuilt workspace dependencies — the AGENTS.md section-9 stale-closure signature, unrelated to this diff and left to CI.Acceptance notes
Everything below was found on the way and is deliberately NOT fixed here.
min/max/step/precision/scale/rows/accept/maxSizeand the picker knobs are refused at parse while the widget one seam over would still honour them, and there is no field-backed route to reach the dialog by. That is a real authoring gap: an author reading the renderer's vocabulary writes a key the runtime now rejects. Sized and located by the measurement in §1a. Dedupe words:BulkActionParam, widget config,min/max/step,bulkParamToFieldspread, field-backed bulk param.objectui'spackages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsxleg B pins thatBulkActionParamSchemaACCEPTS a nonsense key, andpackages/types/src/__tests__/bulk-action-param-options.test.ts:139parses an authored param through the same schema. Both are correct against installed 17.4.0 and both turn red the day objectui's spec pin crosses this release; the first has to be re-judged into a refusal pin the way this PR re-judged its own. Nothing here breaks objectui's BUILD — no export is removed or renamed — so this is a coordination note, not a Post-Task-Checklist-4 blocker. Dedupe words: objectui,bulkLookupDependsOnReach-8755, leg B null reading, spec pin bump, bulk param strict.packages/spec/src/shared/union-author-message-pins.test.tscarries a hand-maintained table of string-or-object union sites, and this PR adds one (BulkActionParam.dependsOn). The file says out loud that nothing mechanically holds that table equal to the tree and that a standing re-scan «is deliberately left to its own card», so the gap is already recorded there. The new site's rendered message is pinned in this PR's own sibling test instead (surface phrase, rename arrow, and the string arm's kind mismatch asserted absent). Carrier: the next PR that touches that table, or the standing-guard card the file already names.min/max/step/formatwas 3-for-4 — no form widget readsformaton this path. The header is corrected in this PR rather than filed, because the sentence lives in the file being edited. Carrier: none needed.Generated by Claude Code