Skip to content

Commit 9432bac

Browse files
feat(spec): ServiceSlotContracts types the webhooks and trigger-api slots (#22922)
Fixes #22848 Clause-②: yes (widening: a published type gains two keys) > ~~Held out of the merge queue by #22949.~~ Released 2026-10-12: #22949 closed, and `main` was merged into this branch with the net diff unchanged. The seat queues it once the new head is green and its remaining pre-queue items are met. ## What changes `ServiceSlotContracts` (`packages/spec/src/contracts/core-service-contracts.ts`) gains two entries: - `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot: `WebhookOutboxPlugin` (`webhook-outbox-plugin.ts:411`) registers an occupant declared as `const service: IWebhookService`. - `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot: `ApiTriggerPlugin` (`plugin.ts:169`) registers an occupant declared as `const service: ITriggerApiService`. Both providers already type the occupant as the contract. So the registration itself meets the file's evidence bar ("an entry is a claim, made only where the binding is established"). Neither slot is a `CoreServiceName` member, so both entries go in `ServiceSlotContracts` and not in `CoreServiceContracts`. Each entry has TSDoc naming the contract and the package that registers it. This is type information only. No runtime file changes. No generated artifact changes: `check:generated` reports all 14 up to date at HEAD `63710c5572`. ## The pin (the card's "Done when") The pin is in `core-service-contracts.test.ts`, which `check:test-typecheck` compiles. - **The two probes.** The test declares a lookup with the runtime's keyed signature: a `K extends keyof ServiceSlotContracts` name in, `ServiceSlotContract` of `K` (or `undefined`) out. That is the shape of `DomainHandlerDeps.resolveService` and `getRequestKernelService`. Through it, `getService('webhooks')?.handleRedeliver` is pinned to `IWebhookService['handleRedeliver']`, and `getService('trigger-api')?.handleInboundHook` to `ITriggerApiService['handleInboundHook']`. Each slot is pinned to its contract or `undefined`. - **CONTROL: other slots.** The same lookup still answers `IHttpServer`, `ISecurityService` and `IDataEngine` (each or `undefined`) for `http.server`, `security` and `data`. The existing pins are untouched and green: `protocol` and `mcp` are `unknown`, `objectql` is the full engine, and `http-server` is the alias. - **CONTROL: exact key set.** The test "holds exactly the non-core slots whose contract is written" used to check only a subset: an array typed as the key union, plus a length. It is now exact at the type level. `keyof ServiceSlotContracts` minus the core keys must equal the seven listed keys. A key added or dropped without updating the list is a type error. The new pins use `expectTypeOf`, not local `type _X` aliases. The file's existing aliases are recorded as TS6196 debt in `test-typecheck-debt.json`, and new aliases would have grown that shrink-only ledger. The ledger is unchanged: 52 files / 246 errors / 135 signatures. ## Ablation (spec pin) The fix was committed first (`9fe6996719`). Then `node scripts/ablation-replace.mjs` (WRAP mode, which restores automatically) deleted `webhooks: IWebhookService;` from `core-service-contracts.ts` and ran `pnpm --filter @objectstack/spec check:test-typecheck`. - **The mutation landed.** Anchor count 1 → 0, blob `13ce41bf32ee` → `80becbb0dba7`. - **The run went RED** (exit 1). Six new error signatures appeared, all in `core-service-contracts.test.ts`: - TS2345 ×3: `"webhooks"` is not assignable to `keyof ServiceSlotContracts`; - TS2339: `handleRedeliver` does not exist; - TS2344 ×2: the slot-type pin and the exact-key-set pin. - **The restore held.** Blob equals HEAD (`13ce41bf32ee`), and `git diff HEAD` is empty. ## Reverse verification: the runtime reads the rebuilt declarations The 36-package dependency closure of the four consumers was built: `turbo build`, concurrency 2, 36 of 36 succeeded. Then `ablation-replace` (WRAP) inserted one line at `packages/runtime/src/domains/webhooks.ts`. The line reads a member that does not exist, `notAMember`, from the uncast `deps.resolveService(context, WEBHOOKS_SLOT, …)` result. The command was `pnpm --filter @objectstack/runtime exec tsc --noEmit`. - **The mutation landed.** Anchor 1 → 0, blob `c920bff6c2bd` → `6aaa357c3a81`. - **The run went RED with exactly one error.** `src/domains/webhooks.ts(115,17): error TS2339: Property 'notAMember' does not exist on type 'IWebhookService'`. A stale declaration set has no `webhooks` key. There the lookup would fall to the `string` overload, which answers `any`, and the line would compile. - **The restore held.** Blob equals HEAD (`c920bff6c2bd`), and `git diff HEAD` is empty. - **A first attempt was a no-op.** Its replacement contained the anchor again, so `ablation-replace` refused it (anchor 1 → 1) before running tsc. Its exit 1 is not a reading, and the run above is the corrected one. ## Readings at BASE `7de0265fc7` 1. **How the map types lookups.** `ServiceSlotContracts` extends `CoreServiceContracts`, and quoted kebab keys already exist (`'http-server'`). `ServiceSlotContract` of `K` answers the entry, or `unknown` when there is none. - Three runtime seams are keyed by the map: - `DomainHandlerDeps.resolveService` (`domain-handler-registry.ts:182`, with an untyped `string` overload at `:183`); - `getRequestKernelService` (`:299` / `:300`); - the execution-context resolver (`resolve-execution-context.ts:57`). - `ObjectKernel.getService` (`packages/core/src/kernel.ts:816`) is generic over a caller-chosen `T`, takes `name: string` and returns `T`. It does not read the map. So the card's "`kernel.getService('webhooks')` is typed" holds for the runtime's keyed lookups, not for the kernel accessor. - The existing slot-typing tests are in `core-service-contracts.test.ts`, which is where the new pin went. 2. **The contracts.** `IWebhookService.handleRedeliver?` (`webhook-service.ts:96`) and `ITriggerApiService.handleInboundHook?` (`trigger-api-service.ts:148`). The contracts barrel exports both (`contracts/index.ts:36`, `:66`). 3. **Registrations.** Both slots are registered (see "What changes"), and each occupant is declared as the contract it is now keyed to. No registration fails to satisfy its interface. `ctx.registerService(name: string, …)` does not read the map either, so no registration is re-checked. 4. **Callers.** Two callers resolve these slots through the typed seam: `runtime/src/domains/webhooks.ts:114` and `runtime/src/domains/automation-hooks.ts:120`. Both still compile. Each casts to `Pick` of the contract's one member, or `undefined`, and that cast is now redundant (see Acceptance notes). Three test files call `kernel.getService` with an explicit `IWebhookService` or `ITriggerApiService` generic: `webhook-redeliver-member.test.ts:189`, `hono-webhooks-redeliver.conformance.test.ts:174` and `inbound-hook-member.test.ts:169`. The map does not affect them. The pinned objectui checkout (`4997995022`) has zero hits for `ServiceSlotContract` or `CoreServiceContract`; the control grep for `@objectstack/spec` hits. 5. **What `check:generated` regenerates.** Nothing. All 14 artifacts are current, and `check:api-surface` reports the public API surface unchanged: the snapshot records that an export exists, not its members. ## Verification (HEAD `63710c5572`; `origin/main` merged through `os-regen-merge.sh`, and the merge touched no `packages/spec` file) - `pnpm --filter @objectstack/spec build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/spec typecheck` (tsc, the scripts typecheck and `check:test-typecheck`): exit 0; test layer OK, 52 / 246 / 135 held. - `pnpm --filter @objectstack/spec test`: 648 files, 19395 passed and 1 todo. `test:repo`: 55 files, 971 passed. Both ran at `fbe0be035b`, and `git diff fbe0be0 63710c5 -- packages/spec` is empty. - Consumer typechecks against the rebuilt spec, all exit 0: - `@objectstack/runtime` (tsc; test layer 27 / 190 / 68 held); - `@objectstack/plugin-webhooks` (tsc, scripts tsc, test layer); - `@objectstack/trigger-api` (tsc); - `@objectstack/http-conformance` (tsc; test layer 3 / 27 / 10 held). - `check:generated` (14 of 14 current) and `check:api-surface` (unchanged): green at HEAD. - Gate derivation (`dispatch-gates.mjs --commands`, no paths, at `63710c5572`): 85 commands, of which 83 ran and exited 0. Two are NOT MEASURED by the dispatch's instruction: `check:dual-build-cjs-loads` (no whole-workspace build) and `check:type-check-debt` (it is the `--re-measure` form). The `--ran` reconciliation reports 85 accounted for, 0 unrun. - The artifact-roster block from the same derivation: 47 rows ran, 46 at exit 0. Two did not measure: - `check:published-readme-exports`: PREREQUISITE NOT MET (exit 3; 19 unbuilt packages); - `check:console-injection`: no console dist, so it skipped at exit 0. The three PR-context guards are run against this PR after it opens. ## Acceptance notes - **Redundant casts.** At `runtime/src/domains/webhooks.ts:114` and `runtime/src/domains/automation-hooks.ts:120`, the cast to `Pick` of the contract's member (or `undefined`) now restates what the lookup answers. Both still compile. Neither file is in this PR. A later edit to either file can drop the cast. - **Implementers.** Adding required keys to an interface breaks any code that builds an object typed as the whole `ServiceSlotContracts`. There are zero such objects in this repository and in the pinned objectui checkout. The map is read only through `keyof` and indexed access. - **Stale header sentence.** The header of `contracts/trigger-api-service.ts` still says the `trigger-api` registration "is a separate change: until it lands no kernel fills the slot". `ApiTriggerPlugin` now registers the slot (`plugin.ts:169`). This is outside this PR's file surface and is noted only. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 111ffff commit 9432bac

3 files changed

Lines changed: 81 additions & 5 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `ServiceSlotContracts` types the `webhooks` and `trigger-api` slots
6+
7+
Clause-②: yes (widening)
8+
9+
`ServiceSlotContracts` (from `@objectstack/spec/contracts`) gains two entries:
10+
11+
- `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot with a service typed as `IWebhookService`.
12+
- `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot with a service typed as `ITriggerApiService`.
13+
14+
FROM → TO:
15+
16+
- `ServiceSlotContract<'webhooks'>` goes from `unknown` to `IWebhookService`, and `ServiceSlotContract<'trigger-api'>` goes from `unknown` to `ITriggerApiService`.
17+
- A lookup keyed by `keyof ServiceSlotContracts` now accepts both names. One example is the runtime dispatcher's `resolveService` / `getRequestKernelService`, which used to answer `any` for them. It now answers the contract or `undefined`. So `resolveService(ctx, 'webhooks')` resolves to `IWebhookService | undefined`, and `?.handleRedeliver` has the declared member's type. The same holds for `'trigger-api'` and `?.handleInboundHook`.
18+
19+
Every member of both contracts is optional, so a reader still checks that the member it calls is present. No other slot's entry changes, and nothing changes at runtime: this is type information only.
20+
21+
Nobody needs to migrate. Existing casts at those lookups still compile. If you build an object typed as the whole `ServiceSlotContracts` map, it must now carry the two new keys. This repository and the pinned objectui checkout build no such object.

‎packages/spec/src/contracts/core-service-contracts.test.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// widening to `any` — the difference between "no contract written yet" and
77
// "checked", which is the distinction this whole line of work exists to make.
88

9-
import { describe, it, expect } from 'vitest';
9+
import { describe, it, expect, expectTypeOf } from 'vitest';
1010
import { CoreServiceName } from '../system/core-services.zod';
1111
import type {
1212
CoreServiceContracts, CoreServiceContract, ServiceSlotContracts, ServiceSlotContract,
@@ -19,6 +19,8 @@ import type { IHttpServer } from './http-server';
1919
import type { IObjectQLEngine } from './objectql-engine';
2020
import type { ISecurityService } from './security-service';
2121
import type { IShareLinkService } from './share-link-service';
22+
import type { IWebhookService } from './webhook-service';
23+
import type { ITriggerApiService } from './trigger-api-service';
2224

2325
/** Compile-time assertion helper: fails to typecheck unless `T` is exactly `true`. */
2426
type Expect<T extends true> = T;
@@ -110,18 +112,47 @@ describe('slot → contract ledger beyond the enum', () => {
110112
expect(true).toBe(true);
111113
});
112114

115+
it('types the webhooks and trigger-api slots through the member a reader probes', () => {
116+
// [#22848] Both contracts declare every member optional, so a reader
117+
// resolves the slot and probes for the one member it calls. This
118+
// lookup has the runtime's keyed signature (`DomainHandlerDeps`'s
119+
// `resolveService` and `getRequestKernelService` take
120+
// `K extends keyof ServiceSlotContracts` and answer
121+
// `ServiceSlotContract<K> | undefined`); spec cannot import the
122+
// runtime, so it restates the one signature the map feeds.
123+
const getService = <K extends keyof ServiceSlotContracts>(_name: K): ServiceSlotContract<K> | undefined =>
124+
undefined;
125+
126+
expectTypeOf(getService('webhooks')).toEqualTypeOf<IWebhookService | undefined>();
127+
expectTypeOf(getService('webhooks')?.handleRedeliver).toEqualTypeOf<IWebhookService['handleRedeliver']>();
128+
expectTypeOf(getService('trigger-api')).toEqualTypeOf<ITriggerApiService | undefined>();
129+
expectTypeOf(getService('trigger-api')?.handleInboundHook)
130+
.toEqualTypeOf<ITriggerApiService['handleInboundHook']>();
131+
132+
// CONTROL: the same lookup resolves the slots that were already
133+
// mapped exactly as before.
134+
expectTypeOf(getService('http.server')).toEqualTypeOf<IHttpServer | undefined>();
135+
expectTypeOf(getService('security')).toEqualTypeOf<ISecurityService | undefined>();
136+
expectTypeOf(getService('data')).toEqualTypeOf<IDataEngine | undefined>();
137+
expect(getService('webhooks')).toBeUndefined();
138+
});
139+
113140
it('holds exactly the non-core slots whose contract is written', () => {
114-
const extra: Array<Exclude<keyof ServiceSlotContracts, keyof CoreServiceContracts>> = [
115-
'security', 'shareLinks', 'objectql', 'http.server', 'http-server',
116-
];
141+
const extra = [
142+
'security', 'shareLinks', 'objectql', 'http.server', 'http-server', 'webhooks', 'trigger-api',
143+
] as const;
144+
// Exact, not merely a subset: a key added to or dropped from the
145+
// non-core ledger without this list changing is a type error here.
146+
expectTypeOf<Exclude<keyof ServiceSlotContracts, keyof CoreServiceContracts>>()
147+
.toEqualTypeOf<(typeof extra)[number]>();
117148
const slots = new Set<string>(CoreServiceName.options);
118149
for (const key of extra) {
119150
// The point of the split: these are deliberately NOT enum members.
120151
// If one is ever promoted to a real core service, its entry moves
121152
// into `CoreServiceContracts` and this assertion is what catches it.
122153
expect(slots.has(key), `'${key}' is a CoreServiceName — move its entry into CoreServiceContracts`).toBe(false);
123154
}
124-
expect(extra).toHaveLength(5);
155+
expect(extra).toHaveLength(7);
125156
});
126157

127158
it('still resolves a slot with no written contract to unknown', () => {

‎packages/spec/src/contracts/core-service-contracts.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import type { ISecurityService } from './security-service';
4141
import type { IShareLinkService } from './share-link-service';
4242
import type { IHttpServer } from './http-server';
4343
import type { IObjectQLEngine } from './objectql-engine';
44+
import type { IWebhookService } from './webhook-service';
45+
import type { ITriggerApiService } from './trigger-api-service';
4446

4547
/**
4648
* The evidenced slot → contract bindings.
@@ -173,6 +175,28 @@ export interface ServiceSlotContracts extends CoreServiceContracts {
173175
* release boundary once none remain.
174176
*/
175177
'http-server': IHttpServer;
178+
/**
179+
* {@link IWebhookService}, registered by `@objectstack/plugin-webhooks`:
180+
* `WebhookOutboxPlugin` registers its service under `webhooks`, typed as
181+
* this contract, once its redelivery veto is installed, and leaves the slot
182+
* empty otherwise.
183+
*
184+
* [#22848] Every member is optional, so a reader still probes for the one
185+
* it calls (`handleRedeliver`); the entry types the probe instead of
186+
* leaving the slot `unknown`.
187+
*/
188+
webhooks: IWebhookService;
189+
/**
190+
* {@link ITriggerApiService}, registered by `@objectstack/trigger-api`:
191+
* `ApiTriggerPlugin` registers its service under `trigger-api`, typed as
192+
* this contract, wherever it installs the `api` trigger, and leaves the
193+
* slot empty otherwise.
194+
*
195+
* [#22848] Every member is optional, so a reader still probes for the one
196+
* it calls (`handleInboundHook`); the entry types the probe instead of
197+
* leaving the slot `unknown`.
198+
*/
199+
'trigger-api': ITriggerApiService;
176200
}
177201

178202
/**

0 commit comments

Comments
 (0)