Skip to content

feat(spec): publish the typed hook ctx.api face — HookApi / HookObjectApi / HookQuery - #19067

Merged
os-steve merged 13 commits into
mainfrom
claude/issue-18163-export-hook-api-types
Sep 18, 2026
Merged

os-steve merged 13 commits into
mainfrom
claude/issue-18163-export-hook-api-types

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #18163

Clause-②: yes (widening)

Executes batch #147 item 1, letter A — maintainer 「其他同意」 2026-09-17T14:01Z.

@objectstack/spec/data now exports the typed hook ctx.api face, so a metadata app's *.hook.ts imports the platform's type instead of hand-declaring one. Nine additive exported names, zero runtime, no removal and no signature change.

HookApi · HookObjectApi · HookQuery · HookCountQuery · HookUpdateOptions · HookDeleteOptions · HookUpdateDoc · HookDoc · HookDriverPassthroughOptions

import type { HookApi } from '@objectstack/spec/data';

const api = ctx.api as HookApi | undefined;
if (!api) return;
const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } });

Measured at the engine seam, not from any app's copy

The ruling's third prescription. Located by declaration site, never by a mention:

fact declaration site
ctx.api is built per dispatch by ObjectQL.buildHookApi and is a ScopedContext packages/objectql/src/engine.ts:4716
ScopedContext.object(name) returns an ObjectRepository packages/objectql/src/engine.ts:15625
the repository's real method set, and that it injects context after the caller's spread packages/objectql/src/engine.ts:15420-15535
the engine's per-method legal option keys packages/objectql/src/engine.ts:519-535
the alias table and the fold, filter to where and top to limit packages/spec/src/data/data-engine.zod.ts:517,551
the throw on a slot whose spellings disagree packages/objectql/src/engine.ts:792-799
the type ctx.api already carries — IScopedContext, from @objectstack/spec/contracts packages/spec/src/contracts/scoped-context.ts:208

What the fold actually does, which is finer than "refuses both": redundant spellings that are deep-equal collapse into where in silence; spellings that carry different values are irreconcilable and the engine throws, naming both. So { where, filter } is a coin toss decided by whether the two happen to agree. Omitting the alias key makes it neither — TS2353 at the authoring site.

The same measurement carries top, the OData alias of limit: same slot table, same throw on a value disagreement, no expressive power of its own. Extending the ruling's filter instruction to top is this PR's reading of the same rule and is flagged in the file's own docblock as the one place contract review should decide whether the type should be wider than the ruling's letter.

Not a second dialect of IScopedContext

contracts/scoped-context.ts stays the CHECKED IMPLEMENTATION contract — ScopedContext and ObjectRepository carry implements clauses against it, and its query bags are deliberately loose, for the reason that file argues at length. This is the authoring half of the same seam. They cannot drift because every option shape here is an Omit or Pick over the very Engine*Options schemas the engine's own legal-key sets are pinned against (engine-unknown-option.test.ts), and hook-api.test.ts pins HookApi as assignable to IScopedContext in both the context and the repository position — so ctx.api as HookApi stays a direct cast, never as unknown as.

Deliberately absent, each with its reason in the docblock: context (injected and discarded), the cursor / distinct / upsert tombstones, sudo() (the #5945 exclusion stands — Hook.runAs: 'system' is the declared way to run elevated), and aggregate / execute / create / deleteById. count is the one shape without the driver pass-through keys, because the engine forwards no bag on that method and rejects them there — engine behaviour no document states.

Verification, all at d3895054b2

Reverse verification from a consumer's vantage — a throwaway probe in a package that resolves @objectstack/spec/data through the exports map into the BUILT dist, run in two legs and then removed (worktree confirmed clean):

LEG A  probe carries `filter` beside `where`   tsc exit 2
       os-hookapi-consumer-probe.ts(5,62): error TS2353: Object literal may only
       specify known properties, and 'filter' does not exist in type 'HookQuery'.
LEG B  same probe, alias key removed           tsc exit 0

On-disk proof was taken per leg (1 occurrence of the mutated text, then 0), so neither leg is a no-op.

  • pnpm --filter @objectstack/spec typecheck — exit 0. Three programs: the build config, tsconfig.scripts.json, and check:test-typecheck, which is what compiles the test layer. The six @ts-expect-error pins in hook-api.test.ts are therefore real: an unused directive is itself an error, so a directive that stopped catching anything turns this red.
  • pnpm --filter @objectstack/spec test — 492 files, 14311 tests, all pass.
  • pnpm --filter @objectstack/spec check:generated — all 16 artifacts up to date.
  • pnpm lint repo-wide (eslint . --no-inline-config) — exit 0, no narrowing claimed.
  • Gate families derived with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack and reconciled with --ran: 83 derived, 79 run 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 exiting 3 — PREREQUISITE NOT MET, which is the code those gates use for "nothing was measured". All four need the whole workspace built (turbo run build over every package); this diff's own dependency closure is empty, since packages/spec has no workspace dependencies. CI builds fresh and runs all four.

origin/main moved onto the same three generated artifacts and onto data/driver.zod.ts, which this file reads for the pass-through keys, so the merge went through scripts/pm/os-regen-merge.sh and the regeneration is its own commit. Both sides survive in the regenerated artifacts: this branch's nine Hook* entries and main's driverSupportsTransactions.

Acceptance notes

  • The card's own grep is not a reading, and the premise is still true. 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 point. 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. Re-derived with a corrected instrument that counts the barrel's export bindings: HookContext 1, EngineQueryOptions 1 (positive controls), HookApi / HookObjectApi / HookQuery 0 across the whole published dist and 0 across every package source. So the gap was real; the instrument that found it could not have told.
  • 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 in packages/objectql/src/engine.ts, where the pin would live, and 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, so an author who wants to name the declared type of ctx.api alongside HookApi imports from two entry points. Carrier: the same follow-up card, which is 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, not a gap. Carrier: none — no consumer needs to spell them.

NOT MEASURED — say it plainly

The ruling names hotcrm's src/objects/_hook-api.ts as the acceptance fixture: it must type-check against this export with its own copy deleted. That leg was not run. hotcrm is not a repository this session can reach, and the ruling is explicit that its file is the fixture and not the source of truth, so nothing here was written from the card's quoted excerpts of it. The reverse verification above is the closest reachable stand-in: a real consumer, resolving through the published exports map into the built .d.ts, refusing filter and accepting the canonical shape. It is not the fixture, and it is not claimed to be.


Generated by Claude Code

…pec/data

Export `HookApi`, `HookObjectApi`, `HookQuery`, `HookCountQuery`,
`HookUpdateOptions`, `HookDeleteOptions` and `HookUpdateDoc` so an app's
`*.hook.ts` imports the platform's type instead of re-deriving engine
semantics in its own copy.

Every option shape is derived from the `Engine*Options` schemas the engine's
own per-method legal-key sets are pinned against, and carries no `filter`
key: the engine folds `filter` into `where` and refuses the slot when the two
spellings disagree, so omitting the alias turns that hazard into a compile
error from the platform's own type.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…ins artifacts

Nine additive exported names from `@objectstack/spec/data`; no removals
(exported names 832 -> 841, declarations 845 -> 854).

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
Clause-②: yes (widening)

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…tree

Discharges the merge deferral from 5f0fef7. Both sides survive: this
branch's nine `Hook*` entries on `./data` and main's `driverSupportsTransactions`.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface-declarations/data.txt, packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, …) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from cdc64bee1a9d022b8dff5c2994712533bb427dd4 — the merge of head 6d8fc4f12fcdaa8fa25e9abb698aba308ce64b84 into base 176b03582e600ee5628d21bff9422073c5a5530c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cdc64bee1a9d022b8dff5c2994712533bb427dd4 && git checkout cdc64bee1a9d022b8dff5c2994712533bb427dd4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 176b03582e600ee5628d21bff9422073c5a5530c 6d8fc4f12fcdaa8fa25e9abb698aba308ce64b84 && git checkout -B drift-repro 176b03582e600ee5628d21bff9422073c5a5530c && git merge --no-ff 6d8fc4f12fcdaa8fa25e9abb698aba308ce64b84

node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 176b03582e600ee5628d21bff9422073c5a5530c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d3895054b2f5e04e46351840c18d0bc5c0dddb1d

Trees and instruments. Head worktree at d3895054b2; base worktree at 9bdc384d66 (the PR's base.sha, NOT an ancestor of head: the merge-base is 7af6983ccf, and the PR's seven-file diff, +622/-2, is 7af6983ccf...d3895054b2; the five main-side commits between the merge-base and 9bdc384d66 touch none of the seven files). Rules read from the shared checkout at 72c1640504. tsc 6.0.3, node v22.22.2, pnpm 10.31.0; @objectstack/spec built from source at both trees (exit 0 each). Consumer probes are one-file tsc programs resolving @objectstack/spec/data through the package exports map into the BUILT dist. Every exit code captured before any pipe.

(1) Derived judgments

  1. Surface delta — RIGHT. api-surface/data.json: merge-base 846 entries / 833 distinct names; head 855 / 842. Added exactly the nine names; removed none. Cross-checked on the built dist export bindings: 833 at base, 842 at head. The dev's "832 to 841" is the pre-merge number; delta +9 either way.
  2. The exported type is what the engine accepts — RIGHT, re-measured by declaration site. ctx.api is built by ObjectQL.buildHookApi (engine.ts:4716); ScopedContext at :15642, object(name) returns ObjectRepository at :15648, which spreads the caller's bag then sets context LAST on every method (:15457-15535) — so omitting context from the published options is correct. Legal key sets at :519 :524 :528 :532, passthroughs at :491, all enforced on the hook path by rejectUnknownEngineOptions (:9951 :10221 :11536 :13972 :14491). Computed from hook-api.ts, each published shape equals its engine set minus context, exactly. Residual channel, note only: the passthrough keys are spelled by value in three places, so a key added to ENGINE_DRIVER_PASSTHROUGH_KEYS alone fails no spec pin — the objectql pin is where such an addition is made.
  3. The CLASS satisfies the face — measured here, holds. A scratch probe in objectql at head (removed afterwards; tree clean): tsc --noEmit exit 0 with [ScopedContext] extends [HookApi] true and [ObjectRepository] extends [HookObjectApi] true, with negative controls erroring on exactly the two control lines, so the file compiled and the class did not resolve to any. ⭐ The dev's substitute did NOT establish this: "HookApi assignable to IScopedContext" is the other direction, and IScopedObjectRepository declares no delete. The objectql-side pin is still missing and is a card to file, not a defect in the type.
  4. where-only — RIGHT, reproduced. Leg A find({ where, filter }) -> tsc exit 2, TS2353 'filter' does not exist in type 'HookQuery'. Leg B -> exit 0.
  5. top — SETTLED: keep it omitted (option A). Not blocking. RPC_QUERY_ALIAS_SLOTS declares {canonical:'limit', aliases:['top']} beside {canonical:'where', aliases:['filter']}; ENGINE_QUERY_SLOTS is exactly those two rows, folded through the same function and the same conflict throw before the legal-key check; and ENGINE_FIND_OPTION_KEYS itself has no top — the objectql drift pin skips it for that very reason. So HookQuery's keys are the engine's accepted set verbatim, and re-adding top would put the published face out of step with the engine AND re-open for {limit, top} the coin toss the ruling closed for {where, filter}. Two structural reasons besides: a narrower face is the reversible direction (adding later is additive, removing later is breaking), and the maintainer's standing line on alias spellings. Leg C find({ limit: 3, top: 1 }) -> TS2353 at head.
  6. count — RIGHT. Leg D2 count({ where, tenantId }) -> TS2353, matching ENGINE_COUNT_OPTION_KEYS and the docblock that says count never forwards the passthrough bag.
  7. ⭐ Naming and reachability — FINDING, BLOCKING. HookApi.transaction structurally references EngineTransactionInfo and EngineTransactionOptions; neither is exported from ./data (absent from the dist barrel at base AND head; present in dist/contracts). Measured in the consumer shape this card serves — a program importing only @objectstack/spec/data and emitting declarations: leg E -> exit 1, TS2883: The inferred type of 'inTx' cannot be named without a reference to 'EngineTransactionInfo' … This is likely not portable.; leg K -> TS2883 on EngineTransactionOptions. Governing text: the maintainer ruling of 2026-08-23 on @objectstack/spec's root entry does not re-export three types its own public API's inferred types mention — every consumer inferring through defineStack hits TS2883 #11350, recorded in check-entry-nameability.ts — "a type that appears structurally in an entry's public declarations must be nameable from that same entry" — chartered 2026-08-25 on @objectstack/spec root entry: two MORE structurally-mentioned types are unnameable through the CHUNKED build (BaseValidationRuleShape, FilterCondition) — a minimal defineStack consumer still hits TS2883 #11709. ⛔ The dev's "check:entry-nameability passes" is NOT evidence: by its own docblock that gate probes the CALL surface of value exports; HookApi is a type and no probe reaches api.transaction(...). Run at head it is exit 0 with 468 call probes and blind here by construction. Control: leg H shows IScopedContext already leaks the same way off HookContext.api at BOTH trees (pre-existing, since [spec] HookContext.api 声明为 z.unknown() —— 按 (ctx: HookContext) 标类型的 hook 无法调用 ctx.api.object(…),而文档/技能全在这么教 #5945) — this PR adds two NEW instances to a face it publishes permanently. WriteObservabilityOptions does NOT leak (leg J, exit 0). Remedy, measured at head then reverted: append to hook-api.ts export type { EngineTransactionInfo, EngineTransactionOptions } from '../contracts/objectql-engine'; (and optionally IScopedContext), rebuild — legs E, H and K all emit at exit 0; check:dual-source-exports stays green; check:api-surface reports "0 breaking (removed), 3 added", so the patch round regenerates the three artifacts and the changeset sentence names the additions.
  8. HookDoc — note, a decision to record. It is a string-keyed record of unknown, so an interface-typed payload is refused (leg F, TS2345 "Index signature for type 'string' is missing"). The engine and the checked contract both accept it, so this narrows the face below the engine with no hazard removed. It fails loudly, never silently, and insert({ ...ctx.input }) compiles. Decide now rather than after publication — a later widening changes what a consumer annotating a value as HookDoc may index.
  9. Deliberately absent members — RIGHT. sudo() off the face matches [spec] HookContext.api 声明为 z.unknown() —— 按 (ctx: HookContext) 标类型的 hook 无法调用 ctx.api.object(…),而文档/技能全在这么教 #5945; execute elevates and belongs out of a CRUD face; create/deleteById/aggregate are aliases or a separate register. Item 3 proves the class still satisfies the face with all of them absent.
  10. Card premise — re-derived; premise TRUE, but the card's zero was NOT a reading. The card's grep returns 0 at base AND head, including for HookContext (exported at both) and for HookApi at head (exported there). Radius: the glob reaches one file per dist, a 449-462 KB re-export barrel whose bindings are spelled k as HookContext and whose declarations live in hashed chunks one directory up. Corrected instruments with controls: barrel bindings — the nine names 0 at base, 9 at head, controls HookContext 1 / EngineQueryOptions 1 at both; declaration sites across dist/**/*.d.ts — HookApi 0 at base, 1 at head; source declaration sites — the nine names 0/0/9, controls 1 at all three. ⭐ My own first source instrument returned 0 on its controls and was discarded — the same lesson, in this record.
  11. Regen, gates and fences — RIGHT. check:generated all 16 up to date; check:dual-source-exports, check:exported-any, spec tsc --noEmit all exit 0; check:test-typecheck OK, so the six @ts-expect-error pins are compiled, not phantoms; hook-api.test.ts 12/12. The fenced data/hook.zod.ts (PR feat(spec)!: every engine-evaluated expression slot requires a non-blank source #18638) is NOT in the seven-file diff.
  12. NOT MEASURED — the hotcrm acceptance fixture. Attaching objectstack-ai/hotcrm was refused by the environment's permission layer, so the leg the ruling names is unrun here, as it was for the dev. The card's acceptance is incomplete on that leg. Not a reason to fail this PR; it is a reason the seat carries it forward: the hotcrm follow-up card runs tsc with _hook-api.ts deleted and its 17 imports rewritten, and reports back on spec: export a typed hook ctx.api (HookApi / HookObjectApi / HookQuery) — every metadata app hand-declares it today #18163. Items 7 and 8 are where that leg is expected to bite if left unrepaired.

(2) Semver level

@objectstack/spec: minor, body Clause-②: yes (widening), matching the PR body and the ruling. Additive, type-only, no removal or narrowing — minor is right. check-adr-0087-registration exit 0 (no declared-breaking changeset, no disposition due); check-empty-changeset exit 0. CONSISTENT. After the item-7 patch the changeset sentence names the re-exported names and the three artifacts regenerate.

(3) Boundary flags

  • top omitted, wider than the ruling's letter? — answered: A, keep it omitted. Not blocking.
  • "the CLASS satisfies HookApi — NOT MEASURED" — measured, holds; the objectql-side pin is a card to file.
  • "IScopedContext reachable only from /contracts" — pre-existing instance confirmed at base and head; optional line in the same remedy.
  • "EngineTransactionInfo / EngineTransactionOptions not nameable — structural use, gate passes" — REVERSED: TS2883 measured in both inferred positions; the gate is blind to type surfaces by its own charter. BLOCKING.
  • "the card's grep is not a reading" — confirmed and re-derived with controls.
  • hotcrm fixture — NOT MEASURED, carried forward with its re-check command.

Implemented-by: claude/issue-18163-export-hook-api-types
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL — one BLOCKING finding, (1) item 7: two names the published face structurally references are not nameable from the entry that publishes them, measured as TS2883 in the consumer shape this card serves. The fix is two type-only re-exports on ./data, three regenerated artifacts and one more clause in the changeset sentence. Everything else is PASS-grade.


Generated by Claude Code


Generated by Claude Code

…e from ./data

Contract review's one blocking finding. `HookApi.transaction` references
`EngineTransactionInfo` and `EngineTransactionOptions` structurally, and
`HookContext.api` has referenced `IScopedContext` the same way since #5945;
none was reachable from `./data`, so a consumer importing only that entry and
emitting declarations answered TS2883. Three type-only re-exports, zero
runtime.

`check:entry-nameability` cannot see this: it probes the call surface of value
exports, and `HookApi` is a type. The sibling test now imports the three names
THROUGH `./hook-api`, so deleting a re-export reds `check:test-typecheck`.

Also records two decisions the review settled, in the docblock rather than as
open questions: `top` stays omitted (it is absent from
`ENGINE_FIND_OPTION_KEYS` itself, so the face carries the engine's accepted set
verbatim), and `HookDoc` stays a string-keyed record of unknown (loud refusal,
one-word call-site remedy, and widening later is the additive direction).

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…ports

Exported names 842 -> 845, declarations 855 -> 858. Added exactly
EngineTransactionInfo, EngineTransactionOptions and IScopedContext on ./data;
no removals.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…s, not three

Measured, not reasoned: exporting `IScopedContext` alone is a HALF closure,
because `IScopedContext.object(name)` returns `IScopedObjectRepository` and a
consumer writing `ctx.api.object('deal')` still answered TS2883 on that fourth
name. With all four, a probe covering both `transaction` arguments, `ctx.api`,
`ctx.api.object(...)`, `api.object(...)` and a repository read emits at exit 0
and every inferred type in the emitted declarations resolves through
`@objectstack/spec/data`. The chain terminates there — the repository's members
answer in structural types that name nothing further.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
… on measurement

Reverts the exploratory fourth name. The blocking finding is
`EngineTransactionInfo` and `EngineTransactionOptions`, both introduced to this
entry by this card's own declarations; those two ship.

The optional leg — `IScopedContext`, leaking off `HookContext.api` since #5945
— is declined here, measured rather than waved off. It needs TWO names, not
one: `IScopedContext.object(name)` returns `IScopedObjectRepository`, so with
the context exported alone `ctx.api.object('deal')` still answered TS2883 on
the repository. And the second name is not free: adding it reorders members
inside object type literals in the unrelated `ui` shard, 330 lines that
`check:api-surface-declarations` reports as "33 reshaped" and asks a reviewer
to rule on. Measured order-only — identical token multiset, identical line
count, generator stable against a fixed dist — but an adjudication in a shard
this card does not touch is its own card, not a rider on a FAIL remediation.

Surface delta vs the merge base: exported names 833 -> 844, no removals, and
`ui.txt` untouched.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the merge deferral from d95e7cf. `data.txt` carries this
branch's eleven additions on top of what main brought; `ui.txt` and
`automation.txt` moved order-only (identical token multiset, identical line
count, nothing added, removed or renamed).

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 18, 2026
The previous regeneration read a dist that turbo served from the shared cache,
which every worktree on this container replays, so the committed bytes were not
what this tree builds. Two independent `build --force` runs of this exact tree
agree with each other and disagree with what was committed; these are the
forced-build bytes.

Both shards move order-only against the merge base — identical token multiset,
identical line count, nothing added, removed or renamed. `data.txt` is
unchanged by this commit and still carries this branch's +11 exported names.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/xl labels Sep 18, 2026
…he-tainted

The previous commit message and this docblock justified declining the
pre-existing `IScopedContext` leak partly on "330 lines of order-only churn in
the ui shard". That measurement was taken against a dist turbo served from the
shared cache, so it is VOID, not disproven. From a forced build this tree
emits `ui.txt` and `automation.txt` byte-identical to main, and no such churn
exists.

The decision is unchanged and now rests only on what survives measurement: the
leak is pre-existing (since #5945, on base as on head), it is outside this
card's ruling, and closing it takes TWO names rather than one — so it is a
change to a face this card does not own, with its own review.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review — round 2

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6d8fc4f12fcdaa8fa25e9abb698aba308ce64b84

Re-verification of the round-1 record (comment 5732448431, head d3895054b2) after the dev's fix. Head worktree at 6d8fc4f12f fetched into an owned ref; base at 176b03582e (this time it IS the merge-base), so the diff is 176b03582e...6d8fc4f12f: 7 files, +784/-2, no hook.zod.ts. tsc 6.0.3, node v22.22.2, pnpm 10.31.0. ⭐ Every dist read this round was produced by the package's own tsup build into a dist/ that had been rm -rf'd first, in a worktree of its own; zero turbo or cache-hit lines in every build log. turbo was never invoked in either round, so no reading of mine rests on the shared cache. Exit codes captured before every pipe; a red control ran in the same instrument beside every green leg.

(1) Derived judgments

  1. The round-1 BLOCKING finding — CLOSED, re-measured, not inherited. hook-api.ts:370 carries the two type-only re-exports. Leg E -> exit 0, emitted info: import("@objectstack/spec/data").EngineTransactionInfo; leg K -> exit 0; new leg L (importing both names directly) -> exit 0. Live controls in the same run, same dist: leg H -> exit 1 TS2883 on IScopedContext; leg H2 -> exit 1 TS2883 on IScopedObjectRepository; leg A -> TS2353 'filter'; leg C -> TS2353 'top'; leg D2 -> TS2353 'tenantId'; leg F -> TS2345. So the instrument fires on this dist and the three green legs are readings. ⭐ The dev's report does not say which producer built the dist ITS legs read, so they are confirmed here rather than accepted.
  2. Surface delta — RIGHT, +11 / -0. merge-base 846 entries / 833 names; head 857 / 844. Added the nine round-1 names plus EngineTransactionInfo and EngineTransactionOptions; removed none. export-origins attributes both to a single declaration. Built dist bindings 844, the two names present, IScopedContext / IScopedObjectRepository absent as declined.
  3. Declining the optional third name — RIGHT on the new reason. The half-closure is certain from the declaration site: IScopedContext.object(name) returns IScopedObjectRepository (contracts/scoped-context.ts:210), so exporting the context alone leaves ctx.api.object(…) inferring a name still not on ./data — leg H2 shows it leaking at head. Both names leaked at both round-1 bases, so neither was introduced by this diff. Closing them is a two-name change to a face this ruling does not name, and underneath sits a design question this card must not decide — whether HookContext.api should be declared as HookApi rather than IScopedContext. FILE it as its own card.
  4. top and HookDoc — DECIDED in the docblock; both round-1 flags struck. hook-api.ts:60-70 and :159-176 carry the decisions with reasoning and the reversibility argument; the round-1 "REVIEW POINT" paragraph is gone (grep 0). Legs C and F reproduce.
  5. The CLASS still satisfies the face. Scratch objectql probe: exit 0 with both extends true; a second run with two negative controls errors on exactly those two lines. The test docblock now says NOT PINNED rather than NOT MEASURED. The objectql-side pin remains a card to file.
  6. Gates at head on the fresh dist. check:api-surface, check:dual-source-exports (204 to 206 single-declaration re-exports, 0 dual-source), check:entry-nameability (468 call probes, blind to this class as before), check:exported-any, spec tsc --noEmit, check:test-typecheck, check-adr-0087-registration, check-empty-changeset — all exit 0. hook-api.test.ts 13/13; objectql engine-unknown-option.test.ts 23/23.
  7. check:generated — 15 of 16 on the FIRST fresh build, 16 of 16 on the next two; not this PR's. The red was check:api-surface-declarations on security.txt, permissionForm, "0 removed, 0 added, 1 reshaped" — differing only in the member ORDER of two literal unions. security.txt is outside the seven files and byte-identical to 176b03582e.
  8. where-only, count, absent members, engine seam — unchanged from round 1; legs A/B/C/D/D2 reproduce.
  9. Changeset names the two re-exports and why, and states "Additive only: eleven new exported names … no removal".
  10. Card premise — unchanged.
  11. NOT MEASURED — the hotcrm acceptance fixture, as in round 1 (attach refused by the permission layer). Carried forward with its re-check command. Items 3 and 4 are where that leg is expected to bite.
  12. ⭐ The cached-dist question, answered — and a reading the seat's defect card needs. (a) None of my readings, either round, rest on a turbo-served dist. (b) The dev's round-2 readings name no producer for their dist and are not certifiable from its report; every one of them was replaced above. (c) ⭐ NEW: on this container, with turbo never invoked, fresh no-cache builds of ONE tree in ONE path do not agree with each other on union member order. Head tree: build 1 RED, builds 2 and 3 green; main: 2 builds green. The generator itself is stable (3/3 identical over one FIXED dist) and pins only its own name ordering — union member order comes from TS's printer over the built d.ts and is never normalized. So the flip is in the dts build layer, at low frequency, and it is the same symptom the dev saw in the ui shard and attributed to the shared cache. The dev was right to VOID that measurement; but its replacement claim ("from a build --force no such churn exists", a sample of two builds) is NOT established — the churn can arise with no cache anywhere. NULL reading, stated so it is not read as evidence: my attempt to diff the two dists' d.ts sets byte-for-byte failed (cpio absent on this container), so the attribution rests on the generator's 3/3 stability plus the disagreement between fresh builds, not on a d.ts diff. Also: .build-input-hash-dts is an INPUT hash (identical across the red and the green build), so a freshness check cannot distinguish the two outputs. Judgment for THIS PR: not blocking — order-only, outside the diff, byte-identical to main, and CI at this head is green. For the seat's check:generated --fix defect card: "build then --fix" AND "build --force then --fix" are BOTH insufficient prescriptions, because a fresh build can itself print a union in a different order; the durable fix belongs where the order is produced or compared.

(2) Semver level

@objectstack/spec: minor, Clause-②: yes (widening) on both carriers, matching the ruling. +11 exported names, 0 removed, 0 reshaped in the PR's own shard, type-only. CONSISTENT.

(3) Boundary flags

  • Round-1 BLOCKING: CLOSED — legs E, K, L green with H, H2, A, C, D2, F red in the same run.
  • Optional third name declined: RIGHT — two-name half-closure, pre-existing, outside the ruling; file the pre-existing leak as its own card.
  • top / HookDoc: DECIDED in the docblock with reasoning — both flags struck.
  • Class-satisfies-face: holds at head; the objectql-side pin is a card to file.
  • Dev-disclosed cache-tainted measurement: the void stands; the replacement claim is not established — fold (1)12(c) into the seat's defect card. Not blocking.
  • hotcrm fixture: NOT MEASURED, carried forward.

Implemented-by: claude/issue-18163-export-hook-api-types
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS — the BLOCKING finding is closed by measurement at 6d8fc4f12f; nothing new at this head is blocking.


Generated by Claude Code


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 18, 2026 17:38
@os-steve
os-steve added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 9a910c4 Sep 18, 2026
40 checks passed
@os-steve
os-steve deleted the claude/issue-18163-export-hook-api-types branch September 18, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants