Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/strict-object-aliases-two-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/spec': patch
---

Correct `aliases`' documented contract: it is not "only for what edit distance cannot reach".

`strictObject`'s `aliases` option was documented as a universal in the three places an adopter reads — the module docblock in `shared/strict-object.ts`, the `StrictObjectOptions.aliases` JSDoc an editor shows on hover, and the same JSDoc on the published `strictUnknownKeyError`'s `StrictUnknownKeyErrorOptions.aliases` — all saying aliases are "semantic near-misses edit distance cannot reach". The word *cannot* denies the option's second job.

The lookup is `aliases[aliasProbe(key)] ?? findClosestMatches(key, knownKeys, maxDistance, 1)[0]`: an alias is consulted **before** the distance fallback and wins outright. So an entry is equally right when distance *does* reach the key and answers with the wrong one — `hosts` is 2 edits from the declared `hooks` against a budget of `Math.max(2, Math.floor(5 / 3))` = 2, so on the plugin `permissions` block the entry is what keeps an author off lifecycle hooks.

Neither role is rare, and the correction carries its own count rather than the hedge it replaces. Measured over every surface the `strictObject` registry records, 2026-09-11: **1910** alias entries, **1658** unreachable by distance, **252** reachable — 211 where the fallback would have answered identically, and **41** where it answers a different key the entry overrules.

The failure mode the old sentence produced is precise and has a live carrier: an adopter with a reachable-but-wrong near-miss read "edit distance cannot reach", concluded `aliases` was not the tool for their case, and left the confident wrong suggestion in place.

Prose only. No alias is added or removed, no schema, key list, strictness or error message changes, and `visibleWhen → visible` (verified still unreachable) stays as the proving case for the gap half.
9 changes: 7 additions & 2 deletions docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,13 @@ schema needs:
lazySchema(() => strictObject({ surface, history, aliases?, guidance? }, { ...shape }))
```

- `aliases` — semantic near-misses edit distance cannot reach (`visibleWhen` →
`visible`, `from` → `source`, `read` → `allowRead`).
- `aliases` — curated near-miss answers, consulted before the distance fallback
and preferred over it, so the table carries both the near-misses distance
**cannot reach** (`visibleWhen` → `visible`, `from` → `source`, `read` →
`allowRead`) and the ones it **reaches and answers wrongly** (`hosts` →
`network`, 2 edits from the declared `hooks` against a budget of 2 — #16859).
Measured 2026-09-11 (#17361): of 1910 entries, 1658 unreachable, 211
reachable-and-agreeing, 41 overruling a wrong hit.
- `guidance` — exact-key prescriptions: **tombstones for retired keys** (the
rejection carries the upgrade — AGENTS.md Post-Task Checklist #3) and
wrong-layer pointers (`apiOperations` is response-side; `objectName` belongs
Expand Down
42 changes: 36 additions & 6 deletions packages/spec/src/shared/strict-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,30 @@
* `aliases` and `guidance` stay hand-written, because they are the part that
* carries judgement rather than transcription:
*
* - `aliases` — semantic near-misses edit distance cannot reach. The one that
* proves the category is `visibleWhen → visible`: ADR-0089 made `visibleWhen`
* the correct spelling on view/page, so an author borrowing it on a different
* surface is not making a typo, and only a human-written entry can catch it.
* - `aliases` — the curated answer for a semantic near-miss, a different *word*
* for the same intent. It is consulted BEFORE the distance fallback and wins
* outright (`aliases[aliasProbe(key)] ?? findClosestMatches(…)` in
* `suggestions.zod.ts`), which is what gives it **two** jobs, not one:
* - **filling a gap** — the near-miss distance cannot reach. The proving case
* is `visibleWhen → visible`: ADR-0089 made `visibleWhen` the correct
* spelling on view/page, so an author borrowing it on a different surface
* is not making a typo, and only a human-written entry can catch it.
* - **overruling a wrong hit** — the near-miss distance CAN reach, and
* answers with the wrong key. The proving case is `hosts → network` on the
* plugin `permissions` block (#16859): the budget is `max(2, len/3)` = 2
* for a five-character key and `hosts` is exactly 2 from the declared
* `hooks`, so without the entry the author is sent to lifecycle hooks on
* the one block that also grants network access.
*
* ⚠️ Neither job is the rare one, and this bullet claimed only the first
* until #17361 measured it. Over every registered surface on 2026-09-11:
* 1910 alias entries, 1658 of them unreachable by distance and **252
* reachable** — 211 where the fallback would have answered identically, and
* **41 where it answers a different key** the entry overrules. ⛔ So do not
* read this option as *only* for what distance cannot reach: that reading
* tells an adopter holding a confident wrong suggestion — the case the
* option is most needed for — that `aliases` is not their tool, which is
* this campaign's own finding-7 shape (see {@link acceptsNothing}).
* - `guidance` — tombstones for retired keys (the rejection must carry the
* upgrade) and wrong-layer pointers.
*
Expand Down Expand Up @@ -114,8 +134,18 @@ export interface StrictObjectOptions {
/** One sentence: what silently happened before this shape was closed. */
history: string;
/**
* Semantic near-misses edit distance cannot reach — a different *word* for
* the same intent, usually correct on a neighbouring surface.
* Curated answers for semantic near-misses — a different *word* for the same
* intent, usually correct on a neighbouring surface.
*
* Looked up BEFORE the edit-distance fallback and preferred over it, so an
* entry is the right tool in **both** directions: the word distance cannot
* reach (`visibleWhen → visible`), and the word distance *does* reach and
* gets WRONG (`hosts → network`, where `hosts` is 2 edits from the declared
* `hooks` against a budget of 2 — #16859). ⛔ Not "only for what distance
* cannot reach": that was this line until #17361, and it sends an author
* holding a confidently wrong suggestion away from the option that fixes it.
* Plain case / underscore slips still need no entry — the fallback folds
* those already, and a second spelling of a covered probe is a dead row.
*/
aliases?: Readonly<Record<string, string>>;
/**
Expand Down
12 changes: 9 additions & 3 deletions packages/spec/src/shared/suggestions.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,15 @@ export interface StrictUnknownKeyErrorOptions {
/**
* Semantic near-misses: a different *word* for the same intent, usually
* borrowed from a neighbouring schema or product where that word is correct.
* Edit distance cannot reach these, so they are named explicitly; plain
* case/underscore slips are left to {@link findClosestMatches}. Map keys are
* matched case-insensitively with `_` / `-` / space separators removed.
* Consulted BEFORE {@link findClosestMatches} and preferred over it, so an
* entry answers both the near-miss distance cannot reach AND the one it
* reaches and gets wrong — `hosts → network` is 2 edits from the declared
* `hooks` against a budget of 2, and the entry is what keeps the author off
* lifecycle hooks (#16859; this line asserted only the first half until
* #17361 measured 41 overruling entries across the spec). Plain
* case/underscore slips are still left to the fallback, which folds them.
* Map keys are matched case-insensitively with `_` / `-` / space separators
* removed.
*/
aliases?: Readonly<Record<string, string>>;
/**
Expand Down
Loading