Commit 4b58dcf
fix(spec): answer an axis-silent key with both ends of the range, not the cheaper-spelled one (#19318)
Fixes #18572
Clause-②: no
⛔ **The card body's mechanism is retracted by its own filer** (comment
5707586601) and the grading that recited it is superseded by comment
5747478673. There is no alias table; options (i) and (ii) on the body
are void. This PR implements the re-graded, suggester-side scope, and
every number below was re-derived here rather than carried from the
card.
## What is actually wrong
`findClosestMatches` ranks by edit distance and nothing else. On a shape
that declares **both ends of a range**, a key that names neither end is
therefore answered with whichever end happens to be spelled more
cheaply:
```text
authored `dateField` (9 chars, budget max(2, 9/3) = 3)
-> `endDateField` distance 3 INSIDE the budget [ANSWERED]
-> `startDateField` distance 5 outside the budget [UNREACHABLE]
```
`end` is a three-letter token and `start` a five-letter one. That
spelling accident is the entire reason the protocol told an author to
bind the **end** of the event. Nobody declared the mapping.
And the suggested key is a **declared key the runtime honours**, so an
author who copied the remedy got a document that **parses**, with the
calendar axis silently on the wrong date. Every objectui read site folds
`dateField` onto `startDateField`. The trap punishes the reader who did
what the protocol said.
## The change
One screen, on the **guess** only. When the candidate the distance
fallback would name carries an axis token the authored key does not, and
the shape also declares that candidate's opposite-pole sibling, the
rename is replaced by a prescription naming **both** ends:
```text
Unrecognized key(s) on this calendar configuration: `dateField`.
• `dateField` does not say which end of the range it binds, and this surface declares
both `startDateField` and `endDateField` — opposite ends of one axis. Write the one
you mean: both parse, so guessing binds the wrong end silently.
Until these shapes were closed an unknown key was dropped silently — …
```
- ⛔ **No alias was declared and the accepted key set does not move.**
`dateField` was refused before and is refused after; only the sentence
the refusal carries changed. This is the fork the card's stop condition
names, and it is not taken — no `dateField → startDateField` entry
exists anywhere in this diff.
- **A declared `aliases` entry is never screened.** A human statement
about one spelling outranks the guard; only a coin flip is replaced.
`this field` keeps answering `length` with `maxLength` exactly as it
declares it.
- **Naming both ends is this repo's own answer, generalised.**
`field.zod.ts` already writes it by hand for `visible`: 「the two answers
have opposite polarity … Naming both is the only answer that cannot be
acted on wrongly」. What a hand-written entry cannot do is cover the keys
nobody enumerated — which is the set a fuzzy suggester answers.
### Omission vs typo — the condition that keeps it narrow
The guard fires only when the authored key is **at least as close to the
candidate minus its axis token as to the candidate itself**. Without
that condition, `axLength` — one dropped character in `maxLength`, with
`minLength` declared beside it — would lose a perfectly good suggestion.
Measured:
| authored | to the candidate | to the candidate minus its axis token |
reads as | verdict |
|:--|--:|--:|:--|:--|
| `axLength` vs `maxLength` | 1 | 2 (`length`) | a typo | rename kept |
| `dateField` vs `endDateField` | 3 | 0 (`datefield`) | the axis-silent
key | both ends named |
## Census — four instances, not one
Measured over **389** registered `strictObject` surfaces (the audit's
own dedup key — surface + alias table + sorted shape keys; the same walk
also reads **421** raw registrations and **388** distinct surface
strings), by deriving each declared key's axis-silent spelling and
asking the real error map what it answers. Four fuzzy instances exist
and all four are fixed here:
| surface | authored | answered before | now |
|:--|:--|:--|:--|
| this calendar configuration | `dateField` | `endDateField` | both ends
named |
| this timeline configuration | `dateField` | `endDateField` | both ends
named |
| this gantt configuration | `dateField` | `endDateField` | both ends
named |
| this gantt configuration | `baselineField` | `baselineEndField` | both
ends named |
Timeline and Gantt are covered, as the dispatch asked. The gantt
`baselineStartField` / `baselineEndField` row was found by the census,
not by the card.
A fifth row the census surfaced is **not** a defect and is deliberately
untouched: `this field` answers `length` with `maxLength` against a
declared `minLength`, and that is a **declared** alias sitting beside
`size: 'maxLength'`. It is a decision, so the guard leaves it exactly as
written — which is also the precedence pin in the tests.
## The axis table is judged, not just declared
`POLARITY_AXES` has four rows, each attested by a real sibling pair in
this package. `alias-integrity.test.ts` now fails on a row no surface
declares both ends of — the same dead-entry judgement it already applies
to `aliases` and `guidance` — with a lit control so an empty verdict is
a reading rather than a walk that matched nothing. It is deliberately
**not** a general antonym dictionary.
## Evidence
**Bright control (the premise, re-measured every run).** The arithmetic
is never written down as `3` and `5`: the test recomputes the distances
and asserts the relation — the wrong end inside the budget, the right
end outside it — so a rename, a fold change or a budget change reds and
names the measurement. A second leg pins that the *unguarded* ranking
still produces `endDateField` on all three surfaces, so the main leg
cannot pass for a reason unrelated to the guard.
**Dark controls.** `endField` (out of budget at 8 chars, exactly as the
filer's control table observed without knowing why) and a nonsense key
are asserted **byte-identical** to each other with only the key name
differing — pinning the refusal text, not merely the absence of a hint.
**Ablation.** Guard removed via `scripts/ablation-replace.mjs` (anchor
hit 1 to 0, blob `2fdc252271c2` to `f1228fa36275`), tests re-run,
restored with `blob == HEAD` and `git diff HEAD` empty:
```text
Tests 5 failed | 15 passed (20)
FAIL main > this calendar configuration: `dateField` names both ends
FAIL main > this gantt configuration: `dateField` names both ends
FAIL main > this timeline configuration: `dateField` names both ends
FAIL main > gantt: the second attested pair on the same surface is covered too
FAIL the guard fires on omission and NOT on a typo > suppresses the rename …
```
The bright, dark, arithmetic and pure-predicate legs stay green under
the ablation — they measure different things, and the dark controls
really are dark. The new audit was ablated too: adding an unattested
axis row reds it by name (`expected [ 'zzleftward/zzrightward' ] to
deeply equal []`).
**The declaration-shard diff is controlled, not assumed.** Adding a
module to the import graph reshuffles TypeScript's declaration-emit
order for enum members — 19 declarations across four shards, and the
**whole** diff is three lines (`read`, `edit`, `update`) changing
position. With `polarity-axes.ts` out of the graph and
`suggestions.zod.ts` restored to the merge base, a fresh build reports
`declaration text unchanged (17 entry points, 5364 declarations)`. The
reshuffle is this branch's, so the artifact is regenerated here.
## Verification
| leg | result |
|:--|:--|
| `pnpm --filter @objectstack/spec test` | 501 files / 14664 tests
passed |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (src, scripts and
the test layer) |
| `pnpm --filter @objectstack/spec build` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | all 16 generated
artifacts up to date |
| derived gate families (`scripts/pm/dispatch-gates.mjs`) | 83 derived,
**81 run green, 0 unrun** |
| `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | exit 0,
clean, at `9c2725b69d` |
Two of the 83 are **NOT MEASURED**, both refusing their own prerequisite
with exit 3 rather than reporting a verdict —
`check:dual-build-cjs-loads` (83 packages with no `dist/`) and
`check:type-check-debt` (27 workspace dependencies unbuilt). Both need a
whole-repo build, which is `Build Core`'s output in CI. ⛔ Neither is a
pass and neither is a finding. Reconciliation: `83 derived famil(ies)
accounted for — 81 run, 2 NOT-MEASURED`.
## Acceptance notes
- **`packages/lint` has four direct `findClosestMatches` call sites that
this guard does not reach**, because it lives in `strictUnknownKeyError`
and those sites call the ranker directly, over user **data field
names**, with the flat default budget of 3 rather than the
length-relative one. Measured against `{start_date, end_date,
min_amount, max_amount, name}`: `the_date` still resolves to `end_date`,
while `amount` and `date` do not reach the trap (a snake_case name
charges the separator too). Same mechanism, second read site, narrow but
non-zero reachability. Not fixed here: it adds `packages/lint` to the
affected set and a verification surface this card does not own. Filed
for triage rather than silently carried.
- `data/object.zod.ts`'s `suggestKey` is a second suggester with the
same length-relative budget. Measured: `ObjectSchema` declares 43
top-level keys and **zero** polarity sibling pairs, so the trap is
unreachable there today and nothing guards it if a range pair is ever
added. noted, not filed — 承接者: the next card that adds a range pair to
`ObjectSchema`.
- `this field`'s `length: 'maxLength'` beside a declared `minLength`
reads as an axis collision but is a declared alias, deliberately placed
next to `size: 'maxLength'`. noted, not filed — 承接者:无, it is a
declaration rather than a defect, and the guard's precedence is pinned
so it stays one.
- ⛔ `packages/spec/src/ui/view.zod.ts` was **not** touched — a
concurrent card (#19088) holds it. The calendar, timeline and gantt
shapes are read by the tests, never edited.
---
> ⭐ **Seat correction, 2026-09-20T13:10Z — the surface count in this
body was wrong and is fixed above.** It read **136**; re-derived with
`alias-integrity.test.ts`'s own instrument at head `72bf22a2e1`, the
reading is **389**. The **136** came from a one-off census script
written for this card whose forcing walk was weaker in four ways: it
imported only `*.zod.ts` plus `index.ts` (**225 of 1012** modules),
returned early on function-valued schemas, capped the walk at depth 12
instead of 40, and never invoked a deferred error map — so every surface
that registers on first use never registered. Re-running that old script
at this head still prints `136`, so the figure is reproducible from the
wrong instrument and from nothing else.
>
> ⛔ The **conclusion is unchanged**: four fuzzy instances, all guarded;
one declared-alias row (`length` → `maxLength`) left alone. The larger
population surfaced one row the undercount had hidden — on `this
object-timeline`, an authored `date` draws `data`, a declared key one
edit away and **not a pole**, so the guard is correctly silent — and it
is not this card's class. The attestation table gained the pairs the
undercount hid (`min`/`max` is 11 pairs, not 3); all four axes stay
attested.
>
> The docblock and the changeset were corrected by the implementer on
`72bf22a2e1`. This body is the seat's to edit — the dev's contract
writes a PR body once and ⛔ never `PATCH`es it, and it flagged that
conflict rather than quietly choosing a side, which was the right call.
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4ed1ab9 commit 4b58dcf
5 files changed
Lines changed: 608 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
123 | 123 | | |
124 | 124 | | |
125 | 125 | | |
| 126 | + | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
| |||
1164 | 1165 | | |
1165 | 1166 | | |
1166 | 1167 | | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
| 1173 | + | |
| 1174 | + | |
| 1175 | + | |
| 1176 | + | |
| 1177 | + | |
| 1178 | + | |
| 1179 | + | |
| 1180 | + | |
| 1181 | + | |
| 1182 | + | |
| 1183 | + | |
| 1184 | + | |
| 1185 | + | |
| 1186 | + | |
| 1187 | + | |
| 1188 | + | |
| 1189 | + | |
| 1190 | + | |
| 1191 | + | |
| 1192 | + | |
| 1193 | + | |
| 1194 | + | |
| 1195 | + | |
| 1196 | + | |
| 1197 | + | |
| 1198 | + | |
| 1199 | + | |
| 1200 | + | |
| 1201 | + | |
| 1202 | + | |
| 1203 | + | |
| 1204 | + | |
| 1205 | + | |
| 1206 | + | |
| 1207 | + | |
| 1208 | + | |
1167 | 1209 | | |
1168 | 1210 | | |
1169 | 1211 | | |
| |||
0 commit comments