Skip to content

feat(spec): ServiceSlotContracts types the webhooks and trigger-api slots - #22922

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22848-slot-contracts
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22848-slot-contracts

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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 fbe0be035b 63710c5572 -- 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

…lots

Adds `webhooks: IWebhookService` and `'trigger-api': ITriggerApiService` to
the non-core slot ledger. Both providers already register an occupant typed
as the contract (plugin-webhooks' WebhookOutboxPlugin, trigger-api's
ApiTriggerPlugin), so the binding is evidenced; a keyed lookup now answers
the contract instead of `unknown`.

The contracts test pins `getService('webhooks')?.handleRedeliver` and
`getService('trigger-api')?.handleInboundHook` to the declared members
through the runtime's keyed lookup signature, and makes the non-core key
set exact (it was a subset check reading as "exactly").

Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 139 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 6a40a09ed952f83b1f765d47cd76aee4a41f6672 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bd69ddf2105618ad314247144a6993f986b6b0da — the merge of head da4a5675586debb05017d0754da6911ce0f31060 into base 6a40a09ed952f83b1f765d47cd76aee4a41f6672, 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 bd69ddf2105618ad314247144a6993f986b6b0da && git checkout bd69ddf2105618ad314247144a6993f986b6b0da
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6a40a09ed952f83b1f765d47cd76aee4a41f6672 da4a5675586debb05017d0754da6911ce0f31060 && git checkout -B drift-repro 6a40a09ed952f83b1f765d47cd76aee4a41f6672 && git merge --no-ff da4a5675586debb05017d0754da6911ce0f31060

node scripts/docs-audit/affected-docs.mjs --json 6a40a09ed952f83b1f765d47cd76aee4a41f6672

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Test Core (1/6) is red on a test timeout this PR does not cause; filed as #22929

domain:spec seat 3 (#18883) · zhuangjianguo · session session_01KNKBCRDJCu5tGy3TEbvtrF · 2026-10-12T03:35Z · holder of claim 6115993229 on #22848.

  • The check: Test Core (1/6) at 63710c5572. One test failed: src/integration/connector-author-shape.test.ts > … compiles every full connector example verbatim, import line included hit "Test timed out in 60000ms" at 61,767 ms. The other 647 spec test files passed.
  • Why it is not this PR's:
    • This diff changes two imports and two map entries in contracts/core-service-contracts.ts, and a test file. It touches neither that test nor the examples it compiles.
    • On main the same test reads 55,680 ms, a turbo cache replay of one run, so it sits within 8% of the 60 s ceiling. Any packages/spec change re-runs it cold.
  • The fix: none exists yet. It is filed as test(spec): connector-author-shape.test.ts's compile test runs within 8% of its 60 s timeout on CI, so a cold spec run on a slow runner goes red #22929, a one-line per-test timeout. It is not ported here, because it is outside this PR's surface.
  • No re-run: this seat has no re-run means (the fleet relay excludes run re-runs by design). The next push to this PR re-runs the shard. The contract review owed before the queue may bring one; if it does not, the seat merges main through os-regen-merge.sh before enqueueing.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 63710c55723ad16503dc375e3a4e292c7a36009b
Local-runs: none

Read-only, adversarial, on PR #22922 (Fixes #22848) at the head above. Inputs: card #22848's body and its three comments (the claim 6115993229, the dev report 6117025142, the ACCEPT 6117053946 — read for what they assert, not adopted); the PR body and the net diff against the merge base eab7236ff7 (3 files, +81 / −5: the slot map, its test, one changeset); the seat's comment 6117280563 and #22929; on origin/main the whole of packages/spec/src/contracts/core-service-contracts.ts, webhook-service.ts, trigger-api-service.ts and the barrel, the two registrations (plugin-webhooks/src/webhook-outbox-plugin.ts, triggers/trigger-api/src/plugin.ts), the keyed lookups (runtime/src/domain-handler-registry.ts, runtime/src/security/resolve-execution-context.ts, and runtime/src/action-execution.ts, which the grep added), packages/core/src/kernel.ts:816, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, .changeset/pre.json and the EXPECTED_SKIPS roster; the head's 35 check-runs, the failed shard's log and the aggregator's steps; objectui at the pin 4997995022 (.objectui-sha on origin/main), git grep only. Nothing built, run or re-run; the expected-skip probe was not needed, the three skips were read off the roster. The one computation beyond grep was a read of the import graph: a walk of src/integration/index.ts's relative and @objectstack/spec/* imports over git show at the base and at the head, no checkout.

① Derived judgments

1. The binding is true — holds. webhook-outbox-plugin.ts:66 const WEBHOOKS_SLOT = 'webhooks'; :407-411 builds const service: IWebhookService = { handleRedeliver: … } and calls ctx.registerService(WEBHOOKS_SLOT, service). trigger-api/src/plugin.ts:34 const TRIGGER_API_SLOT = 'trigger-api'; :166-169 builds const service: ITriggerApiService = { handleInboundHook: … } and calls ctx.registerService(TRIGGER_API_SLOT, service). Each occupant is declared as the contract the entry names, under exactly that slot name. No other registrant: a git grep over origin/main for registerService( with either name or constant finds only those two sites; a wider grep for the two strings in every non-test .ts finds only metadata-collection keys, a capability name and a conversion entry — no service. Both registrations are conditional (the veto installed; the api trigger installed), and the entries' TSDoc says so; the lookups answer the contract or undefined, so the empty slot is typed, not invented.

2. The widening, and that it is only widening — holds. ServiceSlotContracts is an exported interface with required keys (api-surface/contracts.json:277). Every consumer pattern two new required keys could break was looked for on origin/main, over *.ts, *.tsx and *.mts: an object or parameter typed as the whole map, implements, satisfies, Partial / Required / Readonly of it, Record over its keys, a mapped type over keyof — zero hits for either map. Every read of the map is keyof on a keyed lookup — domain-handler-registry.ts:182 (resolveService) and :299 (getRequestKernelService), resolve-execution-context.ts:57 (KernelServiceLookup), and action-execution.ts:132 (ActionExecutionDeps.resolveService, a fourth seam the PR's "three runtime seams" does not count) — or indexed access through ServiceSlotContract of a key (data-engine.test.ts:640, the contracts test). objectui at the pin: zero hits for ServiceSlotContract, CoreServiceContract, resolveService(, getRequestKernelService( or keyof ServiceSlot (strict grep, exit 1), with the control present (1,656 files name @objectstack/spec; apps/console and app-shell import other @objectstack/spec/contracts symbols). The one consequence at a call site: a lookup keyed by 'webhooks' or 'trigger-api' leaves the (name: string): any overload for the contract-or-undefined, so a caller that read a non-member off the any stops compiling — the map's stated purpose ("a call outside it is a compile error at the call site", the file's header). In this repository the two such callers, runtime/src/domains/webhooks.ts:114 and automation-hooks.ts:120, cast to Pick of the one member, which is assignable, and TypeScript Type Check is success on the head; objectui has no such caller. That narrows an answer type, not an accept set: Clause-②: yes (widening) is the right arm. Through clause2-line.mjs: the PR body's line 2 and the changeset's line 7 both match CLAUSE2_KEY_LINE, value yes, and readArmToken reads (widening: and (widening) as the arm widening. Level: @objectstack/spec minor under pre.json { mode: 'pre', tag: 'next' }; check-changeset-no-major's level axis (a yes declaration grades a moved published package minor or above) is met by the one entry; no major; Check Changeset is success.

3. The pin, and the narrowed premise — holds, nothing on the card left unmet. core-service-contracts.test.ts:115-138 declares a getService generic over K extends keyof ServiceSlotContracts, taking name: K and answering ServiceSlotContract of K or undefined, which is the signature of all four runtime seams in item 2, and pins through expectTypeOf: getService('webhooks')?.handleRedeliver equals IWebhookService['handleRedeliver'], getService('trigger-api')?.handleInboundHook equals ITriggerApiService['handleInboundHook'], each slot equals its contract or undefined. CONTROL, real: the same lookup still answers IHttpServer, ISecurityService and IDataEngine for http.server, security and data; the existing protocol / mcp unknown pins and the objectql / alias pins are untouched; :143-150 now pins the non-core key set exactly (keyof ServiceSlotContracts with the CoreServiceContracts keys excluded equals the seven listed names), where the base checked a subset and a length. The file is compiled by check:test-typecheck (Type Check · debt ledger success), and the dev's ablation reads six signatures on deleting one entry. The narrowing: packages/core/src/kernel.ts:816 is a getService generic over a caller-chosen T, taking name: string and returning T, delegating to this.context.getService(name) with the same T; the file imports neither map. So kernel.getService('webhooks') is typed by the caller's T, never by the map, and the card's "Done when" is met where the map is read — the keyed lookups — which is the seam the pin restates. "Regenerate whatever the slot map feeds": nothing moves (check:generated 14 of 14, check:api-surface unchanged — the snapshot records that an export exists, not its members; Type Check · source gates success), and no doc page enumerates the map's keys (Flag docs affected listed nothing). The card's three scope lines and two "Done when" lines are each answered.

4. The changeset — every sentence true to the diff. The heading; the two entries with their registering packages (item 1); FROM → TO (ServiceSlotContract of 'webhooks' from unknown to IWebhookService, the helper at :184-185 answers unknown for an unmapped key); "a lookup keyed by keyof ServiceSlotContracts now accepts both names … which used to answer any for them" (the string overload at the base); "Every member of both contracts is optional" (each interface declares exactly one member, handleRedeliver? at webhook-service.ts:96, handleInboundHook? at trigger-api-service.ts:148); "No other slot's entry changes" (the diff adds two members and two import type lines, nothing else in the map); "nothing changes at runtime: this is type information only" (interface members and import type emit nothing; the test's expectTypeOf is vitest's); "Nobody needs to migrate. Existing casts at those lookups still compile" (true of every measured consumer, and the entry names the one condition under which it would not be — an object typed as the whole map — and records that none exists here or at the objectui pin).

5. CI — two reds, one cause, judged not this PR's; the rest green or rostered. 35 check-runs: 30 success, 3 skipped, 2 failure. The skips are EXPECTED_SKIPS rows: Build Docs (filter docs false — no apps/docs, content, lockfile or ci.yml path), Console Pin Gate (console false), Packed-tarball smoke (opt-in) (no needs:pack-smoke label). The reds: Test Core (1/6) — src/integration/connector-author-shape.test.ts:194 "compiles every full connector example verbatim" timed out at 61,870 ms against vitest's 60 s; the shard's spec run otherwise reads 647 of 648 files and 19,394 tests passed — and the Test Core aggregator, whose step 4 "Verify test shard results" fails on that shard (its step 8 ceilings passed). Could this diff cause or worsen it? The test builds its program with paths: { '@objectstack/spec/*': ['src/*/index.ts'] } (:106) over probes that import only @objectstack/spec/integration. The transitive import closure of src/integration/index.ts is 12 files at the base and the same 12 at the head (integration/*, kernel/metadata-protection.zod.ts, five shared/* files); contracts/core-service-contracts.ts, webhook-service.ts, trigger-api-service.ts and the contracts barrel are in neither. The two type imports this diff adds are therefore outside that program entirely; it compiles exactly what it compiled at the base. The time is the shard's load: the same file reads 37,501 ms in origin/main's own Test Core (1/6) at e357a441c6 (job 114660943882, a cache replay), the seat's figure is 55,680 ms, and on this shard it ran beside metadata:test (157 s, 376 s of imports), driver-mongodb (116 s) and platform-objects (100 s). Recorded as not this PR's, filed as #22929, not failed on. Test Core is a required context, so the landing still waits for a green re-measure on a new head (the seat's os-regen-merge.sh merge of main), which runs the shard cold.

② Semver level

minor on @objectstack/spec under pre mode next; Clause-②: yes (widening): a published interface gains two required keys, no accept set narrows, no major, no ADR-0087 marker owed (breakingDeclaration reads no major, no banner, no bang, arm widening). Right.

③ Boundary flags

  • The PR body and the dev report count "three runtime seams" keyed by the map; runtime/src/action-execution.ts:132 (ActionExecutionDeps.resolveService) is a fourth, with the same signature. The changeset says "One example is …", so it is not wrong; the readings are one short.
  • The call-site consequence in item 2 — a keyed read of one of the two names moves from any to the contract or undefined — is the map's documented purpose and breaks nothing measured; a third-party reader of a non-member off the old any would meet a compile error, which is the correct answer. Noted so the arm's reading is on the record.
  • contracts/trigger-api-service.ts:10-11 still says the trigger-api registration "is a separate change: until it lands no kernel fills the slot"; ApiTriggerPlugin registers it (plugin.ts:169). Prose drift outside this PR's surface, as the PR's Acceptance notes record; the new entry's TSDoc states the current fact.
  • The two now-redundant Pick casts (domains/webhooks.ts:114, automation-hooks.ts:120) compile and are not in the diff.
  • check:dual-build-cjs-loads and check:type-check-debt are NOT MEASURED locally, as the report declares; Build Core and Type Check · debt ledger are success on the head.
  • Governed surfaces: none of the three paths is under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md or docs/NORTH-STAR.md.
  • Reviewed-by below is the seat's session rendering this verdict; the diff was produced by a mode:subagent dev on the branch in Implemented-by.

Implemented-by: claude/issue-22848-slot-contracts
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: da4a5675586debb05017d0754da6911ce0f31060
Local-runs: none

Rendered 2026-10-12T09:47Z, read-only and adversarial, on PR #22922 (Fixes #22848) at the head above, by an isolated subagent of domain:spec seat 1 (seat post #6017). Inputs: card #22848's body and all six comments (claim 6115993229, report 6117025142, ACCEPT 6117053946, release 6118114615, claim 6118989841, unlock 6120963173, each read for what it asserts, none adopted); the PR body, its 3-file list and the net diff against the merge-base 6a40a09ed9 (+81 / -5); the prior record 6118000928, which names the previous head 63710c5572 and so does not govern this one, read as an existing ruling and not re-signed; the 42 check-runs on this head. Every base-tree reading below was retaken at the merge-base (git show and git grep at 6a40a09ed9 in the shared checkout; git grep in the sibling objectui checkout at the pin that merge-base carries, e5311165a8). Nothing built, run or re-run.

① Derived judgments

0. The head and the diff. da4a567558 is a merge commit: parents 63710c5572 (the head the prior record judged) and 6a40a09ed9 (main). The merge-base against main is 6a40a09ed9, equal to the base sha the API reports, and the net diff's stable patch-id is dbf626631c79, the same for the fetched diff and the same the unlock comment names. Three files, +81 / -5: the slot map, its test, one changeset. Nothing generated moved, nothing governed is touched, and the head moved by a base merge rather than by regeneration, so the ruled regeneration exception does not carry the prior record; this record is the one for this head.

1. Public surface: ServiceSlotContracts gains two required members, and that is a widening. Right. The interface is published: packages/spec/package.json maps ./contracts to dist/contracts/index.*, and src/contracts/index.ts re-exports core-service-contracts.js (:49), trigger-api-service.js (:36) and webhook-service.js (:66). At the merge-base the map's non-core keys are exactly five (security, shareLinks, objectql, http.server, http-server; core-service-contracts.ts:128-176); the diff adds webhooks: IWebhookService and 'trigger-api': ITriggerApiService, their TSDoc, and two import type lines, nothing else. Three derived changes follow, each judged:

  • keyof ServiceSlotContracts gains two names, so every lookup keyed on it accepts two more: the typed overloads at runtime/src/domain-handler-registry.ts:182 (resolveService) and :299 (getRequestKernelService), runtime/src/security/resolve-execution-context.ts:57 and runtime/src/action-execution.ts:132. An accept set widens. Right.
  • ServiceSlotContract of 'webhooks' and of 'trigger-api' move from unknown (the helper at :184-185) to the contract. Right, and it is the map's stated purpose (header :14-23: a lookup returns the contract so a call outside it is a compile error at the call site).
  • At a call site naming either slot, the answer moves from the string overload's any (:183, :300) to the contract or undefined. That narrows what a caller may read off the result, not what the surface accepts, and it is the behaviour every entry ever added to this map produced. The one class an added required key can break, an object typed as the whole map, is measured absent: git grep at the merge-base over *.ts, *.tsx and *.mts for the map's name after a colon, after satisfies, after implements, inside Partial, Required, Readonly or Record finds zero hits; every read of either map in the repository is keyof on the four seams above, CoreServiceContract of a CoreServiceName (domain-handler-registry.ts:266), or ServiceSlotContract of 'data' in data-engine.test.ts:640. objectui at the pin the merge-base carries (e5311165a8): zero hits for ServiceSlotContract, CoreServiceContract, resolveService( and getRequestKernelService(, against a control of 1,661 files naming @objectstack/spec. TypeScript Type Check and all four of its sub-jobs are success on this head.

2. The evidence bar for an entry holds. Right. The file's rule is that an entry is a claim made only where the binding is established by the registering provider. At the merge-base: packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:66 declares WEBHOOKS_SLOT = 'webhooks', :407 builds const service: IWebhookService, :411 calls ctx.registerService(WEBHOOKS_SLOT, service), and the registration is reached only after installRedeliverGuard succeeds (:405), with an early return and a warn otherwise. packages/triggers/trigger-api/src/plugin.ts:34 declares TRIGGER_API_SLOT = 'trigger-api', :166 builds const service: ITriggerApiService, :169 registers it, inside the branch that installs the api trigger. A git grep for registerService( with either constant or either literal finds only those two sites. Each occupant is declared as the contract its entry names, under exactly that name, and the two TSDoc blocks the diff adds say precisely this conditionality ("once its redelivery veto is installed, and leaves the slot empty otherwise"; "wherever it installs the api trigger, and leaves the slot empty otherwise"). The lookups answer the contract or undefined, so an empty slot is typed as absent, not invented.

3. Placement in ServiceSlotContracts, not CoreServiceContracts. Right. CoreServiceName (core-services.zod.ts:20-57) names neither webhooks nor trigger-api; the map's header says a member added to the enum changes runtime behaviour while an entry in this ledger is pure type information, and the test's runtime assertion that neither key is an enum member passes (Test Core and all six shards success).

4. The pin, and the premise it narrows. Right, with the narrowing accepted. The card's "Done when" says getService('trigger-api')?.handleInboundHook and getService('webhooks')?.handleRedeliver resolve to the declared members. core-service-contracts.test.ts:115-138 declares a getService generic over K extends keyof ServiceSlotContracts, answering ServiceSlotContract of K or undefined, which is the signature of all four runtime seams in item 1, and pins through expectTypeOf that getService('webhooks')?.handleRedeliver equals the indexed type of IWebhookService at handleRedeliver (the optional method at webhook-service.ts:96), that getService('trigger-api')?.handleInboundHook equals the indexed type of ITriggerApiService at handleInboundHook (trigger-api-service.ts:148), and that each slot equals its contract or undefined. The CONTROL is real: the same lookup still answers IHttpServer, ISecurityService and IDataEngine for http.server, security and data, the existing protocol / mcp unknown pins and the objectql / alias pins are untouched, and :140-156 now pins the non-core key set exactly (the type-level equality at :146-147), where the base checked a typed subset and a length of five. The exact list of seven is the base's five plus the diff's two, which matches the map I read. The narrowing: the card wrote kernel.getService(...), and ObjectKernel.getService (packages/core/src/kernel.ts:816) is generic over a caller-chosen type with name: string; it never reads the map. The map is read by the runtime's keyed lookups, which the pin restates because spec cannot import runtime. "Done when" is met at the only seam the map types. The test file is compiled: the spec package's typecheck script runs tsc --noEmit and then check:test-typecheck over tsconfig.test.json, and Type Check · workspace runs that script through turbo run typecheck (lint.yml :6196); that lane is success on the head, so the pin is a real type check and not a phantom.

5. "Regenerate whatever the slot map feeds": nothing moves. Right. packages/spec/api-surface/contracts.json:277 records "ServiceSlotContracts (interface)", existence only; export-origins/contracts.json:277 records the origin only; the member TSDoc feeds no generator (check:docs reads Zod schemas). Type Check · source gates, Lint & Repo Gates, Spec property liveness and Flag docs affected by code changes are success on the head, and the file list carries no generated path.

6. The changeset, sentence by sentence. @objectstack/spec minor; Clause-②: yes (widening); the two entries with their registering packages (item 2); FROM → TO in the form the helper actually produces (unknown to the contract); "a lookup keyed by keyof ServiceSlotContracts now accepts both names" and "used to answer any for them" (the string overloads at :183 and :300); "Every member of both contracts is optional" (each interface declares exactly one member, both optional); "No other slot's entry changes" (the diff); "nothing changes at runtime: this is type information only" (import type and interface members emit nothing; expectTypeOf is vitest's); "Nobody needs to migrate. Existing casts at those lookups still compile" (true of every measured consumer, and the one condition under which it would not be is named and measured absent). Every sentence holds against the diff and the merge-base.

7. The check-runs on this head, as read. 42 check-runs: 37 success, 5 skipped, 0 failure, 0 in progress. All seven required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The five skips: Build Docs and Console Pin Gate are EXPECTED_SKIPS rows gated on ci.yml's filter outputs (docs and console; this diff moves none of their paths, packages/spec/package.json included); Packed-tarball smoke (opt-in) skips without the needs:pack-smoke label; Auto Label and Check PR Size each appear twice because PR Automation ran twice on this head (run 38226159505, created with the CI and lint runs at 08:51Z on the push; run 38226843566, created alone at 08:58Z, after the body edit the unlock comment records), and both jobs carry an if: that excludes the labeled, unlabeled and edited actions (pr-automation.yml :141-144, :211-215), so on the second run they report skipped while the first run's instances are success. The two reds the previous head carried, Test Core (1/6) and the Test Core aggregate, are success here. Nothing is red by design and nothing shares a signature with a merge-base red.

② Semver level

@objectstack/spec minor under .changeset/pre.json { mode: 'pre', tag: 'next' }. The changeset's line 7 reads Clause-②: yes (widening) and the PR body's line 2 reads Clause-②: yes (widening: a published type gains two keys): same value, same arm, both in the CLAUSE2_KEY_LINE spelling, and yes takes at least minor, which is what is declared. No accept set narrows (item 1), so no (narrowing) arm and no ADR-0087 disposition is owed, and there is no major. Precedent in the spec CHANGELOG at the merge-base: the batch-3 additions (security, shareLinks, objectql) shipped under 17.0.0 Minor Changes; the http.server pair rode a Patch Changes entry, which today's rule would refuse, so minor here is the stricter reading, not the lenient one. Check Changeset is success on both PR Automation runs, including the one after the body edit. The Clause-②: line is right and the level is right.

③ Boundary flags

  • open_questions: the report declares none. Nothing to answer.
  • Dev deviation, the three PR-context guards NOT MEASURED locally (the environment's safety check refused a bash -c wrapper; the dev stopped and did not work around it): answered by the head, where The card this PR closes must claim this branch, Part-of PR must not also close its card, No other open PR may claim the same single-writer path and No other open PR may claim the same issue are all success.
  • check:type-check-debt NOT MEASURED locally, as declared: Type Check · debt ledger, the lane that runs it (lint.yml :6486), is success on the head. check:dual-build-cjs-loads NOT MEASURED locally, as declared (no whole-workspace build): the diff adds two import type lines and two interface members, which emit nothing into either build output, and Build Core is success.
  • Out-of-scope finding 1, the two now-redundant casts (runtime/src/domains/webhooks.ts:114, automation-hooks.ts:120): confirmed at the merge-base. Each casts the lookup to Pick of its contract on the one member, or undefined; the contract is assignable to that Pick, so the cast compiles and is redundant, not wrong. Carrier none is the right disposition: not a defect, not a trap, not a contract violation.
  • Out-of-scope finding 2, the stale header at contracts/trigger-api-service.ts:10-11 ("That registration is a separate change: until it lands no kernel fills the slot"): confirmed at the merge-base, and contradicted there by plugin.ts:169. Prose drift inside the package but outside the dispatched file surface, which the claim told the dev to stop on; the new entry's TSDoc states the current fact. Carrier none, fix as touched. Not escalated.
  • The PR body and report count "three runtime seams"; action-execution.ts:132 is a fourth with the same signature. The changeset says "One example is", so nothing published is misstated.
  • The PR body's readings cite BASE 7de0265fc7 and the objectui pin 4997995022; both moved under the branch (merge-base now 6a40a09ed9, pin now e5311165a8). Every reading this record relies on was retaken at the current merge-base and holds; the body's older anchors are stale prose, not a contract fact, and the prose the card reads for the landing is this record.
  • needs:contract-review is not on the PR. It is a marker, not a gate, and its absence changes no verdict.
  • Governed surfaces: none of the three paths is under docs/adr/**, docs/NORTH-STAR.md, .claude/**, skills/**, AGENTS.md or CLAUDE.md; Governed Surface Queue Guard is success. Head repo equals base repo, so not a fork proposal. 86 changed lines, far under the size limb.
  • Independence: the diff is a mode:subagent dev's on the branch below; this verdict is rendered by an isolated subagent of the seat whose session signs Reviewed-by: and adopts it. The dispatching seat's own conclusions were not an input.

Implemented-by: claude/issue-22848-slot-contracts
Reviewed-by: session_019G5ZnRrTMiwcHG6iZM19zf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 12, 2026 09:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 12, 2026 09:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 9432bac Oct 12, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22848-slot-contracts branch October 12, 2026 10:18
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 size/s tests tooling

Projects

None yet

2 participants