Skip to content

spec: export a typed hook ctx.api (HookApi / HookObjectApi / HookQuery) — every metadata app hand-declares it today #18163

Closed

Description

@os-elon-musk

Gap

@objectstack/spec 17.4.0 exports no type for the ctx.api a *.hook.ts body receives. Checked on the installed package: grep -rhoE "export (type|interface) (HookApi|HookContext|HookObjectApi)" node_modules/@objectstack/spec/dist/data/*.d.ts returns nothing. A hook therefore casts ctx.api as HookApi | undefined against a type the app wrote.

Evidence (hotcrm, origin/main c716a2c)

  • src/objects/_hook-api.ts — ~2,358 authored tokens declaring HookQuery, HookCountQuery, HookUpdateDoc, HookUpdateOptions, HookDeleteOptions, HookObjectApi, HookApi; imported by all 17 hook files in the app.
  • The file exists to make the where / filter mixing hazard a compile error (HookQuery omits filter; test/hook-query-predicate.test.ts pins the engine per method) — i.e. it encodes engine behaviour the platform knows and the app re-derived.
  • Under the ADR-0130 package split (Epic: split HotCRM into ADR-0130 packages — sales is the app, service / revenue / marketing are modules, a directory is a package hotcrm#1904) it is the largest source file more than one package needs, and it is a compensation for a platform gap, which hotcrm's AGENTS.md forbids keeping once the platform supplies the type.

Ask

Export the hook API surface from @objectstack/spec/data (or wherever the hook contract lives) so that an app's hook can import type { HookApi } from '@objectstack/spec/data' and delete its own copy. The where-only query shape (no filter key) should be the exported shape, since the engine folds filter to where and refuses both.

Dedupe words: HookApi, hook ctx.api type, _hook-api.ts, HookQuery, HookObjectApi.

Activity

  1. hotlong commented on Sep 17, 2026

    @hotlong
    Contributor

    Ruling: batch #147 item 1 · letter A (export the hook ctx.api type surface from @objectstack/spec/data, where-only query shape) · maintainer 「其他同意」 2026-09-17T14:01Z

    Director seat, summon #24, session_01Wj1HUjzyeiBQ8atRf1ZhaL. Presented in detail under the maintainer's principle 「创业期现在不追求功能多,追求稳定」 with the recommendation A — read as a stability item, not a feature: the platform already implements the hook API; not exporting its type makes every app re-derive engine semantics (hotcrm src/objects/_hook-api.ts, ~2,358 tokens, 17 hook files, HookQuery deliberately omitting filter because the engine folds filter to where and refuses both). The maintainer agreed 「其他同意」.

    Ruling — A

    • @objectstack/spec/data exports HookApi / HookObjectApi / HookQuery (and the count / update / delete option shapes the engine actually accepts), with the where-only query shape — no filter key — so the where/filter mixing hazard is a compile error from the platform's own type.
    • Source of truth is the engine's behaviour, ⛔ not hotcrm's copy: the dev measures what ctx.api really is at the hook seam (the objectql engine's hook context) and exports that; hotcrm's file is the acceptance fixture (it must type-check against the export with its own copy deleted), ⛔ not the spec.
    • New published type surface ⇒ Clause-②: yes, contract review at tier; changeset @objectstack/spec minor; the reference app's follow-up (delete _hook-api.ts, import from spec) is hotcrm's own card after the release.

    Four-facet reading: ① one source of truth for engine semantics; ② measured pull, 17 files in the reference third-party app; ③ apps re-deriving the engine drift silently — the exported type makes the hazard loud at compile time; ④ a type export, zero runtime, one published face to keep honest.

    Execution

    needs-user-decision → pm:queue, domain:spec, p2 stands.


    Generated by Claude Code

  2. self-assigned this
    on Sep 18, 2026
  3. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Claim: PM loop round 6
    Session: session_01AmH9bKvGoLjiY86Q4Z3og2
    Branch: claude/issue-18163-export-hook-api-types
    Worktree: objectstack-issue-18163
    Domain: domain:spec · Seat: domain:spec#4
    Ruling executed: 批次 #147 item 1 · letter A(5717xxxxx,maintainer 「其他同意」 2026-09-17T14:01Z)。
    File surface: packages/spec/src/data/ 下新建的类型文件 · packages/spec/src/data/index.ts(导出桶)· 其姊妹测试。读 packages/objectql/** 取引擎真实形状(只读)。
    ⚠️ 硬围栏: packages/spec/src/data/hook.zod.ts 被开着的 PR #18638 持有 —— 活取其文件页所得。裁决要求从 @objectstack/spec/data 导出,⛔ 不要求改 hook.zod.ts;若实现看起来非改它不可,停下报告。
    Container & model: M,mode:subagent,model: opus(dispatch-gates --tier 对该面无路径强制,floor sonnet · default opus · ceiling fable ⇒ 本席按卡判断)。
    Clause-②: yes —— 新增已发布类型面,裁决已明写,达档复核在案。

    预认领检查,全部本轮实测

    ① 卡内裁决已读。 本卡已裁,⛔ 不重走决策。裁决要点两条易被读反的:源真值是引擎行为,⛔ 不是 hotcrm 的副本(hotcrm 的 _hook-api.ts 是验收夹具:删掉它自己的副本后必须能对导出类型通过类型检查);查询形状是 where-only,无 filter 键。

    ② 活取 32 个开 PR 的文件页(14:0xZ):

    路径 持有者
    data/index.ts CLEAN
    contracts/scoped-context.ts · contracts/data-engine.ts CLEAN
    data/hook.zod.ts #18638 ⇒ 围栏

    ③ H17(锚 #9857,updated 2026-09-18T13:49:26Z)交集 EMPTY。

    • 仪器可达半径: 锚正文 H17 节的文本行,内容形。
    • 半径外已知目标: 只写在 hold 评论里、未被提取进索引的触发文件 —— 锚自陈该提取「under-reports and never invents」。⇒ 这个 EMPTY 只对「已进索引的触发文件」成立。
    • 点亮对照:索引含 packages/spec 路径 8 条。

    ④ 按角色定位,⛔ 不按 grep 命中: 本卡要新建导出,⛔ 无既有声明点可定位;要读的引擎侧 hook 上下文在 packages/objectql,由 dev 自行按声明点定位后在报告里给出。


    Generated by Claude Code


    Generated by Claude Code

  4. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 18163,
      "status": "done",
      "branch": "claude/issue-18163-export-hook-api-types",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19067",
      "session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
      "premise_still_valid": true,
      "summary": "Ruling A of batch #147 item 1 executed. `packages/spec/src/data/hook-api.ts` (new) plus one barrel line in `data/index.ts` publish nine additive type-only exports from `@objectstack/spec/data`: HookApi, HookObjectApi, HookQuery, HookCountQuery, HookUpdateOptions, HookDeleteOptions, HookUpdateDoc, HookDoc, HookDriverPassthroughOptions. The fenced file `data/hook.zod.ts` was NOT touched and the implementation never needed it. Source of truth was the engine, measured by declaration site: ctx.api is a ScopedContext built by ObjectQL.buildHookApi (objectql/src/engine.ts:4716), its object(name) returns ObjectRepository (:15625, methods at :15420-15535, which injects `context` after the caller's spread), and the per-method legal option keys are at :519-535. Every option shape is an Omit or Pick over the Engine*Options schemas those legal-key sets are pinned against, so the published type and the engine's accepted set move together. The query shape carries no `filter`; the fold is finer than the ruling's wording, namely deep-equal spellings collapse silently and disagreeing ones throw, so omitting the alias removes both halves. The same measurement carries `top` (the alias of `limit`), which the ruling does not name -- flagged as a review point, not decided here. PREMISE: true, but the card's own grep is NOT a reading -- see out_of_scope_findings. assignee was already set to os-steve by the dispatch; not written by me. No label was written: the dispatch named none and `skip-changeset` does not apply (a minor changeset ships). `needs:contract-review` is absent from both carriers and I did not hang it (seat's); `check-clause2-carriers.mjs --pair 19067` exits 4 on row C3 for exactly that reason.",
      "tests": "All at HEAD d3895054b2 (after merging origin/main through scripts/pm/os-regen-merge.sh). `pnpm --filter @objectstack/spec typecheck` exit 0 -- three programs, including check:test-typecheck, which is what compiles the test layer (spec's build tsconfig excludes **/*.test.ts), so the six @ts-expect-error pins in hook-api.test.ts are real checks and not phantoms. `pnpm --filter @objectstack/spec test` -- 492 files / 14311 tests passed. `pnpm --filter @objectstack/spec check:generated` -- all 16 artifacts up to date; api-surface, api-surface-declarations and export-origins regenerated (exported names 832 to 841, no removals). `pnpm lint` repo-wide (eslint . --no-inline-config) exit 0 -- full run, no narrowing claimed. Gate families: scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 83; re-derived and re-run on the merged head; reconciled with --ran carrying exit codes: 83 derived, 79 green, 4 NOT MEASURED. The four are check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt, each exit 3 = PREREQUISITE NOT MET (they read built output of the whole workspace; this diff's own dependency closure is empty because packages/spec has no workspace dependencies). CI builds fresh. REVERSE VERIFICATION (consumer vantage, against the BUILT dist through the exports map, two legs, on-disk proof per leg, probe removed and worktree confirmed clean): LEG A, probe spelling `filter` beside `where`, tsc exit 2, `error TS2353: Object literal may only specify known properties, and 'filter' does not exist in type 'HookQuery'.`; LEG B, same probe with the alias key removed, tsc exit 0. On-disk proof: 1 occurrence of the mutated text in leg A, 0 in leg B, so neither leg was a no-op. NOT MEASURED: the hotcrm acceptance leg (type-check `src/objects/_hook-api.ts` against the export with its own copy deleted) -- hotcrm is not reachable from this session. Nothing was written from the card's quoted excerpts of it. NOT MEASURED: that the CLASS ScopedContext satisfies HookApi -- spec must not depend on objectql, and objectql is read-only on this card; hook-api.test.ts pins HookApi as assignable to IScopedContext instead, in both the context and the repository position.",
      "mcp_calls": "0 -- no MCP GitHub tool was called, read or write.",
      "api_writes": "2 -- POST /repos/objectstack-ai/objectstack/pulls (the draft PR); POST /repos/objectstack-ai/objectstack/issues/18163/comments (this report). 0 label writes. Reads (GET issue, GET issue comments, GET pull 19067 body read-back) are not writes. git push was used for the branch, which is not a REST endpoint.",
      "open_questions": [
        {
          "question": "The exported HookQuery omits `top` as well as `filter`. The ruling names only `filter`. Should the published type be wider than the ruling's letter?",
          "options": [
            "A -- keep `top` omitted: it is the OData alias of `limit` in the same RPC_QUERY_ALIAS_SLOTS table, folds through the same code path, and throws on the same value disagreement, so the hazard the ruling removed for `filter` is byte-identical here.",
            "B -- re-add `top`: hold the type to exactly the keys the ruling enumerated, and accept that `{ limit: 3, top: 1 }` stays a runtime throw."
          ],
          "recommendation": "A, because the ruling's stated reason (an alias spelling whose disagreement the engine refuses should be a compile error from the platform's own type) is a property of the slot table, not of the word `filter`. Flagged in the file's docblock and in the PR body as the one point contract review should settle. NOT a blocker: the PR is complete under either answer, and B is a two-line edit."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: the card's grep instrument cannot answer the question it was asked. `grep -rhoE \"export (type|interface) (HookApi|HookContext|HookObjectApi)\" ... dist/data/*.d.ts` returns zero for HookContext too -- a symbol that has always been exported from that entry. INSTRUMENT RADIUS: that glob reaches exactly one file, dist/data/index.d.ts, which is a renamed re-export barrel (`export { k as HookContext } from '../datasource.zod-....js'`); every declaration lives in a hashed chunk one directory up, outside the glob. KNOWN TARGET OUTSIDE THE RADIUS: HookContext itself, declared in dist/datasource.zod-QH9iAKjg.d.ts. Re-derived with a corrected instrument counting the barrel's export bindings: HookContext 1 and EngineQueryOptions 1 (positive controls), HookApi / HookObjectApi / HookQuery 0 across the whole published dist and 0 across every package source -- so the gap was real and premise_still_valid is true, but the zero the card printed was not a reading. Carrier: this report and the PR's acceptance notes; no file to change.",
        "noted, not filed: nothing pins that the CLASS ScopedContext satisfies HookApi. packages/spec must not depend on packages/objectql, so that leg belongs beside hook-input-shape-contract.test.ts in objectql, which this card's file surface excludes. Carrier: whoever next edits ScopedContext or ObjectRepository is already in packages/objectql/src/engine.ts, where the pin would live; the reference app's follow-up card the ruling names is the other side of the same check.",
        "noted, not filed: IScopedContext is reachable only from @objectstack/spec/contracts, never from /data, so an author naming the declared type of ctx.api alongside HookApi imports from two entry points. Carrier: the reference app's follow-up card, the first consumer to feel it.",
        "noted, not filed: EngineTransactionInfo and EngineTransactionOptions are referenced by HookApi.transaction but are not nameable from ./data. Structural use needs no name and check:entry-nameability passes, so this is an observation. Carrier: none -- no consumer needs to spell them."
      ]
    }

    Generated by Claude Code

  5. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    os-dev-report

    {
      "issue": 18163,
      "status": "done",
      "branch": "claude/issue-18163-export-hook-api-types",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19067",
      "session": "session_01AmH9bKvGoLjiY86Q4Z3og2",
      "premise_still_valid": true,
      "summary": "Round 2 — the review's one BLOCKING finding is fixed at head 6d8fc4f12f. `hook-api.ts` now ends with `export type { EngineTransactionInfo, EngineTransactionOptions } from '../contracts/objectql-engine';` -- the two names THIS card's own declarations introduced to the entry. The optional third name is DECLINED, and the reason changed under measurement: `IScopedContext` alone is a HALF closure, because `IScopedContext.object(name)` returns `IScopedObjectRepository` and `ctx.api.object('deal')` still answers TS2883 on it -- so the pre-existing leak takes TWO names, on a face this card does not own, and it is a card of its own. Both review-settled points are now recorded in the docblock as DECIDED, not open: `top` stays omitted (it is absent from ENGINE_FIND_OPTION_KEYS itself, so HookQuery carries the engine's accepted set verbatim), and `HookDoc` stays a string-keyed record of unknown (loud TS2345, one-word call-site remedy, and widening later is the additive direction). The review's correction about the class-satisfies-face leg is accepted and the test docblock now says so. Also re-merged origin/main: PR #18638 landed, so the fence on `data/hook.zod.ts` is released -- that file is still untouched and is not in the diff. PR is still 7 files, still draft; `needs:contract-review` is hung on both carriers by the seat and I did not touch it; 0 label writes.",
      "tests": "All at head 6d8fc4f12f, after merging origin/main (176b03582e) through scripts/pm/os-regen-merge.sh. THE FINDING'S OWN LEGS, consumer program importing only `@objectstack/spec/data` with declaration emit, resolving through the exports map into the BUILT dist: leg E (`(api: HookApi) => api.transaction(async (tx, info) => ({tx, info}))`) and leg K (the second `Parameters` element of `transaction`) -- tsc exit 0, and the emitted .d.ts shows both inferred types resolving as `import(\"@objectstack/spec/data\").EngineTransactionInfo` and `...EngineTransactionOptions`. LIVE CONTROL in the same instrument at the same head: legs H and H2 -- `(ctx: HookContext) => ctx.api` and `... => ctx.api.object('deal')` -- exit 1 with TS2883 on `IScopedContext` and on `IScopedObjectRepository`, and leg A still TS2353 on `filter`. So the probe demonstrably still fires; the two green legs are a reading, not a silence. Probe removed, worktree confirmed clean. `pnpm --filter @objectstack/spec typecheck` exit 0 (build config + tsconfig.scripts + check:test-typecheck, which is what compiles the test layer). `pnpm --filter @objectstack/spec test` 493 files / 14496 tests passed; hook-api.test.ts 13/13, one leg added that imports the two re-exported names THROUGH ./hook-api so deleting a re-export reds check:test-typecheck. `check:generated` all 16 up to date. `pnpm lint` repo-wide exit 0. Gate families re-derived at this head and reconciled with --ran carrying exit codes: 83 derived, 79 green, 4 NOT MEASURED (check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt -- each exit 3 PREREQUISITE NOT MET, all four needing the whole workspace built; CI builds fresh). Surface delta vs the merge base: exported names 833 to 844 (+11 = 9 declarations + 2 re-exports), 0 removed; `ui.txt` and `automation.txt` byte-identical to main, so the PR is still exactly 7 files.",
      "mcp_calls": "0 -- no MCP GitHub tool was called, read or write, in either round.",
      "api_writes": "This round: 1 -- POST /repos/objectstack-ai/objectstack/issues/18163/comments (this report). 0 label writes; `needs:contract-review` was hung by the seat and left alone. 0 PATCH of the PR body (dev writes it once, at open) -- the body additions this round owes are itemised in out_of_scope_findings for the seat to write. Cumulative across both rounds: 3 REST writes (POST pulls, POST issues/18163/comments x2).",
      "open_questions": [],
      "out_of_scope_findings": [
        "CORRECTION TO MY OWN ROUND-2 WORK, disclosed rather than quietly fixed: I first shipped the optional third name AND a fourth (`IScopedObjectRepository`), then reverted both, justifying the revert partly on '330 lines of order-only churn in the unrelated ui shard'. That measurement is VOID -- not disproven, void. It was taken against a dist that turbo served from the SHARED cache, which every worktree on this container replays. From a `build --force` this tree emits `ui.txt` and `automation.txt` byte-identical to main and no such churn exists. Commit 6d8fc4f12f corrects the docblock; the decision is unchanged and now rests only on what survives measurement (pre-existing since #5945, outside this card's ruling, and a two-name change to a face this card does not own).",
        "TO FILE (class a, reproducible defect). Dedupe words: check:generated --fix, turbo cache, stale dist, api-surface-declarations, worktree shared cache. `pnpm --filter @objectstack/spec check:generated --fix` will WRITE generated artifacts from a dist that turbo served from the shared cache, and then report 'All 16 generated artifacts are up to date' -- a verifier reporting success over a measurement it did not take, which Route and surface ownership rule 3 calls worse than no verifier. Repro on this container: in a linked worktree run `pnpm --filter @objectstack/spec build` (plain), then `check:generated --fix`, then `build --force` and `check:generated` again -- the shards differ, and two independent `--force` builds agree with each other. AGENTS.md documents the shared-cache hazard in the read direction ('a typecheck failing on a package your diff never touched') and prescribes force-rebuild, but the gate's own prescription says only 'Build first', so the write direction is unguarded. Carrier: whoever regenerates spec artifacts in a worktree, which is every spec card.",
        "noted, not filed: `check:entry-nameability` cannot see a type-only surface. Its reachable radius is the CALL surface of VALUE exports that have a call signature; `HookApi` is a type, so no probe of that gate reaches `api.transaction(...)`. Known target outside the radius: the two names of this card's own blocking finding, which the gate reported green over at the reviewed head. The instrument that answers is a consumer program with declaration emit. Carrier: the review record on PR 19067, which already states it; extending the gate is `packages/spec/scripts/`, outside this card's file surface.",
        "noted, not filed: the pre-existing `HookContext.api` nameability leak, now characterised for whoever files it -- it takes TWO names (`IScopedContext` and `IScopedObjectRepository`), because exporting the context alone leaves `ctx.api.object(...)` answering TS2883 on the repository. Measured at head, both legs, with green legs in the same run as controls. Carrier: the reference app's follow-up card the ruling names is the first consumer to feel it.",
        "PR BODY ADDITIONS THIS ROUND OWES, for the seat to write (dev writes the body once, at open, and does not PATCH it): (1) under Verification, that the blocking finding is closed at 6d8fc4f12f with legs E and K at exit 0 and legs H/H2 red as the live control; (2) that `top` and `HookDoc` are now DECIDED in the docblock, not open, so the two boundary flags in the review can be struck; (3) that the optional third name is declined because the pre-existing leak takes two names on a face this card does not own; (4) that the surface delta is now +11 exported names, 0 removed, and the diff is still 7 files."
      ]
    }

    Generated by Claude Code

  6. os-steve commented on Sep 18, 2026

    @os-steve
    Collaborator

    Release: PR #19067 MERGED 2026-09-18T18:07:22Z。卡由 Fixes #18163 自动关闭;本笔摘掉遗留的 pm:dispatched 并清空 assignee(关卡不留状态标),priority:p2 / domain:spec 留下。席位 domain:spec#4,session_01AmH9bKvGoLjiY86Q4Z3og2,seat post #18917。

    落地按内容核验,⛔ 不按 API 字段

    origin/main tip 07c6f822ed。半径:内容形 grep,只看追踪文件 —— 四个目标全在追踪的 .ts 里。

    探针 读数
    data/hook-api.ts 存在 1
    修复行(两个 type-only 再导出) 1
    export interface HookApi 1
    data/index.ts 导出它 1
    点亮对照 HookQuery 5
    暗对照 0

    围栏 data/hook.zod.ts 的最后一次改动是 ce5785790c(main 侧),⛔ 不是本 PR。

    达档复核:两轮,FAIL → PASS

    round 1 FAIL,一条 BLOCKING:HookApi.transaction 结构性引用 EngineTransactionInfo / EngineTransactionOptions,而两者不能从 ./data 具名 ⇒ 只 import @objectstack/spec/data 并产出声明的消费者拿到 TS2883。依据是维护者 2026-08-23 对 #11350 的裁决。

    ⭐ 该发现推翻了 dev 的一条"绿":它报「check:entry-nameability 通过」,而复核读了那个门禁自己的 docblock —— 它只探值导出的调用面,HookApi 是类型,468 个调用探针按构造够不到 api.transaction(…)。一个看不见问题的仪器给出的绿,不是读数。

    round 2 PASS,BLOCKING 由测量闭合:legs E / K / L 全 exit 0,且同一轮同一份 dist 上跑了六条红对照(H、H2、A、C、D2、F)—— 绿腿有红腿背书才算读数。复核并指出 dev 的报告没说自己那份 dist 是谁构建的,所以它是「确认」而非「采信」。

    两轮里复核自己产出的三条,已各就各位

    1. top 保持省略 / HookDoc 保持字符串键记录 —— 两条都已作为 DECIDED 写进 docblock(round-1 的 "REVIEW POINT" 段 grep 归零),两个边界旗已撤。
    2. 拒绝第三个名字是对的 —— IScopedContext 单独导出是半闭包(.object() 返回 IScopedObjectRepository,leg H2 仍红)。该既有泄漏要两个名字、在本卡不拥有的面上,且底下压着一个本卡不得裁的设计问题(HookContext.api 该不该改声明为 HookApi)。⇒ 另立卡。
    3. 构建不确定性 —— 已折进 finding(tooling): check:generated --fix writes artifacts from a turbo CACHE-SERVED dist and then reports 「All 16 generated artifacts are up to date」 — a verifier reporting success over a measurement it did not take #19086。

    ⛔ 席位更正

    我在派发词里写了「hotcrm 本会话够不到」。错的 —— 它是公开仓,会话的 git proxy 直接服务匿名读。该腿(删掉 _hook-api.ts 后对导出做类型检查)因此仍是 NOT MEASURED,但不是因为不可达,而是因为它按裁决属于 hotcrm 自己的后续卡、且只能对已发布或已链接的包跑。


    Generated by Claude Code


    Generated by Claude Code

  7. removed their assignment
    on Sep 18, 2026
  8. added a commit that references this issue on Sep 28, 2026
    9a910c4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions