From 28eaade226b025db1fac2c314b77cf0ee947bcd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:32:37 +0000 Subject: [PATCH 1/7] fix(spec)!: an aggregation entry refuses an undeclared key by name on every door AggregationNodeSchema becomes a strictObject (the unknown-key campaign's message, hints where/filters/conditions -> filter and as -> alias); the retired distinct keeps its tombstone sentence. REST POST /data/:object/query answered 200 with the key stripped; it now answers 400 VALIDATION_FAILED naming the key, before any read. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- ...regation-entry-undeclared-key-door.test.ts | 168 ++++++++++++++++++ packages/spec/src/data/query.zod.ts | 29 ++- 2 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 packages/rest/src/aggregation-entry-undeclared-key-door.test.ts diff --git a/packages/rest/src/aggregation-entry-undeclared-key-door.test.ts b/packages/rest/src/aggregation-entry-undeclared-key-door.test.ts new file mode 100644 index 00000000000..2e6fc6973d2 --- /dev/null +++ b/packages/rest/src/aggregation-entry-undeclared-key-door.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28609] An aggregation entry is closed at the producer: a key + * `AggregationNodeSchema` does not declare is refused by name, on the door a + * caller actually uses — `POST /api/v1/data/:object/query` → `RestServer` → + * `ObjectStackProtocolImplementation.findData` → `ObjectQL.aggregate` → a real + * sqlite `SqlDriver` (the harness `aggregation-filter-where-doors.test.ts` + * boots). + * + * Measured on the base through this door: an entry carrying `bogusKey` (or + * `where`, meant as the entry's own `filter`) answered `200` with the + * unfiltered counts, because the node was a plain `z.object` and zod's + * `.strip` default deleted the key before anyone read it. The MCP tool + * `aggregate_records` had refused the same entry by name since #28550, so one + * entry shape got two verdicts depending on the door. Now the node itself is a + * `strictObject`: REST, the runtime dispatcher's twin (both parse + * `FindDataRequestSchema`) and MCP (which takes the node as its entry) read + * the one refusal, word for word. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { AggregationNodeSchema } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'ledger_order'; + +const LEDGER_ORDER = { + name: OBJECT, + label: 'Ledger Order', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + }, +}; + +const ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 100 }, + { id: 'o2', customer_id: 'c1', amount: 400 }, + { id: 'o3', customer_id: 'c2', amount: 900 }, + { id: 'o4', customer_id: 'c2', amount: 300 }, + { id: 'o5', customer_id: 'c2', amount: 50 }, +]; + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +async function boot() { + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER_ORDER as any); + await engine.syncSchemas(); + await installDefaultOrganization(engine); + await engine.insert(OBJECT, ROWS as any); + + const calls = { aggregate: 0, find: 0 }; + const find = driver.find.bind(driver); + const aggregate = driver.aggregate.bind(driver); + driver.find = (o: string, ...rest: unknown[]) => { if (o === OBJECT) calls.find += 1; return find(o, ...rest); }; + driver.aggregate = (o: string, ...rest: unknown[]) => { if (o === OBJECT) calls.aggregate += 1; return aggregate(o, ...rest); }; + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(route).toBeDefined(); + const post = async (body: Record) => { + const res = makeRes(); + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); + return res; + }; + return { post, calls }; +} + +/** The refusal the spec node itself prints for one entry — the words every door shares. */ +function nodeRefusal(entry: Record): string { + const parsed = AggregationNodeSchema.safeParse(entry); + expect(parsed.success, 'the spec node refuses this entry').toBe(false); + return parsed.error!.issues[0]!.message; +} + +const grouped = (entry: Record) => ({ + groupBy: ['customer_id'], + aggregations: [{ function: 'count', alias: 'n' }, entry], +}); + +describe('[#28609] POST /data/:object/query — an aggregation entry refuses an undeclared key by name', () => { + const REFUSED: ReadonlyArray, string, string | undefined]> = [ + ['a slip (`bogusKey`)', { function: 'sum', field: 'amount', alias: 's', bogusKey: true }, 'bogusKey', undefined], + ['`where` meant as the entry\'s own `filter`', { function: 'count', alias: 'm', where: { amount: { $gt: 100 } } }, 'where', 'filter'], + ['`as` meant as `alias`', { function: 'sum', field: 'amount', alias: 's', as: 'total' }, 'as', 'alias'], + ]; + + for (const [name, entry, key, canonical] of REFUSED) { + it(`${name}: 400 VALIDATION_FAILED naming the key, before any read`, async () => { + const { post, calls } = await boot(); + const res = await post(grouped(entry)); + expect(res._status).toBe(400); + expect(res._json.code).toBe('VALIDATION_FAILED'); + expect(calls).toEqual({ aggregate: 0, find: 0 }); + const fields = res._json.fields as Array<{ field: string; message: string }>; + expect(fields.map((f) => f.field)).toContain('query.aggregations.1'); + const message = fields.find((f) => f.field === 'query.aggregations.1')!.message; + // The spec node's own words — the sentence MCP's `aggregate_records` relays too. + expect(message).toBe(nodeRefusal(entry)); + expect(message.split('\n')[0]).toContain(`\`${key}\``); + if (canonical) expect(message).toContain(`\`${key}\` → \`${canonical}\``); + }); + } + + it('CONTROL — the retired `distinct` keeps its tombstone sentence, not the generic refusal', async () => { + const { post, calls } = await boot(); + const entry = { function: 'sum', field: 'amount', alias: 's', distinct: true }; + const res = await post(grouped(entry)); + expect(res._status).toBe(400); + expect(res._json.code).toBe('VALIDATION_FAILED'); + expect(calls).toEqual({ aggregate: 0, find: 0 }); + const fields = res._json.fields as Array<{ field: string; message: string }>; + const tombstone = fields.find((f) => f.field === 'query.aggregations.1.distinct'); + expect(tombstone?.message.split(' — ')[0]).toBe('`query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049)'); + expect(fields.some((f) => f.message.startsWith('Unrecognized key'))).toBe(false); + }); + + it('CONTROL — entries with only declared members answer as before, filtered and unfiltered', async () => { + const { post, calls } = await boot(); + const res = await post({ + groupBy: ['customer_id'], + aggregations: [ + { function: 'count', alias: 'n' }, + { function: 'sum', field: 'amount', alias: 's' }, + { function: 'count', alias: 'big', filter: { amount: { $gt: 100 } } }, + ], + orderBy: [{ field: 'customer_id', order: 'asc' }], + }); + expect(res._status ?? 200).toBe(200); + const rows = (res._json.records ?? res._json.data) as Array>; + const byCustomer = Object.fromEntries(rows.map((r) => [r.customer_id, { n: Number(r.n), s: Number(r.s), big: Number(r.big) }])); + expect(byCustomer).toEqual({ c1: { n: 2, s: 500, big: 1 }, c2: { n: 3, s: 1250, big: 2 } }); + expect(calls.aggregate + calls.find).toBeGreaterThan(0); + }); +}); diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 71a0e529697..484652ddff6 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -270,8 +270,32 @@ const AGGREGATION_DISTINCT_REMOVED = * ], * groupBy: ['lead_source'] * } + * + * Closed (#28609): a key this node does not declare is refused by name, with + * the unknown-key campaign's message, on every door that takes an entry — + * `POST /data/:object/query` (REST and the runtime dispatcher, both through + * `FindDataRequestSchema`), `EngineAggregateOptionsSchema.aggregations`, and the + * MCP tool `aggregate_records`, which takes this node as its entry. Before, the + * key was stripped and the call answered; MCP alone refused it, from a copy of + * the entry (#28550), so one entry shape got two verdicts by door. The retired + * `distinct` keeps its own tombstone sentence: a declared-but-unwritable key is + * answered by its prescription, never by the generic refusal. */ -export const AggregationNodeSchema = lazySchema(() => z.object({ +export const AggregationNodeSchema = lazySchema(() => strictObject( + { + surface: 'this aggregation entry', + history: + 'Until this shape was closed (#28609), an undeclared key here was dropped silently and the ' + + 'aggregation answered as if it had not been sent — a `where` meant as the entry\'s own `filter` ' + + 'computed over every row of the group under an ordinary 200.', + // `where` is the QUERY's filter one level up; the entry's own predicate is + // `filter`. `filters` / `conditions` are the plural and the third-party + // spellings of the same intent, and `as` is SQL's alias keyword. The MCP + // tool `aggregate_records` hinted exactly these from its own copy of the + // entry (#28550) until this node closed and the copy folded into it. + aliases: { where: 'filter', filters: 'filter', conditions: 'filter', as: 'alias' }, + }, + { function: AggregationFunction.describe('Aggregation function'), field: z.string().optional().describe('Field to aggregate (optional for COUNT(*))'), alias: z.string().describe('Result column alias'), @@ -295,7 +319,8 @@ export const AggregationNodeSchema = lazySchema(() => z.object({ * and avg/min/max answer null (`emptyGroupValueFor`, aggregation-policy.ts). */ filter: FilterConditionSchema.optional().describe('Per-aggregation filter (SQL FILTER (WHERE …) semantics): narrows the source rows THIS aggregation reads, leaving sibling aggregations unfiltered. Enforced by engine.aggregate: lowered in memory for drivers without native conditional aggregation; a driver reached directly refuses rather than silently dropping it.'), -})); +}, +)); // ─── Joins: REMOVED (#4286, ADR-0049) ──────────────────────────────────────── // The whole join cluster — `JoinType`, `JoinStrategy`, the internal From edb20d07d384b3a8b61b9c85151cb5b63c566530 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:33:58 +0000 Subject: [PATCH 2/7] refactor(mcp): aggregate_records takes the spec's closed aggregation node whole The tool's own closed copy of the entry folds into AggregationNodeSchema, which now refuses undeclared keys at the producer: one refusal, the REST door's words, and the retired distinct answers its tombstone on both doors. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- .../mcp-http-tools.aggregation-entry.test.ts | 33 +++++++-- packages/mcp/src/mcp-http-tools.ts | 67 ++++--------------- 2 files changed, 42 insertions(+), 58 deletions(-) diff --git a/packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts b/packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts index 938db499515..14a216767aa 100644 --- a/packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts +++ b/packages/mcp/src/mcp-http-tools.aggregation-entry.test.ts @@ -23,10 +23,20 @@ * * CONTROLS. Entries without a filter reach the bridge exactly as before, and * the top-level unknown-key posture is unchanged. + * + * [#28609] The entry is now the spec's `AggregationNodeSchema` itself, closed at + * the producer, instead of this tool's own closed copy of its members. So a + * refused entry reads here in the very words the REST query door prints for + * the same body (`packages/rest/src/aggregation-entry-undeclared-key-door.test.ts` + * pins that half against the same spec message), and the retired `distinct` + * answers its tombstone sentence on both doors. `tools/list` declares + * `distinct` as a schema nothing satisfies (`not: {}`), with the removal text. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { AggregationNodeSchema } from '@objectstack/spec/data'; + import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { McpDataBridge } from './mcp-http-tools.js'; @@ -54,6 +64,13 @@ function makeBridge(): RecordingBridge { let nextId = 1; +/** The refusal the spec node prints for one entry — the words the REST door answers too. */ +function nodeRefusal(entry: Record): string { + const parsed = AggregationNodeSchema.safeParse(entry); + expect(parsed.success, 'the spec node refuses this entry').toBe(false); + return parsed.error!.issues[0]!.message; +} + async function rpc(runtime: MCPServerRuntime, bridge: RecordingBridge, method: string, params?: unknown) { const body = { jsonrpc: '2.0', id: nextId++, method, ...(params === undefined ? {} : { params }) }; const req = new Request('http://localhost/api/v1/mcp', { @@ -124,6 +141,8 @@ describe('aggregate_records — the aggregation entry (#28550)', () => { }); expect(refused).toBe(true); expect(text).toContain('bogusKey'); + // [#28609] The spec node's own sentence, the one REST prints for this entry. + expect(text).toContain(nodeRefusal({ function: 'count', alias: 'n', bogusKey: 1 })); expect(bridge.calls).toHaveLength(0); }); @@ -135,16 +154,18 @@ describe('aggregate_records — the aggregation entry (#28550)', () => { expect(refused).toBe(true); expect(text).toContain('where'); expect(text).toContain('filter'); + expect(text).toContain(nodeRefusal({ function: 'count', alias: 'n', where: { status: 'active' } })); expect(bridge.calls).toHaveLength(0); }); - it('the retired `distinct` entry key is refused, not dropped', async () => { + it('the retired `distinct` entry key is refused with its tombstone sentence, as on REST', async () => { const { refused, text } = await aggregate(runtime, bridge, { objectName: 'showcase_project', aggregations: [{ function: 'sum', field: 'budget', alias: 's', distinct: true }], }); expect(refused).toBe(true); - expect(text).toContain('distinct'); + expect(text).toContain('`query.aggregations[].distinct` was removed in @objectstack/spec 17 (ADR-0049)'); + expect(text).not.toContain('Unrecognized key'); expect(bridge.calls).toHaveLength(0); }); @@ -170,13 +191,17 @@ describe('aggregate_records — the aggregation entry (#28550)', () => { expect(bridge.calls).toHaveLength(0); }); - it('`tools/list` advertises the entry as closed, with `filter` declared', async () => { + it('`tools/list` advertises the entry as closed, with `filter` declared and `distinct` unsatisfiable', async () => { const json = await rpc(runtime, bridge, 'tools/list'); const tool = (json.result?.tools ?? []).find((t: any) => t.name === 'aggregate_records'); expect(tool).toBeDefined(); const entry = tool.inputSchema?.properties?.aggregations?.items; expect(entry?.additionalProperties).toBe(false); - expect(Object.keys(entry?.properties ?? {}).sort()).toEqual(['alias', 'field', 'filter', 'function']); + expect(Object.keys(entry?.properties ?? {}).sort()).toEqual(['alias', 'distinct', 'field', 'filter', 'function']); + // [#28609] The tombstone: declared so the refusal carries its prescription, + // and advertised as a schema no value satisfies. + expect(entry?.properties?.distinct?.not).toEqual({}); + expect(entry?.properties?.distinct?.description).toMatch(/^\[REMOVED\] /); expect(entry?.required).toEqual(expect.arrayContaining(['function', 'alias'])); }); }); diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 72a5e1b6fd3..d79012f8a8e 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -39,9 +39,10 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { EngineAggregateOptions } from '@objectstack/spec/data'; -// [#28550] The engine's aggregation entry. Each `aggregate_records` entry is -// built from ITS members, so this door and the REST query door (which parses -// `FindDataRequestSchema`, whose `aggregations` are this node) judge one shape. +// [#28550, #28609] The engine's aggregation entry. Each `aggregate_records` +// entry IS this node, so this door and the REST query door (which parses +// `FindDataRequestSchema`, whose `aggregations` are this node) judge one shape +// with one refusal. import { AggregationNodeSchema } from '@objectstack/spec/data'; // [#23529] The catalogue's spelling of `PERMISSION_DENIED`, the code whose // `details` `errorResultFromThrown` withholds. Imported, never hand-spelled. @@ -693,56 +694,6 @@ function strictToolInput( .strict(); } -/** - * [#28550] One `aggregate_records` aggregation entry: closed, and carrying the - * per-aggregation `filter`. - * - * The entry used to be a plain `z.object({ function, field, alias })`. Zod's - * default for an object is `.strip`, and {@link strictToolInput} closes only the - * tool's TOP-LEVEL keys, so an entry's `filter` was deleted before the handler - * ran: `{ function: 'count', alias: 'n', filter: { status: 'active' } }` answered - * the unconditional count, beside the REST query door answering the filtered - * one for the same body. Any other unknown entry key vanished the same way. - * - * Both halves of the fix are the entry's shape, nothing else: - * - * - **`filter` is declared**, and it is the engine's own member, - * `AggregationNodeSchema.shape.filter` (`FilterConditionSchema`), the slot the - * REST door parses. The handler forwards `aggregations` to the bridge - * unchanged, and every bridge hands them to `engine.aggregate`, which applies - * the filter and runs it through the same refusal doors as `where` (shape, - * unknown field, and the security middleware's field-read check). ⛔ No filter - * is evaluated here: a second evaluator in this package would be a second - * answer to the same question. - * - **The entry is strict**, through {@link strictToolInput}, so an undeclared - * entry key is refused by name, as an undeclared top-level key already is. - * The aliases are semantic near-misses for `filter` and `alias`; they are - * named in the refusal and never parsed. - * - * `function`, `field` and `alias` are the engine's members too. Each keeps the - * description this tool advertised before; `.describe` copies the schema and - * leaves its parse unchanged. - */ -function aggregationEntrySchema() { - const node = AggregationNodeSchema.shape; - return strictToolInput( - { - surface: 'this aggregate_records aggregation entry', - aliases: { where: 'filter', filters: 'filter', conditions: 'filter', as: 'alias' }, - }, - { - function: node.function.describe('Aggregation function'), - field: node.field.describe('Field to aggregate (omit for count(*))'), - alias: node.alias.describe('Result column name'), - filter: node.filter.describe( - 'Optional per-aggregation filter, e.g. {"status":"active"}: only rows matching it feed THIS ' - + 'aggregation, while the other aggregations in the call still read every row. Same operators ' - + 'as where.', - ), - }, - ); -} - /** * The spellings every object-scoped tool shares, split out because they are the * same guess wherever an object name is taken. @@ -1233,7 +1184,15 @@ export function registerObjectTools( { objectName: z.string().describe('The object/table name'), aggregations: z - .array(aggregationEntrySchema()) + // [#28550 → #28609] One entry is the engine's own node, taken whole: + // `AggregationNodeSchema` is closed at the producer, so an + // undeclared entry key is refused here in the words the REST + // query door prints for the same body (`aliases` and the retired + // `distinct`'s tombstone included), and `filter` reaches the + // bridge unchanged for `engine.aggregate` to apply. ⛔ No local + // copy of the entry: the copy this tool closed by itself (#28550) + // was a second key list, which this node now is. + .array(AggregationNodeSchema) .min(1) .describe( 'Metrics to compute, e.g. [{"function":"sum","field":"amount","alias":"total"},' From d3e0f31f79490a445d2d56383dc523b1a98f5e34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:45:53 +0000 Subject: [PATCH 3/7] test: the two query doors and the REST gate table refuse an undeclared aggregation-entry key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- packages/rest/src/request-schema-gate.conformance.test.ts | 4 ++++ packages/runtime/src/domains/data-write-request-gate.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/rest/src/request-schema-gate.conformance.test.ts b/packages/rest/src/request-schema-gate.conformance.test.ts index 6d082a4329b..6a864dfd93e 100644 --- a/packages/rest/src/request-schema-gate.conformance.test.ts +++ b/packages/rest/src/request-schema-gate.conformance.test.ts @@ -117,6 +117,10 @@ const CASES: GateCase[] = [ // fallback and an ordinary sum on every SQL datasource — one query, two // numbers, both plausible. { aggregations: [{ function: 'sum', field: 'amount', alias: 'total', distinct: true }] }, + // #28609 — an undeclared aggregation-entry key. The node was a plain + // object, so `bogusKey` was stripped and the query answered; it is + // closed at the producer now, as the MCP tool's entry already was. + { aggregations: [{ function: 'count', alias: 'n', bogusKey: true }] }, // #23574 — `null` was read as no query (`req.body ?? {}`) and answered // the full record set; `query` declares a QueryAST or nothing. null, diff --git a/packages/runtime/src/domains/data-write-request-gate.test.ts b/packages/runtime/src/domains/data-write-request-gate.test.ts index 540b0713263..94f2fc9d812 100644 --- a/packages/runtime/src/domains/data-write-request-gate.test.ts +++ b/packages/runtime/src/domains/data-write-request-gate.test.ts @@ -417,6 +417,9 @@ describe('[#23491] the two POST doors answer each non-record body alike', () => const QUERY_REFUSED: ReadonlyArray = [ ['a mistyped clause, `limit: "ten"`', { limit: 'ten' }, 'query.limit'], ['the retired `joins`', { joins: [] }, 'query.joins'], + // [#28609] An aggregation entry is closed at the producer + // (`AggregationNodeSchema`): both doors stripped `bogusKey` and answered. + ['an aggregation entry\'s undeclared key', { aggregations: [{ function: 'count', alias: 'n', bogusKey: true }] }, 'query.aggregations.0'], ['a string', 'garbage', 'query'], ['an array', [{}], 'query'], ['a number', 42, 'query'], From 7a0a298972711619ebb2921787cf39baca1f5ab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:49:26 +0000 Subject: [PATCH 4/7] chore: changeset and strictness ledger for the closed aggregation entry Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- .changeset/28609-aggregation-entry-strict.md | 20 +++++++++++++++++++ .../data.md | 10 +++++----- .../2026-07-unknown-key-strictness-ledger.md | 7 ++++--- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 .changeset/28609-aggregation-entry-strict.md diff --git a/.changeset/28609-aggregation-entry-strict.md b/.changeset/28609-aggregation-entry-strict.md new file mode 100644 index 00000000000..1603fa66461 --- /dev/null +++ b/.changeset/28609-aggregation-entry-strict.md @@ -0,0 +1,20 @@ +--- +'@objectstack/spec': patch +'@objectstack/mcp': patch +--- + +fix(spec)!: an aggregation entry refuses an undeclared key by name on every door, and MCP's `aggregate_records` reads the same refusal (#28609) + +Clause-②: no (narrowing) — `AggregationNodeSchema` refuses an entry key it does not declare, which the REST query door stripped and ignored before; no key, value or export is added. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing of a query's aggregation entry (`AggregationNodeSchema`, `@objectstack/spec`), at every door that takes one. + +- **Before.** The entry was a plain object, so zod deleted a key it does not declare and the call answered: `POST /api/v1/data/:object/query` with `{"function":"sum","field":"amount","alias":"s","bogusKey":true}` returned 200 and the sum, and an entry `where` meant as the entry's own `filter` computed over every row of the group. The runtime dispatcher's `/data/:object/query` did the same. The MCP tool `aggregate_records` alone refused the key, from its own closed copy of the entry (#28550), so one entry shape got two verdicts depending on the door. +- **Now.** The node is a `strictObject`. Every door that parses it (REST and the runtime dispatcher through `FindDataRequestSchema`, `EngineAggregateOptionsSchema.aggregations`, and `aggregate_records`, which now takes the node whole instead of its own copy) answers one refusal, word for word: `Unrecognized key(s) on this aggregation entry: KEY.` plus the near-miss hint, at `400 VALIDATION_FAILED` with `fields[]` on REST and as the tool's input error on MCP. Nothing reaches the engine. The retired `distinct` keeps its tombstone sentence on both doors (MCP used to answer it with the generic unknown-key refusal), and `tools/list` now lists `distinct` as a schema no value satisfies (`not: {}`) with its removal text. +- **Unchanged.** Entries that carry only `function`, `field`, `alias` and `filter` answer as before on every door. This supersedes the #28550 entry's "the REST query door is untouched": it is closed here. +- **Who sees it.** A REST, dispatcher or in-process caller that sent an extra key inside an aggregation entry: the query was answered with that key ignored, and it is now refused naming the key. + +**The remedy.** Drop the key; for a per-aggregation condition send it as the entry's `filter`, and for the result column name use `alias`. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index a77032c64eb..6e725764dd2 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 156 | 90 | 2 | 0 | 64 | +| `data/` | 156 | 91 | 2 | 0 | 63 | ## `data/` — sites @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**64 strip of 156**, in 10 file(s). +**63 strip of 156**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -77,15 +77,15 @@ over it is here. | `field.zod.ts` | 1 | 15 | | `filter.zod.ts` | 11 | 13 | | `hook.zod.ts` | 5 | 8 | -| `query.zod.ts` | 4 | 5 | +| `query.zod.ts` | 3 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **64** | **156** | +| **total** | **63** | **156** | | Bucket | Sites | |---|---| | authorable — the ruling's forced scope | 0 | | unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 63 | +| wire / open — out of forced scope | 62 | | no door — no carrier, ADR-0049 territory | 1 | | no gate — carrier live, no parse | 0 | | covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index b2f6c8c13cc..43bc511c989 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -719,7 +719,7 @@ column does not move and the `strip` column falls by the count of what left. | `seed-loader.zod.ts` | wire | **re-verdicted 2026-08-14 (#4001 batch D)** — the `(p)` split's "authored" half dissolved under measurement. The seed FILE shape this file's old note meant is `SeedSchema` (`seed.zod.ts`, a registered type, strict since the registered-types batch) — it enters this file only as the `seeds[]` VALUE inside `SeedLoaderRequestSchema`, so the authored half was never one of these 12 sites. Every producer of the request/config halves is framework code, enumerated: `runtime/app-plugin.ts` ×3 (org replay / inline seed / hot-reload), `runtime/domains/packages.ts`, `metadata-protocol/protocol.ts` `applySeedBodies`, `metadata-protocol/seed-loader.ts` `validate()`, `cloud-connection` marketplace install — all `.parse()`/`.safeParse()` over config LITERALS written in code; no `defineStack` key, no metadata type, no Studio form, no CLI flag writes a loader config. `SeedIdentity` is server-constructed by design (`execution-context.zod.ts`: "never client-supplied"); graph/resolution/result/error shapes are built by the loader itself (`buildDependencyGraph`, `buildResult`). BFS from all 26 metadata-type roots + `ObjectStackSchema` (6689 nodes; `ObjectSchema` positive control REACHABLE, fresh uncarried shape negative control UNREACHABLE, synthetic-carrier flip green, same run): all 12 UNREACHABLE. Same class as `data-engine.zod.ts` — an internal service contract (`ISeedLoaderService`), machine-fed at every door | | `field.zod.ts` | authorable | partially strict | | `filter.zod.ts` | open | query dialect — user data flows through the predicate values; validated semantically elsewhere | -| `query.zod.ts` | open, **except `SortNodeSchema` → authorable** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites are the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and keep the class. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) | +| `query.zod.ts` | open, **except `SortNodeSchema` and `AggregationNodeSchema` → strict** | Blanket `open` was the imprecise verdict here, not the strictness. Four sites were the dialect proper (`BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm) and kept the class; three still do. **`AggregationNodeSchema` is strict as of #28609** (`strictObject` + `aliases: { where, filters, conditions → filter; as → alias }`, the retired `distinct` keeping its tombstone): the MCP tool `aggregate_records` had closed its own copy of the entry (#28550) while `POST /data/:object/query` stripped the same undeclared key and answered, so one entry got two verdicts by door; closing the node at the producer gave every door the one refusal. `SortNodeSchema` is not dialect: a closed two-key tuple `{field, order}` with **no user-data face at all** — so #4721 carved it out and it is **strict as of #4721** (`strictObject` + `aliases: { direction: 'order' }`). What that bought, measured on `main` first: `SortNodeSchema.parse({field, direction:'desc'})` → `{field, order:'asc'}` — the sort ran the OTHER WAY, and with `limit` that is a different set of rows under an ordinary 200. Per the 11:41Z ruling on #4721 this is a NEW door, not the completion of #4371's: that check is a hand-written top-level allowlist in `objectql/src/engine.ts` (`rejectUnknownEngineOptions`) that never recurses into `orderBy[]`, and `QuerySchema` itself is **not** strict (probe: `QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true`) — top-level strictness is #4001's, tracked separately. Site history: one site dropped in #4196 (`FieldNodeSchema`'s nested-select object form narrowed to `z.string()`); four more in #4286 with the `joins`/`windowFunctions` removals (`JoinNodeBaseSchema`, `WindowFunctionNodeSchema`, `WindowSpecSchema`'s outer + `frame`) | | `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | wire | driver capability contracts | | `datasource.zod.ts` | authorable | **strict as of #4001 data step** — all 6: `DatasourceSchema` (+ `pool` / `ssl`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. **#4583 B/C dropped two more sites**: the `healthCheck` and `retryPolicy` blocks are gone — nothing scheduled a probe and nothing retried, so their strictness was validating a shape no code consumed. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged | `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | @@ -1260,7 +1260,7 @@ triage row record which one was taken. | `filter.zod.ts` | open | **out of scope** — query dialect; user data flows through, validated semantically elsewhere | | `driver-nosql.zod.ts` | wire | **out of scope** | | `driver.zod.ts` | wire | **out of scope** — driver capability contract | -| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites keep `open`. Those 4 are the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721 | +| `query.zod.ts` | open | ~~⚠️ classification conflict — see #4721~~ **RESOLVED (11:41Z ruling, closed by #4721).** The conflict was real and the answer was that per-FILE classification was the imprecise instrument: `SortNodeSchema` was carved out as `authorable` and closed (`strictObject` + `aliases: { direction: 'order' }`), the other 4 sites kept `open`. Those 4 were the dialect proper — `BaseQuerySchema`, `AggregationNodeSchema`, `FullTextSearchSchema`, `GroupByNodeSchema`'s object arm — and `BaseQuerySchema`'s own top-level strictness is #4001's to schedule, deliberately **not** taken by #4721. #28609 then closed `AggregationNodeSchema` (one entry, one refusal on REST, the dispatcher and MCP), leaving 3 | | `external-catalog.zod.ts` | wire (p) | **out of scope** | | `hook.zod.ts` | wire | **out of scope** — `HookContextSchema` + `.session`/`.provenance`/`.user` are the runtime shape handed to a handler; verified in the data step | | `field.zod.ts` | no door | ⛔ **not strictness work — re-verdicted 2026-08-13 (#4001 data batch).** *(2026-10-28, #27182: `CurrencyValueSchema` and its `CurrencyValue` type are DELETED — registered as `RETIRED_DEFS_BY_MAJOR[18]` `data/CurrencyValue` with the D3 entry `currency-value-schema-retired`, ADR-0104 D1 carried out ahead of the multi-currency epic #8345. One strip site remains, `LocationCoordinatesSchema`, and the verdict below stands for it.)* This row's own instruction was "check whether they are record data (→ open) before closing", and the measured answer is the THIRD one: neither authorable nor open. Both remaining strip sites (`LocationCoordinatesSchema`, `CurrencyValueSchema`) are `@deprecated` DEAD EXPORTS that contradict the enforced value contract — `currency` stores a BARE NUMBER everywhere (validator, SQL driver `float` column, import coercion, field-zoo oracle), `location` stores `{lat, lng}` not `{latitude, longitude}`. Carrier: absent — no schema in the tree references either, so unreachability from every authoring root holds by construction and no BFS is needed (the only non-test references are two `type-alias-convention.pin.test.ts` rows). Parse: absent outside their own `field.test.ts` cases. Consumers' vocabulary: absent — zero references in objectui; `field-value.test.ts` pins from the other direction that the enforced contract REJECTS the retired `CurrencyValueSchema` object shape. The ADR-0049 answer this class prescribes already exists: ADR-0104's "Reality wins" section decides both removals ("an exported-but-unconsumed value schema is exactly the inert metadata ADR-0078 forbids"), the JSDoc deprecations are on `main` with "Removal rides the next spec major", and the removal is tracked at **#8562** — this row points there, never at a batch. Closing them instead would be #4583's precisely-validated dead slot, and worse than most instances of it: a `strictObject` `surface` name plus did-you-mean suggestions on a shape authors must NOT use is an invitation dressed as enforcement, on the exact spelling (`{value, currency}` / `{latitude, longitude}`) the real contract rejects. The third named shape the old row carried, `Address`, was never this file's site — `AddressSchema` is DECLARED in `field-value.zod.ts` since #7127 and only re-exported here | @@ -1287,7 +1287,8 @@ until #8075 retired the whole file under ADR-0049 — its per-schema read arrive zero-consumer verdict, and the strictness question died with the shapes.) The rest is wire/open and out of the ruling's forced scope; that count fell by one when #4721 closed `query.zod.ts`'s `SortNodeSchema`, the one row in this directory where the per-schema read moved a site OUT -of `open` rather than confirming it, and by one more when **#4001 batch B** closed +of `open` rather than confirming it, by one when #28609 closed the same file's +`AggregationNodeSchema` (a door-parity close: MCP already refused the entry key REST stripped), and by one more when **#4001 batch B** closed `driver/memory.zod.ts`'s remaining 5 sites (the persistence-adapter union under `datasource.config` — `PersistenceAdapterSchema`, `FilePersistenceConfigSchema`, `LocalStoragePersistenceConfigSchema`, `CustomPersistenceConfigSchema`, From a40eee9d1d761f45d8577722d798dff01e770a1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:50:03 +0000 Subject: [PATCH 5/7] test(spec): pin the closed aggregation entry at the producer Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- packages/spec/src/data/query.test.ts | 59 ++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/data/query.test.ts b/packages/spec/src/data/query.test.ts index a87b9328e6a..c7982b3b8d5 100644 --- a/packages/spec/src/data/query.test.ts +++ b/packages/spec/src/data/query.test.ts @@ -189,6 +189,50 @@ describe('FieldNode — the nested-select object form is REMOVED', () => { * (`direction` → `order` is not a typo), which is why the alias table is the * load-bearing half. */ +describe('AggregationNode — closed: an undeclared entry key is refused by name (#28609)', () => { + it('refuses a slip, naming the surface and the key — it used to be stripped and the call answered', () => { + const r = AggregationNodeSchema.safeParse({ function: 'sum', field: 'amount', alias: 's', bogusKey: true }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.code).toBe('unrecognized_keys'); + expect(r.error!.issues[0]!.message).toMatch(/^Unrecognized key\(s\) on this aggregation entry: `bogusKey`\./); + }); + + it('names `filter` for `where` / `filters` / `conditions`, and `alias` for `as`', () => { + for (const [key, canonical] of [['where', 'filter'], ['filters', 'filter'], ['conditions', 'filter'], ['as', 'alias']] as const) { + const r = AggregationNodeSchema.safeParse({ function: 'count', alias: 'n', [key]: key === 'as' ? 'x' : { a: 1 } }); + expect(r.success, key).toBe(false); + expect(r.error!.issues[0]!.message, key).toContain(`\`${key}\` → \`${canonical}\``); + } + }); + + it('the retired `distinct` answers its tombstone, not the generic refusal, and is never suggested', () => { + const tomb = AggregationNodeSchema.safeParse({ function: 'sum', field: 'amount', alias: 's', distinct: true }); + expect(tomb.error!.issues.map((i) => i.code)).toEqual(['invalid_type']); + const typo = AggregationNodeSchema.safeParse({ function: 'sum', field: 'amount', alias: 's', distinc: true }); + expect(typo.error!.issues[0]!.message).not.toContain('`distinct`'); + }); + + it('closes the entry through `QueryAST.aggregations` and `EngineAggregateOptionsSchema.aggregations`', () => { + const entry = { function: 'count', alias: 'n', bogusKey: 1 }; + for (const r of [ + QuerySchema.safeParse({ object: 'order', aggregations: [entry] }), + EngineAggregateOptionsSchema.safeParse({ aggregations: [entry] }), + ]) { + expect(r.success).toBe(false); + expect(JSON.stringify(r.error!.issues)).toContain('Unrecognized key(s) on this aggregation entry: `bogusKey`'); + } + }); + + it('CONTROL — entries with only declared members parse as before', () => { + const entries = [ + { function: 'count', alias: 'n' }, + { function: 'sum', field: 'amount', alias: 's' }, + { function: 'count', alias: 'big', filter: { amount: { $gt: 100 } } }, + ]; + for (const e of entries) expect(AggregationNodeSchema.parse(e)).toEqual(e); + }); +}); + describe('SortNode — closed, and `direction` carries its translation', () => { it('accepts the canonical two-key node, defaulting `order` to asc', () => { expect(SortNodeSchema.parse({ field: 'updated_at', order: 'desc' })) @@ -240,7 +284,7 @@ describe('SortNode — closed, and `direction` carries its translation', () => { it('leaves the REST of the query dialect open — the carve-out is this schema only', () => { // `query.zod.ts` stays classed `open` in the strictness ledger; only - // `SortNodeSchema` was re-classed authorable. Pinned so a later blanket + // `SortNodeSchema` and (#28609) `AggregationNodeSchema` were closed. Pinned so a later blanket // `.strict()` on the file is a deliberate decision with its own ruling // rather than a side effect of this change (#4001 tracks the top level). expect(QuerySchema.safeParse({ object: 'sales', nonsenseKey: 1 }).success).toBe(true); @@ -738,12 +782,13 @@ describe('AggregationNode.distinct — REMOVED (ADR-0049)', () => { } }); - it('a legal aggregation parses and carries NO `distinct` property (non-strict strip path)', () => { - // `AggregationNodeSchema` is `z.object()`, not `.strict()`. A bare - // deletion would therefore have made zod SILENTLY STRIP a caller's - // `distinct` — replacing a divergent flag with an ignored one, which is - // the #3733 / ADR-0104 shape. The tombstone is what makes the removal - // audible; this pins that the surviving members are untouched by it. + it('a legal aggregation parses and carries NO `distinct` property', () => { + // When `distinct` was retired `AggregationNodeSchema` was `z.object()`, + // not `.strict()`, so a bare deletion would have made zod SILENTLY STRIP + // a caller's `distinct` — the #3733 / ADR-0104 shape. The tombstone made + // the removal audible; since #28609 the node is closed too, and the + // tombstone still answers first (the block below). This pins that the + // surviving members are untouched by it. const parsed = AggregationNodeSchema.parse({ function: 'sum', field: 'amount', alias: 'total' }); expect(parsed).not.toHaveProperty('distinct'); expect(parsed).toEqual({ function: 'sum', field: 'amount', alias: 'total' }); From 3f3028254cc062e0a837eb2a71b45b41ec67505d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:13:29 +0000 Subject: [PATCH 6/7] fix(spec): keep the issue id out of the aggregation entry's customer-facing history sentence Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- packages/spec/src/data/query.zod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 484652ddff6..524d8598c09 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -285,7 +285,7 @@ export const AggregationNodeSchema = lazySchema(() => strictObject( { surface: 'this aggregation entry', history: - 'Until this shape was closed (#28609), an undeclared key here was dropped silently and the ' + 'Until this shape was closed, an undeclared key here was dropped silently and the ' + 'aggregation answered as if it had not been sent — a `where` meant as the entry\'s own `filter` ' + 'computed over every row of the group under an ordinary 200.', // `where` is the QUERY's filter one level up; the entry's own predicate is From 09f089de54fbb0fd6adaacc09b021d3ad47509c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:32:56 +0000 Subject: [PATCH 7/7] chore(spec): regenerate the strictness ledger counts on the merged tree Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5m158G1nvmxNYr7Jwq --- .../data.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 6e725764dd2..a60c482ffbc 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 156 | 91 | 2 | 0 | 63 | +| `data/` | 158 | 93 | 2 | 0 | 63 | ## `data/` — sites @@ -50,14 +50,14 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 8 | | `mapping.zod.ts` | 5 | -| `object.zod.ts` | 23 | +| `object.zod.ts` | 25 | | `picklist.zod.ts` | 3 | | `query.zod.ts` | 5 | | `seed-file.zod.ts` | 2 | | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **156** | +| **total** | **158** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**63 strip of 156**, in 10 file(s). +**63 strip of 158**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -79,7 +79,7 @@ over it is here. | `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 3 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **63** | **156** | +| **total** | **63** | **158** | | Bucket | Sites | |---|---|