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
27 changes: 27 additions & 0 deletions .changeset/fold-unrecognized-key-error-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/spec": patch
---

refactor(spec): 三个手写 unrecognized_keys 错误映射折叠进共享 `strictObject` guidance 模板(#6619,#6416 方向 2)

`strictVisibilityError`(`shared/visibility.ts`)、`strictWidgetAnalyticsError`
(`ui/dashboard.zod.ts`)、`strictTenancyError`(`data/object.zod.ts`)此前是
手写 `$ZodErrorMap`——#5955 的模板重排、#5593 的 `strictObject` 迁移都够不到
它们,`alias-integrity.test.ts` 的两个注册表也都看不见它们(#6416 命名的盲区)。

- **共享模板新增按集合取键的 guidance 形态**(`KeySetGuidance` / `guidanceSets`):
一条处方由一个具名键族共享(枚举列表或模式),每条消息每个集合最多发声一次。
优先级规则固定并有测试钉住:精确 `guidance` 条目永远胜过集合;集合之间按声明
顺序;集合命中即抑制改名建议。
- **三个映射全部折入模板**,`alias-integrity.test.ts` 新增集合成员死条目检查、
模式 examples 检查与折叠闭合钉(还原任何一个手写映射会红在门上而不是重新
变成盲区)。
- **接受面逐字节不变**:所有 schema 接受/拒绝的输入集合与折叠前完全一致。
#6453 留下的 13 个消息顺序钉全部随代码迁移、零删除。消息字节有三处刻意变化
(处方从内联空格改为模板的 `\n • ` 项目符号;无处方的键获得模板的编辑距离
改名建议,如 `tenantfield` → `tenantField`;widget 多键族时各族处方全部给出
而非只给第一个命中分支)。
- 公开导出面:`./shared` 移除 `strictVisibilityError`,新增
`VISIBILITY_STRICT_OPTIONS`、`KeySetGuidance`、`keySetMatches`;新增
`strictObjectError`(`strictObject` 的注册+错误映射一半,供不关门的
module-private base 使用)。
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/spec/api-surface/shared.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"HttpRequestSchema (const)",
"IsolationLevel (type)",
"IsolationLevelEnum (const)",
"KeySetGuidance (interface)",
"MAP_SUPPORTED_FIELDS (const)",
"METADATA_ALIASES (const)",
"MapSupportedField (type)",
Expand Down Expand Up @@ -89,6 +90,7 @@
"TemplateExpressionInput (type)",
"TemplateExpressionInputSchema (const)",
"VISIBILITY_ALIAS_KEYS (const)",
"VISIBILITY_STRICT_OPTIONS (const)",
"ViewName (type)",
"ViewNameParsed (type)",
"ViewNameSchema (const)",
Expand All @@ -100,6 +102,7 @@
"formatSuggestion (function)",
"formatZodError (function)",
"formatZodIssue (function)",
"keySetMatches (function)",
"lazySchema (function)",
"levenshteinDistance (function)",
"normalizeMetadataCollection (function)",
Expand All @@ -113,7 +116,6 @@
"safeParsePretty (function)",
"singularToPlural (function)",
"strictUnknownKeyError (function)",
"strictVisibilityError (const)",
"suggestFieldType (function)",
"tmpl (function)"
]
Expand Down
25 changes: 14 additions & 11 deletions packages/spec/scripts/strictness-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,21 @@ describe('posture reading, with a red control for each', () => {
// The fixture used to be `security/permission.zod.ts`, whose four sites
// were the campaign's canonical `z.object(shape, { error }).strict()`
// wiring; #5593 migrated all four to `strictObject`, so the file no longer
// exercises the branch under test. `data/object.zod.ts` carries the
// spelling deliberately and is expected to keep carrying it: its two
// remaining `{ error: … }` maps are HAND-WRITTEN `$ZodErrorMap`s
// (`strictCapabilitiesError`, `strictTenancyError`) that emit a standing
// explainer the shared template cannot express, which #6416 recorded as
// out of #5593's reach. If they are ever converted, move this fixture
// rather than deleting the assertion — the AST reader still has to make
// the reading, and `packages/spec` is not the only tree it reads.
// exercised the branch under test, and the fixture moved to
// `TenancyConfigSchema` — until #6619 folded ITS hand-written map into the
// shared template (the set-keyed `guidance` form gave the template the
// vocabulary #6416 had recorded as out of reach) and the site became
// `strictObject` like the rest. `ObjectCapabilities`, same file, is the
// spelling's remaining deliberate carrier: its map
// (`strictCapabilitiesError`) emits NO trailing history sentence, which
// the shared template still cannot express. If it is ever converted, move
// this fixture rather than deleting the assertion — the AST reader still
// has to make the reading, and `packages/spec` is not the only tree it
// reads.
const objectSites = analyzeSites(at('data/object.zod.ts'));
const tenancy = objectSites.find((s) => s.name === 'TenancyConfigSchema');
expect(tenancy?.posture, 'a plain `.strict()` chain is still strict').toBe('strict');
expect(tenancy?.idiom).toBe('z.object');
const capabilities = objectSites.find((s) => s.name === 'ObjectCapabilities');
expect(capabilities?.posture, 'a plain `.strict()` chain is still strict').toBe('strict');
expect(capabilities?.idiom).toBe('z.object');

// The permission file's four are now the helper, and still strict — the
// control that keeps this test a statement about the READER rather than
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const AddressSchema = lazySchema(() => z.object({
*/
/**
* Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120
* §Terminology; pattern of `strictTenancyError`): the error must carry the
* §Terminology; pattern of `strictCapabilitiesError`): the error must carry the
* vocabulary and, for the two predictable near-misses (`'tenant'`, `'org'`),
* name `'organization'` explicitly — a typo must be a loud, fixable parse
* error, never a silent scope change. Declared before `UniqueScopeSchema`
Expand Down
75 changes: 54 additions & 21 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1504,10 +1504,24 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
});

it('rejects arbitrary unknown tenancy keys instead of silently stripping them (#1535)', () => {
// Truly arbitrary — no tombstone, no near-declared-key. Rejected with the
// surface named; there is nothing more the message can honestly offer.
const result = TenancyConfigSchema.safeParse({ enabled: true, zzNotAKey: 1 });
expect(result.success).toBe(false);
expect(result.error!.issues.map((i) => i.message).join('\n'))
.toContain('Unrecognized key(s) on `tenancy`: `zzNotAKey`');
});

it('a near-miss of a live key gets the template rename, not a dead-end verdict (#6619)', () => {
// While the map was hand-written, `tenantfield` was answered with
// "`tenantfield` is not a `tenancy` key." — a verdict that names the
// problem and never the fix. The fold onto `strictObject` brought the
// edit-distance channel with it: the same input now points at the key the
// author meant. A deliberate byte change, recorded as such.
const result = TenancyConfigSchema.safeParse({ enabled: true, tenantfield: 'org_id' });
expect(result.success).toBe(false);
expect(result.error!.issues.map((i) => i.message).join('\n'))
.toContain('`tenantfield` is not a `tenancy` key');
.toContain('Did you mean `tenantfield` → `tenantField`?');
});

it('rejects a retired key on ObjectSchema.create() (the authoring entrypoint)', () => {
Expand All @@ -1522,22 +1536,28 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
});

/**
* Message ORDER on `strictTenancyError` (#6416, applying #5955's ruling).
* Message ORDER on the `tenancy` unknown-key rejection (#6416, applying
* #5955's ruling; #6619 folded the map into the shared template).
*
* A hand-written `$ZodErrorMap`: it never calls `strictUnknownKeyError`, so
* #5955's reorder of the shared template did not reach it, and it is not one of
* the 44 direct call sites #5593 migrates to `strictObject` either. Its
* explanatory sentence is the standing two-modes explainer, and its FIX channel
* is the per-key ` • ` bullets built just above it — the tombstone that tells
* an upgrading author what to write instead. Those bullets used to sit BEHIND
* ~160 characters of standing background, which is past the front of the
* single-line renders several consumers use (`os validate`'s `• where: message`,
* CI logs).
* Written against `strictTenancyError`, the hand-written `$ZodErrorMap` that
* neither #5955 nor #5593 could reach; #6416 direction 1 reordered it in place
* with these pins as acceptance criteria, and #6619 folded it into
* `strictObject` — the tombstones as exact `guidance` entries, the standing
* two-modes explainer in the template's `history` slot (which is the slot for
* "the sentence that must come LAST"). The pins migrated with the code: the
* ORDER contract (front matter → fix channels → explainer last) is now the
* template's own. Two byte-level changes rode the fold, pinned below:
*
* - a near-miss of a live key (`tenantfield`) gets the template's rename in
* the front matter instead of the dead-end "`x` is not a `tenancy` key."
* bullet the hand-written map emitted;
* - a key with no fix at all gets NO bullet — the front matter plus the
* explainer carry everything the old catch-all bullet said.
*
* ORDER pins, not presence checks. Every `toContain` in the block above stays
* green under either order — that is exactly why they cannot carry this fact.
*/
describe('strictTenancyError message order — bullets before the explainer (#6416)', () => {
describe('tenancy unknown-key message order — bullets before the explainer (#6416 / #6619)', () => {
const EXPLAINER =
'The two supported tenancy modes are: database-per-tenant = environment-level ' +
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
Expand All @@ -1562,30 +1582,43 @@ describe('strictTenancyError message order — bullets before the explainer (#64
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
});

it('keeps EVERY per-key bullet ahead of the explainer, not just the first', () => {
it('keeps EVERY fix channel ahead of the explainer, not just the first', () => {
// One issue names every offending key, so the explainer is a per-MESSAGE
// sentence: a reorder that put it after the first bullet would bury the rest.
// sentence: a reorder that put it after the first fix would bury the rest.
// Three keys, all three channels at once: two tombstone bullets plus the
// rename `tenantfield` earns since #6619 (the hand-written map answered it
// with a dead-end "is not a `tenancy` key." bullet instead).
const m = messageFor({ strategy: 'isolated', crossTenantAccess: true, tenantfield: 'org_id' });
for (const bullet of [
for (const fix of [
'Did you mean `tenantfield` → `tenantField`?',
'`tenancy.strategy` was removed',
'`tenancy.crossTenantAccess` was removed',
'`tenantfield` is not a `tenancy` key.',
]) {
expect(m).toContain(bullet);
expect(m.indexOf(bullet), bullet).toBeLessThan(m.indexOf(EXPLAINER));
expect(m).toContain(fix);
expect(m.indexOf(fix), fix).toBeLessThan(m.indexOf(EXPLAINER));
}
expect(m.split(EXPLAINER)).toHaveLength(2);
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
});

it('is a full-message pin for the plain unknown-key case', () => {
it('is a full-message pin for the near-miss case', () => {
// Any stray separator, dropped newline or duplicated clause fails here.
// Byte change vs the hand-written map, deliberate (#6619): the dead-end
// bullet became the rename the author can act on.
expect(messageFor({ tenantfield: 'org_id' })).toBe(
'Unrecognized key(s) on `tenancy`: `tenantfield`.\n' +
' `tenantfield` is not a `tenancy` key. ' +
'Unrecognized key(s) on `tenancy`: `tenantfield`. ' +
'Did you mean `tenantfield` → `tenantField`? ' +
EXPLAINER,
);
});

it('is a full-message pin for the no-fix case', () => {
// No tombstone, no near key: no bullet at all — the front matter and the
// explainer carry everything the old catch-all bullet said.
expect(messageFor({ zzNotAKey: 1 })).toBe(
`Unrecognized key(s) on \`tenancy\`: \`zzNotAKey\`. ${EXPLAINER}`,
);
});
});

describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => {
Expand Down
85 changes: 42 additions & 43 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,20 @@ const CAPABILITIES_RETIRED_KEY_GUIDANCE: Record<string, string> = {
};

/**
* Custom zod `error` for the `.strict()` capabilities block (pattern of
* `strictTenancyError` below): an unknown key — a retired `trash`/`mru` or a
* typo like `feedEnabled` — is a loud, *fixable* parse error instead of a
* silent strip (#1535), and a retired key's error carries its upgrade
* prescription. Every other issue code defers to zod's default.
* Custom zod `error` for the `.strict()` capabilities block: an unknown key —
* a retired `trash`/`mru` or a typo like `feedEnabled` — is a loud, *fixable*
* parse error instead of a silent strip (#1535), and a retired key's error
* carries its upgrade prescription. Every other issue code defers to zod's
* default.
*
* The LAST hand-written `unrecognized_keys` map in this file — #6619 folded
* its sibling `strictTenancyError` into the shared `strictObject` template,
* and this one stayed out for a reason the template can measure: it emits NO
* trailing history sentence, and `strictUnknownKeyError` appends its `history`
* unconditionally. Fold it only when the template can express a
* history-less surface; `scripts/strictness-ledger.test.ts` uses the
* `ObjectCapabilities` site below as its `z.object(…).strict()` fixture, so
* move that fixture in the same change.
*/
const strictCapabilitiesError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
Expand Down Expand Up @@ -415,44 +424,21 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record<string, string> = {
};

/**
* Custom zod `error` for the `.strict()` tenancy block (#2763, pattern of
* `strictVisibilityError` / ADR-0089 D3a): an unknown key — a retired
* `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error
* instead of a silent strip (#1535), and a retired key's error carries its
* upgrade prescription. Every other issue code defers to zod's default.
*
* ## Message order: the fix comes before the explainer (#5955 / #6416)
*
* ```text
* Unrecognized key(s) on `tenancy`: `k1`. ← which key is wrong
* • {per-key tombstone / "not a `tenancy` key"} ← the fix
* The two supported tenancy modes are: … ← the standing explainer
* ```
* The standing two-modes explainer, emitted LAST on every `tenancy` rejection.
*
* Same emission order the shared `strictUnknownKeyError` template took in
* #5955 — bullets first, the surface-level sentence appended to the last one.
* A hand-written `$ZodErrorMap` is reachable by neither that fix nor #5593's
* `strictObject` migration, so #6416 applies the ruling here directly. The
* two-modes explainer used to sit between the key statement and the bullets,
* which on the single-line renders several consumers use (`os validate`'s
* `• where: message`, CI logs) buried each key's actual prescription behind
* ~160 characters of standing background. Nothing is dropped: the explainer is
* still emitted verbatim, just last.
* It occupies the template's `history` slot, which is the slot for exactly this
* — the one sentence of standing background that follows both fix channels
* (#5955 / #6416). It is background rather than history in the literal sense,
* and that is fine: the contract the slot encodes is *position*, and this
* sentence is the thing that must not sit in front of a key's own prescription.
* On the single-line renders several consumers use (`os validate`'s
* `• where: message`, CI logs) it used to bury each bullet behind ~160
* characters.
*/
const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
const lines = keys.map((key) =>
TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`,
);
return (
`Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` +
lines.map((l) => ` • ${l}`).join('\n') +
' The two supported tenancy modes are: database-per-tenant = environment-level ' +
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
'`tenancy.tenantField`.'
);
};
const TENANCY_MODES_EXPLAINER =
'The two supported tenancy modes are: database-per-tenant = environment-level '
+ 'deployment (no object config); row-level isolation = `tenancy.enabled` + '
+ '`tenancy.tenantField`.';

/**
* Multi-Tenancy Configuration Schema
Expand All @@ -465,6 +451,15 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
* `.strict()`: unknown keys (incl. the retired `strategy` /
* `crossTenantAccess`, #2763) are rejected with guidance, not stripped (#1535).
*
* Closed with the shared `strictObject` template since #6619. The tombstone
* bullets and the trailing explainer are byte-for-byte what the hand-written
* `strictTenancyError` emitted; what the fold changes is the *other* key — a
* near-miss like `tenantfield` now resolves to `tenantField` through the
* template's rename channel instead of being told only that it "is not a
* `tenancy` key", which named the problem and never the fix. Folding it in is
* also what puts this table under `alias-integrity.test.ts`, which no
* hand-rolled map has ever been judged by.
*
* `tenantField` carries **no default** (#5315). It used to default to
* `'tenant_id'`, which no consumer could act on: the platform's tenant column
* is `organization_id` (kernel-injected; the same column `tenantPolicy()` in
Expand All @@ -488,7 +483,11 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
* tenantField: 'workspace_id'
* }
*/
export const TenancyConfigSchema = lazySchema(() => z.object({
export const TenancyConfigSchema = lazySchema(() => strictObject({
surface: '`tenancy`',
history: TENANCY_MODES_EXPLAINER,
guidance: TENANCY_RETIRED_KEY_GUIDANCE,
}, {
enabled: z.boolean().describe('Enable multi-tenancy for this object'),
tenantField: z.string().optional().describe(
'Column this object is tenant-scoped by. Omit it unless the tenant column ' +
Expand All @@ -498,7 +497,7 @@ export const TenancyConfigSchema = lazySchema(() => z.object({
'object really has that field — otherwise the same `organization_id` ' +
'fallback applies. No default is materialized here on purpose (#5315).',
),
}, { error: strictTenancyError }).strict());
}));

/**
* [ADR-0066] Platform-global posture: `tenancy.enabled === false` explicitly
Expand Down
Loading
Loading