From 22847cbbb6b1aa881bf260ede2822ad41770d182 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:32:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec)!:=20`HookContext.api`=20=E4=BB=8E=20?= =?UTF-8?q?z.unknown()=20=E6=94=B6=E7=AA=84=E4=B8=BA=E6=9C=80=E5=B0=8F=20I?= =?UTF-8?q?ScopedContext=20(#5945)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者裁决 C:`HookContext.api` 不再是 `z.unknown()`,改为指向 `packages/spec/src/contracts/scoped-context.ts` 里新增的 `IScopedContext` / `IScopedObjectRepository`(与 IDataEngine / IObjectQLEngine 同层同风格,含 evidence-bar 模块头)。 声明面 = 语料库实测的调用点,不多不少: IScopedContext object(name) + transaction(cb, opts?) IScopedObjectRepository find / findOne / count / insert / update / updateById upsert / delete / aggregate / create 只出现在文档的方法表与能力表里、没有任何 调用点(表格不过编译器);sudo() 的三个调用方全部把值持成 any 且它是提权动作。 一律不声明,等到有调用点再按同一条规则加 —— 与 IDataEngine (#4251) 同款纪律。 运行时零变化:Zod 侧仍是 z.unknown(),收窄是静态 cast(与 object.zod.ts 的 ObjectCapabilities.apiMethods 同一惯用法)。z.custom 试过,它让 HookContext 在 JSON Schema 里不可表达 —— gen:schema 直接不再产出 json-schema/data/HookContext.json, 会在下次 gen:docs 抹掉参考页(#2978),故不用。接受的值、JSON Schema、生成的参考页 行全部不变,只有 .describe() 文案改了。 漂移由编译器盯着:objectql 的 ScopedContext / ObjectRepository 声明 implements。 实测把 updateById 改名,objectql 的 tsc 在 implements 处 + 五个 hook 派发点同时报错。 content/docs/kernel/runtime-services/examples.mdx 里那段 os:check 块删掉了自建的 `type CrossObjectApi` + `ctx.api as CrossObjectApi`,改为直接读契约。 scripts/engine-double-contract.baseline.json 新增两条 EXEMPT:两个新 fake 是 IScopedObjectRepository 的类型符合性见证,不是 engine double(scoped repository 是 该门二分法没有的第三种);spec 也无法 import objectql/metadata-core(依赖反转)。 dormancy 已用 stderr 探针实测(对照组会打印,被测的 update 全程静默)。 Fixes #5945 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/hook-context-api-scoped-context.md | 48 +++ .../docs/kernel/runtime-services/examples.mdx | 18 +- content/docs/references/data/hook.mdx | 2 +- packages/objectql/src/engine.ts | 30 +- packages/spec/api-surface/contracts.json | 2 + packages/spec/src/contracts/index.ts | 2 + .../spec/src/contracts/scoped-context.test.ts | 329 ++++++++++++++++++ packages/spec/src/contracts/scoped-context.ts | 209 +++++++++++ packages/spec/src/data/hook.test.ts | 119 +++++++ packages/spec/src/data/hook.zod.ts | 55 ++- scripts/engine-double-contract.baseline.json | 16 + skills/objectstack-data/references/_index.md | 4 + 12 files changed, 811 insertions(+), 23 deletions(-) create mode 100644 .changeset/hook-context-api-scoped-context.md create mode 100644 packages/spec/src/contracts/scoped-context.test.ts create mode 100644 packages/spec/src/contracts/scoped-context.ts diff --git a/.changeset/hook-context-api-scoped-context.md b/.changeset/hook-context-api-scoped-context.md new file mode 100644 index 0000000000..f8aa538501 --- /dev/null +++ b/.changeset/hook-context-api-scoped-context.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: `HookContext.api` 从 `z.unknown()` 收窄为 `IScopedContext`,文档教的第一个 hook 终于编译得过 (#5945) + +`HookContext.api` 是文档教的**主数据通道**,而它的类型是 `unknown`。于是所有文档、技能、示例里那个标准写法: + +```ts +handler: async (ctx: HookContext) => { + const users = ctx.api.object('user'); // error TS18046: 'ctx.api' is of type 'unknown'. +} +``` + +一行都编译不过 —— 包括 `hook.zod.ts` 里 `api` 这个键**自己 JSDoc 上的示例**。语料库全在这么教(`skills/objectstack-data/references/data-hooks.md`、`content/docs/automation/hooks.mdx`、`content/docs/api/error-handling-server.mdx`、`content/docs/kernel/runtime-services/*`),这些块都没进 `os:check`,所以从来没有一道门看见过。唯一进了 `os:check` 的那块(`runtime-services/examples.mdx`)也只能靠在示例里自建一个 `type CrossObjectApi = …` 再 `ctx.api as CrossObjectApi` 才编得过 —— 每个消费方各 cast 一遍、cast 的形状无人校验,正是 contract-first 要终结的方向。 + +**本次落地维护者裁决 C**:`packages/spec/src/contracts/` 新增 `IScopedContext` / `IScopedObjectRepository`(与 `IDataEngine` / `IObjectQLEngine` 同层同风格),`HookContext.api` 的 TS 类型指向它。 + +**声明面 = 语料库实测的调用点**,不多也不少(证据表在 PR 正文,逐条 file:line): + +- `IScopedContext`:`object(name)` + `transaction(cb, opts?)` +- `IScopedObjectRepository`:`find` / `findOne` / `count` / `insert` / `update` / `updateById` + +`upsert` / `delete` / `aggregate` / `create` 只出现在文档的**方法表与能力表**里、从没有一处调用点(表格不过编译器),`sudo()` 的三个调用方全部把值持成 `any` 且它是提权动作 —— 一律不声明,等到有调用点再按同一条规则加。这与 `IDataEngine` 当年(#4251)确立的「有证据才声明」是同一条纪律。 + +**运行时零变化**:Zod 侧仍是 `z.unknown()`(`z.custom` 会让 `HookContext` 在 JSON Schema 里不可表达,`gen:schema` 直接不再产出 `json-schema/data/HookContext.json`,进而在下次 `gen:docs` 抹掉它的参考页 —— 实测过,不是推测)。收窄是纯静态的:接受的值、JSON Schema、生成的参考页行全部逐字节不变,只有 `.describe()` 文案改了。 + +**漂移由编译器盯着**:`packages/objectql` 的 `ScopedContext` / `ObjectRepository` 声明了 `implements`,契约与引擎实际绑定的那个对象再也不能各说各话(把 `updateById` 改个名,objectql 的 `tsc` 会在 `implements` 处和五个 hook 派发点同时报错 —— 实测过)。 + +**FROM → TO —— 什么代码需要改** + +读取端只会变宽,原来编译得过的读法一行都不用动(原来根本没有能编译过的读法)。两类**写入端**可能要改: + +```ts +// 1. 自建 cast 的消费方 —— 删掉 cast 即可,`ctx.api` 现在自带类型 +-const api = ctx.api as CrossObjectApi; +-const account = await api.object('crm_account').findOne({ where: { id } }); ++const account = await ctx.api?.object('crm_account').findOne({ where: { id } }); + +// 2. 构造 HookContext 字面量的测试替身 —— `api` 现在必须是 IScopedContext 形状(或省略) + const ctx: HookContext = { + object: 'account', event: 'beforeInsert', input: {}, ql: {}, +- api: whateverStub, ++ api: undefined, // 或一个带 object(name) / transaction(cb) 的替身 + }; +``` + +`api` **仍是可选的**:`buildHookApi` 在全部五个派发点都会设置它,但改成必填会开始拒绝今天能过的部分上下文(没有活引擎时构造的 context),所以读法是 `ctx.api?.object(…)`。 diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx index 8ba05ef416..7bd93f7bf5 100644 --- a/content/docs/kernel/runtime-services/examples.mdx +++ b/content/docs/kernel/runtime-services/examples.mdx @@ -123,17 +123,6 @@ hook adds is the **business** rule the engine cannot know. ```ts import { defineHook, type HookContext } from '@objectstack/spec/data'; -/** - * The one call this hook makes on `ctx.api`. The contract declares - * `HookContext.api` opaque (`api: unknown`) because the object the engine binds is - * ObjectQL's `ScopedContext`, so a typed handler names the slice it uses. - */ -type CrossObjectApi = { - object(name: string): { - findOne(query: { where: Record }): Promise<{ credit_limit?: number } | null>; - }; -}; - export const ContractWithinCreditLimit = defineHook({ name: 'contract_within_credit_limit', object: 'contract', @@ -142,10 +131,11 @@ export const ContractWithinCreditLimit = defineHook({ const accountId = ctx.input.account_id; if (typeof accountId !== 'string') return; - const api = ctx.api as CrossObjectApi; - const account = await api.object('crm_account').findOne({ where: { id: accountId } }); + // `ctx.api` is typed (`IScopedContext`) — no cast. It is optional because a + // context can be built without a live engine, so reach it with `?.`. + const account = await ctx.api?.object('crm_account').findOne({ where: { id: accountId } }); - const limit = account?.credit_limit ?? 0; + const limit = Number(account?.credit_limit ?? 0); const amount = Number(ctx.input.amount ?? 0); if (limit > 0 && amount > limit) { throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit'); diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 8c51a66ab9..0ab8189f9c 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -41,7 +41,7 @@ const result = HookContextSchema.parse(data); | **provenance** | `{ flowRunId?: string; attributedUserId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | -| **api** | `any` | optional | Cross-object data access (ScopedContext) | +| **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) | | **user** | `{ id?: string; name?: string; email?: string; organizationId?: string }` | optional | Current user info shortcut | diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 1c99bdc1e2..b5c6961cfc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -29,6 +29,10 @@ import { } from '@objectstack/spec/system'; import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; +// Imported from spec directly rather than through `@objectstack/core`'s +// re-export block: that block is labelled backward-compatibility, and this +// contract is new (#5945). +import type { IScopedContext, IScopedObjectRepository } from '@objectstack/spec/contracts'; import { IDataDriver, IDataEngine, @@ -7305,7 +7309,21 @@ export class ObjectQL implements IObjectQLEngine { * and convenience aliases (create, updateById, deleteById) matching * the @objectql/core ObjectRepository API. */ -export class ObjectRepository { +/** + * A repository bound to one object and one execution context — what + * `ScopedContext.object(name)` returns, and what a hook reaches as + * `ctx.api.object(name)`. + * + * `implements IScopedObjectRepository` (#5945): the six members that contract + * declares are the ones the documentation corpus is measured to CALL, and the + * `implements` clause is what keeps the two from drifting — before it, the + * only descriptions of this face were the private slices each consumer + * hand-rolled (`type CrossObjectApi = …`), which nothing checked. The class + * stays WIDER than the contract on purpose (`create`, `delete`, `deleteById`, + * `aggregate`, `execute`); `implements` allows that, and those members join the + * contract when a call site turns up to justify them. + */ +export class ObjectRepository implements IScopedObjectRepository { constructor( private objectName: string, private context: ExecutionContextInput, @@ -7398,12 +7416,18 @@ export class ObjectRepository { /** * Scoped execution context with object() accessor. - * + * * Provides identity (userId, tenantId/spaceId, roles), * repository access via object(), privilege escalation via sudo(), * and transactional execution via transaction(). + * + * `implements IScopedContext` (#5945) — this class IS `HookContext.api`, built + * per dispatch by {@link ObjectQL.buildHookApi}. The contract declares the two + * members hooks reach (`object`, `transaction`); `sudo()`, the discrete + * begin/commit/rollback trio and the identity getters stay off it, so this + * class is deliberately wider than what it implements. */ -export class ScopedContext { +export class ScopedContext implements IScopedContext { constructor( private executionContext: ExecutionContextInput, private engine: IDataEngine diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 18c17ce892..2e1cf0fc1c 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -132,6 +132,8 @@ "IRlsMembershipResolver (interface)", "ISchemaDiffService (interface)", "ISchemaDriver (interface)", + "IScopedContext (interface)", + "IScopedObjectRepository (interface)", "ISearchService (interface)", "ISecurityService (interface)", "ISeedLoaderService (interface)", diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 64f42aff82..ef2b30afef 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -10,6 +10,8 @@ export * from './logger.js'; export * from './data-engine.js'; export * from './objectql-engine.js'; +// The hook-facing slice of the engine: what `HookContext.api` is (#5945). +export * from './scoped-context.js'; export * from './data-driver.js'; export * from './http-server.js'; export * from './service-registry.js'; diff --git a/packages/spec/src/contracts/scoped-context.test.ts b/packages/spec/src/contracts/scoped-context.test.ts new file mode 100644 index 0000000000..74584c8aac --- /dev/null +++ b/packages/spec/src/contracts/scoped-context.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { IScopedContext, IScopedObjectRepository } from './scoped-context'; + +// ─── [#5945] `HookContext.api` is typed — proved with the compiler ────────── +// +// Ruling C (maintainer, 2026-08-07): `api` stops being `z.unknown()` and gains +// the minimum `IScopedContext` the corpus actually teaches. The fact under test +// is therefore a COMPILE fact, and it has two halves that fail in opposite +// directions: +// +// 1. the documented calls COMPILE (they reported `TS18046: 'ctx.api' is of +// type 'unknown'` before), and +// 2. a member the contract does NOT declare is a NAMED type error, so the +// evidence bar is enforced rather than merely written down. +// +// ## REVERSE VERIFICATION — predicted first, then MEASURED, and the prediction +// ## was half wrong in a way that changed these assertions +// +// Predicted: restore `api: z.unknown()` and every `legal-*` probe goes red with +// `TS18046: 'ctx.api' is of type 'unknown'`, while the `rejects-*` probes stay +// green for the wrong reason (a different code satisfying a bare "did it +// error?"). Ran it. What actually happens, on `api: z.unknown().optional()`: +// +// no-chain ctx.api.object('x') TS18046: 'ctx.api' is of type 'unknown'. +// chain-legal ctx.api?.object('x') TS2339: Property 'object' does not exist on type '{}'. +// rejects-upsert ctx.api?.object(…) TS2339: Property 'object' does not exist on type '{}'. +// rejects-sudo ctx.api?.sudo() TS2339: Property 'sudo' does not exist on type '{}'. +// +// TS18046 is the issue's reported symptom and it reproduces EXACTLY — but only +// without optional chaining. `?.` strips `null | undefined` from `unknown` and +// leaves `{}`, so the chained form (which is how the corpus writes it, `api` +// being optional) fails as TS2339 instead. Which means asserting the CODE is +// NOT enough: `rejects-*` kept reporting TS2339 across the revert, and an +// assertion of `toContain('TS2339')` passed over the reverted contract — a +// phantom check, caught only by running the reversal instead of reasoning +// about it. So the negative probes assert the diagnostic names the OWNER TYPE +// (`IScopedContext` / `IScopedObjectRepository`); `'{}'` can no longer satisfy +// them. Direction, corrected and measured: +// - `legal-*` → RED (TS2339 on `{}`, TS18046 where unchained); +// - `rejects-*` → RED, because the type named in the message is gone. +// Deleting a single member from `IScopedObjectRepository` is the narrower +// probe: only the `legal-*` line calling it goes red, with TS2339. +// +// ## Why the compiler API rather than `@ts-expect-error` +// +// `@ts-expect-error` is satisfied by ANY error on the next line, so it cannot +// tell "this member is undeclared" from "this literal is malformed" — and this +// file's whole subject is which diagnostic a call gets. #5286 did put +// `src/**/*.test.ts` in front of `tsc` (via `tsconfig.test.json`), so a +// directive here would at least be read — but it would be read imprecisely. +// The probes below drive `ts.createProgram` and assert on codes, the way +// `automation/etl-author-shape.test.ts` does, with the same anti-vacuity +// control: a harness that resolves nothing reports zero diagnostics and looks +// exactly like success. + +const SPEC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const HOOK_ZOD = resolve(SPEC_DIR, 'src/data/hook.zod.ts'); + +/** + * Compile a set of probe files against this package's real source and return + * each one's diagnostics, keyed by probe name. + * + * `@objectstack/spec/` is mapped through `paths` to the entry barrel in + * `src/`, so a probe can be written the way a hook author would actually type + * it — import line included — instead of through relative paths no reader of + * the docs would ever use. + */ +function compileProbes(probes: Readonly>): Map { + const dir = resolve(SPEC_DIR, 'src/__scoped_context_probes__'); + const paths = new Map(); + for (const [name, text] of Object.entries(probes)) paths.set(resolve(dir, `${name}.ts`), text); + + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + // A snippet declares a `const` and stops; TS6133 is an opinion about the + // snippet's framing, not about whether the call is well-typed. + noUnusedLocals: false, + noUnusedParameters: false, + baseUrl: SPEC_DIR, + paths: { '@objectstack/spec/*': [resolve(SPEC_DIR, 'src/*/index.ts')] }, + }; + + const host = ts.createCompilerHost(options, true); + const realGetSourceFile = host.getSourceFile.bind(host); + const realFileExists = host.fileExists.bind(host); + const realReadFile = host.readFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const overlay = paths.get(resolve(fileName)); + return overlay === undefined + ? realGetSourceFile(fileName, languageVersion, onError, shouldCreate) + : ts.createSourceFile(fileName, overlay, languageVersion, true); + }; + host.fileExists = (fileName) => paths.has(resolve(fileName)) || realFileExists(fileName); + host.readFile = (fileName) => paths.get(resolve(fileName)) ?? realReadFile(fileName); + + const program = ts.createProgram([...paths.keys()], options, host); + const out = new Map(); + for (const name of Object.keys(probes)) out.set(name, []); + for (const d of ts.getPreEmitDiagnostics(program)) { + const file = d.file?.fileName ? resolve(d.file.fileName) : undefined; + for (const name of Object.keys(probes)) { + if (file === resolve(dir, `${name}.ts`)) out.get(name)!.push(d); + } + } + return out; +} + +/** One diagnostic per line, `TS: `, for readable assertions. */ +function render(diagnostics: readonly ts.Diagnostic[]): string { + return diagnostics + .map((d) => `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`) + .join('\n'); +} + +/** A hook handler wrapper, so a probe body reads like the documented code. */ +function hook(body: string): string { + return [ + "import type { HookContext } from '@objectstack/spec/data';", + 'export const handler = async (ctx: HookContext) => {', + body, + '};', + ].join('\n'); +} + +/** + * The `Usage in hooks` example out of `hook.zod.ts`'s own JSDoc, read from the + * source rather than copied — the issue's headline complaint was that THAT + * example did not compile, so the pin has to be the real text. Extraction: + * everything after the first blank comment line following `Usage in hooks`, + * while the lines stay indented three spaces past the ` * `. + */ +function jsdocUsageExample(): string { + const lines = readFileSync(HOOK_ZOD, 'utf8').split('\n'); + const start = lines.findIndex((l) => l.includes('Usage in hooks')); + if (start < 0) throw new Error('`Usage in hooks` marker vanished from hook.zod.ts'); + let i = start; + while (i < lines.length && !/^\s*\*\s*$/.test(lines[i])) i += 1; + i += 1; + const body: string[] = []; + for (; i < lines.length; i += 1) { + const m = lines[i].match(/^\s*\*\s{3}(.*)$/); + if (!m) break; + body.push(m[1]); + } + return body.join('\n'); +} + +describe('[#5945] the `HookContext.api` JSDoc example compiles', () => { + const example = jsdocUsageExample(); + + it('extracts a real example from hook.zod.ts (anti-vacuity)', () => { + // Without this, a marker rename would yield an empty probe — and an empty + // file compiles clean, so the pin below would report success over nothing. + expect(example).toContain('ctx.api'); + expect(example).toContain('findOne'); + expect(example.split('\n').length).toBeGreaterThanOrEqual(3); + }); + + it('compiles verbatim, with zero diagnostics', () => { + const results = compileProbes({ + 'jsdoc-usage': hook(example), + // The harness's own control: a probe that MUST fail. A resolution failure + // reports zero diagnostics for every probe and reads as green. + 'harness-self-test': hook("const x: number = ctx.api?.object('user');"), + }); + expect(render(results.get('harness-self-test')!), 'the harness must be able to report an error') + .toContain('TS2322'); + expect(render(results.get('jsdoc-usage')!), 'the JSDoc example must compile').toBe(''); + }); +}); + +describe('[#5945] every corpus-measured call compiles', () => { + /** + * One probe per declared member, each written the way the corpus writes it, + * so a failure names the member rather than "the big probe went red". The + * evidence for each is recorded in `scoped-context.ts`'s module header. + */ + const legal: Record = { + 'legal-findOne': "const rec = await ctx.api?.object('candidate').findOne({ where: { id: ctx.input.id } });", + 'legal-find': "const rows = await ctx.api?.object('candidate').find({ where: { stage: 'hired' } });", + 'legal-count': "const n = await ctx.api?.object('invoice').count({ where: { amount: { $gte: 1000 } } });", + 'legal-insert': "await ctx.api?.object('audit_log').insert({ action: 'created' });", + 'legal-update-single': "await ctx.api?.object('position').update({ id: 'p1', status: 'filled' });", + 'legal-update-bulk': "await ctx.api?.object('contact').update({ industry: 'tech' }, { where: { account_id: 'a1' }, multi: true });", + 'legal-updateById': "await ctx.api?.object('task').updateById('t1', { ai_suggested_tags: ['x'] });", + // `filter` is a documented, engine-folded alias of `where` + // (`RPC_QUERY_ALIAS_SLOTS`), and `data-hooks.md` has a live snippet using + // it. Typing the query bag as `EngineQueryOptions` would reject this by + // excess-property checking — a compile error over a call that works. This + // probe is why the parameter is an open object; see the module header. + 'legal-filter-alias': "const n = await ctx.api?.object('opportunity').count({ filter: { account_id: ctx.input.id } });", + // The transaction shape `error-handling-server.mdx` teaches: reach objects + // through the callback's context, not the outer one. + 'legal-transaction': [ + 'await ctx.api?.transaction(async (tx) => {', + " await tx.object('task').insert({ title: 'kickoff' });", + " await tx.object('project').update({ id: 'p1', task_count: 1 });", + '});', + ].join('\n'), + // Contravariance: the corpus writes zero- and one-argument callbacks, and + // the two-argument form the engine actually calls must work too. + 'legal-transaction-no-args': 'await ctx.api?.transaction(async () => undefined);', + 'legal-transaction-info': 'await ctx.api?.transaction(async (_tx, info) => info.owned);', + }; + + it('compiles all of them clean', () => { + const probes: Record = {}; + for (const [name, body] of Object.entries(legal)) probes[name] = hook(body); + probes['harness-self-test'] = hook("const x: number = ctx.api?.object('user');"); + + const results = compileProbes(probes); + expect(render(results.get('harness-self-test')!), 'the harness must be able to report an error') + .toContain('TS2322'); + + for (const [name, diagnostics] of results) { + if (name === 'harness-self-test') continue; + expect(render(diagnostics), `${name} must compile clean`).toBe(''); + } + // Anti-vacuity: the probe set is the declared surface, not a subset that + // drifted. Six repo members + `object` reached three ways + transaction's + // three callback arities. + expect(Object.keys(legal).length).toBe(11); + }); +}); + +describe('[#5945] the evidence bar is enforced, not just documented', () => { + /** + * An undeclared member is TS2339 — a NAMED refusal, which is the point: + * before this contract every one of these was TS18046 ("`ctx.api` is of type + * 'unknown'"), i.e. the same undifferentiated wall for legal and illegal + * calls alike. + * + * The repo members below are REAL on ObjectQL's `ObjectRepository` and + * deliberately off the contract — they appear in the corpus only in method / + * capability TABLES, and a table never goes through a compiler. Each joins + * the contract the day a call site does. This test is therefore also the + * ledger of that decision: adding one without adding its evidence turns this + * red. + */ + /** probe name → [call, the type the message must name]. */ + const rejected: Record = { + 'rejects-invented-member': ['await (ctx.api as IScopedContext).deleteEverything();', 'IScopedContext'], + 'rejects-upsert': ["await ctx.api?.object('candidate').upsert({ id: 'c1' });", 'IScopedObjectRepository'], + 'rejects-delete': ["await ctx.api?.object('candidate').delete({ where: { id: 'c1' } });", 'IScopedObjectRepository'], + 'rejects-aggregate': ["await ctx.api?.object('invoice').aggregate({ groupBy: ['status'] });", 'IScopedObjectRepository'], + 'rejects-sudo': ['ctx.api?.sudo();', 'IScopedContext'], + 'rejects-top-level-insert': ["await ctx.api?.insert('task', { title: 't' });", 'IScopedContext'], + }; + + it('reports TS2339 naming the contract type — not merely "some error"', () => { + const probes: Record = {}; + for (const [name, [body]] of Object.entries(rejected)) { + probes[name] = name === 'rejects-invented-member' + ? [ + "import type { HookContext } from '@objectstack/spec/data';", + "import type { IScopedContext } from '@objectstack/spec/contracts';", + 'export const handler = async (ctx: HookContext) => {', + body, + '};', + ].join('\n') + : hook(body); + } + const results = compileProbes(probes); + + for (const [name, diagnostics] of results) { + const text = render(diagnostics); + const owner = rejected[name][1]; + expect(text, `${name} must fail as an undeclared property`).toContain('TS2339'); + // The OWNER TYPE, not just the code. Measured on the reversal (see the + // header): with `api: z.unknown()` these same probes still report TS2339 + // — `Property 'x' does not exist on type '{}'` — so a code-only + // assertion passed over the reverted contract. Naming the type is what + // makes the refusal a statement about THIS contract. + expect(text, `${name} must name the contract type it was refused by`).toContain(`type '${owner}'`); + } + expect(Object.keys(rejected).length).toBe(6); + }); +}); + +describe('[#5945] IScopedContext is implementable', () => { + it('accepts a minimal implementation', async () => { + const rows = [{ id: 'c1', stage: 'hired', position_id: 'p1' }]; + + const repo: IScopedObjectRepository = { + find: async () => rows, + findOne: async () => rows[0] ?? null, + count: async () => rows.length, + insert: async (data) => data, + update: async (data) => data, + updateById: async (id, data) => ({ id, ...data }), + }; + + const api: IScopedContext = { + object: () => repo, + // The engine always passes `info`; an implementation is free to ignore + // `opts` when its driver has no transaction support (the documented + // degrade), which is why the parameter is optional. + transaction: async (callback) => callback(api, { owned: true }), + }; + + expect(await api.object('candidate').findOne({ where: { id: 'c1' } })).toEqual(rows[0]); + expect(await api.object('candidate').count()).toBe(1); + expect( + await api.transaction(async (tx) => tx.object('position').updateById('p1', { status: 'filled' })), + ).toEqual({ id: 'p1', status: 'filled' }); + }); + + it('is what ObjectQL declares it implements — checked at objectql build, not here', () => { + // `packages/objectql`'s `ScopedContext` / `ObjectRepository` carry + // `implements IScopedContext` / `implements IScopedObjectRepository`, so + // drift between this contract and the object the engine actually binds is a + // compile error over THERE. Spec must not depend on the engine, so this + // file cannot assert it — recorded here so the next reader knows where the + // other half of the guard lives rather than adding a second, weaker copy. + expect(true).toBe(true); + }); +}); diff --git a/packages/spec/src/contracts/scoped-context.ts b/packages/spec/src/contracts/scoped-context.ts new file mode 100644 index 0000000000..3442fe5656 --- /dev/null +++ b/packages/spec/src/contracts/scoped-context.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `IScopedContext` — the cross-object data channel a hook is handed as + * `HookContext.api`, and the repository face it hands back. + * + * ## Why this exists (#5945, maintainer ruling C of 2026-08-07) + * + * `HookContext.api` was `z.unknown()`, so `HookContext['api']` inferred as + * `unknown` and a handler typed the way every document teaches — + * `handler: async (ctx: HookContext) => …` — could not call the channel those + * same documents point it at: + * + * error TS18046: 'ctx.api' is of type 'unknown'. // ctx.api.object('x') + * + * The JSDoc on the key itself carried a two-line example that did not compile, + * and the one doc block already under `check:skill-examples` + * (`content/docs/kernel/runtime-services/examples.mdx`) could only get there by + * declaring a private `type CrossObjectApi = { object(name): { findOne(…) } }` + * and writing `ctx.api as CrossObjectApi` — a consumer-side cast standing in + * for a contract, with no one checking its shape against the object the engine + * actually binds. That is the direction contract-first exists to refuse: N + * consumers, N hand-rolled slices, N chances to be wrong in a different way. + * + * The object the engine binds is ObjectQL's `ScopedContext` + * (`packages/objectql/src/engine.ts`, built by `buildHookApi` at every hook + * dispatch); `object(name)` returns its `ObjectRepository`. That class declares + * `implements IScopedContext`, so every member below is verified against the + * implementation on every objectql build — this is a checked contract, not a + * seventh private slice with better manners. + * + * ## The evidence bar — what is declared here, and why not more + * + * A member is declared when the CORPUS CONTAINS A CALL of it on a hook's + * `ctx.api` — a call being the thing that has to compile. That bar is the + * ruling's, and it is the same "declare what has evidence" discipline + * `IDataEngine` was argued into being under (#4251): the contract records what + * is consumed, not a transcription of the class. + * + * Measured over `skills/`, `content/docs/`, `examples/`, `apps/` and this + * file's own JSDoc, hook `ctx.api` call sites reach exactly seven members + * (per-member file:line evidence is in PR #5945's body): + * + * - `object(name)` — every site; the only way into a repository. + * - `transaction(cb)` — `content/docs/api/error-handling-server.mdx`, + * `skills/objectstack-data/references/data-hooks.md` (the `api.transaction` + * capability row). + * - repo `findOne` / `find` / `count` / `insert` / `update` / `updateById`. + * + * DELIBERATELY NOT DECLARED, each with the reason, so the next reader can tell + * "measured and excluded" from "overlooked": + * + * - `upsert`, `delete`, `aggregate` on the repository. Real methods, but they + * appear in the corpus only inside METHOD/CAPABILITY TABLES + * (`data-hooks.md`'s repo table and its `api.write` row, + * `hook-bodies.mdx`'s capability table). A table row is prose: it never + * goes through a compiler, so declaring these buys nothing for the reason + * this interface exists. `create` and `delete` do occur as call text, but + * only inside `packages/lint`'s hook/action body-source FIXTURE STRINGS, + * which are inputs to a lint rule and likewise never type-checked. + * - `create` (an `insert` alias), `deleteById`, `execute` — no call site at + * all outside those fixtures. + * - `sudo()`. The one exclusion with a real cross-package caller — plugin-audit's + * `persistAuditTrailRow` and its `captureBefore` snapshot both reach + * `api.sudo()` — and it is excluded ON PURPOSE rather than for lack of + * evidence, which is why it is called out instead of listed above. Every + * one of those callers holds the value as `any` (`api: any`, + * `(ctx as any).api`), i.e. none is a typed consumer this declaration would + * unblock; and `sudo()` is PRIVILEGE ESCALATION — putting it on the + * documented hook-author surface would advertise "bypass record-level + * permissions" as part of the first-hook vocabulary, which no document + * teaches and #5945's ruling did not authorize. The engine's own privileged + * writers reach it as the engine, not as a hook. Declaring it is a + * maintainer call, not a measurement. + * - The discrete `beginTransaction` / `commitTransaction` / `rollbackTransaction` + * trio, which exists for the sandbox RUNNER — it drives a body's + * `ctx.api.transaction(fn)` across host event-loop turns where the ambient + * `AsyncLocalStorage` does not survive — and not for hook authors, who get + * the callback form above (see the class doc in objectql). + * - `userId` / `tenantId` / `spaceId` / `roles` / `isSystem` / + * `transactionHandle` getters. A hook reads the caller from + * `ctx.session`, which is declared, typed, and carries the ADR-0090 D3 + * vocabulary; routing that through a second spelling here would be a + * second dialect of one fact — and `roles` is a name ADR-0090 D3 bans + * outright (see the `HookContext.session.roles` tombstone). + * + * Growing this is a two-line change plus the call site that proves it: add the + * member, add its evidence to the list above. Option A of #5945 — the full + * repository face declared up front — was explicitly deferred to that rule + * rather than taken now. + * + * ## Why the parameter types are loose, and what would be WRONG here + * + * The repository forwards its query straight to `IDataEngine` + * (`{ ...query, context }`), so the obvious move is to type these parameters + * with spec's own `EngineQueryOptions` / `EngineUpdateOptions`. That would be a + * FALSE narrowing. The engine folds documented alias spellings before it reads + * a query — `filter` → `where`, `top` → `limit` (`RPC_QUERY_ALIAS_SLOTS`, + * applied in `find`, `findOne` and `update`) — and `data-hooks.md` teaches + * `filter` as a tolerated object-valued alias, with a live snippet using it. + * `EngineQueryOptions` declares no `filter`, so an object literal spelling it + * would be rejected by excess-property checking: a compile error over a call + * the runtime accepts and the docs teach. Contract-first cuts the other way + * here — the option VOCABULARY is `EngineQueryOptions`' to own and the engine's + * to enforce (it rejects undeclared option keys since #4371); this interface's + * job is the METHOD FACE. So the query bag is an object, the returns mirror + * `IDataEngine`'s (`Promise` / `Promise`), and nothing here claims + * to be the query schema. + */ + +import type { EngineTransactionInfo, EngineTransactionOptions } from './objectql-engine.js'; + +/** + * A repository bound to one object AND to the calling hook's execution context + * (user, organization, and the ambient transaction) — what + * `IScopedContext.object(name)` returns. + * + * Scoping is the whole point: a write through here goes down the engine's + * normal path and is therefore gated by the TARGET object's permission and + * sharing rules, not by whoever happens to be elevated. An admin is not exempt; + * the gate is `canEdit`. See `data-hooks.md` "cross-object writes obey the + * target's sharing model". + */ +export interface IScopedObjectRepository { + /** + * Read every record the query selects. + * + * An unpredicated `find` is legitimate — returning every row is an honest + * answer to a question that asked for every row. + */ + find(query?: Record): Promise; + + /** + * Read the ONE record the query selects, or `null`. + * + * The query MUST say which record it wants — a `where`, a `search` that + * expands to one, or an `orderBy` meaning "the first in this order". A + * query with none of those is REJECTED at runtime (#4419): `findOne` reads + * a single row, so an empty predicate does not come back empty, it comes + * back as the object's FIRST row — a real, plausible-looking record with + * nothing to do with what was asked, which no `if (!row)` can catch. When + * any row genuinely will do, that is `find({ limit: 1 })`, which says so. + */ + findOne(query?: Record): Promise; + + /** Count the records the query selects. */ + count(query?: Record): Promise; + + /** Insert one record (or an array of records). */ + insert(data: any): Promise; + + /** + * Update records. + * + * The single-record form puts the primary key INSIDE `data` — + * `update({ id, ...fieldsToChange })` — because the repository reads the + * key out of the payload. The bulk form is `update(data, { where, multi: true })`; + * there is no `updateMany`. + */ + update(data: any, options?: Record): Promise; + + /** Update a single record by id — the id travels as the first argument. */ + updateById(id: string | number, data: any): Promise; +} + +/** + * The scoped cross-object API a hook reaches through `ctx.api`. + * + * It carries NO top-level `insert` / `update` / `find`: a caller names the + * object first and operates on the repository that comes back + * (`ctx.api.object('task').insert(…)`). `content/docs/api/error-handling-server.mdx` + * states that shape explicitly, and it is what makes the scoping legible — + * every operation is addressed to a named object. + */ +export interface IScopedContext { + /** The repository for `name`, bound to this context. */ + object(name: string): IScopedObjectRepository; + + /** + * Run `callback` inside one driver transaction: committed when it returns, + * rolled back when it throws. + * + * The callback receives a NEW `IScopedContext` whose operations share the + * transaction handle — reach objects through THAT context (`tx.object(…)`), + * not the outer one, or the writes land outside the transaction. + * + * ## Why the callback's second parameter is declared even though no corpus + * site reads it + * + * The evidence bar above governs WHICH MEMBERS exist, not what a declared + * member's signature is allowed to say. A parameter list describes what the + * PRODUCER hands the callback, and the producer hands it two arguments + * unconditionally (`ScopedContext.transaction`, #5696): declaring one would + * be a false statement that additionally makes `async (tx, info) => …` + * — legal, working code — a compile error. Contravariance means the + * zero-argument and one-argument callbacks the corpus actually writes still + * satisfy this, so the truthful signature is also the more permissive one. + * {@link EngineTransactionInfo} is reused rather than re-spelled, for the + * same reason `opts` is: ADR-0119 D1 and #5696 rule that this surface is a + * second IMPLEMENTATION of `IObjectQLEngine.transaction`, never a second + * DIALECT of it — `opts.require`'s fail-closed refusal is honoured here + * identically, and a contract that omitted it would re-open in declaration + * the dialect the implementation was made to close. + */ + transaction( + callback: (trxCtx: IScopedContext, info: EngineTransactionInfo) => Promise, + opts?: EngineTransactionOptions, + ): Promise; +} diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 09ae194d36..03cfea93d4 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -1142,3 +1142,122 @@ describe('session.positions / session.preserveAudit declaration (#5605)', () => expect(preserveAuditDoc).toMatch(/not an authorization input/i); }); }); + +/** + * `HookContext.api` is typed as {@link IScopedContext} (#5945, maintainer + * ruling C of 2026-08-07). + * + * The THIRD entry in this file's drift family, and the one whose two ends both + * pointed the same way: `session.roles` was declared-never-produced (removed), + * `session.positions` / `.preserveAudit` were produced-never-declared + * (declared), and `api` was produced, DOCUMENTED, and typed `unknown` — so the + * documentation's primary data channel could not be called from the annotation + * the documentation itself teaches: + * + * error TS18046: 'ctx.api' is of type 'unknown'. // ctx.api.object('x') + * + * measured on `origin/main` and reproduced by reverting this change (the exact + * diagnostics, chained and unchained, are tabulated in + * `contracts/scoped-context.test.ts`, which owns the COMPILE half of the pin). + * What lives here is the half that belongs to the schema: the change is + * type-only, and the runtime must be able to prove it. + * + * REVERSE VERIFICATION, direction predicted then measured: restoring + * `z.unknown()` leaves every assertion in THIS block green — `z.custom()` with + * no validator and `z.unknown()` accept exactly the same values, which is the + * point of choosing it. The block is a REGRESSION guard, not evidence that the + * type landed; the pins for that are in `scoped-context.test.ts`. Said out loud + * because a green companion test read as a pin is how #5605's third assertion + * nearly got over-credited. + */ +describe('HookContext.api typing (#5945)', () => { + /** Shaped like ObjectQL's `ScopedContext` — a live object, not authored data. */ + const liveApi = { + object: (_name: string) => ({ + find: async () => [], + findOne: async () => null, + count: async () => 0, + insert: async (data: unknown) => data, + update: async (data: unknown) => data, + updateById: async (id: string | number, data: object) => ({ id, ...data }), + }), + transaction: async (cb: (tx: unknown, info: { owned: boolean }) => unknown) => cb(liveApi, { owned: true }), + sudo: () => liveApi, + }; + + it('still accepts a live engine object, and hands back the SAME instance', () => { + // `z.custom()` with no validator neither rejects nor clones. Identity is + // the load-bearing assertion: a copied `api` would be a repository whose + // closures point at the wrong execution context, which no shape check + // could see. + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + api: liveApi, + ql: {}, + }); + + expect(context.api).toBe(liveApi); + }); + + it('stays OPTIONAL — a context built without an engine still parses', () => { + // Every `buildHookApi` dispatch site sets it, but making it required would + // start rejecting the partial contexts this schema accepts today (and that + // this file's own older tests build). Declaring the type must not change + // what parses. + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + ql: {}, + }); + + expect(context.api).toBeUndefined(); + }); + + it('accepts the values `z.unknown()` accepted — the change is type-only', () => { + for (const api of [undefined, null, 42, 'nope', {}, liveApi]) { + expect(() => HookContextSchema.parse({ + object: 'account', event: 'beforeInsert', input: {}, api, ql: {}, + })).not.toThrow(); + } + }); + + it('type-checks the code the docs teach — `(ctx: HookContext)` calling ctx.api', () => { + // The TSC channel, and the reason this block is in a file `tsconfig.test.json` + // compiles (#5286). Every line below was TS18046 or TS2339-on-`{}` before + // the declaration. Explicit annotations, so a widened or renamed + // declaration fails here rather than being absorbed by inference. + const crossObjectRead = async (ctx: HookContext) => { + const owner: unknown = await ctx.api?.object('user').findOne({ + where: { id: ctx.input.owner_id }, + }); + const open: number | undefined = await ctx.api?.object('task').count({ + where: { done: false }, + }); + await ctx.api?.object('audit_log').insert({ object_type: ctx.object }); + return { owner, open }; + }; + + // ...and the transaction shape the docs teach: objects are reached through + // the CALLBACK's context, which must itself be an IScopedContext. + const transactional = async (ctx: HookContext) => ctx.api?.transaction(async (tx) => { + await tx.object('task').insert({ title: 'kickoff' }); + return tx.object('project').update({ id: 'p1', task_count: 1 }); + }); + + expect(typeof crossObjectRead).toBe('function'); + expect(typeof transactional).toBe('function'); + }); + + it('names the contract in the `.describe()`, so the generated reference does too', () => { + // `api`'s JSON Schema is `{}` either way, so the generated reference row + // renders its TYPE as `any` in both states — the description is the only + // channel that page has for saying what the value actually is. + const doc = HookContextSchema.shape.api.description ?? ''; + expect(doc).toContain('IScopedContext'); + expect(doc).toContain('object(name)'); + expect(doc).toContain('transaction(cb)'); + }); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 19b500905a..0f50bd992d 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -12,6 +12,10 @@ import { retiredKey } from '../shared/retired-key'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { HookBodySchema } from './hook-body.zod'; +// Type-only, and it must stay that way: `contracts/` already imports `data/` +// (`contracts/data-engine.ts`), so a VALUE import here would close a runtime +// cycle. `import type` is erased, leaving the edge in the type graph only. +import type { IScopedContext } from '../contracts/scoped-context'; /* * ── Unknown-key strictness (#4001 data step) ──────────────────────────────── @@ -580,13 +584,54 @@ export const HookContextSchema = lazySchema(() => z.object({ * Cross-Object API * Provides a scoped data access interface for performing CRUD operations * on other objects within hooks. Bound to the current execution context - * (userId, tenantId, transaction). + * (userId, organizationId, transaction). * - * Usage in hooks: - * const users = ctx.api.object('user'); - * const admin = await users.findOne({ where: { role: 'admin' } }); + * Usage in hooks — this example COMPILES, and is pinned as a compile probe + * by `contracts/scoped-context.test.ts` so that it keeps doing so: + * + * const owner = await ctx.api?.object('user').findOne({ + * where: { id: ctx.input.owner_id }, + * }); + * + * TYPED as {@link IScopedContext} since #5945 (maintainer ruling C), where it + * was `z.unknown()` — which made `HookContext['api']` infer as `unknown`, so + * the two lines this JSDoc used to show were themselves a `TS18046: 'ctx.api' + * is of type 'unknown'`. Every document teaches `(ctx: HookContext)` plus + * `ctx.api.object(…)`; none of it type-checked, and the one doc block already + * under `check:skill-examples` compiled only by casting to a private + * hand-rolled `CrossObjectApi`. The declared face is deliberately the minimum + * the corpus is measured to CALL — see the evidence bar in + * `contracts/scoped-context.ts` for what is excluded and how to grow it. + * + * The RUNTIME schema stays `z.unknown()` and the narrowing is a static cast, + * the same idiom (and for the same reason) as `ObjectCapabilities.apiMethods` + * in `object.zod.ts`: keep the TS type the authors' one, keep the parse the + * permissive one. Two things force it here. + * + * 1. This key carries a LIVE engine object — ObjectQL's `ScopedContext`, a + * class instance with methods — not authored data. Validating it would + * mean checking a class against a JSON shape on every parse, on a schema + * that is deliberately the non-authored runtime context (see the header). + * 2. `z.custom()` was the obvious spelling and is WRONG + * here: `custom` is unrepresentable in JSON Schema, so `gen:schema` + * stopped emitting `json-schema/data/HookContext.json` altogether — + * "1 previously published schema disappeared", which unpublishes the + * generated `references/data/hook.mdx` page on the next `gen:docs` + * (#2978). Measured, not guessed: the spec build failed on it. `unknown` + * keeps the schema representable, so the JSON Schema, the manifest and + * the reference page are all byte-identical to before. + * + * The change is therefore type-only: same accepted values, same JSON Schema + * (`{}`), same generated row — only the `.describe()` text moves, which is + * that page's only channel for saying what the value is. + * + * Stays OPTIONAL, though `buildHookApi` sets it at all five dispatch sites: + * making it required would start REJECTING the partial contexts that + * `HookContextSchema.parse` accepts today (a context built without a live + * engine). Read it as `ctx.api?.object(…)`, or bind it once and narrow. */ - api: z.unknown().optional().describe('Cross-object data access (ScopedContext)'), + api: (z.unknown().optional() as unknown as z.ZodOptional>) + .describe('Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`)'), /** * Current User Info diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index 9e1ebbbc33..829af79871 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -1113,6 +1113,22 @@ "kind": "EXEMPT", "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at lines 24, 46, 82, 119, 152. EXEMPT for the same reason the delete-slice entry for this file is, restated for the update verb because a per-verb ledger may not inherit a verdict: these are not stand-ins that code under test drives, they are TYPE-CONFORMANCE witnesses that `IDataEngine` is implementable, and `update` is present on each only because the interface requires the member. They cannot be pinned even in principle — @objectstack/objectql depends on @objectstack/spec, so the import would invert the dependency. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "nothing — permanent" + }, + { + "file": "packages/spec/src/contracts/scoped-context.test.ts", + "verb": "update", + "unguarded": 1, + "kind": "EXEMPT", + "why": "MEASURED (#5945): NOT an engine double — a conformance witness for the NEW `IScopedObjectRepository` contract (`packages/spec/src/contracts/scoped-context.ts`), which is a THIRD species this gate's two-way engine-vs-driver attribution has no arm for. A scoped REPOSITORY is bound to one object, so its write verb is `update(data, options?)` — the object name is not a parameter at all, where `IDataEngine.update(objectName, data, options?)` takes it first and `IDataDriver` takes an id second. Discovery reaches it anyway because the literal carries `find`/`findOne`/`count`/`insert` (four ENGINE_SIBLINGS, and `insert` is in ENGINE_ONLY_MEMBERS, which attributes it to the engine side) — accurate for what the scan can see, wrong about what the object is. EXEMPT, not DEBT, on the same ground already recorded twice for `packages/spec/src/contracts/data-engine.test.ts`: it cannot be pinned even in principle, because `assertEngineUpdateDispatch` lives in @objectstack/objectql / @objectstack/metadata-core and BOTH depend on @objectstack/spec — the import would invert the dependency graph. DORMANCY PROBED, not assumed (the #5629 method, which the two update entries above explicitly say they lack): a `process.stderr.write` marker injected as the first statement of this `update` printed NOTHING across a full run of both files (83/83 tests green). The control that makes that silence evidence rather than a broken probe: the same injection in this witness's sibling `updateById` — which the transaction test does call — DID print, in the same run of the same harness. So the looseness is unexercised today. Dormant is not harmless: a future test that starts updating through this witness inherits a double free to accept what ObjectQL.update refuses (`where: { id: { $in: [...] } }` reads as an id and is a multi-row predicate). Discovered at line 296 — the `IScopedObjectRepository` literal in the 'IScopedContext is implementable' block.", + "closes": "nothing — permanent, unless this gate grows a scoped-repository arm (then this witness becomes out of scope rather than exempt)" + }, + { + "file": "packages/spec/src/data/hook.test.ts", + "verb": "update", + "unguarded": 1, + "kind": "EXEMPT", + "why": "MEASURED (#5945): NOT an engine double — a conformance witness for the NEW `IScopedObjectRepository` contract (`packages/spec/src/contracts/scoped-context.ts`), which is a THIRD species this gate's two-way engine-vs-driver attribution has no arm for. A scoped REPOSITORY is bound to one object, so its write verb is `update(data, options?)` — the object name is not a parameter at all, where `IDataEngine.update(objectName, data, options?)` takes it first and `IDataDriver` takes an id second. Discovery reaches it anyway because the literal carries `find`/`findOne`/`count`/`insert` (four ENGINE_SIBLINGS, and `insert` is in ENGINE_ONLY_MEMBERS, which attributes it to the engine side) — accurate for what the scan can see, wrong about what the object is. EXEMPT, not DEBT, on the same ground already recorded twice for `packages/spec/src/contracts/data-engine.test.ts`: it cannot be pinned even in principle, because `assertEngineUpdateDispatch` lives in @objectstack/objectql / @objectstack/metadata-core and BOTH depend on @objectstack/spec — the import would invert the dependency graph. DORMANCY PROBED, not assumed (the #5629 method, which the two update entries above explicitly say they lack): a `process.stderr.write` marker injected as the first statement of this `update` printed NOTHING across a full run of both files (83/83 tests green). The control that makes that silence evidence rather than a broken probe: the same injection in this witness's sibling `updateById` — which the transaction test does call — DID print, in the same run of the same harness. So the looseness is unexercised today. Dormant is not harmless: a future test that starts updating through this witness inherits a double free to accept what ObjectQL.update refuses (`where: { id: { $in: [...] } }` reads as an id and is a multi-row predicate). Discovered at line 1176 — the repository the `liveApi` ScopedContext stand-in returns from `object(name)`, which exists so the #5945 block can assert `HookContextSchema.parse` neither rejects nor clones a live engine object. Nothing in that block calls any repository method at all.", + "closes": "nothing — permanent, unless this gate grows a scoped-repository arm (then this witness becomes out of scope rather than exempt)" } ] } diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index a2e76fe975..c5be60ce17 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -19,6 +19,8 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/automation/flow-function.zod.ts` — The contract for a **named handler function a `script` node invokes** — +- `node_modules/@objectstack/spec/src/data/driver.zod.ts` — Common Driver Options - `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes (#4410). - `node_modules/@objectstack/spec/src/data/driver/config-registry.zod.ts` — The driver-id → `datasource.config` shape registry (#4410). - `node_modules/@objectstack/spec/src/data/driver/memory.zod.ts` — Memory Driver Configuration Schema @@ -28,8 +30,10 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. +- `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol +- `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema