From 7c0bfcfb9aa965aa02bd419508128b4365ccfad3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:54:15 +0000 Subject: [PATCH 1/5] fix(spec): stop the unknown-key suggester naming one arbitrary end of a range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../shared/opposite-pole-suggestion.test.ts | 265 ++++++++++++++++++ packages/spec/src/shared/polarity-axes.ts | 231 +++++++++++++++ packages/spec/src/shared/suggestions.zod.ts | 32 ++- 3 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/shared/opposite-pole-suggestion.test.ts create mode 100644 packages/spec/src/shared/polarity-axes.ts diff --git a/packages/spec/src/shared/opposite-pole-suggestion.test.ts b/packages/spec/src/shared/opposite-pole-suggestion.test.ts new file mode 100644 index 00000000000..11d7bf0b7c5 --- /dev/null +++ b/packages/spec/src/shared/opposite-pole-suggestion.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The axis-silent key trap — `dateField` answered with `endDateField` while + * every consumer folds `dateField` onto the event's START. + * + * What makes this defect worth its own file rather than a row in + * `suggestions.test.ts` is that none of its parts is wrong on its own. + * `findClosestMatches` ranks correctly, the budget is the right budget, and + * `endDateField` is a real declared key the runtime honours perfectly. The + * defect is the COMPOSITION: on a shape declaring both ends of a range, the + * cheaper-spelled end wins a question the author never asked, and the answer + * parses. So the legs below are written to fail for different reasons — the + * arithmetic leg re-measures the premise, the bright leg pins that the raw + * suggester still WOULD name the wrong end, the main leg pins what ships, and + * the dark legs pin everything that must not have moved. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { levenshteinDistance, findClosestMatches } from './suggestions.zod'; +import { strictObject } from './strict-object'; +import { POLARITY_AXES, oppositePoleAmbiguity } from './polarity-axes'; +import { CalendarConfigSchema, GanttConfigSchema, TimelineConfigSchema } from '../ui/view.zod'; + +/** The budget `strictUnknownKeyError` actually spends, reproduced, not imported. */ +const budget = (key: string) => Math.max(2, Math.floor(key.length / 3)); +/** The fold `findClosestMatches` scores under. */ +const fold = (v: string) => v.toLowerCase().replace(/[-\s]/g, '_'); + +const RANGE_SURFACES = { + 'this calendar configuration': CalendarConfigSchema, + 'this gantt configuration': GanttConfigSchema, + 'this timeline configuration': TimelineConfigSchema, +} as const; + +/** The one `unrecognized_keys` message a probe object produces, or `undefined`. */ +function rejectionMessage(schema: z.ZodType, doc: unknown): string | undefined { + const result = schema.safeParse(doc); + if (result.success) return undefined; + return result.error.issues.find((i) => i.code === 'unrecognized_keys')?.message; +} + +/** A probe that is valid except for the one key under test. */ +const CANONICAL = { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' } as const; +const probe = (key: string) => ({ ...CANONICAL, [key]: 'due_date' }); + +// --------------------------------------------------------------------------- +// LEG 1 — the arithmetic, RE-MEASURED. Never 3 and 5 as literals. +// --------------------------------------------------------------------------- + +describe('the distance arithmetic that creates the trap', () => { + // Filed as "`datefield` is 3 from `enddatefield` and 5 from `startdatefield`, + // against a 9-character key's budget of 3". Those two numbers are the premise + // the whole card rests on, and a premise copied forward is how this card came + // to carry a mechanism its own filer had retracted. So the relation is + // recomputed here on every run and the numbers are never written down: if a + // rename, a fold change or a budget change moves it, this fails and names the + // measurement rather than leaving a stale comment behind. + it('re-derives that the WRONG end is reachable and the right one is not', () => { + const authored = 'dateField'; + const toEnd = levenshteinDistance(fold(authored), fold('endDateField')); + const toStart = levenshteinDistance(fold(authored), fold('startDateField')); + const allowed = budget(authored); + + expect(toEnd).toBeLessThanOrEqual(allowed); + expect(toStart).toBeGreaterThan(allowed); + // The asymmetry is pure spelling: `end` is a 3-letter token, `start` a + // 5-letter one. Nothing semantic separates them, which is exactly why the + // ranking cannot be trusted to choose between them. + expect(toStart - toEnd).toBe('start'.length - 'end'.length); + }); + + it('re-derives that `endField` is out of budget — the dark control is structural', () => { + // The filer's own control table observed "no hint for `endField`" without + // knowing why. This is why: an 8-character key buys a budget of 2 and the + // nearest declared key is further than that. + const authored = 'endField'; + const allowed = budget(authored); + for (const candidate of ['endDateField', 'startDateField']) { + expect(levenshteinDistance(fold(authored), fold(candidate))).toBeGreaterThan(allowed); + } + }); +}); + +// --------------------------------------------------------------------------- +// LEG 2 — BRIGHT CONTROL. The raw suggester still points at the wrong end. +// --------------------------------------------------------------------------- + +describe('bright control: the trap candidate is still what the ranking produces', () => { + // This is the leg that keeps the main leg honest. If `endDateField` ever + // stopped being the nearest-within-budget candidate, the main leg below would + // pass for a reason that has nothing to do with the guard, and deleting the + // guard would leave every test green. Pin the input to the guard, not just + // its output. + it.each(Object.keys(RANGE_SURFACES))('%s: unguarded ranking answers `endDateField`', (surface) => { + const schema = RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES]; + const declared = Object.keys((schema as unknown as { _zod: { def: { shape: object } } })._zod.def.shape); + expect(declared).toContain('startDateField'); + expect(declared).toContain('endDateField'); + expect(findClosestMatches('dateField', declared, budget('dateField'), 1)[0]).toBe('endDateField'); + }); +}); + +// --------------------------------------------------------------------------- +// LEG 3 — MAIN. What actually ships to the author. +// --------------------------------------------------------------------------- + +describe('main: an axis-silent key is answered with BOTH ends, never one', () => { + it.each(Object.keys(RANGE_SURFACES))('%s: `dateField` names both ends', (surface) => { + const message = rejectionMessage(RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES], probe('dateField')); + expect(message).toBeDefined(); + // The defect, stated as the assertion that would have failed before: + // a single rename pointing at the end of the event. + expect(message).not.toContain('→ `endDateField`'); + expect(message).not.toMatch(/Did you mean/); + // What replaces it names both ends, in axis order. + expect(message).toContain('`startDateField`'); + expect(message).toContain('`endDateField`'); + expect(message!.indexOf('`startDateField`')).toBeLessThan(message!.indexOf('`endDateField`')); + // And says out loud that the wrong choice is a SILENT one — the property + // that made this worth a card rather than a note. + expect(message).toContain('both parse'); + }); + + it('gantt: the second attested pair on the same surface is covered too', () => { + // `baselineStartField` / `baselineEndField`, found by census rather than by + // reading the card: `baselineField` is 3 from `baselineendfield` inside a + // 13-character key's budget of 4, and 5 from `baselinestartfield`. + const declared = Object.keys( + (GanttConfigSchema as unknown as { _zod: { def: { shape: object } } })._zod.def.shape, + ); + expect(findClosestMatches('baselineField', declared, budget('baselineField'), 1)[0]) + .toBe('baselineEndField'); + const message = rejectionMessage(GanttConfigSchema, { ...CANONICAL, baselineField: 'x' }); + expect(message).not.toContain('→ `baselineEndField`'); + expect(message).toContain('`baselineStartField`'); + expect(message).toContain('`baselineEndField`'); + }); + + it('⛔ the accepted key set does NOT move — `dateField` is still refused', () => { + // The stop condition on this card is that declaring `dateField` as an alias + // would widen the accepted surface. Nothing here accepts it: before the + // change it was rejected with a misleading hint, after the change it is + // rejected with an honest one. + for (const [surface, schema] of Object.entries(RANGE_SURFACES)) { + const withAxisSilentKey = schema.safeParse(probe('dateField')); + expect(withAxisSilentKey.success, surface).toBe(false); + expect( + withAxisSilentKey.success ? [] : withAxisSilentKey.error.issues.map((i) => i.code), + ).toContain('unrecognized_keys'); + // The canonical document still parses, so nothing was narrowed either. + expect(schema.safeParse(CANONICAL).success, surface).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// LEG 4 — DARK CONTROLS. Refusal code AND text unchanged. +// --------------------------------------------------------------------------- + +describe('dark controls: keys that had no hint before still have none', () => { + it.each(Object.keys(RANGE_SURFACES))('%s: `endField` and a nonsense key are untouched', (surface) => { + const schema = RANGE_SURFACES[surface as keyof typeof RANGE_SURFACES]; + const nonsense = rejectionMessage(schema, probe('zzqqwx')); + const endField = rejectionMessage(schema, probe('endField')); + expect(nonsense).toBeDefined(); + expect(endField).toBeDefined(); + for (const message of [nonsense!, endField!]) { + expect(message).not.toMatch(/Did you mean/); + expect(message).not.toContain('•'); + } + // Stronger than "no hint": byte-identical to the no-suggestion shape, with + // only the key name differing. This pins the refusal TEXT, not just the + // absence of a suggestion, without transcribing the surface's `history` + // sentence into this file where it would rot. + expect(endField).toBe(nonsense!.replace('`zzqqwx`', '`endField`')); + }); +}); + +// --------------------------------------------------------------------------- +// LEG 5 — the guard's boundaries, on synthetic shapes. +// --------------------------------------------------------------------------- + +describe('the guard fires on omission and NOT on a typo', () => { + const RangeSurface = strictObject( + { surface: 'this probe surface', history: 'History.' }, + { + minLength: z.number().optional(), + maxLength: z.number().optional(), + startDateField: z.string().optional(), + endDateField: z.string().optional(), + titleField: z.string().optional(), + }, + ); + + it('keeps the rename for a dropped character in a pole-carrying key', () => { + // `axLength` is one deleted character from `maxLength` and names no pole, + // so conditions 1-3 of the guard all hold. Condition 4 — "closer to the + // stripped key than to the key itself" — is what tells a typo apart from + // an omission, and it is the reason this suggestion survives. + const message = rejectionMessage(RangeSurface, { axLength: 1 }); + expect(message).toContain('Did you mean `axLength` → `maxLength`?'); + }); + + it('suppresses the rename when the author simply omitted the axis', () => { + const message = rejectionMessage(RangeSurface, { dateField: 'x' }); + expect(message).not.toMatch(/Did you mean/); + expect(message).toContain('`startDateField`'); + expect(message).toContain('`endDateField`'); + }); + + it('leaves a key that NAMES its end alone', () => { + // `startDatField` carries `start`, so the author already answered the + // question the guard exists to stop the suggester from answering for them. + const message = rejectionMessage(RangeSurface, { startDatField: 'x' }); + expect(message).toContain('→ `startDateField`'); + }); + + it('never screens a DECLARED alias, even onto one end of an axis', () => { + // Precedence, pinned from the direction that matters: a human statement + // about one spelling outranks this guard. ⛔ No in-repo surface declares + // `dateField` — the stop condition on #18572 forbids exactly that — so the + // precedence is proven on a synthetic table instead of by adding one. + const Declared = strictObject( + { surface: 'this declaring surface', history: 'History.', aliases: { dateField: 'startDateField' } }, + { startDateField: z.string().optional(), endDateField: z.string().optional() }, + ); + expect(rejectionMessage(Declared, { dateField: 'x' })) + .toContain('Did you mean `dateField` → `startDateField`?'); + }); + + it('does nothing when only ONE end of the axis is declared', () => { + // Condition 3. With no sibling there is no coin flip — the suggestion is + // the only reading available and stays. + const OneEnded = strictObject( + { surface: 'this one-ended surface', history: 'History.' }, + { endDateField: z.string().optional(), titleField: z.string().optional() }, + ); + expect(rejectionMessage(OneEnded, { dateField: 'x' })).toContain('→ `endDateField`'); + }); + + it('screens the guessed candidate only — the helper is a pure predicate', () => { + const candidates = ['startDateField', 'endDateField', 'titleField']; + expect(oppositePoleAmbiguity('dateField', 'endDateField', candidates)?.poles) + .toEqual(['startDateField', 'endDateField']); + expect(oppositePoleAmbiguity('dateField', 'titleField', candidates)).toBeUndefined(); + expect(oppositePoleAmbiguity('endDatField', 'endDateField', candidates)).toBeUndefined(); + expect(oppositePoleAmbiguity('dateField', 'endDateField', ['endDateField'])).toBeUndefined(); + }); +}); + +describe('the axis table', () => { + it('declares each pair once, in a stable naming order, with distinct tokens', () => { + const seen = new Set(); + for (const [low, high] of POLARITY_AXES) { + expect(low).not.toBe(high); + for (const token of [low, high]) { + expect(seen.has(token), `\`${token}\` appears on two axes`).toBe(false); + seen.add(token); + } + } + }); +}); diff --git a/packages/spec/src/shared/polarity-axes.ts b/packages/spec/src/shared/polarity-axes.ts new file mode 100644 index 00000000000..11590298b98 --- /dev/null +++ b/packages/spec/src/shared/polarity-axes.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The AXIS vocabulary — the token pairs that name the two ends of one range, + * and the predicate that recognises when the edit-distance suggester is about + * to answer an axis-silent key with one arbitrary end of it. + * + * ## The defect this exists for + * + * `findClosestMatches` ranks by edit distance and nothing else, so on a shape + * that declares BOTH ends of a range it answers a key that names neither end + * with whichever end is spelled more cheaply. Measured on `main`, folded, at + * the budget `strictUnknownKeyError` actually spends: + * + * ```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` is a five-letter one. That spelling + * accident is the entire reason the author is sent to the end of the event + * rather than its start; nobody declared the mapping. And the answer is not + * refused downstream — `endDateField` is a declared key, so an author who does + * what the protocol told them gets a document the runtime ACCEPTS with the axis + * bound to the wrong end. The obedient reader is the one it punishes. + * + * ## What this module changes, and what it deliberately does not + * + * It changes ONE thing: 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. The accepted key set is untouched — the + * axis-silent key is rejected before this module is consulted and is still + * rejected after it. ⛔ No alias is declared: `dateField` does not acquire a + * meaning here, it acquires an honest answer. + * + * Naming both ends rather than picking one is not invented here. `field.zod.ts` + * already answers `visible` that way, by hand, for the same reason in its own + * words — 「the two answers have opposite polarity … Naming both is the only + * answer that cannot be acted on wrongly」. What a hand-written `guidance` entry + * cannot do is cover the keys nobody thought to enumerate, which is precisely + * the set a fuzzy suggester answers. + * + * ## Precedence — this module is the LAST resort, never the first + * + * A declared `aliases` entry is a human statement about one spelling and always + * wins; so does an exact `guidance` entry and a `guidanceSets` match. This + * predicate is consulted only on the candidate the DISTANCE fallback produced, + * because a guess is the only thing here that can be a coin flip. `this field` + * declares `length: 'maxLength'` beside `size: 'maxLength'` against a declared + * `minLength` sibling: that is a decision, it reads as an axis collision, and it + * is left exactly as written. + * + * ## Why this is a leaf module and not part of `suggestions.zod.ts` + * + * Same reason as `alias-probe.ts`: **two readers must agree.** + * `strictUnknownKeyError` reads {@link oppositePoleAmbiguity} to answer an + * author, and `alias-integrity.test.ts` reads {@link POLARITY_AXES} to prove no + * row in it is dead. A second copy of either in the gate would fail silently. + * Like `alias-probe.ts` it is deliberately **not** re-exported from + * `shared/index.ts` — an internal seam the audit reaches by relative path, not + * part of the `@objectstack/spec` contract. + */ + +/** + * Pairs of tokens that name opposite ends of ONE axis. + * + * ⚠️ **Every row must be ATTESTED** — some `strictObject` shape in this package + * declares both poles of it as sibling keys. `alias-integrity.test.ts` asserts + * that, the same way it refuses a dead `aliases` entry: a row nothing can ever + * match is a claim nothing judges. So this table is NOT a general antonym + * dictionary, and rows are not added ahead of the shape that needs them. + * + * Attested on 2026-09-20 over 136 registered surfaces (one row per pair, with + * the surfaces that declare both poles): + * + * | axis | attested by | + * |:----------------|:---------------------------------------------------------------| + * | `start` / `end` | `startDateField` / `endDateField` on calendar, gantt, timeline; | + * | | `baselineStartField` / `baselineEndField` on gantt; `start` / | + * | | `end` on the gantt shift band | + * | `min` / `max` | `minLength` / `maxLength` and `min` / `max` on form field; | + * | | `minRows` / `maxRows` on subform | + * | `input` / `output` | `inputMapping` / `outputMapping` on API endpoint | + * | `read` / `write` | `read` / `write` on the `api` data source | + * + * The order inside a pair is the order the two ends are NAMED IN, so a message + * reads `startDateField` before `endDateField` however the candidates happened + * to be declared. It carries no claim that the first is the better guess — + * there is no better guess, which is the whole point. + */ +export const POLARITY_AXES: readonly (readonly [string, string])[] = [ + ['start', 'end'], + ['min', 'max'], + ['input', 'output'], + ['read', 'write'], +]; + +/** + * Split a key into its lower-cased word tokens. + * + * `startDateField`, `start_date_field` and `Start-Date-Field` all tokenize to + * `['start', 'date', 'field']`: the same case/separator fold `foldForScoring` + * and `aliasProbe` apply, one level finer so a token can be compared as a word + * rather than as a substring. Word-level is load-bearing — a substring test + * would find `in` inside `minLength` and `to` inside `total`. + */ +function keyTokens(key: string): string[] { + return key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** + * Levenshtein distance over the folded spelling, duplicated from + * `suggestions.zod.ts` only in the sense that both measure the same thing — + * this module imports nothing from it, because `suggestions.zod.ts` imports + * THIS one and the package already carries two import cycles through + * `field.zod` that its own docblocks describe at length. + */ +function distance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + let prev = new Array(b.length + 1); + let curr = new Array(b.length + 1); + for (let j = 0; j <= b.length; j++) prev[j] = j; + for (let i = 1; i <= a.length; i++) { + curr[0] = i; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[b.length]; +} + +/** One end of an axis the author did not choose between. */ +export interface OppositePoleAmbiguity { + /** The candidate the distance fallback named. */ + readonly named: string; + /** Its opposite-pole sibling, declared on the same shape. */ + readonly opposite: string; + /** The axis, as {@link POLARITY_AXES} declares it. */ + readonly axis: readonly [string, string]; + /** Both keys ordered by the axis — `['startDateField', 'endDateField']`. */ + readonly poles: readonly [string, string]; +} + +/** + * True-ish when naming `candidate` back to the author of `input` would be a + * coin flip on the axis rather than a correction of a typo. + * + * All four conditions hold, and the fourth is the one that keeps this narrow: + * + * 1. `candidate` carries an axis token as one of its WORDS; + * 2. `input` carries neither end of that axis — the author asked no question + * about which end, so an answer that picks one is inventing the question; + * 3. the shape also declares the opposite-pole sibling, so both ends really are + * reachable spellings and the choice really is between two live keys; + * 4. `input` is at least as close to `candidate` MINUS its axis token as it is + * to `candidate` itself — the authored spelling is better explained as "the + * key without the axis" than as "the key with a typo in it". + * + * Condition 4 is what separates this from a blanket suppression, and it was + * measured rather than reasoned. Without it, `axLength` — an ordinary dropped + * character in `maxLength` — loses its suggestion, because `minLength` is + * declared right beside it and `axLength` names no pole either. With it: + * `axLength` is distance 1 from `maxlength` and 2 from the stripped `length`, + * so it reads as the typo it is and keeps its rename, while `dateField` is + * distance 0 from the stripped `datefield` and 3 from `enddatefield`, and reads + * as the axis-silent key it is. + */ +export function oppositePoleAmbiguity( + input: string, + candidate: string, + candidates: readonly string[], +): OppositePoleAmbiguity | undefined { + const candTokens = keyTokens(candidate); + // A key that is NOTHING but a pole (`start` / `end` on the gantt shift band) + // has no axis-silent spelling to be confused with — there is no remainder. + if (candTokens.length < 2) return undefined; + const inputTokens = new Set(keyTokens(input)); + const folded = keyTokens(input).join(''); + + for (let i = 0; i < candTokens.length; i++) { + const axis = POLARITY_AXES.find((pair) => pair[0] === candTokens[i] || pair[1] === candTokens[i]); + if (!axis) continue; + // (2) The author named an end. Whatever else is wrong with their spelling, + // it is not that they failed to choose. + if (inputTokens.has(axis[0]) || inputTokens.has(axis[1])) continue; + const other = candTokens[i] === axis[0] ? axis[1] : axis[0]; + const wanted = candTokens.slice(); + wanted[i] = other; + const wantedKey = wanted.join(''); + // (3) Both ends must be live keys on this shape. + const opposite = candidates.find( + (c) => c !== candidate && keyTokens(c).join('') === wantedKey, + ); + if (!opposite) continue; + // (4) Omission, not typo. + const stripped = candTokens.filter((_, t) => t !== i).join(''); + if (distance(folded, stripped) > distance(folded, candTokens.join(''))) continue; + const poles: readonly [string, string] = + candTokens[i] === axis[0] ? [candidate, opposite] : [opposite, candidate]; + return { named: candidate, opposite, axis, poles }; + } + return undefined; +} + +/** + * The author-facing bullet an {@link OppositePoleAmbiguity} is answered with. + * + * It names both ends and prescribes nothing between them, because there is + * nothing to prescribe: the key the author wrote does not carry the + * information. The last clause is the part that matters most to an AI author, + * which will otherwise read a rejection as "any accepted key will do" — both + * spellings parse, so the failure this replaces was silent. + */ +export function oppositePolePrescription(key: string, ambiguity: OppositePoleAmbiguity): string { + const [first, second] = ambiguity.poles; + return ( + `\`${key}\` does not say which end of the range it binds, and this surface declares both ` + + `\`${first}\` and \`${second}\` — opposite ends of one axis. Write the one you mean: both ` + + `parse, so guessing binds the wrong end silently.` + ); +} diff --git a/packages/spec/src/shared/suggestions.zod.ts b/packages/spec/src/shared/suggestions.zod.ts index 2887029fe28..2fdc252271c 100644 --- a/packages/spec/src/shared/suggestions.zod.ts +++ b/packages/spec/src/shared/suggestions.zod.ts @@ -4,6 +4,10 @@ import type { z } from 'zod'; import { FieldType } from '../data/field.zod'; import { aliasProbe } from './alias-probe'; +import { + oppositePoleAmbiguity, + oppositePolePrescription, +} from './polarity-axes'; /** * "Did you mean?" Suggestion Utilities @@ -378,6 +382,13 @@ export interface StrictUnknownKeyErrorOptions { * length-relative edit-distance fallback (matching `suggestKey` in * `data/object.zod.ts`: a flat distance of 3 is noise on a short key). * + * The fallback's answer is screened once more before it ships, because a + * distance ranking cannot see meaning: on a shape that declares both ends of a + * range, an axis-silent key is answered with whichever end is spelled more + * cheaply, and that end PARSES. Such a guess is replaced by a prescription + * naming both ends — `polarity-axes.ts`, which also explains why a declared + * alias is never screened. + * * Wire it as the object's `error` alongside `.strict()`: * * ```ts @@ -465,8 +476,25 @@ export function strictUnknownKeyError(options: StrictUnknownKeyErrorOptions): z. continue; } const maxDistance = Math.max(2, Math.floor(key.length / 3)); - const canonical = - aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]; + const declared = aliases[aliasProbe(key)]; + const guessed = declared ? undefined : findClosestMatches(key, knownKeys, maxDistance, 1)[0]; + // Screen the GUESS, never the declaration. `findClosestMatches` ranks by + // edit distance and nothing else, so on a shape declaring both ends of a + // range it answers a key that names neither end with whichever end is + // spelled more cheaply — `dateField` lands on `endDateField` at distance + // 3 while `startDateField` sits at 5, outside the budget, unreachable. + // Both parse, so the author who takes the advice binds the wrong end of + // the event and is told nothing. A declared `aliases` entry is a human + // statement about that one spelling and is left exactly as written; only + // a coin flip is replaced, and it is replaced by naming BOTH ends rather + // than by silence. `polarity-axes.ts` carries the four conditions, the + // measurement, and why the accepted key set does not move. + const ambiguity = guessed ? oppositePoleAmbiguity(key, guessed, knownKeys) : undefined; + if (ambiguity) { + prescriptions.push(oppositePolePrescription(key, ambiguity)); + continue; + } + const canonical = declared ?? guessed; if (canonical && canonical !== key) renames.push(`\`${key}\` → \`${canonical}\``); } // Order: WHICH KEY IS WRONG → HOW TO FIX IT → why it used to be silent. From de700e7a42fa99116851f864122bf9cb10f4faf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 09:59:41 +0000 Subject: [PATCH 2/5] test(spec): judge the polarity axis table against the shapes it claims about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../spec/src/shared/alias-integrity.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/spec/src/shared/alias-integrity.test.ts b/packages/spec/src/shared/alias-integrity.test.ts index c99df6091ed..5214fab3440 100644 --- a/packages/spec/src/shared/alias-integrity.test.ts +++ b/packages/spec/src/shared/alias-integrity.test.ts @@ -123,6 +123,7 @@ import ts from 'typescript'; import { aliasProbe } from './alias-probe'; import { acceptsNothing, strictObjectDeclarations, type StrictObjectDeclaration } from './strict-object'; +import { POLARITY_AXES } from './polarity-axes'; import { keySetMatches } from './suggestions.zod'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -1164,6 +1165,47 @@ describe('alias integrity — every table is a true claim about its schema', () expect(handwrittenMapSites(field)).toEqual([]); }); + it('every declared POLARITY axis is attested by a real sibling pair', () => { + // `polarity-axes.ts` is a table of claims about these shapes, so it earns + // the same judgement as `aliases` and `guidance`: an axis no shape declares + // both ends of can never match, and a row nothing can match is a row + // nothing judges. It would read as coverage of a trap that, on this + // protocol, does not exist. + // + // ⛔ This is NOT a general antonym dictionary and must not grow into one. + // A row arrives with the shape that needs it; when the last shape + // declaring both ends of an axis loses one, this fails and the row goes. + const attested = new Map(POLARITY_AXES.map((axis) => [axis.join('/'), [] as string[]])); + const tokens = (key: string) => key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + + for (const s of SURFACES) { + const keys = Object.keys(s.shape).filter((k) => !acceptsNothing(s.shape[k])); + const spelled = new Map(keys.map((k) => [k, tokens(k)])); + for (const key of keys) { + const t = spelled.get(key)!; + for (let i = 0; i < t.length; i++) { + for (const axis of POLARITY_AXES) { + if (t[i] !== axis[0] && t[i] !== axis[1]) continue; + const other = t[i] === axis[0] ? axis[1] : axis[0]; + const wanted = t.slice(); wanted[i] = other; + const sibling = keys.find( + (o) => o !== key && spelled.get(o)!.join('|') === wanted.join('|'), + ); + if (sibling) attested.get(axis.join('/'))!.push(`${s.options.surface}: ${key} / ${sibling}`); + } + } + } + } + const dead = [...attested.entries()].filter(([, hits]) => hits.length === 0).map(([axis]) => axis); + expect(dead, 'axis rows nothing in this package declares both ends of').toEqual([]); + // The control: the search DOES find pairs, so an empty `dead` is a reading + // rather than a walk that matched nothing at all. + expect([...attested.values()].flat().length).toBeGreaterThan(POLARITY_AXES.length); + }); + it('no guidance key is itself a declared key (the same dead entry, other channel)', () => { // `guidance` is consulted from the same `unrecognized_keys` path, so a // prescription filed under a key the shape DECLARES is unreachable in From 76404f3b7bdabe6a8c6c4f8a352f506fa46e86ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:02:33 +0000 Subject: [PATCH 3/5] chore(changeset): patch for the opposite-pole suggester guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/18572-suggester-opposite-pole.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/18572-suggester-opposite-pole.md diff --git a/.changeset/18572-suggester-opposite-pole.md b/.changeset/18572-suggester-opposite-pole.md new file mode 100644 index 00000000000..9a85c06c93a --- /dev/null +++ b/.changeset/18572-suggester-opposite-pole.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': patch +--- + +The unknown-key suggester no longer answers an axis-silent key with one arbitrary end of a range — `dateField` on a calendar, timeline or gantt config is told about `startDateField` **and** `endDateField` instead of being sent to the end of the event (#18572). + +Clause-②: no + +`findClosestMatches` ranks by edit distance and nothing else. On a shape that declares both ends of a range, a key naming neither end is therefore answered with whichever end is spelled more cheaply — re-derived here rather than taken from the card: + +```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 was the whole reason the protocol told an author to bind the **end** of the event. 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 axis silently on the wrong date. ⛔ Nobody had declared that mapping — a generic fuzzy matcher picked one sibling out of two. + +- **The fallback's answer is screened; a declared `aliases` entry never is.** When the guessed candidate carries an axis token the authored key does not, and the shape also declares its opposite-pole sibling, the rename is replaced by a prescription naming both ends: *"`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."* A human-written alias is a statement about one spelling and outranks this; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares. +- ⛔ **No alias was added and the accepted key set does not move.** `dateField` was refused before this change and is refused after it; what changed is the sentence the refusal carries. Naming both ends rather than picking one is the answer `field.zod.ts` already writes by hand for `visible` — 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」 — generalised to the keys nobody thought to enumerate, which is the set a fuzzy suggester answers. +- **The guard separates an omission from a typo, and that condition was measured.** It fires only when the authored key is at least as close to the candidate MINUS its axis token as to the candidate itself. Without it `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. With it, `axLength` reads as the typo it is (distance 1 vs 2) and `dateField` as the axis-silent key it is (distance 3 vs 0). +- **Census, not just the filed case.** Over 136 registered `strictObject` surfaces the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`. From 1287f0658d0ed3152bfd199e36a33eb0b62c9d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 10:41:36 +0000 Subject: [PATCH 4/5] chore(spec): regenerate the api-surface declaration shards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../spec/api-surface-declarations/api.txt | 12 +++++------ .../spec/api-surface-declarations/kernel.txt | 8 ++++---- .../spec/api-surface-declarations/root.txt | 4 ++-- .../api-surface-declarations/security.txt | 20 +++++++++---------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/spec/api-surface-declarations/api.txt b/packages/spec/api-surface-declarations/api.txt index 9347bcb8083..d0a06f0e7a4 100644 --- a/packages/spec/api-surface-declarations/api.txt +++ b/packages/spec/api-surface-declarations/api.txt @@ -4302,10 +4302,10 @@ type CheckPermissionRequest = z.input; declare const CheckPermissionRequestSchema: z.ZodObject<{ object: z.ZodString; action: z.ZodEnum<{ + read: "read"; create: "create"; delete: "delete"; edit: "edit"; - read: "read"; transfer: "transfer"; restore: "restore"; purge: "purge"; @@ -5274,10 +5274,10 @@ declare const CrudEndpointsConfigSchema: z.ZodObject<{ // ── CrudOperation (const) ── declare const CrudOperation: z.ZodEnum<{ + read: "read"; create: "create"; update: "update"; delete: "delete"; - read: "read"; list: "list"; }>; @@ -6557,10 +6557,10 @@ declare const EndpointRegistrySchema: z.ZodObject<{ path: z.ZodString; object: z.ZodString; operation: z.ZodUnion, z.ZodString]>; handler: z.ZodString; @@ -6586,10 +6586,10 @@ declare const EndpointRegistrySchema: z.ZodObject<{ path: z.ZodString; object: z.ZodString; operation: z.ZodUnion, z.ZodString]>; handler: z.ZodString; @@ -6614,10 +6614,10 @@ declare const EndpointRegistrySchema: z.ZodObject<{ path: z.ZodString; object: z.ZodString; operation: z.ZodUnion, z.ZodString]>; handler: z.ZodString; @@ -7995,10 +7995,10 @@ declare const GeneratedEndpointSchema: z.ZodObject<{ path: z.ZodString; object: z.ZodString; operation: z.ZodUnion, z.ZodString]>; handler: z.ZodString; diff --git a/packages/spec/api-surface-declarations/kernel.txt b/packages/spec/api-surface-declarations/kernel.txt index 68c037bc09c..dfc7a71d66d 100644 --- a/packages/spec/api-surface-declarations/kernel.txt +++ b/packages/spec/api-surface-declarations/kernel.txt @@ -4805,8 +4805,8 @@ type PermissionAction = z.input; // ── PermissionActionSchema (const) ── declare const PermissionActionSchema: z.ZodEnum<{ delete: "delete"; - update: "update"; read: "read"; + update: "update"; create: "create"; export: "export"; execute: "execute"; @@ -5461,8 +5461,8 @@ declare const PluginPermissionSchema: z.ZodObject<{ }>; actions: z.ZodArray; actions: z.ZodArray; actions: z.ZodArray; accessLevel: z.ZodDefault>; sharedWith: z.ZodObject<{ type: z.ZodEnum<{ @@ -43606,8 +43606,8 @@ declare const ObjectStackSchema: z.ZodObject<{ object: z.ZodString; active: z.ZodDefault; accessLevel: z.ZodDefault>; sharedWith: z.ZodObject<{ type: z.ZodEnum<{ diff --git a/packages/spec/api-surface-declarations/security.txt b/packages/spec/api-surface-declarations/security.txt index 1351e0c27a8..d37d0cd7a73 100644 --- a/packages/spec/api-surface-declarations/security.txt +++ b/packages/spec/api-surface-declarations/security.txt @@ -175,8 +175,8 @@ declare const CriteriaSharingRuleSchema: z.ZodObject<{ object: z.ZodString; active: z.ZodDefault; accessLevel: z.ZodDefault>; sharedWith: z.ZodObject<{ type: z.ZodEnum<{ @@ -254,8 +254,8 @@ declare const ExplainDecisionSchema: z.ZodObject<{ object: z.ZodString; operation: z.ZodEnum<{ delete: "delete"; - update: "update"; read: "read"; + update: "update"; create: "create"; transfer: "transfer"; restore: "restore"; @@ -345,8 +345,8 @@ declare const ExplainDecisionSchema: z.ZodObject<{ name: z.ZodString; grants: z.ZodOptional>; via: z.ZodOptional; predicate: z.ZodOptional; @@ -463,8 +463,8 @@ declare const ExplainLayerSchema: z.ZodObject<{ name: z.ZodString; grants: z.ZodOptional>; via: z.ZodOptional; predicate: z.ZodOptional; @@ -496,8 +496,8 @@ declare const ExplainMatchedRuleSchema: z.ZodObject<{ name: z.ZodString; grants: z.ZodOptional>; via: z.ZodOptional; predicate: z.ZodOptional; @@ -514,8 +514,8 @@ type ExplainOperation = z.input; // ── ExplainOperationSchema (const) ── declare const ExplainOperationSchema: z.ZodEnum<{ delete: "delete"; - update: "update"; read: "read"; + update: "update"; create: "create"; transfer: "transfer"; restore: "restore"; @@ -552,8 +552,8 @@ declare const ExplainRecordAttributionSchema: z.ZodObject<{ name: z.ZodString; grants: z.ZodOptional>; via: z.ZodOptional; predicate: z.ZodOptional; @@ -574,8 +574,8 @@ declare const ExplainRequestSchema: z.ZodObject<{ object: z.ZodString; operation: z.ZodEnum<{ delete: "delete"; - update: "update"; read: "read"; + update: "update"; create: "create"; transfer: "transfer"; restore: "restore"; @@ -1068,8 +1068,8 @@ type ShareRecipientType = z.input; // ── SharingLevel (const) ── declare const SharingLevel: z.ZodEnum<{ - edit: "edit"; read: "read"; + edit: "edit"; }>; // ── SharingLevel (type) ── @@ -1109,8 +1109,8 @@ declare const SharingRuleSchema: z.ZodObject<{ object: z.ZodString; active: z.ZodDefault; accessLevel: z.ZodDefault>; sharedWith: z.ZodObject<{ type: z.ZodEnum<{ From 72bf22a2e13c63440b561f7e3fdf9bb19e2aea67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 13:08:49 +0000 Subject: [PATCH 5/5] docs(spec): correct the surface-population citation from 136 to the measured 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 --- .changeset/18572-suggester-opposite-pole.md | 2 +- packages/spec/src/shared/polarity-axes.ts | 42 +++++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/.changeset/18572-suggester-opposite-pole.md b/.changeset/18572-suggester-opposite-pole.md index 9a85c06c93a..2392f02962f 100644 --- a/.changeset/18572-suggester-opposite-pole.md +++ b/.changeset/18572-suggester-opposite-pole.md @@ -19,4 +19,4 @@ authored `dateField` (9 chars, budget max(2, 9/3) = 3) - **The fallback's answer is screened; a declared `aliases` entry never is.** When the guessed candidate carries an axis token the authored key does not, and the shape also declares its opposite-pole sibling, the rename is replaced by a prescription naming both ends: *"`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."* A human-written alias is a statement about one spelling and outranks this; only a coin flip is replaced. `this field` keeps answering `length` with `maxLength` exactly as it declares. - ⛔ **No alias was added and the accepted key set does not move.** `dateField` was refused before this change and is refused after it; what changed is the sentence the refusal carries. Naming both ends rather than picking one is the answer `field.zod.ts` already writes by hand for `visible` — 「the two answers have opposite polarity … Naming both is the only answer that cannot be acted on wrongly」 — generalised to the keys nobody thought to enumerate, which is the set a fuzzy suggester answers. - **The guard separates an omission from a typo, and that condition was measured.** It fires only when the authored key is at least as close to the candidate MINUS its axis token as to the candidate itself. Without it `axLength` — one dropped character in `maxLength`, with `minLength` declared beside it — would lose a perfectly good suggestion. With it, `axLength` reads as the typo it is (distance 1 vs 2) and `dateField` as the axis-silent key it is (distance 3 vs 0). -- **Census, not just the filed case.** Over 136 registered `strictObject` surfaces the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`. +- **Census, not just the filed case.** Over **389 unique authoring surfaces** — the population `alias-integrity.test.ts`'s forcing walk registers, deduplicated by its own key (surface + alias table + sorted shape keys); the same walk also yields 421 raw `strictObject` registrations and 388 distinct surface strings, which are different facts — the trap occurs four times, all four fixed here: `dateField` on the calendar, timeline and gantt configs, and `baselineField` on the gantt config (`baselineStartField` / `baselineEndField`). The axis vocabulary is held to the shapes: `alias-integrity.test.ts` now fails on an axis row no surface declares both ends of, the same dead-entry judgement it already applies to `aliases` and `guidance`. diff --git a/packages/spec/src/shared/polarity-axes.ts b/packages/spec/src/shared/polarity-axes.ts index 11590298b98..d84a67c5f49 100644 --- a/packages/spec/src/shared/polarity-axes.ts +++ b/packages/spec/src/shared/polarity-axes.ts @@ -72,18 +72,36 @@ * match is a claim nothing judges. So this table is NOT a general antonym * dictionary, and rows are not added ahead of the shape that needs them. * - * Attested on 2026-09-20 over 136 registered surfaces (one row per pair, with - * the surfaces that declare both poles): - * - * | axis | attested by | - * |:----------------|:---------------------------------------------------------------| - * | `start` / `end` | `startDateField` / `endDateField` on calendar, gantt, timeline; | - * | | `baselineStartField` / `baselineEndField` on gantt; `start` / | - * | | `end` on the gantt shift band | - * | `min` / `max` | `minLength` / `maxLength` and `min` / `max` on form field; | - * | | `minRows` / `maxRows` on subform | - * | `input` / `output` | `inputMapping` / `outputMapping` on API endpoint | - * | `read` / `write` | `read` / `write` on the `api` data source | + * Attested on 2026-09-20 over **389 unique authoring surfaces** — the population + * `alias-integrity.test.ts`'s own forcing walk registers, deduplicated by its + * own key (surface + alias table + sorted shape keys). ⚠️ State which reading + * you are quoting: the same walk also yields **421** raw `strictObject` + * registrations (factories that build one shape from several call sites) and + * **388** distinct surface STRINGS (two surfaces share a name). They are three + * different facts and only the first is the count of surfaces this table was + * judged against. + * + * Pair counts below are that measurement, one row per axis: + * + * | axis | pairs | attested by | + * |:-------------------|------:|:-------------------------------------------------| + * | `start` / `end` | 5 | `startDateField` / `endDateField` on calendar, | + * | | | gantt and timeline; `baselineStartField` / | + * | | | `baselineEndField` on gantt; `start` / `end` on | + * | | | the gantt shift band | + * | `min` / `max` | 11 | `minLength` / `maxLength` and `min` / `max` on | + * | | | form field and on field; `minRows` / `maxRows` on | + * | | | subform; `minZoom` / `maxZoom` on the ER diagram; | + * | | | `minDate` / `maxDate` on `object-timeline`; and | + * | | | `min` / `max` on screen field, chart axis, zoom | + * | | | settings and the datasource pool config | + * | `input` / `output` | 2 | `inputMapping` / `outputMapping` on API endpoint; | + * | | | `isInput` / `isOutput` on a flow variable | + * | `read` / `write` | 2 | `read` / `write` on the `api` data source; | + * | | | `readScope` / `writeScope` on object permission | + * + * ⛔ These counts are a reading, not a floor: the gate that keeps them honest is + * the attestation test, which re-derives them from the shapes on every run. * * The order inside a pair is the order the two ends are NAMED IN, so a message * reads `startDateField` before `endDateField` however the candidates happened