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

refactor(spec): 折叠 #6619 漏掉的两个手写 `unrecognized_keys` 错误映射,并把闭合钉从实例拓宽为类(#6805)

#6416 命名的盲区由 #6619/PR #6804 折掉三个手写 `$ZodErrorMap` 后宣告闭合,但那份清单**少了两个**——同一形态(`unrecognized_keys` 处方表经 `{ error: … }` 挂在 `.strict()` 对象上),同样不在任何注册表视野内:

| 手写映射 | 位置(在 `origin/main` @ `2672f855f` 实测) | 折叠后 |
|---|---|---|
| `strictToolError` | `ai/tool.zod.ts:83`(消费于 `:180`) | `strictObject` + `guidance: TOOL_RETIRED_KEY_GUIDANCE` |
| `strictCapabilitiesError` | `data/object.zod.ts:169`(消费于 `:274`) | `strictObject` + `guidance: CAPABILITIES_RETIRED_KEY_GUIDANCE` |

- **两张表首次进入 `alias-integrity.test.ts` 的审计视野**(注册表可见面 291 → 293,带 guidance 的面 129 → 131,新增恰为 `` `enable` `` 与 `the tool definition`,无一移除)。`TOOL_RETIRED_KEY_GUIDANCE` 是一张手工维护的**按键退役处方表**——正是这道门为之存在的最易腐烂的内容。
- **#6619 记录的「折不了的理由」被证伪,而不是被绕过**:模板无条件追加 `history`,而这两张表不发解释句。那是**文案的缺口,不是模板的极限**——`history` 槽位编码的是位置(两条修复通道之后),两个面都有真实历史,只是从没写下来。写下来即可折。
- **闭合钉从实例拓宽为类**:`alias-integrity.test.ts` 新增一条按 AST 判定的钉子——包内任何模块把自己写的、分支在 `unrecognized_keys` 上的错误映射交给 `z.<factory>(…, { error })`,即红。判据是「已挂载」∧「决断 `unrecognized_keys`」两个合取项,**无豁免名单**:`data/field.zod.ts` 的 `uniqueScopeError`(`invalid_union`,值级)与 `shared/error-map.zod.ts` 的 `objectStackErrorMap`(按次解析的全局兜底,从不挂到 shape 上)都由仪器本身判为不在类,各自作为实测对照钉住。
- **接受面逐字节不变**:46 例探针矩阵在折叠前后逐例比对,parse 输出与 issue `code`+`path` 完全一致。消息装配按 #6804 的既有三类变化移动——处方文本逐字节保留,无处方的键改由模板的编辑距离通道作答(`labl`→`label`、`searchible`→`searchable` 等 8 处,此前只被告知「不是本 schema 的字段」),解释句在最后。
- `scripts/strictness-ledger.test.ts` 的 `z.object(…).strict()` 夹具按其自身注释搬到 `PerOperationRequiredPermissionsSchema`(同文件),并新增「被腾空的两处现读为 helper」的对照。
39 changes: 27 additions & 12 deletions packages/spec/scripts/strictness-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,19 +169,34 @@ describe('posture reading, with a red control for each', () => {
// wiring; #5593 migrated all four to `strictObject`, so the file no longer
// 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.
// shared template. It then moved to `ObjectCapabilities`, same file, on the
// reading that its map (`strictCapabilitiesError`) emitted NO trailing
// history sentence and so could not fold. **#6805 disproved that reading**:
// the missing sentence was a gap in the TEXT, not a limit of the template
// (`history` encodes position, and `enable` had a real history nobody had
// written down), so that site is `strictObject` too now.
//
// `PerOperationRequiredPermissionsSchema`, still the same file, is the
// spelling's carrier today — and a more durable one, because it carries no
// guidance table at all and therefore nothing pulls it toward the helper.
// If it is ever converted, move this fixture AGAIN 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 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');
const perOperation = objectSites.find((s) => s.name === 'PerOperationRequiredPermissionsSchema');
expect(perOperation?.posture, 'a plain `.strict()` chain is still strict').toBe('strict');
expect(perOperation?.idiom).toBe('z.object');

// …and the two sites the fixture vacated read as the helper now, which is
// the control that keeps the line above a statement about the READER
// rather than about one lucky survivor. Without it, a reader that simply
// stopped distinguishing idioms would still satisfy the assertion.
for (const name of ['ObjectCapabilities', 'TenancyConfigSchema']) {
const folded = objectSites.find((s) => s.name === name);
expect(folded, `${name} is not a site any more — re-point this test, do not delete it`).toBeDefined();
expect(folded?.idiom, `${name} folded into the helper at #6619/#6805`).toBe('strictObject');
expect(folded?.posture).toBe('strict');
}

// 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
75 changes: 75 additions & 0 deletions packages/spec/src/ai/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,4 +207,79 @@ describe('defineTool', () => {
}
expect(message).toMatch(/skills?\/agents|skill\/agent/i);
});

// ── #6805 — the map folded into the shared `strictObject` template ────────
// `strictToolError` was a hand-written `$ZodErrorMap`, so no registry saw
// `TOOL_RETIRED_KEY_GUIDANCE` and nothing judged it (#6416's blind spot,
// which #6619's inventory recorded as closed while this one survived). The
// acceptance surface did not move — every case above is unchanged and still
// passes — so what these pin is the assembly: the two channels the template
// brings that the hand-rolled map did not have.

const rejectionMessage = (extra: Record<string, unknown>): string => {
const result = ToolSchema.safeParse({
name: 't', label: 'T', description: 'd', parameters: {}, ...extra,
});
expect(result.success).toBe(false);
return result.success ? '' : result.error.issues.map((i) => i.message).join('\n');
};

it.each([
['labl', 'label'],
['paramaters', 'parameters'],
['objectname', 'objectName'],
['outputSchem', 'outputSchema'],
])('a near-miss `%s` now gets the rename channel, not just "not a ToolSchema field"', (written, canonical) => {
// The hand-written map's fallback line named the problem and never the
// fix: every unprescribed key got "`x` is not a ToolSchema field." and
// stopped there. The template's edit-distance channel answers instead.
// This is the one reader-visible gain of the fold, so it is pinned rather
// than left as a claim in the PR body.
const message = rejectionMessage({ [written]: 'x' });
expect(message).toContain(`\`${written}\` → \`${canonical}\``);
expect(message).not.toContain('is not a ToolSchema field');
});

it('a key beyond edit distance is still named, with no misleading suggestion', () => {
// The other half of the same contract: the rename channel must stay quiet
// when it has nothing true to say. Suggesting SOMETHING for `notAToolField`
// would be ledger finding 7 — steering an author at a key they did not
// want — which is worse than the removed fallback line, not better.
const message = rejectionMessage({ notAToolField: 1 });
expect(message).toContain('`notAToolField`');
expect(message).not.toContain('Did you mean');
});

it('emission order: which key is wrong → the fix → the history, last (#5955)', () => {
// The template's ordering contract, asserted on this surface because the
// fold is what brings this surface under it. `history` sat in the middle
// until #5955 and pushed the fix past ~character 220 on the single-line
// renderers several consumers use.
const message = rejectionMessage({ requiresConfirmation: true, labl: 'x' });
const preamble = 'Unrecognized key(s) on the tool definition:';
const fix = 'action.ai.requiresConfirmation';
const history = 'the #1535 silent-strip class';

expect(message.startsWith(preamble)).toBe(true);
expect(message.indexOf(fix)).toBeGreaterThan(message.indexOf(preamble));
expect(message.indexOf(history)).toBeGreaterThan(message.indexOf(fix));
expect(message.trimEnd().endsWith(`${history}).`)).toBe(true);
// One history sentence per message, however many keys were written.
expect(message.split(history)).toHaveLength(2);
});

it('the prescriptions survive the fold byte-for-byte — the fold moved assembly, not text', () => {
// The whole value of `TOOL_RETIRED_KEY_GUIDANCE` is the text. A fold that
// quietly reworded a retirement prescription would be the defect this
// table exists to prevent, wearing the fold as cover.
expect(rejectionMessage({ permissions: ['x'] })).toContain(
'`tool.permissions` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — it '
+ 'promised a capability gate on tool invocation that nothing ever enforced',
);
expect(rejectionMessage({ builtIn: true })).toContain(
'`tool.builtIn` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no '
+ 'runtime branches on it; it never affected registration, selection or execution. Delete '
+ 'the key.',
);
});
});
52 changes: 32 additions & 20 deletions packages/spec/src/ai/tool.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
// ==========================================

import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';

/*
* REMOVED — `ToolCategorySchema` / `ToolCategory` (#3896 audit close-out).
Expand Down Expand Up @@ -71,26 +72,33 @@ const TOOL_RETIRED_KEY_GUIDANCE: Record<string, string> = {
};

/**
* Custom zod `error` for the `.strict()` ToolSchema.
* The standing history sentence for the tool surface, emitted LAST on every
* rejection — the shared template's `history` slot.
*
* `.strict()` matters more than usual here. Removing a key from a NON-strict
* schema replaces one silent no-op with another: the author keeps writing
* `requiresConfirmation: true`, zod strips it without a word, and the safety
* flag goes on meaning nothing — the exact "silent strip" ADR-0032 / #1535
* closed for objects. Rejecting loudly, with the prescription attached, is what
* turns the removal into a fix instead of a rename of the problem.
* `.strict()` matters more than usual here, and this sentence is why. Removing
* a key from a NON-strict schema replaces one silent no-op with another: the
* author keeps writing `requiresConfirmation: true`, zod strips it without a
* word, and the safety flag goes on meaning nothing — the exact "silent strip"
* ADR-0032 / #1535 closed for objects. Rejecting loudly, with the prescription
* attached, is what turns the removal into a fix instead of a rename of the
* problem.
*
* ## Why the slot, and why it was empty before (#6805)
*
* Until #6805 this file carried a hand-written `$ZodErrorMap` instead of the
* shared template, and #6416/#6619 recorded the reason it could not fold: the
* template appends `history` unconditionally (`${message} ${history}`) and this
* surface emitted no trailing sentence at all. That is a statement about the
* TEXT, not about the template — the surface has a real history, it simply had
* never been written down. Writing it is what makes the fold possible, and the
* fold is what puts `TOOL_RETIRED_KEY_GUIDANCE` — a hand-maintained per-key
* retirement table, the most rot-prone content the audit exists for — under
* `alias-integrity.test.ts` for the first time.
*/
const strictToolError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'unrecognized_keys') return undefined;
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
const lines = keys.map((key) =>
TOOL_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a ToolSchema field.`,
);
return (
`Unrecognized key(s) on the tool definition: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` +
lines.map((l) => ` • ${l}`).join('\n')
);
};
const TOOL_STRICT_HISTORY =
'Until this shape was closed an undeclared key was dropped without a word — the tool '
+ 'still registered and still reached the LLM tool set, minus whatever the key was meant '
+ 'to do (the #1535 silent-strip class).';

/**
* Tool Schema
Expand Down Expand Up @@ -121,7 +129,11 @@ const strictToolError: z.core.$ZodErrorMap = (issue) => {
* });
* ```
*/
export const ToolSchema = lazySchema(() => z.object({
export const ToolSchema = lazySchema(() => strictObject({
surface: 'the tool definition',
history: TOOL_STRICT_HISTORY,
guidance: TOOL_RETIRED_KEY_GUIDANCE,
}, {
/** Machine name (snake_case, globally unique) */
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Tool unique identifier (snake_case)'),

Expand Down Expand Up @@ -177,7 +189,7 @@ export const ToolSchema = lazySchema(() => z.object({
// ADR-0010 — runtime protection envelope (internal — set by loader).
...MetadataProtectionFields,

}, { error: strictToolError }).strict().describe('AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition (liveness audit #1878/#1892).'));
}).describe('AI tool definition. [READ-ONLY PROJECTION — not an execution entry point] Authoring a tool as metadata does NOT make it runnable: this schema has no `implementation`/`handler` field and no framework executor loads a metadata-authored tool. The runtime executes a separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); tool metadata is a one-way projection for Studio/discovery. Do not expect a hand-authored tool to run in the open edition (liveness audit #1878/#1892).'));

export type Tool = z.input<typeof ToolSchema>;

Expand Down
24 changes: 19 additions & 5 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,25 @@ export const AddressSchema = lazySchema(() => z.object({
*/
/**
* Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120
* §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`
* because `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ).
* §Terminology): 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` because
* `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ).
*
* ⚠️ **The last hand-written `$ZodErrorMap` in `packages/spec`, and it stays
* one.** This docblock used to say "pattern of `strictCapabilitiesError`";
* #6805 folded that sibling into the shared `strictObject` template and the
* pointer would have gone stale, so it is replaced by the reason this map is
* NOT following it. The fold's channel is `unrecognized_keys` — an unknown
* KEY, answered from a per-key `guidance` table. This map answers
* `invalid_union`, a VALUE-level verdict on a key the schema declares, which
* `strictObject` does not address at any level. Folding it would be a category
* error, and `alias-integrity.test.ts`'s class pin
* (`NO module outside the shared helpers writes its own unrecognized_keys
* map`) is scoped by `issue.code` precisely so this site is out of class by
* measurement rather than by an exemption — that pin reads this file as a live
* control.
*/
const uniqueScopeError: z.core.$ZodErrorMap = (issue) => {
if (issue.code !== 'invalid_union') return undefined;
Expand Down
50 changes: 50 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,56 @@ describe('ObjectCapabilities', () => {
const result = ObjectCapabilities.safeParse({ feedEnabled: true });
expect(result.success).toBe(false);
});

// ── #6805 — the map folded into the shared `strictObject` template ────────
// `strictCapabilitiesError` was a hand-written `$ZodErrorMap`, so
// `CAPABILITIES_RETIRED_KEY_GUIDANCE` registered in no registry and nothing
// judged it. Acceptance did not move (every case above is unchanged); these
// pin the assembly the template brings.

const capabilityRejection = (body: Record<string, unknown>): string => {
const result = ObjectCapabilities.safeParse(body);
expect(result.success).toBe(false);
return result.success ? '' : result.error.issues.map((i) => i.message).join('\n');
};

it.each([
['searchible', 'searchable'],
['trackHistroy', 'trackHistory'],
['clon', 'clone'],
['feed', 'feeds'],
])('a near-miss `%s` now gets the rename channel, not just "not an `enable` capability flag"', (written, canonical) => {
const message = capabilityRejection({ [written]: true });
expect(message).toContain(`\`${written}\` → \`${canonical}\``);
expect(message).not.toContain('is not an `enable` capability flag');
});

it('a key beyond edit distance is still named, with no misleading suggestion', () => {
const message = capabilityRejection({ feedEnabled: true });
expect(message).toContain('`feedEnabled`');
expect(message).not.toContain('Did you mean');
});

it('emission order: which key is wrong → the fix → the history, last (#5955)', () => {
const message = capabilityRejection({ trash: false, searchible: true });
const preamble = 'Unrecognized key(s) on `enable`:';
const fix = 'os migrate meta --from 16';
const history = 'every flag carries an enforcement contract (#2707)';

expect(message.startsWith(preamble)).toBe(true);
expect(message.indexOf(fix)).toBeGreaterThan(message.indexOf(preamble));
expect(message.indexOf(history)).toBeGreaterThan(message.indexOf(fix));
expect(message.trimEnd().endsWith(`${history}.`)).toBe(true);
expect(message.split(history)).toHaveLength(2);
});

it('the tombstone text survives the fold byte-for-byte', () => {
expect(capabilityRejection({ mru: true })).toContain(
'`enable.mru` was removed from @objectstack/spec in the 16.x line (#2377/#3207, '
+ 'ADR-0049) — Most-Recently-Used tracking was never implemented; no reader '
+ 'existed, so the flag changed nothing.',
);
});
});

describe('LifecycleSchema (ADR-0057)', () => {
Expand Down
Loading
Loading