feat(spec): the seven converged rule-array filter doors name the array form when they refuse the record form - #17846
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…uses the record form Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…lter-rule-array-guidance
…lter-rule-array-guidance
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 a1c25d57bb30baba5d068b6a926da93664d0499e && git checkout a1c25d57bb30baba5d068b6a926da93664d0499e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6059b29c03e470dbaa513cc3f0992a8e610855be cdfd8d142add46f581e020ca621f27a8cf025f1f && git checkout -B drift-repro 6059b29c03e470dbaa513cc3f0992a8e610855be && git merge --no-ff cdfd8d142add46f581e020ca621f27a8cf025f1f
node scripts/docs-audit/affected-docs.mjs --json 6059b29c03e470dbaa513cc3f0992a8e610855be
|
Seat review of head
|
| # | check | reading |
|---|---|---|
| ① | at-tier verdict | not owed — Clause-②: no |
| ③ | check-governed-merges --test over the five final paths |
exit 0 · ⭐ control with .claude/skills/pm-dispatch/SKILL.md appended → exit 3 GOVERNED |
| ④ | every check, newest run per name | 35 names, 0 pending, 0 failing |
| — | carrier | needs:contract-review absent on the PR and on card #17320, checked before any flip |
What the round measured, accepted as read
Seven doors, seven separate readings before and after against the built artifact — not one generalised — each still producing exactly one issue at filter, now naming the array form, the author's own key lifted into field, the canonical operator and the migration id. Three door-shaped negative controls confirm the ordinary messages are untouched: a bad element still reports at filter.0.operator in zod's own words, a string still gets expected array, received string, and a valid rule array is accepted at all seven.
⭐ Two things in the ablation are worth keeping. It has two legs, and they differ by exactly one case — unwiring the doors reds 12, neutralising the helper reds the same 12 plus the helper's own unit pin, which leg A cannot reach. And leg B's first attempt did not happen: a quoting error wrote nothing, the on-disk marker count came back 0, and the guard refused the run rather than reporting a green ablation over an unmutated tree. ⛔ That is the vacuous pass caught by its own control, and it was reported rather than silently retried into existence.
⭐ The dispatch order's load-bearing claims were all re-derived and held — the first round today where nothing in my order turned out to be wrong. The one nuance it added rather than corrected: the pins resolve src/, so no rebuild leg was needed, and it said so instead of assuming it.
Landing
Flipping ready, re-reading ④ after the flip, arming, then confirming the queue ref.
Generated by Claude Code
…code, and pins the re-declared key (objectstack-ai#18539) Fixes objectstack-ai#17848 Clause-②: no `REFUSAL_SENTENCE` in `scripts/pm/check-widening-tells.mjs` now names the two doors that actually move its exit code, and `--self-test` pins the re-declared-key shape in both directions. One file changed. ⛔ No exit code moves. ## First act: the card's own specimens, re-measured — half one did not reproduce The card's table was taken 2026-09-12T11:5xZ. Re-run today against all three PR diffs it names (fetched as the PR's own diff and fed to `--declaration no --diff`), this matcher reports **no widening tell on any of them**: | specimen | judged files | tells | exit | |:--|--:|--:|--:| | PR objectstack-ai#17638 — `system/cache.zod.ts:197` | 3 of 7 (4 NOT MEASURED) | 0 | 0 | | PR objectstack-ai#17796 — `ui/view.zod.ts:1615` | 1 of 3 (2 NOT MEASURED) | 0 | 0 | | PR objectstack-ai#17846 — seven `filter:` doors | 3 of 5 (2 NOT MEASURED) | 0 | 0 |⚠️ Every zero above is bracketed by controls, because a zero from a dead invocation reads the same as a zero from a decline: - **Lit control** — a genuinely new key added to `packages/spec/src/ui/view.zod.ts` (the same file as objectstack-ai#17796's specimen) fires `T1 … :101` and exits **4**. The surface covers these files and the run is alive. - **Dark control** — the card's sharpest specimen reduced to its hunk, with the removed line deleted and the three added lines byte-identical, fires `T1 packages/spec/src/ui/component.zod.ts:2504` and exits **4**. The silence is bought by the replacement, never by the shape. ⭐ And it did not fire at the card's own filing commit either. Running `check-widening-tells.mjs` **as it stood at `758ac409`** (`origin/main`, 2026-09-12T11:38Z — seventeen minutes before the card was written) against the same five inputs reproduces the same five readings: three specimens exit 0, both controls exit 4. The repair had landed three days earlier, in objectstack-ai#16943's per-change-block replacement budget: a key re-declared in place removes a T1 line and adds one, and the removal pays. ⇒ the card's "nine tells" was carried over from the threads that accumulated on closed card objectstack-ai#17618; it was not a reading of the gate on the day it was filed. Two of the three PRs have since merged (objectstack-ai#17846 on 2026-09-12, objectstack-ai#17638 on 2026-09-13) and objectstack-ai#17796 was closed unmerged, so the "three PRs parked" cost is also spent. ## Why no matcher change was made for half one ⛔ The shapes the card floated — pairing across a HUNK, or diffing the file's key SET instead of the block's added lines — are the silence `changeBlocks`'s own docblock refuses: a hunk carries three context lines each side and routinely holds an unrelated removal at one end and a real addition at the other, so pairing across it pays for a new key with a removal that has nothing to do with it. Buying that would trade a loud failure for a quiet one to repair a defect that is not there. **A fix that silences T1 generally is worse than the bug**, and this PR does not make one. What half one did leave is a gap in the **instrument**, not in the reader: the `{ error: … }` re-declaration had no case of its own, and it is arithmetically distinct from the `.describe()` pair objectstack-ai#16943 pinned — the block removes ONE line and adds THREE, of which exactly one is a key. A budget counting LINES instead of KINDS comes up short right there. It is pinned now, with both controls above and a third: - ⭐ **surplus control** — a genuinely new key (`filterLogic:`) added in the SAME block as the re-declaration still fires at its own file:line. One removal pays for one key; a real widening riding along with a re-declaration is still caught. ## Half two — the remedy with no reader — is what this PR repairs The sentence offered two doors and only one was real: > re-declare `yes` or explain in the claim why this addition does not widen `c5WideningTell()` compares the declaration against the diff's tells and stops there; nothing in either file reads an explanation. So an author who followed the instruction got the identical exit 4 with no way to learn that the remedy was never implemented — and the only door that DID move the number was `Clause-②: no` → `yes`, which on a false tell is the one thing the standing rule forbids outright: 「⛔ 永不把 `no` 翻成 `yes` 去过门」.⚠️ A gate whose only working door is a lie teaches the lie.⚠️ This file's header had already recorded that twice — at objectstack-ai#16822 and again at objectstack-ai#16943 — without ever changing the string a refused author actually reads. So the file knew and the author could not: a declared-but-unenforced remedy, which this repo removes rather than documents. **Three shapes were weighed:** 1. **Delete the second branch.** ⛔ Refused. It leaves `re-declare yes` as the only door, which on a FALSE tell is exactly the forbidden lie — the message would then instruct it. 2. **Give the explanation a reader.** ⛔ Refused. An author-written sentence that clears the author's own gate is 自查放行, and it needs the new claim-line syntax objectstack-ai#16448 forbids. 3. **Name the door that was always the right one.** ⭐ Chosen. objectstack-ai#16822 already ruled where a demonstrated false positive gets repaired — HERE, in the matcher, with a `--self-test` case pinning the shape — and the sentence now says so, names the file to open, allows filing it as its own card when it is out of the PR's scope, and states outright that an explanation moves no exit code so nobody spends a round rediscovering it. Both doors the sentence now names are doors this file can open. That is the pin. ## Exit-code contract — ⛔ UNCHANGED `EXIT_OK` 0 · `EXIT_USAGE` 1 · `EXIT_INCOMPLETE` 2 · `EXIT_REFUSED` 4, all unmoved, and no verdict state changes. Every tell fires exactly where it fired: the only behavioural surface touched is the TEXT a refusal renders. The seat reading `$?` reads the same table it read yesterday. ## Verification `--self-test`: **309 cases pass**, exit 0 — measured against **298** on `origin/main`'s own copy of this file, so 11 cases were added and none removed. Sibling `check-clause2-carriers.mjs --self-test`: **715 cases pass**, exit 0 — it renders `REFUSAL_SENTENCE` unparaphrased and that pin still holds through the constant. All **31** commands derived by `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` for this file surface were run; **every one exited 0**, each exit code captured by redirect-then-`$?`, never through a pipe. **Non-vacuity — three ablation legs**, each proving the mutation reached disk by a `grep -c` (LINES) count on the anchored text before and after, each restored under a `trap … EXIT INT TERM`, and each restore proven by `git hash-object` equalling the HEAD blob `9ecddb0c…` with `git diff HEAD` empty: | leg | mutation | on-disk proof | self-test | which cases red | |:--|:--|:--|--:|:--| | budget | the replacement budget never pays | 1 → 0 | exit 1, 26 fail | the specimen case, the surplus control, and objectstack-ai#16943's own live pairs | | t1-silent | T1 declines unconditionally | 0 → 1 | exit 1, 43 fail | ⭐ the dark control AND the surplus control — the "silencing T1 generally" direction | | old-sentence | the pre-objectstack-ai#17848 wording restored | 0 → 1 | exit 1, 4 fail | all four sentence pins | ⛔ The first attempt at the `t1-silent` leg counted an anchor the mutation does not move (before=1, after=1). It was reported as a void reading and re-run against the injected text rather than quietly retried until something landed. **`skip-changeset`, measured not asserted.** `npm pack --dry-run --json --ignore-scripts` in `packages/spec` packs **271** entries (the lit control) and **0** matching `scripts/pm` or `check-widening-tells`. The root package is `private: true`, and 0 of the 70 publishable workspace packages contain the changed path. Nothing published moves. ## Acceptance notes - **noted, not filed** — the card's own "nine tells, 2026-09-12T11:5xZ" figure was not a reading of the gate on that date; its table's `git grep -oF` counts (which prove the keys are not new) were fresh, the tell count was not. Successor: this PR's body and the report. - **noted, not filed** — `SELF_TEST_BATTERY_FLOOR` is 16 against a roster of 22 declared batteries, so five could be deleted without reddening. That is what a floor IS (AGENTS.md prescribes a minimum, not an equality), and adding batteries must not red — an observation about slack, not a defect. Successor: none. - ⛔ Nothing here attaches, removes or waits on `needs:contract-review`; `scripts/pm/**` is not in `GOVERNED_SURFACES` (`check-governed-merges.mjs`: `docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`), so this is an ordinary landing path. - ⛔ The three PRs the card names were not touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 --- _Generated by [Claude Code](https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6)_ --- ## Landing note (seat, 2026-09-17) Contract review at `CONTRACT_REVIEW_TIER` on head `f8ca2fdddf`: **PASS** — record is comment `5706908992` on this PR. ⭐ The review did not reason about this gate, it **executed** it: it `git archive`d a runnable subset at the head, at `origin/main` and at `758ac409` (main seventeen minutes before the card was filed), ran `--self-test` at each (309 / 298 / 269, all exit 0), re-fetched the three specimen PR heads and fed their real diffs through all three file versions, and ran three ablations with the blob hash re-verified after each restore. That is what makes its central finding trustworthy. **Central finding, which confirms this PR rather than undermining it:** the card's half one — T1 firing on a re-declared key — **was never real on any reachable version of the file**, with a lit control alive at every one. The card's 「nine tells」 figure reproduces nowhere. Half two (a remedy with no reader) **was** real and is what this PR fixes. ⇒ closing objectstack-ai#17848 on this PR is supportable. **The direction that mattered — does this weaken the gate?** No. Every predicate (`patchLines`, `changeBlocks`, `memberTellKind`, `tellsInFile` incl. the objectstack-ai#16943 budget, `wideningRefusal`, `exitForRefusal`) is byte-for-byte unchanged; the exported symbol list is identical at 46; the exit register stays 0/1/2/4; and the t1-silent ablation reds both the new dark and surplus controls. The only output change is the text of `REFUSAL_SENTENCE`, which no parser outside the file reads (0 hits, control 9).⚠️ **Two self-narration discrepancies in this body, appended rather than rewritten** (this repo squashes, so the body becomes the permanent commit message): the battery roster is **21** at head, not the 22 the body states (20 at main); and the budget-ablation failure count measured **24**, not 26 — the direction is confirmed, the exact figure is not as stated.⚠️ **One pin label overclaims, recorded not fixed:** 「the row it reports is the new key, never the re-declared one」 holds for the fixture's ordering only. With the new key written *before* the re-declaration in the same block the gate still refuses (1 tell) but reports the re-declared line. That is objectstack-ai#16943's pre-existing patch-order budget and it is the loud direction, so nothing is weakened — the word 「never」 is simply too strong. ⛔ Not fixed in-branch: that moves the head and voids an otherwise complete review record, for a word in a test label. **Pre-landing checks:** ① review PASS on record ✅ · ② `check-clause2-carriers --pair 18539` exit 0; ⛔ no carriers were hung on this pair (`Clause-②: no`, no declared surface) so there is nothing to strip ✅ · ③ re-taken at landing time, latest-run-per-check-name ✅. Governed-surface predicate: **0 of 1 path hits the register** ⇒ ordinary queue landing. --- _Generated by [Claude Code](https://claude.ai/code)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17320
Clause-②: no — the change is to what a refusal says. No accept set moves in either direction, no key is added to any published payload, and the generated
json-schema/+authorable-surfaceartifacts are byte-identical after the change (git statusclean across two full@objectstack/specbuilds).check:api-surfaceis green with no regeneration: the helper is module-internal and is not exported from any barrel, exactly as its modelshared/strict-object.tsis not.What was wrong
Seven
filterdoors converged onz.array(ViewFilterRuleSchema)in the objectui#6206 family. On the record form an author used to write, each produced exactly one zod issue and nothing else — measured on the built artifact before the change, at all seven:The prescription was already written down twice, in two places a parse never reaches: every one of the seven
.describe()strings, and in full in the three18.*-filter-rule-arraysemantic migration entries. Nothing bridges.describe()into a zod issue, and this package installs no global error map. Re-verified on this branch's own head with the card's own lit control:setErrorMap/z.configunderpackages/spec/src→ 0 hits;strictObjectError(which does exactly this bridging for the unknown-key case) → 14 hits, so the probe reaches.The population at these doors is the authors — human and AI — whose previously-legal metadata the convergence broke, which is when a refusal most needs to name the new spelling.
The seven doors, located by declaring symbol
Each was re-derived from the tree rather than trusted from the card; the card's list is correct and complete. The marker that separates a converged door from a door that was always an array is the migration pointer in its own
.describe()—The MongoDB-style record form is refused — see migration ...— which occurs exactly 7 times in non-test sources, in exactly these two files:ElementDataSourceSchema.filterpackages/spec/src/ui/page.zod.tsobject-gridObjectGridPropsSchema.filterpackages/spec/src/ui/component.zod.tsobject-metricObjectMetricPropsSchema.filterpackages/spec/src/ui/component.zod.tsobject-kanbanObjectKanbanPropsSchema.filterpackages/spec/src/ui/component.zod.tsobject-calendarObjectCalendarPropsSchema.filterpackages/spec/src/ui/component.zod.tselement:numberElementNumberPropsSchema.filterpackages/spec/src/ui/component.zod.tselement:record_pickerElementRecordPickerPropsSchema.filterpackages/spec/src/ui/component.zod.tsThe six
ComponentPropsMaprows name those schemas, so the card'sComponentPropsMap['x'].filterspelling and the symbol spelling are the same door. Four otherz.array(ViewFilterRuleSchema)keys exist inui/(RecordRelatedListProps.filterand its Add-affordance picker,ViewTabSchema.filter,ListViewShapeSchema.filter,FormFieldPublicPickerSchema.filter,ListPageSchema.filterBy) — none carries the migration pointer, because none of them ever took the record form. They are out of this card's population and are untouched.The shape
shared/strict-object.tsis the model, for the reason the card gives: guidance derived from the schema rather than transcribed beside it, so it cannot drift. A hand-copied sentence at seven sites is what that argues against — and this card's own subject is a prescription that fell out of step with a refusal.New module
packages/spec/src/ui/filter-rule-array.tsexports one helper, wired through the zod-v4{ error }param at all seven doors. Everything it can derive, it derives:[{ field, operator, value }, ...]is read fromViewFilterRuleSchema's own shape (_zod.def.shape), on first refusal — never at module load, which would force thelazySchemawhileview.zodis still initialising underOS_EAGER_SCHEMAS=1, the import-cycle footgunstrictObjectErroralready defers around;normalizeFilterOperator('eq'), the same fold the door itself runs;What stays per-call is what carries judgement rather than transcription — the same split
strictObjectdraws:surfaceand themigrationid. Both are pinned: the test holds everymigrationequal to a real entry inMIGRATIONS_BY_MAJOR, and holds every wired door'ssurfaceequal to the one its ownstrictObjectdeclaration registered (walked out ofstrictObjectDeclarations(), with a lit control that the walk reached all seven).Fall-through is deliberate and pinned: the map answers only a plain record and returns
undefinedfor everything else, asflattenedViewOverlayFields()does. A blanket message would overwrite the element-level issues an array author needs, which is the diagnosis this change exists to protect.Before / after, per door, on the BUILT artifact
Seven separate readings, taken by parsing against
packages/spec/dist/ui/index.mjs—pnpm --filter @objectstack/spec buildrun to completion (both tsup passes;check-dts-emitted: 34/34) before each side.Before — every door, exactly one issue:
[invalid_type] path=["filter"] expected=array, "Invalid input: expected array, received object".After — every door, still exactly one issue, still
invalid_typeatfilter, now saying (this isobject-grid; the other six differ only in the surface and, forelement:number/element:record_picker, the migration id):The message names the new spelling: the array form, the author's own field lifted into
field, the canonicalequals, and the entry id.Per-door surfaces after the change:
this element data source·this `object-grid`·this `object-metric`·this `object-kanban`·this `object-calendar`·this `element:number`·this `element:record_picker`. Per-door migration ids: the fiveelement-data-source-and-object-block-filter-rule-arraydoors, pluselement-number-filter-rule-arrayandelement-record-picker-filter-rule-array.Door-shaped negative controls, all seven, after the change — unchanged from before:
filter.0.operator, "Invalid option: expected one of "equals"|"not_equals"|…" — zod's own words, no guidance text, and the array door itself says nothing atfilter;Ablation
The pins resolve
src/, notdist/:filter-rule-array-guidance.test.tsimports./component.zod,./page.zod,./view.zodrelatively, andpackages/spec's vitest config declares no alias that would route them elsewhere. So no rebuild leg is needed, and both legs below changed the verdict from a source-only mutation, which is itself the proof.Leg A — restore the two door files to their pre-change bytes (
git checkout BASE -- THE_TWO_DOOR_PATHS), i.e. the helper exists but nothing is wired:ruleArrayFilterErroroccurrencespage.zod.ts2 → 0,component.zod.ts7 → 0;Leg B — neutralise the helper itself (one injected early
return undefined):Both legs restored and proven restored by
git hash-objectagainst the HEAD blob, not assumed from an exit code: leg A3c4942e3cbfd8071eb372b9dec91dced7682c1d1/65eb6d491b12e9879238bafa03c7c127e0e9ae47, leg B9b2f92cf38f5d3fc6534f87e6b637825319d100a, each equal togit rev-parse HEAD:PATH, withgit diff HEADempty for those paths afterwards. Both scripts carriedtrap RESTORE EXIT INT TERMwith absolute paths resolved fromgit rev-parse --show-toplevel, and both treated an empty or mismatched hash as a loud failure. Leg B's first attempt is worth recording: aperl -0piquoting error wrote nothing, the marker count came back 0, and the guard refused the run rather than reporting a green ablation over an unmutated tree.Changeset
Measured, not assumed. Both tsup passes confirmed finished before the reading (
check-dts-emitted: @objectstack/spec - 34/34), thennpm pack --dry-run --json— 2012 packed files:dist/*/index.js|.mjs, browser builds included);src/ui/page.zod.tsandsrc/ui/component.zod.tsare additionally shipped as source byfiles[]'ssrc/**/*.zod.ts. The new helper and the new test are not packed. ⇒ published text moves ⇒.changeset/17320-filter-rule-array-guidance.md,patch.Verification
pnpm --filter @objectstack/spec test— 472 files / 13426 tests passed.pnpm --filter @objectstack/spec typecheck— green (tsc --noEmit,check:scripts-typecheck,check:test-typecheck: the test layer compiles, 54 files / 259 pinned errors held).pnpm lint(eslint . --no-inline-config, repo-wide, no narrowing) — green.node scripts/pm/dispatch-gates.mjs --commands→ 82; all 82 run with the exit code captured before any pipe;--ranreconciliation: 82 derived, 82 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero — every family recorded a code and none is 3). Five needed a second, correct invocation and are reported at their real reading, not their first:check:doc-formula-expressions,check:lean-entry-closure,check:dual-build-cjs-loadsandcheck:type-check-debteach exited 3 = PREREQUISITE NOT MET = NOT MEASURED and were re-run after building the closure they named (the last two after a fullturbo run buildover every workspace package);check:react-declaration-parityexited 1 only becauseMANIFESTwas unset, and is green run as CI runs it, with the baseline ratchet clean.[\x00-\x08\x0b\x0c\x0e-\x1f\x7f], with the lit control (same engine, same file list, class widened by one printable byte) hitting 149 / 209 / 840 / 3133 / 48.pnpm check:nul-bytesgreen.#+ digits 0, model identifiers 0 (the only matches for a deliberately over-broad model pattern are the two requiredClaude-Session:trailers).Gate sweep derived and run at
cea666718f;origin/mainwas merged once more afterwards (cdfd8d142a, disjoint files) and the pin file re-run green on that head. Mergedorigin/mainbefore opening, as asked — PR #17835 is parked onui/page.zod.tsand its hunks are untouched.验收备注
Out of scope, noted, not filed — no PR or person is queued to touch these files for these reasons:
see migration ...ids that seven.describe()strings already carry; a renamed or deleted entry would strand all seven silently. This change's ownmigrationids are pinned against the registry, so the coupling is checked on the new channel but not on the old one. Carrier: none today.invalid_typeatfilter.0, raised byViewFilterRuleSchemaitself rather than by the array door. Out of this card's population (the array door is the subject), and deliberately left alone so the element-level diagnosis stays zod's.objectStackErrorMap(shared/error-map.zod.ts) does exist and does handleinvalid_type— it is opt-in per parse (safeParsePretty), never installed globally. The card's "no global error map" reading is exact as written; this is a note that the package is not entirely without one, in case a future round looks for a home for cross-cutting guidance.Generated by Claude Code