fix(spec): answer an axis-silent key with both ends of the range, not the cheaper-spelled one - #19318
Conversation
… a range `findClosestMatches` ranks by edit distance alone, so on a shape declaring both ends of a range an axis-silent key is answered with whichever end is spelled more cheaply. `dateField` lands on `endDateField` (distance 3, inside a 9-char key's budget of 3) while `startDateField` sits at 5, structurally unreachable — and the suggested key PARSES, so an author who follows the protocol's own correction binds the wrong end of the event and is told nothing. Screen the fallback's answer: when the candidate carries an axis token the authored key does not and the shape also declares its opposite-pole sibling, replace the rename with a prescription naming BOTH ends. Declared `aliases` entries are never screened. The accepted key set does not move. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…s about An axis row no shape declares both ends of can never match, and a row nothing can match reads as coverage of a trap this protocol does not have — the same dead-entry shape `alias-integrity.test.ts` already refuses for `aliases` and `guidance`. Judge it in the same walk, with a lit control so an empty verdict is a reading rather than a search that matched nothing. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…ggester-opposite-sibling
Clause-②: no — the accepted key set does not move; only the refusal's suggestion text does. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Adding a module to the import graph reshuffles TypeScript's declaration-emit order for enum members: 19 declarations across four shards move `read`, `edit` or `update` to a different position. Those three lines are the WHOLE diff — nothing is added, removed or retyped, and a structural object type does not depend on member order. Controlled, not assumed: with `polarity-axes.ts` removed from 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 rather than left for the next card to find. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…ggester-opposite-sibling
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 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 8b3dbbfd047485c47090f6eb16034a6de344da66 && git checkout 8b3dbbfd047485c47090f6eb16034a6de344da66
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c334ba0f3a6dc2a107ffca06bc28fb27775cba28 4e2406481e0153723dae7fc91293a22d85de34c9 && git checkout -B drift-repro c334ba0f3a6dc2a107ffca06bc28fb27775cba28 && git merge --no-ff 4e2406481e0153723dae7fc91293a22d85de34c9
node scripts/docs-audit/affected-docs.mjs --json c334ba0f3a6dc2a107ffca06bc28fb27775cba28
|
Contract reviewServed-tier: 75/75 Served-tier reading: the reviewer's own subagent transcript file, per-request harness Sources read: card #18572 body (retracted mechanism), retraction 5707586601, re-grading 5747478673 (the live scope and the stop condition), os-dev-report 5749401816; PR body, 9 file rows, 6 commits, 35 check runs at head; the tree at the head and at the merge base ① Derived judgments
Tests at head: ② Semver levelpatch — matches the changeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code Generated by Claude Code |
…easured 389 The figure 136 does not reproduce. It came from a one-off census script whose forcing walk was weaker than the audit's in four ways: it imported only `*.zod.ts` plus `index.ts` (225 of 1012 modules under `packages/spec/src`), returned early on function-valued schemas, capped its walk at depth 12 instead of 40, and never invoked a deferred error map, so surfaces that register on first use never registered at all. Re-measured with `alias-integrity.test.ts`'s own instrument copied verbatim: 389 unique surfaces, 421 raw registrations, 388 distinct surface strings. Those are three different facts, so each citation now says which one it quotes. The conclusion is unchanged and was re-derived over the larger population: the same four fuzzy instances, the same single declared-alias row left alone. The attestation table gains the pairs the undercount had hidden (min/max 11 rather than 3, input/output and read/write 2 each). Comment-only in the source: `check:api-surface-declarations` reports "declaration text unchanged (17 entry points, 5364 declarations)" and `check:generated` all 16 artifacts up to date. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
⛔ 达档契约复核暂时起不来 —— 本 PR 在队列外等档,⛔ 不降档、⛔ 不绕
事实本 PR 的 head 在 2026-09-20T13:05Z 前后从 本席据此起了一次增量达档复核。它没能开始,平台原因逐字:
处置,按章程原文
⇒ 本席:⛔ 不在别的档位跑这次复核;⛔ 不援引「已在旧 head 上 PASS」把新 head 当作已复核;⛔ 不动任何标签。本 PR 停在这里等档。
⭐ 这次耗尽的成因,如实记下本席今日按维护者当日指令「后续优先派需要 fable 的任务」执行,本轮先后起了五次达档复核(#19024、#19296、#19313、#19318、#19302),这正是额度耗尽的直接原因。⛔ 这不是对该指令的异议 —— 指令被执行了,代价随之出现,而代价该被看见而不是被绕过。 Generated by Claude Code |
…ggester-opposite-sibling Resolves 4 delete/modify conflicts in packages/spec/api-surface-declarations/ (api.txt, kernel.txt, root.txt, security.txt) by taking main's deletion: #19024 retired the whole declaration-text snapshot mechanism (script, package.json scripts, check:generated gate, files) and this branch had only modified those now-retired files. Verified check:api-surface and check:generated still pass, and that the branch's new polarity-axes.ts exports are not part of the public barrel (not re-exported from shared/index.ts), so no export-recording artifact needs a change. Co-Authored-By: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Isolated reviewer, round 3 (the head moved twice since the PASS at ① Derived judgments
Text only. Not a widening; not a narrowing. RIGHT.
② Semver levelpatch, and the changeset agrees ( ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Landing provenance — contract review PASS, landing preconditions measured, 2026-09-20T14:52ZThe at-tier contract review of this PR is on the record as comment Independence pair, machine-readable on the record:
Distinct kinds, so no SELF-REVIEW is reported. Carriers. Neither this PR nor card #18572 carries The three landing preconditions, each measured first-hand at 2026-09-20T14:52Z
Why the file list shrankThe base merge resolved four modify/delete conflicts on The reviewer verified the resolution lost nothing owed, by construction rather than by assertion: the merged tree differs from main parent Reported and not blocking
Flipping to ready and arming the queue on the strength of the readings above. ⛔ No seat approval was sought or given; this PR is not a governed surface, so none is owed. Generated by Claude Code |
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
findClosestMatchesranks 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:endis a three-letter token andstarta 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
dateFieldontostartDateField. 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:
dateFieldwas 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 — nodateField → startDateFieldentry exists anywhere in this diff.aliasesentry is never screened. A human statement about one spelling outranks the guard; only a coin flip is replaced.this fieldkeeps answeringlengthwithmaxLengthexactly as it declares it.field.zod.tsalready writes it by hand forvisible: 「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 inmaxLength, withminLengthdeclared beside it — would lose a perfectly good suggestion. Measured:axLengthvsmaxLengthlength)dateFieldvsendDateFielddatefield)Census — four instances, not one
Measured over 389 registered
strictObjectsurfaces (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:dateFieldendDateFielddateFieldendDateFielddateFieldendDateFieldbaselineFieldbaselineEndFieldTimeline and Gantt are covered, as the dispatch asked. The gantt
baselineStartField/baselineEndFieldrow was found by the census, not by the card.A fifth row the census surfaced is not a defect and is deliberately untouched:
this fieldanswerslengthwithmaxLengthagainst a declaredminLength, and that is a declared alias sitting besidesize: '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_AXEShas four rows, each attested by a real sibling pair in this package.alias-integrity.test.tsnow fails on a row no surface declares both ends of — the same dead-entry judgement it already applies toaliasesandguidance— 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
3and5: 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 producesendDateFieldon 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, blob2fdc252271c2tof1228fa36275), tests re-run, restored withblob == HEADandgit diff HEADempty: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. Withpolarity-axes.tsout of the graph andsuggestions.zod.tsrestored to the merge base, a fresh build reportsdeclaration text unchanged (17 entry points, 5364 declarations). The reshuffle is this branch's, so the artifact is regenerated here.Verification
pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec check:generatedscripts/pm/dispatch-gates.mjs)pnpm lint(repo-wide,eslint . --no-inline-config)9c2725b69dTwo 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 nodist/) andcheck:type-check-debt(27 workspace dependencies unbuilt). Both need a whole-repo build, which isBuild 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/linthas four directfindClosestMatchescall sites that this guard does not reach, because it lives instrictUnknownKeyErrorand 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_datestill resolves toend_date, whileamountanddatedo 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 addspackages/lintto the affected set and a verification surface this card does not own. Filed for triage rather than silently carried.data/object.zod.ts'ssuggestKeyis a second suggester with the same length-relative budget. Measured:ObjectSchemadeclares 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 toObjectSchema.this field'slength: 'maxLength'beside a declaredminLengthreads as an axis collision but is a declared alias, deliberately placed next tosize: '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.tswas not touched — a concurrent card ([finding]FormFieldBaseSchema.scale(view.zod.ts:2768)是第三处无上界的scale—— 撞同一个渲染面天花板,而 #18972 只覆盖了field.zod.ts的两处 #19088) holds it. The calendar, timeline and gantt shapes are read by the tests, never edited.Generated by Claude Code