From cbe8e245a76c3bcf1c2dccb498e522d9b195017e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 01:34:36 +0200 Subject: [PATCH 01/21] fix(core)!: the start gate names what is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phantom rest tuple fails as an arity error, and arity errors never print types — so NO RUNTIME never reached a reader, and tsc's related info pointed at the wrong fix ("an argument for 'options' was not provided"). The marker rides an intersection on the module parameter now, where it prints in full. --- packages/core/src/run-main.ts | 9 ++---- packages/core/src/start.test-d.ts | 16 ++--------- packages/core/src/start.ts | 43 ++++++++++++++++------------ packages/testing/src/boot-fixture.ts | 3 +- 4 files changed, 31 insertions(+), 40 deletions(-) diff --git a/packages/core/src/run-main.ts b/packages/core/src/run-main.ts index 11dd161..f8eb7b1 100644 --- a/packages/core/src/run-main.ts +++ b/packages/core/src/run-main.ts @@ -110,17 +110,14 @@ export const awaitExit = async ( // Result world and become a process exit code. It is the boundary, and a // top-level `await runMain(...)` in an entry point is the intended shape. export const runMain = async ( - module: Module, + // The same phantom gate `start` carries, for the same reason: it makes the + // runtime's declared needs a compile-time check at *this* call site. + module: Module & StartGate, options: StartOptions = {}, exit: (code: number) => void = (code) => { process.exitCode = code; }, - // The same phantom gate `start` carries, for the same reason: it makes the - // runtime's declared needs a compile-time check at *this* call site. - ...gate: StartGate ): Promise => { - void gate; - // The gate above proves the needs at the call site, but that proof is not // visible inside a body where `X` is still an unresolved type parameter — // the same reason `bootFixture` discharges the tuple the same way. diff --git a/packages/core/src/start.test-d.ts b/packages/core/src/start.test-d.ts index 6ed37e9..86ba161 100644 --- a/packages/core/src/start.test-d.ts +++ b/packages/core/src/start.test-d.ts @@ -46,8 +46,9 @@ const Satisfied = Module("Satisfied")({ }); expectTypeOf(start(Satisfied)).toEqualTypeOf>(); -// The gate bites: `Unsatisfied` does not export `Clock`, so the phantom rest -// tuple is non-empty and the one-argument call no longer typechecks. +// The gate bites: `Unsatisfied` does not export `Clock`, so the marker +// intersected onto `module` is a sentence the argument cannot satisfy — and +// the sentence is what tsc prints as the parameter type it did not match. const Unsatisfied = Module("Unsatisfied")({ imports: [AppModule], provides: [Provider(NeedsClock)({ value: needsClock })], @@ -56,16 +57,9 @@ const Unsatisfied = Module("Unsatisfied")({ // @ts-expect-error -- UNSATISFIED RUNTIME NEEDS: the runtime needs `Clock`, which the module does not export start(Unsatisfied); -// Documented, deliberate limit (verified, not assumed): a caller who spells the -// phantom arguments out by hand does typecheck. That is the same escape hatch -// di's own UNSATISFIED DEPENDENCIES gate leaves open — it takes a deliberate -// act, and the gate exists to catch the accident, not to be unforgeable. -start(Unsatisfied, {}, "UNSATISFIED RUNTIME NEEDS", new Clock()); - // The other way the gate bites: a module that exports no runtime port at all. // @ts-expect-error -- NO RUNTIME: `AppModule` exports no port declared over `RuntimePort` start(AppModule); -start(AppModule, {}, "NO RUNTIME", "the module exports no port declared over RuntimePort"); // A needs-free runtime works against any module: `InstanceType` is // `never`, and `[never] extends [X]` holds for every `X`. `testRuntime` ships @@ -92,10 +86,6 @@ const ClockyUnit = Module("ClockyUnit")({ // @ts-expect-error -- UNSATISFIED UNIT NEEDS: the unit module reads `Clock`, which the module does not export start(Satisfied, { unit: ClockyUnit }); -// The same escape hatch as the runtime half, naming the unit error literal — -// which is also what pins WHICH branch of the gate rejected the call above. -start(Satisfied, { unit: ClockyUnit }, "UNSATISFIED UNIT NEEDS", new Clock()); - // A runtime may NOT draw a need from the unit module's exports: `Span` exists // only while a unit is open, and `RuntimeHost.ctx` is the application context // — so a runtime that names it is rejected here rather than left to `ctx.get` diff --git a/packages/core/src/start.ts b/packages/core/src/start.ts index 268552e..5251cb4 100644 --- a/packages/core/src/start.ts +++ b/packages/core/src/start.ts @@ -123,15 +123,22 @@ export type RunningApp = { }; /** - * The phantom rest tuple `start`, `runMain` and `Boot` all carry: empty — - * and invisible — when the module exports a runtime and its exports cover - * that runtime's declared needs, a named error tuple otherwise, so a missing - * runtime or an unmet need fails to typecheck at the call site. A trailing - * rest tuple rather than a conditional type on `module` or `options` is - * deliberate: a conditional on an inference-bearing parameter makes - * TypeScript defer that parameter's inference and can collapse `X` or `E` to - * `unknown`. Same shape, and the same reasoning, as di's own UNSATISFIED - * DEPENDENCIES gate on `Module.scoped`. + * The phantom marker `start`, `runMain` and `Boot` all intersect onto their + * `module` parameter: `unknown` — and invisible — when the module exports a + * runtime and its exports cover that runtime's declared needs, a sentence + * otherwise, so a missing runtime or an unmet need fails to typecheck at the + * call site. + * + * It rides the `module` parameter rather than a trailing rest tuple because a + * rest tuple fails as an **arity** error, and an arity error never prints a + * type: `NO RUNTIME` never reached a reader, and tsc's related info pointed at + * the wrong fix ("an argument for 'options' was not provided"). Intersected, + * the sentence prints in full as the parameter type the argument did not + * match. `X` still infers from `Module` alongside the marker — measured, + * since a conditional type in an inference-bearing position can otherwise + * collapse `X` or `E` to `unknown`, which is what the rest tuple was avoiding + * and is why `unknown`, not `{}` or `never`, is the satisfied case: it leaves + * the module type untouched. * * With a `unit` module in play it also checks the fork's own direction: the * unit module's needs must be covered by the module's exports, `Scope` or @@ -143,12 +150,12 @@ export type RunningApp = { * into a startup defect. */ export type StartGate = [Extract] extends [never] - ? [error: "NO RUNTIME", hint: "the module exports no port declared over RuntimePort"] + ? "NO RUNTIME — the module exports no port declared over RuntimePort" : [InstanceType>] extends [X] ? [Exclude] extends [never] - ? [] - : [error: "UNSATISFIED UNIT NEEDS", missing: Exclude] - : [error: "UNSATISFIED RUNTIME NEEDS", missing: Exclude>, X>]; + ? unknown + : "UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export" + : "UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"; // `Module`, not `Module`: `Needs` sits in // covariant position on `Module`, so this accepts a module with no needs at @@ -156,14 +163,12 @@ export type StartGate = [Extract] exte // the single need `Module.scoped` discharges by opening the scope itself — and // one whose configuration providers read `Env`, which the kernel provides. A // module with a genuine unmet dependency is rejected here, as di's own gate -// would reject it. The `gate` rest parameter is a phantom: it never carries a -// runtime argument. +// would reject it. The intersected `StartGate` is a phantom: it is `unknown` +// whenever the gate is satisfied, so no argument ever carries it. export const start = ( - module: Module, + module: Module & StartGate, options: StartOptions = {}, - ...gate: StartGate ): RunningApp> => { - void gate; type Info = RuntimeInfoOf; type Needs = RuntimeNeedsOf; const clock = options.clock ?? systemClock; @@ -394,7 +399,7 @@ export const start = ( // `Context` is contravariant, so an application context whose // exports cover the runtime's needs is assignable here. The assertion is - // needed only because the `gate` rest parameter proves + // needed only because the `StartGate` intersected onto `module` proves // `InstanceType extends X` at the *call site*, and that proof is // not visible to the checker inside this body, where `X` and `Needs` are // still unresolved type parameters. diff --git a/packages/testing/src/boot-fixture.ts b/packages/testing/src/boot-fixture.ts index 8d82255..3df47df 100644 --- a/packages/testing/src/boot-fixture.ts +++ b/packages/testing/src/boot-fixture.ts @@ -14,9 +14,8 @@ import type { Module, Scope } from "@btravstack/di"; * when the test ends. */ export type Boot = ( - module: Module, + module: Module & StartGate, options?: Omit, "signals">, - ...gate: StartGate ) => RunningApp>; /** What every `boot` in the fixture starts with; a call's own options win. */ From f5b7d246c88093bfd024e1774922be2f35ade708 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 01:44:47 +0200 Subject: [PATCH 02/21] fix(core): the start gate's arms are asserted, and its spec says what is true The three sentences were unasserted after the rest tuple went: @ts-expect-error accepts any error, so an arm could be swapped silently. One expectTypeOf per arm pins the sentence itself. order-temporal-worker's NO RUNTIME fixture owed Logger as well as a runtime, so tsc elaborated the other failure; observability() in its imports leaves the runtime as the only thing missing and the sentence prints. packages/core/CLAUDE.md described a rest tuple failing on arity and claimed parity with di's gate for a hand-spelled bypass that no longer exists. The hatch survives as an ordinary cast; di's gate is still a rest tuple, so the two are no longer the same shape. --- .../src/needs-gate.test-d.ts | 11 ++-- packages/core/CLAUDE.md | 61 +++++++++++-------- packages/core/src/start.test-d.ts | 14 ++++- 3 files changed, 57 insertions(+), 29 deletions(-) diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index d91c987..de452cc 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -30,6 +30,7 @@ import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { orderContract } from "@btravstack/example-order-temporal-contract"; +import { observability } from "@btravstack/observability"; import { TemporalModule, TemporalRuntime, temporal } from "@btravstack/temporal"; import { OrderTemporalWorker, orderActivities } from "./module.js"; @@ -45,15 +46,17 @@ const options = { signals: false, probes: false } as const; const _wired = start(OrderTemporalWorker, options); // The same graph without the starter: nothing declared over `RuntimePort` is -// exported, so there is nothing for `start` to boot. +// exported, so there is nothing for `start` to boot. `observability()` is here +// so this arm fails on the RUNTIME alone — the two slices owe `Logger` +// otherwise, and a module failing two gates at once elaborates the other one. const RuntimelessTemporal = Module("RuntimelessTemporal")({ - imports: [FulfillmentSlice, BillingSlice], + imports: [FulfillmentSlice, BillingSlice, observability()], provides: [orderActivities], exports: [orderActivities.port], }); -// Negative: the gate becomes a required two-element tuple naming the absence, -// and the call fails on arity. +// Negative: the marker intersected onto `module` becomes a sentence naming the +// absence, which is what the call fails to match. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _noRuntime = start(RuntimelessTemporal, options); diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 27e29b8..d675020 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -31,13 +31,16 @@ never>`: `Needs` is covariant on `Module`, so this accepts a needs-free service of that module**, not an option: the module exports a port declared over `RuntimePort`, the kernel builds the graph, resolves that port and drives what it finds. The kernel is DI initialisation and lifecycle, nothing - else. Followed by the phantom `...gate` rest tuple, `StartGate`: `NO RUNTIME` when the module exports no runtime port, - `UNSATISFIED RUNTIME NEEDS` when the runtime's declared needs are not among - the module's exports (the module's alone — a unit-only port exists only + else. The `module` parameter is intersected with the phantom marker + `StartGate`: `NO RUNTIME` when the module exports no runtime + port, `UNSATISFIED RUNTIME NEEDS` when the runtime's declared needs are not + among the module's exports (the module's alone — a unit-only port exists only while a unit is open, and `RuntimeHost.ctx` is the application context), `UNSATISFIED UNIT NEEDS` for the fork's own direction — all three at the - call site, on arity. + call site, as an assignability failure that **prints the arm's sentence**. + `unknown` is the satisfied arm, and it has to be: intersecting `unknown` + leaves the module type untouched, so a good call infers exactly as it + would without the marker. - **`RuntimePort`** — `Port("Runtime")`, exported **generic** (no fixed service): a runtime package declares its own concrete port over it — `class HttpRuntime extends RuntimePort> {}` @@ -346,9 +349,12 @@ Type-level invariants live in `start.test-d.ts` and are checked by - **The module must export a runtime, and that runtime's declared `needs` are checked against the module's exports at the `start` call site** (the phantom - rest-tuple gate, `StartGate`). A composition with - no port declared over `RuntimePort` among its exports fails on arity with - `NO RUNTIME`; a missing need fails with `UNSATISFIED RUNTIME NEEDS`. + marker `StartGate`, intersected onto `module`). A composition + with no port declared over `RuntimePort` among its exports fails to match + `NO RUNTIME — …`; a missing need fails to match `UNSATISFIED RUNTIME NEEDS — …`. + Each arm's sentence is pinned by an `expectTypeOf>` in + `start.test-d.ts` — `@ts-expect-error` accepts any error, so the sentence a + reader is shown is asserted there or nowhere. `InstanceType` is `never`, so a needs-free runtime works against any module. `Needs` and `Info` are not type parameters of `start` any more: they are read off `X` (`RuntimeNeedsOf`, `RuntimeInfoOf` — `ServiceOf` of @@ -356,11 +362,14 @@ Type-level invariants live in `start.test-d.ts` and are checked by exported from the package, the rest are the gate's internals), which is what lets `RunningApp>` type `runtimeInfo()` from the module alone. -- **The gate is bypassable, deliberately.** A caller who spells the phantom - arguments out by hand (`start(M, o, "UNSATISFIED RUNTIME NEEDS", new Clock())`) - does typecheck — asserted, not assumed. This is the same escape hatch di's own - UNSATISFIED DEPENDENCIES gate leaves: it takes a deliberate act, and the gate - exists to catch the accident, not to be unforgeable. +- **The gate is bypassable, deliberately — by a cast.** `start(M as never)` + typechecks (verified), which is the ordinary TypeScript escape rather than + anything this gate offers: the gate exists to catch the accident, not to be + unforgeable. It used to be forgeable a second way — spelling the phantom rest + arguments out by hand — and that went with the rest tuple, so **parity with + di's UNSATISFIED DEPENDENCIES gate no longer holds**: di's is still a rest + tuple and still has the hand-spelled hatch. Nothing asserts the cast, because + a cast defeats every gate and asserting it would pin TypeScript, not this. `docs-examples.test-d.ts` compiles every code sample the two READMEs ship — the `@btravstack/testing` ones (`testRuntime`, `createFakeClock`, `bootFixture`) @@ -414,20 +423,24 @@ ConfigInvalid })` rather than widening `exited`'s error union for every wrapper) because a third-party Standard Schema may be async — and may throw, which the wrapper turns into the defect it is. -- **The needs check is a trailing phantom rest tuple, not a conditional on an - inference-bearing parameter.** - `...gate: [InstanceType>] extends [X] ? [] : [error: "UNSATISFIED RUNTIME NEEDS", missing: …]` +- **The needs check is a phantom marker intersected onto `module`, not a + trailing rest tuple.** + `module: Module & ([InstanceType>] extends [X] ? unknown : "UNSATISFIED RUNTIME NEEDS — …")` (preceded by the `NO RUNTIME` arm on `Extract`) — against the module's exports alone, never the `unit` module's: a unit-only port exists only while a unit is open, and `RuntimeHost.ctx` is the application context, so accepting it would type-check into a startup defect (`start.test-d.ts`'s `SpanApp` pins the rejection). - A conditional type on `module` or `options` would make TypeScript defer that - parameter's inference and can collapse `X` or `E` to `unknown` — the same - shape, and the same reasoning, as di's own gate on `Module.scoped`, and the - same rule unthrown records for `fromPromise`. It **is** bypassable by a caller - who hand-writes the phantom arguments (proved in `start.test-d.ts`); that is - accepted, exactly as di accepts it. + A rest tuple was the earlier spelling, on the grounds that a conditional type + in an inference-bearing position can defer that parameter's inference and + collapse `X` or `E` to `unknown`. It bought that safety at the cost of the + diagnostic: a missing rest argument is an **arity** error, and an arity error + never prints a type, so `NO RUNTIME` never reached a reader and tsc's related + info pointed at the wrong fix ("an argument for 'options' was not provided"). + Measured: `X` still infers from `Module` with the marker alongside, so + the intersection costs nothing the tuple was protecting. di's own gate on + `Module.scoped` is **still** a rest tuple, so the two are no longer the same + shape — do not describe them as parallel. - **The runtime is resolved from the built graph, through the one generic port.** `RuntimePort` is `Port("Runtime")` left generic (its construct @@ -451,7 +464,7 @@ ConfigInvalid })` rather than widening `exited`'s error union for every body where `X` and `Needs` are still unresolved type parameters. `@btravstack/testing`'s `bootFixture` has the same problem and solves it the same way — by forwarding through a signature with the - phantom tuple already discharged. + phantom marker already discharged. - **`finish` skips the drain for every reason but `"signal"` — and aborts the registry on exactly those paths.** @@ -591,7 +604,7 @@ ConfigInvalid })` rather than widening `exited`'s error union for every `forkScope` call goes through a discharged-signature cast — the same move `runMain` and `@btravstack/testing`'s `bootFixture` make on `start` — because the fork's gates are - proven by `start`'s rest tuple at the call site and invisible in a body + proven by `start`'s intersected marker at the call site and invisible in a body where `X`, `Needs` and `UnitX` are unresolved. The work's return union is normalised by an `async` wrapper exactly as `registry.run` does it. diff --git a/packages/core/src/start.test-d.ts b/packages/core/src/start.test-d.ts index 86ba161..11d9442 100644 --- a/packages/core/src/start.test-d.ts +++ b/packages/core/src/start.test-d.ts @@ -4,7 +4,7 @@ import { OkAsync } from "unthrown"; import { expectTypeOf } from "vitest"; import { RuntimePort, type Runtime, type Serving } from "./runtime.js"; -import { start, type RunningApp } from "./start.js"; +import { start, type RunningApp, type StartGate } from "./start.js"; class Greeting extends Port("Greeting")<{ readonly text: string }> {} class Clock extends Port("Clock")<{ readonly now: () => number }> {} @@ -57,9 +57,18 @@ const Unsatisfied = Module("Unsatisfied")({ // @ts-expect-error -- UNSATISFIED RUNTIME NEEDS: the runtime needs `Clock`, which the module does not export start(Unsatisfied); +// WHICH arm rejected it, pinned: the directive above accepts ANY error, so the +// sentence a reader is actually shown is asserted here or nowhere. +expectTypeOf< + StartGate +>().toEqualTypeOf<"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export">(); + // The other way the gate bites: a module that exports no runtime port at all. // @ts-expect-error -- NO RUNTIME: `AppModule` exports no port declared over `RuntimePort` start(AppModule); +expectTypeOf< + StartGate +>().toEqualTypeOf<"NO RUNTIME — the module exports no port declared over RuntimePort">(); // A needs-free runtime works against any module: `InstanceType` is // `never`, and `[never] extends [X]` holds for every `X`. `testRuntime` ships @@ -85,6 +94,9 @@ const ClockyUnit = Module("ClockyUnit")({ // @ts-expect-error -- UNSATISFIED UNIT NEEDS: the unit module reads `Clock`, which the module does not export start(Satisfied, { unit: ClockyUnit }); +expectTypeOf< + StartGate +>().toEqualTypeOf<"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export">(); // A runtime may NOT draw a need from the unit module's exports: `Span` exists // only while a unit is open, and `RuntimeHost.ctx` is the application context From db7223ddffb73e7d039a3435ce4eb8a8f4e585da Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 01:56:28 +0200 Subject: [PATCH 03/21] fix(amqp,temporal): the composer gates print a sentence, not an expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker prints LAST: TypeScript names the source type first, and the source is the piece the caller wrote — di's Provider over the contract, several hundred characters wide and outside these packages to name. Measured three alias shapes against tsc; an indexed access into a mapped type loses its name in the source position where a plain reference and an intersection with {} keep theirs, but PieceOf is the TARGET, so no shape applied to it moves the first line. Applying the surviving shape to the real PieceOf produced a byte-identical diagnostic. So the remedy available here is the literal itself: a longer string costs nothing to print, and a reader who reaches the end of the line now reads an instruction instead of a label. The six documents quoting the old literal move in the same commit. --- CLAUDE.md | 4 ++-- docs/how-to/split-a-worker-into-slices.md | 8 ++++---- docs/reference/amqp.md | 5 +++-- docs/reference/temporal.md | 5 +++-- packages/amqp/CLAUDE.md | 4 ++-- packages/amqp/src/amqp-runtime.ts | 22 +++++++++++++++++----- packages/temporal/CLAUDE.md | 2 +- packages/temporal/src/temporal-module.ts | 20 +++++++++++++++----- 8 files changed, 47 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40d5a6c..c1b74a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -670,8 +670,8 @@ AuditSlice, observability()], … })`), the piece's own port id rather than on a record position — and `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` compose them: every key the contract declares must be covered (an uncovered - one is refused at the call, against an `"UNCOVERED HANDLERS"` / - `"UNCOVERED ACTIVITIES"` marker that names the missing key too once the + one is refused at the call, against an `"UNCOVERED HANDLERS — …"` / + `"UNCOVERED ACTIVITIES — …"` marker that names the missing key too once the array's length matches the marker tuple's own length of 2), and two slices both discharged for one key are di's duplicate-provider defect at build — the same exactness the keyed HTTP diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index a278f9b..c978324 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -229,7 +229,7 @@ is refused right there — not at the root, and not at startup. The third is caught at the composing call. `AmqpHandlers(contract)([...])` and `TemporalActivities(contract)([...])` are exact against every top-level key the contract declares: an array missing one is refused, against an -`"UNCOVERED HANDLERS"` / `"UNCOVERED ACTIVITIES"` marker — readable straight +`"UNCOVERED HANDLERS — …"` / `"UNCOVERED ACTIVITIES — …"` marker — readable straight off the type error rather than a runtime stack trace, and never a silent failure or an `undefined` merged into the record: @@ -242,10 +242,10 @@ TemporalActivities(orderContract)([chargeOrder]); ``` Both arrays above are one element long, so both diagnostics report only the -marker (`"UNCOVERED HANDLERS"`, `"UNCOVERED ACTIVITIES"`) — the missing key +marker (`"UNCOVERED HANDLERS — …"`, `"UNCOVERED ACTIVITIES — …"`) — the missing key itself is not in either message. The key IS named — as -`readonly ["UNCOVERED HANDLERS", "orderAudit"]` or -`readonly ["UNCOVERED ACTIVITIES", "fulfillOrder"]` — but only once the array +`readonly ["UNCOVERED HANDLERS — …", "orderAudit"]` or +`readonly ["UNCOVERED ACTIVITIES — …", "fulfillOrder"]` — but only once the array under test is as long as the marker tuple itself (2), a two-piece array missing one key being the common case. Below that length TypeScript can no longer line the array up against the tuple positionally and falls back to diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index 60fea3a..d2db5d3 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -170,8 +170,9 @@ A third call composes several **pieces** instead of one record: piece first — they are the composed provider's own `deps`, declared under the very key each piece's port id carries, so the services record IS the handlers record. Every key the contract declares must be covered: an array -missing one is refused at the call, against an `"UNCOVERED HANDLERS"` marker -(`readonly ["UNCOVERED HANDLERS", ...]`) — the missing key itself is named +missing one is refused at the call, against an +`"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"` +marker (`readonly ["UNCOVERED HANDLERS — …", ...]`) — the missing key itself is named too once the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone; a piece built for another contract diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index 4c4164a..c29b4f6 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -200,8 +200,9 @@ constructs every piece first — they are the composed provider's own `deps`, declared under the very key each piece's port id carries, so the services record IS the activities record. Every top-level key the contract's activities record declares must be covered: an array missing one is refused -at the call, against an `"UNCOVERED ACTIVITIES"` marker -(`readonly ["UNCOVERED ACTIVITIES", ...]`) — the missing key itself is named +at the call, against an +`"UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover"` +marker (`readonly ["UNCOVERED ACTIVITIES — …", ...]`) — the missing key itself is named too once the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone; a piece built for another contract is refused too, structurally, since its port's service is diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index deb2ce1..55701b0 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -91,7 +91,7 @@ Provider>> & Compose` — di's builder first, the composer last. Reversed, TypeScript reports the FIRST arm's failure on a non-covering array, and the diagnostic degrades to `not assignable to 'Qualification'`, naming nothing; last, it reports the - composing arm's own conditional against `readonly ["UNCOVERED HANDLERS", + composing arm's own conditional against `readonly ["UNCOVERED HANDLERS — …", K]`, which always names the marker — the missing key `K` itself appears only when the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone — measured, not @@ -287,7 +287,7 @@ right])`, pinning that both slices run (_"serves a record composed from one composing form's compile-time gates on a contract of its own — a piece typed by its own key, an array covering every declared key, an uncovered array refused as `@ts-expect-error` (its own single-element case reports only the - `"UNCOVERED HANDLERS"` marker, not the missing key — see the composing-arm + `"UNCOVERED HANDLERS — …"` marker, not the missing key — see the composing-arm entry above for when the key itself is named), and a piece built for another contract refused structurally (that contract's own key needs its own message, not a reused one, or the two ports are the same type and there is nothing to diff --git a/packages/amqp/src/amqp-runtime.ts b/packages/amqp/src/amqp-runtime.ts index 3669cf9..7a71052 100644 --- a/packages/amqp/src/amqp-runtime.ts +++ b/packages/amqp/src/amqp-runtime.ts @@ -150,13 +150,25 @@ type Uncovered[]> = Exc /** * The composing arm. Declared LAST in the intersection below on purpose: * TypeScript reports the last overload's failure, so a non-covering array is - * refused against the `"UNCOVERED HANDLERS"` marker rather than degrading to - * di's `Qualification`, which names nothing. The missing key itself is named - * in the diagnostic only when the array's length matches the marker tuple's - * own length (2) — measured. + * refused against the `"UNCOVERED HANDLERS — …"` marker rather than degrading + * to di's `Qualification`, which names nothing. The missing key itself is + * named in the diagnostic only when the array's length matches the marker + * tuple's own length (2) — measured. + * + * The marker is a **sentence**, not a bare label, because it is the only part + * of this diagnostic a reader can act on and it prints LAST: TypeScript names + * the source type first, and the source here is the piece the caller wrote — + * di's `Provider<…>` over the contract, several hundred characters wide and + * outside this package to name. Widening the literal costs nothing to print + * and is what carries the explanation to where the eye lands. */ type Compose = []>( - pieces: [Uncovered] extends [never] ? T : readonly ["UNCOVERED HANDLERS", Uncovered], + pieces: [Uncovered] extends [never] + ? T + : readonly [ + "UNCOVERED HANDLERS — the contract declares a consumer this array does not cover", + Uncovered, + ], ) => Provider, never, InstanceType> & { readonly port: HandlersPortOf; }; diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 445ff5e..5ee83d4 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -78,7 +78,7 @@ Provider>> & Compose` — di's builder first, the non-covering array, and the diagnostic degrades to `not assignable to 'Qualification'`, naming nothing; last, it reports the composing arm's own conditional against `readonly ["UNCOVERED -ACTIVITIES", K]`, which always names the marker — the missing key `K` itself +ACTIVITIES — …", K]`, which always names the marker — the missing key `K` itself appears only when the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone — measured, not stylistic. The diff --git a/packages/temporal/src/temporal-module.ts b/packages/temporal/src/temporal-module.ts index f1cee91..38cd552 100644 --- a/packages/temporal/src/temporal-module.ts +++ b/packages/temporal/src/temporal-module.ts @@ -155,15 +155,25 @@ type Uncovered[]> = /** * The composing arm. Declared LAST in the intersection below on purpose: * TypeScript reports the last overload's failure, so a non-covering array is - * refused against the `"UNCOVERED ACTIVITIES"` marker rather than degrading - * to di's `Qualification`, which names nothing. The missing key itself is - * named in the diagnostic only when the array's length matches the marker - * tuple's own length (2) — measured. + * refused against the `"UNCOVERED ACTIVITIES — …"` marker rather than + * degrading to di's `Qualification`, which names nothing. The missing key + * itself is named in the diagnostic only when the array's length matches the + * marker tuple's own length (2) — measured. + * + * The marker is a **sentence**, not a bare label, because it is the only part + * of this diagnostic a reader can act on and it prints LAST: TypeScript names + * the source type first, and the source here is the piece the caller wrote — + * di's `Provider<…>` over the contract, several hundred characters wide and + * outside this package to name. Widening the literal costs nothing to print + * and is what carries the explanation to where the eye lands. */ type Compose = []>( pieces: [Uncovered] extends [never] ? T - : readonly ["UNCOVERED ACTIVITIES", Uncovered], + : readonly [ + "UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover", + Uncovered, + ], ) => Provider, never, InstanceType> & { readonly port: ActivitiesPortOf; }; From 0081e055b3efe09c247edbbc2c3c7acf021cf605 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:07:09 +0200 Subject: [PATCH 04/21] fix(http): the undeclared-key gate names the rule, not 'never' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyed `HttpRouter(contract)(controllers)` form typed a key the contract does not declare as `never`, so the whole complaint printed as `Type 'Minted<…>' is not assignable to type 'never'` — the least actionable type tsc can print, and it named neither the key nor the rule. It now types as the sentence "UNDECLARED KEY — the contract declares no fragment under this key", so the message ends on the rule in English. The trade is real and recorded: `never` made the intersection reduce, so the target printed in one short token; a string literal does not reduce, so the middle line carries the expanded `Minted` shape. That is the shape four other gates in controller.test-d.ts already print, and the line a reader lands on is the last one. All five gates still fire against both the plain and the marked contract, and _ComposedNeedsAreDeclared still holds: on a valid composition the mapped type is {}, so the sentence is reachable only on the failing call. di's module gate was measured and left alone — the wrapper it would have taken came back byte-identical, and TypeScript's own third line already names the unhandled member. --- packages/http/CLAUDE.md | 12 ++++++++++-- packages/http/src/orpc.ts | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 16b04e3..58f1022 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -97,11 +97,19 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm went in; the marked fixtures in `controller.test-d.ts` mark a **key**, which is why neither showed there. The exactness intersection is on the parameter, not on `M`: a key - `M` has that `C` does not declare types - as `never` there, so the call fails to compile rather than silently + `M` has that `C` does not declare types as the sentence + `"UNDECLARED KEY — the contract declares no fragment under this key"` + there, so the call fails to compile rather than silently dropping the key, without the intersection leaking into `M` and collapsing the needs channel di orders the controllers by (the failure mode `controller.test-d.ts`'s `_ComposedNeedsAreDeclared` check exists to catch). + It was a bare `never` until the diagnostics pass: `never` made the + intersection **reduce**, so the whole complaint printed as + `Type 'Minted<…>' is not assignable to type 'never'` — one short line that + named neither the key nor the rule. The sentence does not reduce, so the + reader pays one wide intersection line (the shape four other gates in + `controller.test-d.ts` already print) and the message **ends** on the rule in + English. Measured both ways; the trade was taken deliberately. **`HttpRouter` is the one helper in the family with THREE forms and only two arguments' worth of arity**, so it is the one place arity alone cannot decide. `(deps, arm)` is settled by arity as everywhere else; the two diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 9b24d00..0376724 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -169,7 +169,9 @@ export const routerFor = }, >( controllers: M & { - readonly [K in Exclude>]: never; + readonly [ + K in Exclude> + ]: "UNDECLARED KEY — the contract declares no fragment under this key"; }, ): Built< Identity, From ac12553620ca5ae3d2eb0b9beedade2c830444e5 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:13:24 +0200 Subject: [PATCH 05/21] fix(http): the undeclared-key gate names the key, and the spec quotes the real type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on 0081e05. The spec still quoted the parameter type as ': never', eight lines above the prose that described the sentence — a file stating the old type and the new rule in the same breath. Fixed. Review then asked me to record 'neither string names the offending key' as a carried limitation, on my own claim that the key was unreachable from the value position. Before writing that into a file the documentation task will quote, I measured it: the claim is false. The mapped type is keyed by K, so ${K & string} puts the key in the sentence, and the gate now ends on ... is not assignable to type '"UNDECLARED KEY — the contract declares no fragment under billing"' Free: same line count, same intersection width, all five gates still firing against both the plain and the marked contract, _ComposedNeedsAreDeclared still holding. A symbol key collapses K & string to never and with it the template, which is the pre-existing terse behaviour rather than a regression. Taken rather than carried, because carrying it would have put a measured-false statement into the spec — the failure this whole pass is about. --- packages/http/CLAUDE.md | 18 ++++++++++++------ packages/http/src/orpc.ts | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 58f1022..703ee54 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -89,17 +89,18 @@ PortInstance<…> }`) rather than the class's own type because a class `HttpController` per key, instead of `(deps, { sync })`. `M` is constrained `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` - **parameter** is typed `M & { readonly [K in Exclude>]: never }` — the same `Exclude` and the same `Inherit` the + **parameter** is typed ``M & { readonly [K in Exclude>]: `UNDECLARED KEY — the contract declares no fragment under +${K & string}` }`` — the same `Exclude` and the same `Inherit` the deps arm's `Implementation` carries, so a **root-marked** contract composes here at all (the phantom key is not a controller to supply) and each fragment inherits the root's mark (a controller under it types `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm went in; the marked fixtures in `controller.test-d.ts` mark a **key**, which is why neither showed there. The exactness intersection is on the parameter, not on `M`: a key - `M` has that `C` does not declare types as the sentence - `"UNDECLARED KEY — the contract declares no fragment under this key"` - there, so the call fails to compile rather than silently + `M` has that `C` does not declare types as a sentence **naming that key** — + `"UNDECLARED KEY — the contract declares no fragment under billing"` — so the + call fails to compile rather than silently dropping the key, without the intersection leaking into `M` and collapsing the needs channel di orders the controllers by (the failure mode `controller.test-d.ts`'s `_ComposedNeedsAreDeclared` check exists to catch). @@ -109,7 +110,12 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the named neither the key nor the rule. The sentence does not reduce, so the reader pays one wide intersection line (the shape four other gates in `controller.test-d.ts` already print) and the message **ends** on the rule in - English. Measured both ways; the trade was taken deliberately. + English, with the offending key in it. `${K & string}` is what carries the + key: the mapped type is keyed by `K`, so the key is in scope at the value + position and costs a template literal to reach. A **symbol** key intersects + to `never` and the whole template collapses to `never`, which is the old + behaviour — still a compile error, just the old terse one. Measured every + way; the trade was taken deliberately. **`HttpRouter` is the one helper in the family with THREE forms and only two arguments' worth of arity**, so it is the one place arity alone cannot decide. `(deps, arm)` is settled by arity as everywhere else; the two diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 0376724..2bbe9c3 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -171,7 +171,7 @@ export const routerFor = controllers: M & { readonly [ K in Exclude> - ]: "UNDECLARED KEY — the contract declares no fragment under this key"; + ]: `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; }, ): Built< Identity, From 03d192414088221cabe97ee985e5455c41668325 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:22:35 +0200 Subject: [PATCH 06/21] docs: the kernel gate's diagnostic, and di's, as measured --- docs/explanation/compile-time-wiring.md | 104 ++++++++++++++------ docs/explanation/design-decisions.md | 34 ++++--- docs/explanation/one-process-one-runtime.md | 3 +- docs/explanation/why-start.md | 4 +- docs/index.md | 2 +- docs/reference/core/exit-codes.md | 12 +-- docs/reference/core/runtime.md | 3 +- docs/reference/core/start.md | 56 ++++++----- docs/reference/di/entry-points.md | 28 +++++- docs/reference/glossary.md | 13 ++- 10 files changed, 175 insertions(+), 84 deletions(-) diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index f338caf..5e1e51f 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -1,6 +1,6 @@ --- title: Compile errors, not surprises -description: How the Needs channel and a conditional rest parameter turn missing dependencies, leaked internals, forgotten scopes and a missing runtime into errors at the call site — and where the compile-time line actually sits. +description: How the Needs channel, a conditional rest parameter and a phantom marker turn missing dependencies, leaked internals, forgotten scopes and a missing runtime into errors at the call site — what each one actually prints, and where the compile-time line sits. --- # Compile errors, not surprises @@ -72,11 +72,27 @@ build( When `Needs` is `never`, the tuple is empty and `Module.build(mod)` is an ordinary call. When it is not, the call is missing two required arguments — -arguments no value can supply — and the error names both the literal -`"UNSATISFIED DEPENDENCIES"` and, in `missing`, the actual ports. The gate -differs per entry point only in what it is entitled to exclude first: `scoped` -excludes `Scope` (it opens a real scope), `forkScope` excludes `Scope` and the -parent context's channel (the parent supplies those). +arguments no value can supply. The gate differs per entry point only in what it +is entitled to exclude first: `scoped` excludes `Scope` (it opens a real +scope), `forkScope` excludes `Scope` and the parent context's channel (the +parent supplies those). + +**What it prints, measured:** + +``` +src/scoped.test-d.ts(65,12): error TS2554: Expected 3 arguments, but got 1. +``` + +That is the whole message. An arity error never prints a type, so neither the +`"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` reach it: with +`--pretty`, TypeScript adds related information pointing at the rest parameter's +_declaration_ in `module.ts`, where a reader sees the labels but sees `N` +un-instantiated. The missing ports are in the parameter's type — an editor shows +them on hover, and spelling the phantom arguments out by hand surfaces them as +an ordinary assignability error (`Argument of type 'number' is not assignable to +parameter of type 'Scope'`). The label is a signpost for whoever goes looking, +not a sentence the compiler hands you. `start`'s gate below is the same idea +paying differently, and the difference is exactly this. The same trick guards a related mistake at declaration time: an `exports` entry must be provided or imported, so a module cannot claim a surface it @@ -105,12 +121,14 @@ it breaks. `start` accepts a `Module` — covariance is what lets a module needing nothing, one owing `Scope` and one reading `Env` all fit — and then asks three questions of `X` that di's gate has no reason to ask. They -arrive as the same shape, a phantom rest tuple named `StartGate` -that `start`, `runMain` and `@btravstack/testing`'s `Boot` all carry: +arrive as a phantom marker named `StartGate`, **intersected onto +the `module` parameter** — `unknown`, and invisible, when the gate is satisfied; +a sentence otherwise. `start`, `runMain` and `@btravstack/testing`'s `Boot` all +carry it: | Arm | Fires when | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` fails on arity here. | +| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` is refused here. | | `UNSATISFIED RUNTIME NEEDS` | The runtime's declared `needs` are not among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context and a unit-only port does not exist at startup. No shipped starter declares any today. | | `UNSATISFIED UNIT NEEDS` | With `StartOptions.unit`, the unit module's needs are not covered by the module's exports, `Scope` or `Env` — `forkScope`'s gate, stated at `start`'s call site, where the parent is actually known. | @@ -120,16 +138,33 @@ const Application = Module("Application")({ exports: [Greeter], }); -start(Application); // NO RUNTIME: the module exports no port declared over RuntimePort +start(Application); ``` -The gate is a trailing rest tuple rather than a conditional type on `module` -or `options` for a reason di shares: a conditional on an inference-bearing -parameter makes TypeScript defer that parameter's inference and can collapse -`X` or `E` to `unknown`. And like di's, it is **bypassable on purpose** — a -caller who spells the phantom arguments out by hand does typecheck, which the -kernel's own type tests assert rather than assume. It takes a deliberate act; -the gate exists to catch the accident, not to be unforgeable. +**What it prints, measured:** + +``` +error TS2345: Argument of type 'Module' is not assignable to parameter of type 'Module & "NO RUNTIME — the module exports no port declared over RuntimePort"'. + Type 'Module' is not assignable to type '"NO RUNTIME — the module exports no port declared over RuntimePort"'. +``` + +The sentence prints because the marker **rides the `module` parameter**: the +argument failed to match a parameter type, and a parameter type is something +TypeScript prints. That is the whole reason for the shape. This gate was a +trailing rest tuple until it was not, on the grounds that a conditional type in +an inference-bearing position can defer that parameter's inference and collapse +`X` or `E` to `unknown` — measured, and it does not here, because `X` still +infers from the `Module` half of the intersection. What the tuple cost was +the diagnostic: a missing rest argument is an arity error, `NO RUNTIME` never +reached a reader, and TypeScript's related information pointed at the wrong fix +("an argument for 'options' was not provided"). di's gate on `Module.scoped` is +**still** a rest tuple, so the two are no longer the same shape — do not read +them as parallel. + +One thing went with the tuple: the hand-spelled bypass. `start`'s gate is still +**bypassable on purpose**, but only by a cast (`start(App as never)`), which is +the ordinary TypeScript escape rather than anything this gate offers. It takes a +deliberate act; the gate exists to catch the accident, not to be unforgeable. ## Where the line actually is @@ -160,22 +195,31 @@ escape hatches (`as never`, `any`) that no library survives; the runtime checks exist precisely so that even those degrade into a loud pre-construction defect rather than silent misbehaviour. -## Why an arity error, of all things - -The gate could have been a constraint (`N extends never`) on the module -parameter. The rest-parameter form was chosen because of what the _error_ -looks like: the constraint form reports a failure on the whole argument, deep -in a generic instantiation; the arity form reports "expected 3 arguments, got -1" with a tuple whose labels spell `UNSATISFIED DEPENDENCIES` — or -`NO RUNTIME` — and whose type names the missing ports, at the call site, in -the order a reader debugs. When a guarantee's only user interface is a -compiler diagnostic, the diagnostic is the design. +## Why an arity error — and why the kernel stopped using one + +di's gate could have been a constraint (`N extends never`) on the module +parameter. The rest-parameter form was chosen for where it puts the blame: a +constraint reports a failure on the whole argument, deep in a generic +instantiation, while the arity form points at the call itself and leaves the +module type alone. That is a real property, and it is the one di keeps. + +What it is not is a message. `Expected 3 arguments, but got 1` is the entire +diagnostic, and the labels a reader is told to look for live in the rest +parameter's declaration rather than in the error. The kernel wanted the arm's +name in the message, so it moved its own gate onto the `module` parameter and +took the constraint-shaped diagnostic on purpose — the sentence is the last +thing printed, which is where the eye lands. **When a guarantee's only user +interface is a compiler diagnostic, the diagnostic is the design**, and this is +the same principle reaching two different answers because the two gates have +different things to say: di's `missing: N` is a set of ports a reader can read +off the signature, the kernel's is one of three fixed sentences. ## The cost, stated plainly -The types work hard, and it shows at the edges: a wiring mistake surfaces as -an arity error rather than a friendly sentence, and hovering a large module -shows real channel unions. The container is also deliberately small — one +The types work hard, and it shows at the edges: di's wiring mistakes surface as +an arity error rather than a friendly sentence, and the kernel's surface as a +long assignability error whose readable half is its last line. Hovering a large +module shows real channel unions. The container is also deliberately small — one construction family, one module algebra, three entry points, one name per concept. Conditional registration DSLs, interceptors and property injection are not missing features; this is the wrong library for them on purpose. diff --git a/docs/explanation/design-decisions.md b/docs/explanation/design-decisions.md index 13c25d5..e9c799a 100644 --- a/docs/explanation/design-decisions.md +++ b/docs/explanation/design-decisions.md @@ -23,18 +23,28 @@ else and reads its collaborators the same way — which is what let every starter's `needs` go to `never`. It rules out `start(module, { runtime })`, and with it a runtime constructed outside the graph that reaches back into it. -## The gate is a phantom rest tuple, and it is bypassable on purpose - -`start`, `runMain` and `@btravstack/testing`'s `Boot` end in -`...gate: StartGate` — empty when the module exports a runtime -whose needs its exports cover, a named error tuple (`NO RUNTIME`, -`UNSATISFIED RUNTIME NEEDS`, `UNSATISFIED UNIT NEEDS`) otherwise. A conditional type on `module` or `options` would make -TypeScript defer that parameter's inference and can collapse `X` or `E` to -`unknown`; a trailing rest tuple leaves inference alone. It is the same shape -as di's `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`. A caller who -hand-writes the phantom arguments does typecheck — proved in -`start.test-d.ts`, not assumed. The gate exists to catch the accident, not to -be unforgeable, and making it unforgeable would cost the inference it protects. +## The gate is a phantom marker on `module`, and it is bypassable on purpose + +`start`, `runMain` and `@btravstack/testing`'s `Boot` all intersect +`StartGate` onto their `module` parameter — `unknown`, and +invisible, when the module exports a runtime whose needs its exports cover; one +of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …`, +`UNSATISFIED UNIT NEEDS — …`) otherwise. It rides the parameter so that the +sentence **prints**: an argument that fails a parameter type makes TypeScript +name that type, where the trailing rest tuple this used to be failed as an +arity error and named nothing. + +The tuple was chosen originally because a conditional type in an +inference-bearing position can make TypeScript defer that parameter's +inference and collapse `X` or `E` to `unknown`. Measured, it does not here: `X` +still infers from the `Module` half of the intersection. di's +`UNSATISFIED DEPENDENCIES` gate on `Module.scoped` is still a rest tuple, so +the two are **no longer the same shape**. + +The gate is still bypassable, by a cast (`start(App as never)`) — the ordinary +TypeScript escape, not a hatch this gate offers, and nothing asserts it because +a cast defeats every gate. Hand-writing the phantom arguments went with the +tuple. The gate exists to catch the accident, not to be unforgeable. ## `RuntimeStartFailed` is the only error the kernel mints diff --git a/docs/explanation/one-process-one-runtime.md b/docs/explanation/one-process-one-runtime.md index 6152b75..aaa5f30 100644 --- a/docs/explanation/one-process-one-runtime.md +++ b/docs/explanation/one-process-one-runtime.md @@ -29,7 +29,8 @@ class HttpRuntime extends RuntimePort> {} — so at runtime every one of them has the id `"Runtime"`, while each carries its own `Needs` and `Info` in the type. `start` builds the graph, resolves that one port, and drives what it finds. A module that exports no port with that id -fails on arity at the call (`NO RUNTIME`); a module that provides two runtimes +is refused at the call, against the sentence +`"NO RUNTIME — the module exports no port declared over RuntimePort"`; a module that provides two runtimes is two providers for one port id, which di reports as a wiring defect before any factory runs. There is no `runtimes: [...]` option, and no surface in the kernel is meant to grow one. diff --git a/docs/explanation/why-start.md b/docs/explanation/why-start.md index 5c4eb95..9283916 100644 --- a/docs/explanation/why-start.md +++ b/docs/explanation/why-start.md @@ -105,8 +105,8 @@ See [Nothing throws](/explanation/nothing-throws). The kernel is one piece of a stack with a stated goal: **you write business code, the framework owns the plumbing, and the type checker is what you -trust**. A composition root that forgets its runtime is an arity error, not a -boot-time crash. Configuration is a provider bound from the environment and +trust**. A composition root that forgets its runtime is a compile error naming +what is missing, not a boot-time crash. Configuration is a provider bound from the environment and validated once, not a string read at call time. A per-request scope is an option the kernel forks around every unit, not a `forkScope` call in every handler. diff --git a/docs/index.md b/docs/index.md index 1d13a00..2f84ee4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ features: - title: One process, one runtime details: An API, a Temporal worker and an AMQP consumer are three processes booting the same module under a different composition root. The runtime is a service of the module, and a graph holds exactly one. - title: Wiring proven at compile time - details: A module that forgets a provider, a runtime whose needs are not exported, a root with no runtime — each is an arity error at the call site, before anything runs. That is @btravstack/di, and start builds on it. + details: A module that forgets a provider, a runtime whose needs are not exported, a root with no runtime — each is a compile error at the call site, before anything runs. That is @btravstack/di, and start builds on it. - title: A drain that survives Kubernetes details: SIGTERM flips readiness, waits for endpoint removal to catch up, then stops accepting and gives in-flight work a deadline. Whatever is still open is reported abandoned, not lost silently. - title: Nothing throws diff --git a/docs/reference/core/exit-codes.md b/docs/reference/core/exit-codes.md index 523c307..044feea 100644 --- a/docs/reference/core/exit-codes.md +++ b/docs/reference/core/exit-codes.md @@ -15,18 +15,18 @@ description: The signature of runMain, the exit-code table (0, 1, 2, 70, 78) wit ```ts const runMain: ( - module: Module, + module: Module & StartGate, options?: StartOptions, exit?: (code: number) => void, - ...gate: StartGate ) => Promise; ``` `runMain` is `start` composed with the wait for `exited`, then a fold of the -`Result` into a code. It carries the same phantom gate as `start` (see -[The gate](/reference/core/start#the-gate-startgate-x-unitneeds)), so `NO RUNTIME`, -`UNSATISFIED RUNTIME NEEDS` and `UNSATISFIED UNIT NEEDS` fail at this call site -too. +`Result` into a code. It carries the same phantom marker as `start`, intersected +onto `module` (see +[The gate](/reference/core/start#the-gate-startgate-x-unitneeds)), so +`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …` and +`UNSATISFIED UNIT NEEDS — …` are printed at this call site too. | Parameter | Default | Semantics | | --------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/core/runtime.md b/docs/reference/core/runtime.md index 8c2135f..899c867 100644 --- a/docs/reference/core/runtime.md +++ b/docs/reference/core/runtime.md @@ -249,4 +249,5 @@ const TickerModule = Module("Ticker")({ ``` A composition root that imports `TickerModule` must also export `Greeter`, or -`start` fails with `UNSATISFIED RUNTIME NEEDS`. +`start` refuses the module against +`"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"`. diff --git a/docs/reference/core/start.md b/docs/reference/core/start.md index eb7b117..688acf6 100644 --- a/docs/reference/core/start.md +++ b/docs/reference/core/start.md @@ -15,9 +15,8 @@ description: The signature of start, every StartOptions field with its default, ```ts const start: ( - module: Module, + module: Module & StartGate, options?: StartOptions, - ...gate: StartGate ) => RunningApp>; ``` @@ -78,29 +77,21 @@ already fired. ## The gate: `StartGate` -The trailing `...gate` rest parameter is a **phantom**: it never carries a -runtime argument. Its type is `[]` when the module is boot-able and a named -error tuple otherwise, so a bad composition fails on arity at the call site. +`StartGate` is a **phantom marker intersected onto the `module` parameter**: no +argument ever carries it. It is `unknown` — and therefore invisible — when the +module is boot-able, and one of three sentences otherwise, so a bad composition +fails to match the parameter type at the call site. ```ts type StartGate = [Extract] extends [ never, ] - ? [ - error: "NO RUNTIME", - hint: "the module exports no port declared over RuntimePort", - ] + ? "NO RUNTIME — the module exports no port declared over RuntimePort" : [InstanceType>] extends [X] ? [Exclude] extends [never] - ? [] - : [ - error: "UNSATISFIED UNIT NEEDS", - missing: Exclude, - ] - : [ - error: "UNSATISFIED RUNTIME NEEDS", - missing: Exclude>, X>, - ]; + ? unknown + : "UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export" + : "UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"; ``` | Arm | Fires when | @@ -109,10 +100,27 @@ type StartGate = [Extract] extends [ | `UNSATISFIED RUNTIME NEEDS` | The runtime's declared `needs` are not all among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context. | | `UNSATISFIED UNIT NEEDS` | The `unit` module's needs are not covered by the module's exports, `Scope` or `Env` — `Module.forkScope`'s gate, stated where the parent is actually known. | -`runMain`, and `@btravstack/testing`'s `Boot`, carry the same tuple. A rest tuple rather than a -conditional type on `module` is deliberate: a conditional on an -inference-bearing parameter makes TypeScript defer that parameter and can -collapse `X` or `E` to `unknown`. +`runMain`, and `@btravstack/testing`'s `Boot`, carry the same marker. + +**What a failing arm prints, measured** — a root exporting a `Greeter` and no +runtime port: + +``` +error TS2345: Argument of type 'Module' is not assignable to parameter of type 'Module & "NO RUNTIME — the module exports no port declared over RuntimePort"'. + Type 'Module' is not assignable to type '"NO RUNTIME — the module exports no port declared over RuntimePort"'. +``` + +The sentence prints because the marker rides the `module` parameter — an +argument that fails a parameter type makes TypeScript name that type. This was +a trailing `...gate` rest tuple until it was not: a rest tuple leaves inference +alone, but fails as an **arity** error, and an arity error never prints a type, +so the arm's name never reached a reader. `X` still infers from the +`Module` half of the intersection — measured, and the reason the swap was +free. Each arm's sentence is asserted by an `expectTypeOf>` in +`start.test-d.ts`, since `@ts-expect-error` accepts any error. + +The gate is bypassable by a cast (`start(App as never)`) — the ordinary +TypeScript escape. Spelling phantom arguments out by hand went with the tuple. ## Reading the runtime back: `RuntimePort` and `RuntimeInfoOf` @@ -151,8 +159,8 @@ const app = start(HttpishApp, { env: {}, probes: false }); const info = await app.runtimeInfo(); // Result ``` -Drop `Httpish` from `exports` and the call to `start` fails to compile with -`NO RUNTIME`. +Drop `Httpish` from `exports` and the call to `start` fails to compile against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. ## Lifecycle, in order diff --git a/docs/reference/di/entry-points.md b/docs/reference/di/entry-points.md index 741dd0b..b90472c 100644 --- a/docs/reference/di/entry-points.md +++ b/docs/reference/di/entry-points.md @@ -25,8 +25,29 @@ parameter: when the module's remaining `Needs` (after the exclusions each entry point is entitled to) is `never`, the gate is the empty tuple and the call is ordinary; when it is not, two required parameters appear — `error: "UNSATISFIED DEPENDENCIES", missing: N` — and the call is an arity -error naming exactly what is missing. There is no value to supply for the -phantom arguments; the fix is always to satisfy the need. +error. There is no value to supply for the phantom arguments; the fix is always +to satisfy the need. + +**What it prints, measured:** + +``` +src/scoped.test-d.ts(65,12): error TS2554: Expected 3 arguments, but got 1. +``` + +That is the whole message, and it is worth knowing before you go looking for +more. An arity error never prints a type, so neither the +`"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` appear in it. +With `--pretty`, TypeScript adds related information pointing at the rest +parameter's declaration in `module.ts` — a reader sees the labels there, but +sees `N` un-instantiated. **To find out which port is missing, hover the call**: +the instantiated `missing: N` is in the parameter's type. (Spelling the phantom +arguments out by hand surfaces it as an ordinary assignability error — +`Argument of type 'number' is not assignable to parameter of type 'Scope'` — +which is a diagnostic technique, not an intended call form.) + +`@btravstack/core`'s [`start`](/reference/core/start) answers this differently: +its gate rides the `module` parameter so its sentence prints. The two are no +longer the same shape. | Entry point | Excludes from `Needs` before checking | | ------------------ | ------------------------------------- | @@ -102,7 +123,8 @@ parent's services; `use` receives a `Context` carrying both. Under the kernel you rarely call this yourself: `StartOptions.unit` names a module the kernel forks around **every unit**, and the same gate is checked at -`start`'s call site as `UNSATISFIED UNIT NEEDS`. See +`start`'s call site as +`"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"`. See [Open a per-request scope](/how-to/open-a-per-request-scope). ## `ScopedOptions` diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 4e85bf8..bdcbe6d 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -55,10 +55,15 @@ context; a provided-but-unexported port is private to the module. See providers, then closed. `StartOptions.unit` is a fork the kernel opens around every unit. See [Open a per-request scope](/how-to/open-a-per-request-scope). -**gate** — A phantom rest tuple that is `[]` when a composition is sound and a named -error tuple otherwise, so a mistake fails on arity at the call site. `start`'s -is `StartGate` (`NO RUNTIME`, `UNSATISFIED RUNTIME NEEDS`, `UNSATISFIED UNIT -NEEDS`); di's `Module.scoped` has `UNSATISFIED DEPENDENCIES`. See +**gate** — A phantom type that is inert when a composition is sound and refuses the +call otherwise. The two shipped here are not the same shape. `start`'s is +`StartGate`, a marker **intersected onto `module`** — `unknown` when sound, one +of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …`, +`UNSATISFIED UNIT NEEDS — …`) otherwise, and the sentence prints in the error. +di's on `Module.scoped` is a conditional **rest tuple** labelled +`UNSATISFIED DEPENDENCIES`, so it fails on arity — `Expected 3 arguments, but +got 1`, which names nothing; the label and the missing ports are in the +parameter's type, not the message. See [start and StartOptions](/reference/core/start) and [Compile errors, not surprises](/explanation/compile-time-wiring). From bab2a08095129902232a0a97b4bc942a7a01f845 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:24:35 +0200 Subject: [PATCH 07/21] docs: the how-to pages say what each gate prints --- docs/how-to/keep-a-port-private.md | 8 +++-- docs/how-to/manage-a-resource.md | 5 +-- docs/how-to/open-a-per-request-scope.md | 8 +++-- docs/how-to/protect-a-procedure.md | 9 +++-- docs/how-to/run-a-temporal-worker.md | 6 ++-- docs/how-to/serve-orpc-over-http.md | 15 ++++---- .../how-to/split-a-router-into-controllers.md | 7 ++-- docs/how-to/split-a-worker-into-slices.md | 34 +++++++++++++------ docs/how-to/swap-an-adapter.md | 7 ++-- docs/how-to/write-a-runtime.md | 8 +++-- 10 files changed, 73 insertions(+), 34 deletions(-) diff --git a/docs/how-to/keep-a-port-private.md b/docs/how-to/keep-a-port-private.md index 762d108..566b12e 100644 --- a/docs/how-to/keep-a-port-private.md +++ b/docs/how-to/keep-a-port-private.md @@ -58,8 +58,12 @@ const App = Module("App")({ The second provider does not wire: `Pool` is not among what `App` can see — its own provides plus its imports' exports — so the dependency stays unmet, -and surfaces as `UNSATISFIED DEPENDENCIES` at the entry point (or at -[`start`](/reference/core/start), which carries the same gate). +and surfaces as `UNSATISFIED DEPENDENCIES` at the entry point — the arity +error, `Expected 3 arguments, but got 1`. Under +[`start`](/reference/core/start) the same mistake is caught differently: the +kernel's `module` parameter is `Module`, so the leftover +need fails to assign and the diagnostic **names the port** +(`Type 'Pool' is not assignable to type 'Env | Scope'`). And on a built context: diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md index d254de4..60ef999 100644 --- a/docs/how-to/manage-a-resource.md +++ b/docs/how-to/manage-a-resource.md @@ -58,8 +58,9 @@ the scope **before its own result settles**. The close runs on every path: failure is released, in reverse order. `Module.build` — no scope, no teardown — refuses the graph at compile time: -the call fails on arity with `UNSATISFIED DEPENDENCIES` and `Scope` named as -the missing piece. +`Expected 3 arguments, but got 1`. That arity line is the whole message; the +`UNSATISFIED DEPENDENCIES` label and `Scope` as the missing piece live in the +rest parameter's type, which an editor shows on hover. ## Under `start`, the process is the scope diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index 91aed71..de39f7d 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -119,10 +119,12 @@ whose `E` is `never`. ## The gate has an arm for it -`start`'s phantom rest tuple checks the fork's direction at the call site: the +`start`'s phantom marker checks the fork's direction at the call site: the unit module's needs must be covered by the module's **exports**, `Scope` or -`Env`. A root that has its runtime and router but does not export `Logger` -fails on arity with `UNSATISFIED UNIT NEEDS`: +`Env`. A root that has its runtime and router but does not export `Logger` is +refused against +`"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"`, +the last line of the error: ```ts const UnloggedApi = Module("UnloggedApi")({ diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 4337453..a33ed70 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -239,10 +239,15 @@ export const OrderApi = HttpModule("OrderApi")({ Two things are checked here, and they are different gates: -- **Omitting the line** is di's own `UNSATISFIED DEPENDENCIES` at `start`. When +- **Omitting the line** leaves an unmet need, refused at `start`. When the contract marks anything, `HttpRouter` appends `AuthenticatorPort` to the router provider's dependencies, so the need is real and unmet — no new gate, - and nothing this package invents. + and nothing this package invents. What prints is the `Needs` channel failing + to assign: `Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, + down to `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. + (Not di's `UNSATISFIED DEPENDENCIES` arity gate — that one guards + `Module.build`/`Module.scoped`; `start` types the need out on its `module` + parameter, which is why the port is named.) - **Supplying one minted on a different identity** is a compile error at the `HttpModule(...)` call itself. di cannot see it — `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges the need diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index 5e8aac8..6e44722 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -157,8 +157,10 @@ line on stdout, every line carrying the activity attempt's own trace id. The starter's runtime provider depends on its activities port through di, so a root whose imports do not cover what the provider declared (`FulfillmentModule` and `BillingModule` here — `chargeOrder`'s `PaymentService` comes from the -latter) is refused at `start` — di's gate; a root with no starter fails on -arity (`NO RUNTIME`). `activities` is typed against the module's own +latter) is refused at `start` — di's gate, an arity error; a root with no +starter is refused against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. +`activities` is typed against the module's own `contract`: a provider built for another contract is refused at the call. `workflows` is a `WorkflowSource`: `{ workflowsPath }` for a process that lets diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index d7aaf2e..39ee550 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -194,9 +194,10 @@ Module("OrdersApi")({ The authenticator sits at the **root**, not beside the router: who a caller is is one answer per process. It is required here because the contract marks the fragment — a marked router carries `AuthenticatorPort` as a dependency, so -omitting the line is di's own `UNSATISFIED DEPENDENCIES` at `start`, and -supplying one minted on a different identity is a compile error at this very -call. +omitting the line leaves it in the module's `Needs` and `start` refuses the +module (`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'` — +the port is named), and supplying one minted on a different identity is a +compile error at this very call. [`observability()`](/reference/observability) is the other starter here: it brings the `Logger` the use cases and the request scope write to, bound from @@ -205,10 +206,12 @@ trace id of the unit `http()` opened around the request. It is exported because the per-request `RequestModule` reads it. Three gates hold at compile time, now that the contract is marked. A root that -forgets the starter exports no runtime port and `start` fails on arity -(`NO RUNTIME`). A root that imports `http()` without providing the router +forgets the starter exports no runtime port and `start` refuses it against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`. A root +that imports `http()` without providing the router carries an unmet need — the starter's runtime provider depends on its router -port through di — and `start` refuses the module. And a root serving a **marked** +port through di — and `start` refuses the module, naming the port +(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). And a root serving a **marked** contract without an authenticator carries `AuthenticatorPort` as a second unmet need, refused the same way; drop the marker and that third gate goes with it. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 5b86c7a..db3f81a 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -205,8 +205,11 @@ and none of them owns it; `Logger` is exported because the per-request module reads it. The `authenticator` is here for the same kind of reason and a stronger one: who a caller is is one answer per process, not a slice's question. It is required because a marked fragment made it a dependency of the -router provider, so omitting it is di's own `UNSATISFIED DEPENDENCIES` at -`start`. Nothing else about what a slice needs is spelled at the root. +router provider, so omitting it leaves `AuthenticatorPort` in the root's +`Needs` and `start` refuses the module — not a gate of this package's, and not +di's arity gate either, but the plain assignability of the `Needs` channel +against `Env | Scope`, which names the port. Nothing else about what a slice +needs is spelled at the root. This form is **exact**: a key the record above is missing, a key the contract does not declare, and a controller wired under the wrong key are all diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index c978324..c1b9e71 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -229,9 +229,9 @@ is refused right there — not at the root, and not at startup. The third is caught at the composing call. `AmqpHandlers(contract)([...])` and `TemporalActivities(contract)([...])` are exact against every top-level key the contract declares: an array missing one is refused, against an -`"UNCOVERED HANDLERS — …"` / `"UNCOVERED ACTIVITIES — …"` marker — readable straight -off the type error rather than a runtime stack trace, and never a silent -failure or an `undefined` merged into the record: +`"UNCOVERED HANDLERS — …"` / `"UNCOVERED ACTIVITIES — …"` marker rather than a +runtime stack trace, and never a silent failure or an `undefined` merged into +the record: ```ts // @ts-expect-error -- the "orderAudit" consumer is uncovered @@ -241,15 +241,27 @@ AmqpHandlers(orderContract)([orderNotifications]); TemporalActivities(orderContract)([chargeOrder]); ``` +**Where the marker actually is.** Both are a `TS2769` — +`No overload matches this call` — three lines long, and the sentence is at the +**tail of the third line**, past three hundred characters of type. TypeScript +names the source type first, and the source is the piece you wrote: di's +`Provider<…>` over your contract, which expands to the contract literal itself. +So this one is not readable at a glance; it is readable once you know the +sentence is the last thing on that line. Nothing either package can spell +shortens it — measured, the width is the caller's own contract in the type +arguments, not a name a package could alias — which is why the marker is a +whole sentence rather than a label: it is the only part of the line a reader +can act on, and it prints where the eye ends up. + Both arrays above are one element long, so both diagnostics report only the -marker (`"UNCOVERED HANDLERS — …"`, `"UNCOVERED ACTIVITIES — …"`) — the missing key -itself is not in either message. The key IS named — as -`readonly ["UNCOVERED HANDLERS — …", "orderAudit"]` or -`readonly ["UNCOVERED ACTIVITIES — …", "fulfillOrder"]` — but only once the array -under test is as long as the marker tuple itself (2), a two-piece array -missing one key being the common case. Below that length TypeScript can no -longer line the array up against the tuple positionally and falls back to -reporting the marker alone. +marker — the missing key itself is in neither message. The key **is** named +once the array under test is as long as the marker tuple itself (2), a +two-piece array missing one key being the common case: TypeScript then lines +the array up against the tuple positionally and reports one error per element, +the trailing one being `is not assignable to type '"orderAudit"'` — the bare +key, as its own diagnostic, not folded into the marker's sentence. Below that +length it can no longer line them up and falls back to reporting the marker +alone. This is why the composing arm is declared **last** in the intersection both packages build it from — di's builder first, the composer last — so diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md index 74005d3..b91becf 100644 --- a/docs/how-to/swap-an-adapter.md +++ b/docs/how-to/swap-an-adapter.md @@ -120,11 +120,14 @@ const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); The wrong pairing does not compile: ```ts -await Module.build(makeAppModule(makePersistenceModule())); // UNSATISFIED DEPENDENCIES +// error TS2554: Expected 3 arguments, but got 1. +await Module.build(makeAppModule(makePersistenceModule())); ``` `Scope` is still in `Needs`, so the call's arity gate rejects it before -anything runs. A test that quietly wires the production adapter into a +anything runs. The message is the arity line and nothing more — the +`UNSATISFIED DEPENDENCIES` label and the missing port are in the rest +parameter's type, which an editor shows on hover. A test that quietly wires the production adapter into a scope-less build breaks at compile time, not in CI at midnight. Passing the in-memory module to `Module.scoped` is fine — `Scope` is simply absent from its `Needs`, and a scope that releases nothing is harmless. diff --git a/docs/how-to/write-a-runtime.md b/docs/how-to/write-a-runtime.md index fec5eab..cd4f769 100644 --- a/docs/how-to/write-a-runtime.md +++ b/docs/how-to/write-a-runtime.md @@ -154,8 +154,12 @@ await runMain(TickerApp); The composition root is what differs between an `api`, a `worker` and a `consumer` process; the application module is the same in all three. Drop -`Ticker` from `exports` and `runMain` fails on arity with `NO RUNTIME`; drop -`Greeter` and it fails with `UNSATISFIED RUNTIME NEEDS`. +`Ticker` from `exports` and `runMain` refuses the module against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`; drop +`Greeter` and it refuses it against +`"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"`. +Either way the sentence is the error's **last** line; the first names the two +`Module<…>` types. ## Honour the three contracts the kernel cannot check From 52c38e5048cd23281be31a023e093181524400b2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:25:59 +0200 Subject: [PATCH 08/21] docs: the reference pages quote the gates' real diagnostics --- docs/explanation/starters.md | 7 ++++--- docs/reference/amqp.md | 13 ++++++++---- docs/reference/http.md | 40 ++++++++++++++++++++++++++++-------- docs/reference/temporal.md | 13 ++++++++---- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index a3b8960..616db38 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -162,9 +162,10 @@ application's router port is" — could not ship its port: `HttpRuntime` has to be one class in `@btravstack/http`, and its type cannot mention a port only the application knows. Making the router a dependency of the runtime's provider moves that knowledge to where it exists — the composition root that -provides the router — and di's own gate checks it there: a root that imports -`http()` without providing the router carries an unmet need `start` -refuses. The kernel keeps `Runtime.needs`, `RunUnit`'s typed `ctx` and the +provides the router — and the `Needs` channel checks it there: a root that +imports `http()` without providing the router carries an unmet need `start` +refuses, naming the port +(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). The kernel keeps `Runtime.needs`, `RunUnit`'s typed `ctx` and the `UNSATISFIED RUNTIME NEEDS` arm as the general contract for a hand-rolled runtime; the starters simply do not need them. diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index d2db5d3..b16896a 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -172,10 +172,15 @@ very key each piece's port id carries, so the services record IS the handlers record. Every key the contract declares must be covered: an array missing one is refused at the call, against an `"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"` -marker (`readonly ["UNCOVERED HANDLERS — …", ...]`) — the missing key itself is named -too once the array's length matches that marker tuple's own length of 2; a -single-element array's diagnostic names the marker alone; a piece built for -another contract +marker. The diagnostic is a three-line `TS2769` and the sentence is at the +**tail of the third line**, past three hundred characters of the caller's own +contract type — measured, and not shortenable from inside this package. The +missing key itself is named too once the array's length matches that marker +tuple's own length of 2: TypeScript then matches the array against the tuple +positionally and reports the trailing element separately, as +`is not assignable to type '"orderAudit"'` — the bare key, not the marker +tuple. A single-element array's diagnostic names the marker alone; a piece +built for another contract is refused too, structurally, since its port's service is that contract's handler for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider diff --git a/docs/reference/http.md b/docs/reference/http.md index 48a4831..2f27c47 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -181,11 +181,30 @@ Each value is what [`HttpController`](#httpcontrollername-fragment) returns. The call is **exact**: `M` is constrained to `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` -**parameter** itself is typed `M & { readonly [K in Exclude>]: never }` — the exactness intersection sits on -the parameter, not on `M`, so a key `C` does not declare is typed `never` there -without collapsing `M` (and with it the needs channel di orders the controllers -by) to `never` too. The `Exclude`/`Inherit` pair is the same one +**parameter** itself is typed: + +```ts +M & { + readonly [K in Exclude>]: + `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; +}; +``` + +The exactness intersection sits on the parameter, not on `M`, so a key `C` does +not declare is refused there without collapsing `M` (and with it the needs +channel di orders the controllers by) to `never` too. Because the mapped type is +keyed by `K`, the sentence **names the offending key**, and it is the last line +of the error: + +``` +error TS2769: No overload matches this call. + The last overload gave the following error. + Type 'Minted<"GateOrders", { place: ContractBuilder; }, never, never>' is not assignable to type 'Provider; }>, never, never> & { ...; } & "UNDECLARED KEY — the contract declares no fragment under billing"'. + Type 'Minted<"GateOrders", { place: ContractBuilder; }, never, never>' is not assignable to type '"UNDECLARED KEY — the contract declares no fragment under billing"'. +``` + +Read the **last** line: the ones above it name the type you passed. The +`Exclude`/`Inherit` pair is the same one [`Implementation`](#authentication) carries: a contract marked at its **root** composes through this form too, and each fragment inherits that mark, so a controller under it types `context.principal`. @@ -375,9 +394,14 @@ When the contract marks anything, `HttpRouter` adds `AuthenticatorPort` to the router provider's deps record under a **namespaced** key (`"@btravstack/http/authenticator"`, so it cannot collide with one you wrote), strips it back out before your own `sync` sees the record, and adds it to the -provider's needs channel. Which makes a marked router with no authenticator behind it di's -existing `UNSATISFIED DEPENDENCIES` gate at `start`, not a gate this package -invented. +provider's needs channel. Which makes a marked router with no authenticator +behind it an ordinary unmet need at `start`, not a gate this package invented. +What prints is `start`'s `module` parameter refusing the leftover need — +`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, down to +`Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'` — so the +port is named. (Not di's `UNSATISFIED DEPENDENCIES` arity gate: that one guards +`Module.build`/`Module.scoped`, and `start` types the need out on its parameter +instead.) What di cannot see is the **identity**: `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges that need. So diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index c29b4f6..ee3a276 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -202,10 +202,15 @@ record IS the activities record. Every top-level key the contract's activities record declares must be covered: an array missing one is refused at the call, against an `"UNCOVERED ACTIVITIES — the contract declares a workflow this array does not cover"` -marker (`readonly ["UNCOVERED ACTIVITIES — …", ...]`) — the missing key itself is named -too once the array's length matches that marker tuple's own length of 2; a -single-element array's diagnostic names the marker alone; a piece built for -another contract is refused too, structurally, since its port's service is +marker. The diagnostic is a three-line `TS2769` and the sentence is at the +**tail of the third line**, past three hundred characters of the caller's own +contract type — measured, and not shortenable from inside this package. The +missing key itself is named too once the array's length matches that marker +tuple's own length of 2: TypeScript then matches the array against the tuple +positionally and reports the trailing element separately, as +`is not assignable to type '"fulfillOrder"'` — the bare key, not the marker +tuple. A single-element array's diagnostic names the marker alone; a piece +built for another contract is refused too, structurally, since its port's service is that contract's activities for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider defect at build catches it only once **both** end up From a77b4f4ec386db307a78a55478bc6fa7503e1e9f Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:27:11 +0200 Subject: [PATCH 09/21] docs: the example walkthroughs tell the two start-time refusals apart --- docs/examples/order-amqp-worker.md | 17 ++++++++++++++--- docs/examples/order-api.md | 25 ++++++++++++++++--------- docs/examples/order-application.md | 9 ++++++++- docs/examples/order-temporal-worker.md | 13 ++++++++++--- examples/README.md | 15 ++++++++++----- examples/hexagonal-order-api/README.md | 5 ++++- 6 files changed, 62 insertions(+), 22 deletions(-) diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index 1c51c94..f59d842 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -301,9 +301,9 @@ an exchange, never a consumer. ## The gate -`needs-gate.test-d.ts` pins `NO RUNTIME`, and di's gate spelled with the -`amqp()` primitive — the sugar cannot leave the handlers out, which is what it -is for: +`needs-gate.test-d.ts` pins `NO RUNTIME — …`, and the unmet-need refusal +spelled with the `amqp()` primitive — the sugar cannot leave the handlers out, +which is what it is for: ```ts const HandlerlessAmqp = Module("HandlerlessAmqp")({ @@ -320,6 +320,17 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ const _missingHandlers = start(HandlerlessAmqp, options); ``` +Two different diagnostics, worth telling apart. The first is `start`'s marker: +the module argument fails to match +`Module<…> & "NO RUNTIME — the module exports no port declared over RuntimePort"`, +and the sentence is the last line. The second is the `Needs` channel: the +handlers port is left outstanding and `start`'s `module` parameter takes only +`Scope | Env`, so what prints is +`Type 'HandlersInstanceOf<…>' is not assignable to type 'Env | Scope'` — wide, +because the contract expands, but ending on +`Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'`, which names the +port. Neither is di's `UNSATISFIED DEPENDENCIES` arity gate. + ## Where to go next - The other two deployments: [Order API (HTTP)](/examples/order-api), diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 897ef8b..8f419c7 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -508,9 +508,11 @@ directions of `start`'s own gate and di's, side by side: const _missingRuntime = start(RuntimelessApi, options); ``` -`RuntimelessApi` is the same list of slices without `http(...)`: `start`'s phantom -rest tuple becomes a required argument naming the absence, and the call fails -on arity. It provides `bearerAuthenticator` even so, deliberately: the contract +`RuntimelessApi` is the same list of slices without `http(...)`: `start`'s +phantom marker becomes the sentence +`"NO RUNTIME — the module exports no port declared over RuntimePort"`, and the +module argument fails to match its parameter type — the sentence is the error's +last line. It provides `bearerAuthenticator` even so, deliberately: the contract marks `orders`, so a graph carrying the router without an authenticator has an unmet need too, and an arm that could fail either way pins neither gate. @@ -524,12 +526,17 @@ const RouterlessApi = Module("RouterlessApi")({ const _missingRouter = start(RouterlessApi, options); ``` -This one is **di's** gate, not the kernel's: `http()`'s runtime provider -depends on the starter's own router port through di, so a composition that -imports the starter without providing the router carries an unmet need, and -`start` — which accepts only `Scope | Env` outstanding — refuses the module. -There is no `UNSATISFIED RUNTIME NEEDS` arm here, because the shipped runtime -declares no needs. +This one is the **`Needs` channel**, not the kernel's marker: `http()`'s runtime +provider depends on the starter's own router port through di, so a composition +that imports the starter without providing the router carries an unmet need, and +`start` — whose `module` parameter accepts only `Scope | Env` outstanding — +refuses it. What prints is that assignability failure, and it names the port: +`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`, down to +`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. It is **not** +di's `UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and +`Module.scoped`; conflating the two is easy and the distinction is the point of +having both pinned here. There is no `UNSATISFIED RUNTIME NEEDS` arm, because +the shipped runtime declares no needs. ```ts // @ts-expect-error — UNSATISFIED UNIT NEEDS: the module does not export Logger for RequestModule to read. diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 020530c..fc0d2fa 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -285,13 +285,20 @@ activities or handlers that implement it. ```ts // Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error naming the unmet need. +// two-element tuple and the call is an arity error. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("o-1", 1), ); ``` +What that prints is `error TS2554: Expected 5 arguments, but got 2.` and +nothing else — an arity error carries no type, so neither the +`UNSATISFIED DEPENDENCIES` label nor `OrderRepository` appears in it. Both are +in the rest parameter's type, which an editor shows on hover. `start`'s three +arms are the deliberate contrast: they ride the `module` parameter precisely so +their sentence prints. + Each vertical's gate is pinned separately, which is the split showing up in the type tests: a graph that provides `OrderRepository` still cannot scope `CustomerApplicationModule`, and one that provides the customer repository diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index a5b4523..7d77d24 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -309,15 +309,22 @@ shutdown to escalate to. See ## The gate -`needs-gate.test-d.ts` pins `NO RUNTIME` (the graph without the starter fails -on arity) and di's gate spelled with the `temporal()` primitive, since the -sugar cannot leave the activities out at all: +`needs-gate.test-d.ts` pins `NO RUNTIME — …` (the graph without the starter +fails to match the sentence intersected onto `start`'s `module` parameter) and +the unmet-need refusal spelled with the `temporal()` primitive, since the sugar +cannot leave the activities out at all: ```ts // @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port, which nothing provides. const _missingActivities = start(ActivitylessTemporal, options); ``` +That second one is the `Needs` channel, not di's `UNSATISFIED DEPENDENCIES` +arity gate: `start`'s `module` parameter accepts only `Scope | Env` +outstanding, so the activities port fails to assign and the diagnostic ends on +`Type '"TemporalActivities"' is not assignable to type '"@di/Scope"'` — the +port named, after several lines of the contract expanding. + Dropping one slice's import while still providing the composed activities is a different failure — the runtime `WiringDefect` the wiring rule above describes — and is not something a compile-time gate can catch, so it is diff --git a/examples/README.md b/examples/README.md index 99ada37..4fdcf12 100644 --- a/examples/README.md +++ b/examples/README.md @@ -192,11 +192,16 @@ kernel's own type test now; what the examples pin is the other two gates. Pinned in `order-api/src/needs-gate.test-d.ts`, `order-temporal-worker/src/needs-gate.test-d.ts` and `order-amqp-worker/src/needs-gate.test-d.ts`: the wired call is an ordinary -one; a composition that forgets its starter fails on **arity** with -`NO RUNTIME`; and a composition that imports the starter without providing its -router / activities / handlers port fails at `start` — di's own -`UNSATISFIED DEPENDENCIES` gate, since the runtime provider depends on that -port. `order-api` also pins both halves of the `unit` gate. +one; a composition that forgets its starter is refused against +`"NO RUNTIME — the module exports no port declared over RuntimePort"`, the +sentence `start` intersects onto its `module` parameter; and a composition that +imports the starter without providing its router / activities / handlers port +fails at `start` too, but by a different mechanism — the port stays in the +module's `Needs`, which `start`'s parameter accepts only as `Scope | Env`, so +the diagnostic names the port. That second one is **not** di's +`UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and +`Module.scoped`; `order-application`'s file is where that one is pinned. +`order-api` also pins both halves of the `unit` gate. The two **worker** files pin one more, and it is the one worth reading if you are about to write a slice: a slice does **not** shield the ports its own diff --git a/examples/hexagonal-order-api/README.md b/examples/hexagonal-order-api/README.md index d9798a6..b8b999e 100644 --- a/examples/hexagonal-order-api/README.md +++ b/examples/hexagonal-order-api/README.md @@ -37,7 +37,10 @@ pnpm --filter @btravstack/example-hexagonal-order-api typecheck propagates through `makeAppModule` to anything built from it. Building that graph with `Module.build` is a compile error, not a runtime leak — the call's arity gate (the "UNSATISFIED DEPENDENCIES" rest parameter every unmet -requirement produces) rejects it before anything runs. `src/index.test-d.ts` +requirement produces) rejects it before anything runs. What it prints is the +arity line alone, `Expected 3 arguments, but got 1`; the label and `Scope` are +in the rest parameter's type, which an editor shows on hover, not in the +message. `src/index.test-d.ts` pins that with a `@ts-expect-error` of its own, right next to the privacy one. `Module.scoped` is the one entry point that opens a scope and discharges `Scope` — used in `src/index.spec.ts` against the production From a85f635a5a9aa7da0a15b128c46f4fddc29ff83a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:29:44 +0200 Subject: [PATCH 10/21] docs: the package specs name the mechanism each gate uses --- CLAUDE.md | 32 ++++++++++++++------- docs/explanation/compile-time-wiring.md | 21 +++++++++++++- packages/amqp/CLAUDE.md | 30 +++++++++++++------- packages/amqp/src/handler.test-d.ts | 5 ++-- packages/di/CLAUDE.md | 10 ++++++- packages/http/CLAUDE.md | 37 +++++++++++++++++-------- packages/temporal/CLAUDE.md | 6 +++- 7 files changed, 104 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c1b74a3..a71214a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -445,12 +445,18 @@ type checker already verifies. **`start`'s** gate (`order-api`, `order-temporal-worker`, `order-amqp-worker` — its `NO RUNTIME` arm, since no starter's runtime declares a `needs` any more; `order-api`'s also pins the `unit` halves) and - **di's** need on the starter's port (a composition importing `http()` / + the **unmet need** on the starter's port (a composition importing `http()` / `temporal({ contract, workflows })` / `amqp({ contract })` without providing - the router / activities / handlers carries the starter's port as an unmet - need `start` refuses); the fourth, - `order-application`'s, pins **di's** `UNSATISFIED DEPENDENCIES` gate on - `Module.scoped`. They are different gates and easy to conflate. `start`'s + the router / activities / handlers carries the starter's port in `Needs`, and + `start`'s `module` parameter takes only `Scope | Env`, so it fails to assign — + measured: a `TS2345` ending on + `Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`, which names the + port); the fourth, `order-application`'s, pins **di's** + `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`, which is a rest-tuple + **arity** error printing `Expected 5 arguments, but got 2` and nothing else. + **Three** different mechanisms, easy to conflate — and only the first prints + its name. Do not call the second "di's `UNSATISFIED DEPENDENCIES` gate": an + earlier revision of this file did, and it is wrong in both halves. `start`'s `UNSATISFIED RUNTIME NEEDS` arm is pinned only by `packages/core`'s own `start.test-d.ts`, since every shipped runtime declares `needs: []`. `examples/` is not the only place the gate is pinned by a **type test**: @@ -671,8 +677,12 @@ AuditSlice, observability()], … })`), `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` compose them: every key the contract declares must be covered (an uncovered one is refused at the call, against an `"UNCOVERED HANDLERS — …"` / - `"UNCOVERED ACTIVITIES — …"` marker that names the missing key too once the - array's length matches the marker tuple's own length of 2), and two slices + `"UNCOVERED ACTIVITIES — …"` marker — at the **tail of the third line** of a + `TS2769`, past three hundred characters of the caller's own contract, which + is not shortenable from inside either package because the width is in the + type arguments rather than in a name; the missing key is named too once the + array's length matches the marker tuple's own length of 2, as a **separate** + diagnostic on the trailing element whose target is the bare key), and two slices both discharged for one key are di's duplicate-provider defect at build — the same exactness the keyed HTTP form gets from the shape of the record it composes, reached here through the @@ -735,9 +745,11 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `port` back off `Serving.info`; binding, the drain and the trace-id policy are the package's. Two gates keep the composition honest at compile time: a root - that forgets `http()` fails on arity (`NO RUNTIME`), and one that imports - it without providing `orderRouter` fails di's own gate at `start`, since the - starter's runtime provider depends on its router port. + that forgets `http()` is refused against + `"NO RUNTIME — the module exports no port declared over RuntimePort"`, the + sentence intersected onto `start`'s `module` parameter, and one that imports + it without providing `orderRouter` leaves `HttpRouterPort` in `Needs`, which + the same parameter refuses by assignability — not di's arity gate. - **oRPC is pinned to an exact beta.** `@orpc/{client,contract,server}` sit at `2.0.0-beta.28` in the catalog because oRPC v2's `latest` dist-tag is still the **1.x** line, while `@unthrown/orpc` peers on `^2.0.0-beta`: an unpinned diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index 5e1e51f..e2589b2 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -109,7 +109,26 @@ declaration and build can drop an entry is variance — the package's one rule: `Needs` and `E` sit in covariant (return) position. Assigning `Module` where `Module` is expected asks the compiler whether `Database` is assignable to `never` — it is not, and the -laundering fails. The opposite choice would make the same assignment reduce to +laundering fails: + +``` +error TS2322: Type 'Module' is not assignable to type 'Module'. + Type 'Database' is not assignable to type 'never'. +``` + +That is as good as this one gets, and it is worth knowing what it does **not** +promise. The two `Module<…>` types on the first line are the whole diagnostic +in the general case: the reader diffs them. On some fixtures TypeScript +elaborates a third line naming the offending member — +`Property 'url' is missing in type 'ConfigError' but required in type 'PoolError'` +names `ConfigError` — but that is structural elaboration into whichever +property happens to differ, so two error types differing only in a `_tag` would +elaborate onto `_tag` and name nothing actionable, and two structurally +identical ones would not elaborate at all. Attaching a named wrapper to the +phantom `_error` field was tried and the re-captured diagnostic came back +**byte-identical** — TypeScript elaborates straight to the leaf mismatch and +never prints the wrapper's key. The width here is in the _type arguments_, not +in a constructor name, so nothing di can spell moves it. The opposite choice would make the same assignment reduce to `never extends Database`, trivially true, and an annotation as innocent as a helper's return type could silently zero the ledger. The source pins this with type-level tests (`*.test-d.ts`), because the guarantee lives entirely in the diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index 55701b0..2ce5e9b 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -92,10 +92,15 @@ Provider>> & Compose` — di's builder first, the composer non-covering array, and the diagnostic degrades to `not assignable to 'Qualification'`, naming nothing; last, it reports the composing arm's own conditional against `readonly ["UNCOVERED HANDLERS — …", -K]`, which always names the marker — the missing key `K` itself appears only - when the array's length matches that marker tuple's own length of 2; a - single-element array's diagnostic names the marker alone — measured, not - stylistic. The +K]`, which always names the marker — printed as the **bare string**, the + tuple's element 0, not as the tuple. Measured, not stylistic, and measured + again for this: the marker sits at the **tail of the third line** of a + `TS2769`, ~360 characters in on a 444-character line, because TypeScript + names the source type first and the source is the caller's own piece. The + missing key `K` itself appears only when the array's length matches that + marker tuple's own length of 2, and then as a **separate** `TS2769` on the + trailing element whose target is the bare key (`is not assignable to type +'"c"'`); a single-element array's diagnostic names the marker alone. The composed provider's own `deps` are the **piece ports** (`InstanceType` in its return type), not what a piece closes over: di constructs each piece first, as its own provider, and the @@ -147,10 +152,13 @@ AmqpInfo>>` — the runtime has **no** needs) and the broker on **`AmqpConfig`** (`{ url }`, bound from `AMQP_URL`, default `amqp://127.0.0.1:5672`), and it **needs** its handlers port, typed for `contract`, which the application provides. The composition root imports - it, provides the handlers, exports `AmqpRuntime`; di's own gate checks the - need where the root is declared, and `start` refuses a module whose needs - channel still carries it - (`examples/order-amqp-worker/src/needs-gate.test-d.ts` pins that + it, provides the handlers, exports `AmqpRuntime`; di's `Needs` channel + carries the port, and `start` refuses a module whose needs channel still + carries it — by assignability against `Env | Scope` on the `module` + parameter, not by di's `UNSATISFIED DEPENDENCIES` arity gate, which is why + the diagnostic ends on + `Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'` and names the + port (`examples/order-amqp-worker/src/needs-gate.test-d.ts` pins that diagnostic, since `start`'s own gate has no `UNSATISFIED RUNTIME NEEDS` arm to fire any more). `AmqpOptions` — `contract: TContract` (`TContract` bounded by @@ -287,8 +295,10 @@ right])`, pinning that both slices run (_"serves a record composed from one composing form's compile-time gates on a contract of its own — a piece typed by its own key, an array covering every declared key, an uncovered array refused as `@ts-expect-error` (its own single-element case reports only the - `"UNCOVERED HANDLERS — …"` marker, not the missing key — see the composing-arm - entry above for when the key itself is named), and a piece built for another contract + `"UNCOVERED HANDLERS — …"` marker, not the missing key — the comment above + that gate says so, since the file's own array is one element long; see the + composing-arm entry above for when the key itself is named), and a piece + built for another contract refused structurally (that contract's own key needs its own message, not a reused one, or the two ports are the same type and there is nothing to refuse — di's port typing is structural on id and service, not nominal diff --git a/packages/amqp/src/handler.test-d.ts b/packages/amqp/src/handler.test-d.ts index d0c06c4..8c6078c 100644 --- a/packages/amqp/src/handler.test-d.ts +++ b/packages/amqp/src/handler.test-d.ts @@ -48,8 +48,9 @@ AmqpModule("Pin")({ contract: pinContract, handlers: composed }); // @ts-expect-error -- "middle" is not one of `pinContract`'s consumer/RPC names AmqpHandler(pinContract, "middle"); -// Negative: an array that misses a declared key is refused at the root, and -// the diagnostic names the key it is missing. +// Negative: an array that misses a declared key is refused at the root. This +// array is one element long, so the diagnostic reports the marker alone — the +// missing key is named only once the array is as long as the marker tuple (2). // @ts-expect-error -- the `right` consumer is uncovered AmqpHandlers(pinContract)([left]); diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index 0db79f5..d13f43a 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -60,7 +60,15 @@ prismaOrderRepository(db) }` — an adapter factory takes the client, not a close on every path), `Module.forkScope` (per-request scope seeded from a built parent `Context`). Unmet dependencies are compile errors via a conditional rest parameter — `[N] extends [never] ? [] : [error: "UNSATISFIED DEPENDENCIES", -missing: N]`. `exports` accepts an available **port class**, a **provider** for +missing: N]`. What that **prints** is the arity line alone — + `error TS2554: Expected 3 arguments, but got 1.` — because an arity error + never carries a type: neither the label nor the ports in `missing` reach the + message, and `--pretty`'s related information points at the declaration in + `module.ts`, where `N` is still un-instantiated. Hover is where a reader gets + the port. `@btravstack/core`'s `start` answers this differently — its marker + rides the `module` parameter so its sentence prints — so **the two gates are + no longer the same shape**; do not describe them as parallel. + `exports` accepts an available **port class**, a **provider** for one (normalised to `provider.port` when the module is built, so the stored `exports` array stays `readonly (AnyPort | AnyModule)[]`, and yielding the identical `Exports` channel either way), or an imported module. The provider diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 703ee54..0fe1739 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -33,7 +33,12 @@ the same commit, and with `README.md` — the package ships no takes. `Auth` is inferred from it and `Provides` spreads `[Auth] extends [undefined] ? [] : [NonNullable]`, so an **omitted** authenticator contributes no element and a marked router's need survives - to `start` — di's `UNSATISFIED DEPENDENCIES`, no gate of this package's. + to `start`, which refuses it — no gate of this package's, and **not** di's + `UNSATISFIED DEPENDENCIES` arity gate either (that one guards + `Module.build`/`Module.scoped`): `start`'s `module` parameter takes only + `Scope | Env` outstanding, so the leftover need fails to assign and the + diagnostic names the port, ending on + `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. What di cannot see is the **principal**: `AuthenticatorPort`'s service type is erased to `unknown`, so any authenticator discharges the need whatever it resolves. That half is checked here instead — `Principal` is inferred from @@ -112,10 +117,13 @@ ${K & string}` }`` — the same `Exclude` and the same `Inherit` the `controller.test-d.ts` already print) and the message **ends** on the rule in English, with the offending key in it. `${K & string}` is what carries the key: the mapped type is keyed by `K`, so the key is in scope at the value - position and costs a template literal to reach. A **symbol** key intersects - to `never` and the whole template collapses to `never`, which is the old - behaviour — still a compile error, just the old terse one. Measured every - way; the trade was taken deliberately. + position and costs a template literal to reach. The wide-line trade and the + named key were both **measured** — all five gates still fire, `tsc` clean, no + widening of `M`. A **symbol** key would intersect to `never` and collapse the + whole template to `never`, back to the old terse error: that one is + **reasoned from `K & string`, not measured**, and it is inert anyway, since a + contract's fragment keys are strings and no call of the keyed form can carry + a symbol. Do not upgrade it to a measured claim without measuring it. **`HttpRouter` is the one helper in the family with THREE forms and only two arguments' worth of arity**, so it is the one place arity alone cannot decide. `(deps, arm)` is settled by arity as everywhere else; the two @@ -308,8 +316,9 @@ InstanceType> & { readonly port: PortClassOf hands the caller's own `sync` the rest — and both `build` overloads add `HasMark extends true ? AuthenticatorPort : never` to the needs channel plus `readonly identity: Identity` to the result. - A marked router whose root provides no authenticator is therefore di's - existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Whether the + A marked router whose root provides no authenticator is therefore an + ordinary unmet need refused at `start` — no new gate, and not di's arity + gate (see the `authenticator` bullet for what prints). Whether the authenticator resolves what the handlers read is the one thing that gate cannot see, and `HttpModule`'s `authenticator` option is where it is checked (see the first bullet). Note @@ -386,12 +395,16 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC `HttpModuleOptions.securityHeaders` (`http-module.ts`) forwards it to `http()` on the same `...(x === undefined ? {} : { x })` spread every other option here uses. -- **Two gates, both compile-time.** `start`'s phantom rest tuple turns a - composition exporting no `HttpRuntime` into an arity error (`NO RUNTIME`); - and because the runtime provider depends on the router port **through di**, +- **Two gates, both compile-time, and they are different mechanisms.** + `start`'s phantom marker — intersected onto `module`, not a rest tuple — turns + a composition exporting no `HttpRuntime` into a `TS2345` whose last line is + `"NO RUNTIME — the module exports no port declared over RuntimePort"`; and + because the runtime provider depends on the router port **through di**, a composition that imports `http()` without providing the router - carries `HttpRouterPort` as an unmet need `start` refuses — di's gate, not - the kernel's. + carries `HttpRouterPort` as an unmet need `start` refuses on the same + parameter's `Module` half, ending on + `Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. Neither is + di's `UNSATISFIED DEPENDENCIES` arity gate. `examples/order-api/src/needs-gate.test-d.ts` pins both, plus the `StartOptions.unit` halves. There is no `UNSATISFIED RUNTIME NEEDS` case for this runtime any more: it declares none. diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 5ee83d4..925054d 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -164,7 +164,11 @@ config: TemporalConfig, activities: TemporalActivitiesPort as ActivitiesPortOf }, { sync })` — the port rides di, which is why `ActivitiesInstanceOf` is in the module's `Needs` and a root that imports the starter without providing the - activities is rejected by `start` for still owing it (the + activities is rejected by `start` for still owing it — by assignability + against `Env | Scope` on the `module` parameter, not by di's + `UNSATISFIED DEPENDENCIES` arity gate, so the diagnostic names the port and + ends on `Type '"TemporalActivities"' is not assignable to type '"@di/Scope"'` + (the `examples/order-temporal-worker` `needs-gate.test-d.ts` pins that; there is no `UNSATISFIED RUNTIME NEEDS` arm any more, the runtime needs nothing). `TemporalActivitiesPort as ActivitiesPortOf` (here and in From 905527cf66c8a7a91fa928e9f91fc4f75710ac5a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:31:35 +0200 Subject: [PATCH 11/21] docs: the example and starter comments name the mechanism, not the wrong gate --- .changeset/authenticated-contracts.md | 4 +-- .../src/needs-gate.test-d.ts | 17 ++++++---- examples/order-api/src/module.ts | 8 +++-- examples/order-api/src/needs-gate.test-d.ts | 32 +++++++++++-------- examples/order-application/src/module.ts | 6 ++-- .../src/needs-gate.test-d.ts | 5 ++- .../src/needs-gate.test-d.ts | 6 ++-- packages/http/src/auth.test-d.ts | 12 ++++--- 8 files changed, 54 insertions(+), 36 deletions(-) diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index 5be3199..48be129 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -23,8 +23,8 @@ contract rather than detecting one that was forgotten. `@btravstack/http` resolves the principal through a new `Authenticator` port — `HttpAuthenticator

()([deps], { sync })`, an ordinary di provider, wired on `HttpModule`'s `authenticator` option. A contract that marks nothing needs no -authenticator; a marked router whose root provides none is di's existing -`UNSATISFIED DEPENDENCIES` gate, and an authenticator minted on a different +authenticator; a marked router whose root provides none carries the port as an +unmet need `start` refuses, and an authenticator minted on a different identity than the router is refused at `HttpModule`. A marked procedure whose authenticator declines is answered `UNAUTHORIZED` before dispatch, with the handler never running and no reason reaching the caller — `Unauthenticated` diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index 37ed586..bd7d9a2 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -1,15 +1,18 @@ /** * The compile-time half of the broadcast deployment: `start` resolves its * runtime from the `AmqpRuntime` port `@btravstack/amqp`'s starter provides - * and the composition root exports, so a module that exports no runtime is a - * call-site arity error. Type-checked by this package's `test:types` script, + * and the composition root exports, so a module that exports no runtime fails + * to match the `NO RUNTIME — …` sentence `start` intersects onto its `module` + * parameter. Type-checked by this package's `test:types` script, * never executed. * * There is no UNSATISFIED RUNTIME NEEDS negative any more: the runtime has * none. What used to be its needs — the handlers, and what they read — is now * the starter's own handlers port, which the starter DEPENDS on, so a - * composition without a provider for it is di's own gate: the module's needs - * channel carries that port, and `start` accepts only `Scope | Env` there. + * composition without a provider for it is the needs channel, not the marker + * and not di's `UNSATISFIED DEPENDENCIES` arity gate: the module's needs + * channel carries that port, `start` accepts only `Scope | Env` there, and the + * assignability failure names the port. * * The third negative is about **slices**, and it is the mirror of * `order-temporal-worker`'s `FulfillmentlessSlice`. Composing pieces into one @@ -46,8 +49,8 @@ const RuntimelessAmqp = Module("RuntimelessAmqp")({ exports: [PlaceOrder, Logger], }); -// Negative: the gate becomes a required two-element tuple naming the missing -// runtime, and the call fails on arity. +// Negative: the marker becomes the `NO RUNTIME — …` sentence, which the module +// argument cannot satisfy, so the call fails to typecheck against it. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _noRuntime = start(RuntimelessAmqp, options); @@ -65,7 +68,7 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ exports: [AmqpRuntime, PlaceOrder, Logger], }); -// Negative, di's gate rather than the kernel's: `start` takes a +// Negative, the needs channel rather than the kernel's marker: `start` takes a // `Module`, and this one still needs the handlers port. // @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides. const _missingHandlers = start(HandlerlessAmqp, options); diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index ae76bda..c2bcad8 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -35,9 +35,11 @@ export const orderRouter = HttpRouter(contract)({ * dropping this line is an unmet dependency `start` refuses, and supplying one * that resolves a different principal is a compile error at this very call. * Importing the router - * and the starter is what closes di's arity gate (a composition without the - * router provider does not compile — the starter's provider depends on it), - * and `HttpRuntime`, which the sugar exports, is what closes the kernel's. + * and the starter is what empties the needs channel (a composition without the + * router provider does not compile — the starter's provider depends on it, so + * `HttpRouterPort` survives into `Needs` and `start`'s `module` parameter, + * which takes only `Scope | Env`, refuses it by name), and `HttpRuntime`, + * which the sugar exports, is what satisfies the kernel's own marker. * * A constant, not a function: configuration is read inside the graph, from the * `Env` port the kernel provides, so nothing has to be passed in from diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 2563f0b..487abaa 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -3,10 +3,13 @@ import { start } from "@btravstack/core"; * The compile-time half of the transport layer: `start` resolves its runtime * from the `HttpRuntime` port the composition root exports, and * `http()`'s runtime provider depends on the router port through - * di. Two gates, both at compile time: `start`'s phantom rest-tuple gate turns - * a module that exports no runtime into a call-site arity error, and di's own + * di. Two gates, both at compile time and NOT the same mechanism: `start`'s + * phantom marker, intersected onto `module`, turns a module that exports no + * runtime into a `TS2345` whose last line is the arm's own sentence; and di's * `Module` typing turns a composition that imports the starter without - * providing its router into an unmet need `start` refuses. Type-checked by + * providing its router into an unmet need the same parameter refuses by + * assignability, naming the port. Neither is di's `UNSATISFIED DEPENDENCIES` + * arity gate. Type-checked by * this package's `test:types` script, never executed. */ import { Module } from "@btravstack/di"; @@ -22,31 +25,31 @@ import { OrdersSlice } from "./slices/orders/module.js"; const options = { signals: false, probes: false } as const; -// Positive: the composition root exports the runtime, so the gate collapses to -// an empty tuple and this is an ordinary two-argument call. +// Positive: the composition root exports the runtime, so the marker collapses +// to `unknown` and this is an ordinary two-argument call. const _wired = start(OrderApi, options); // The same graph without `http(...)`: nothing declared over `RuntimePort` is // exported, so there is no runtime for `start` to resolve. const RuntimelessApi = Module("RuntimelessApi")({ imports: [OrdersSlice, CustomersSlice, observability()], - // The authenticator is here so this arm fails on arity ALONE: the contract + // The authenticator is here so this arm fails on the marker ALONE: the contract // marks `orders`, so a graph carrying the router without one has an unmet // need too, and an arm that could fail either way pins neither gate. provides: [orderRouter, bearerAuthenticator], exports: [Logger], }); -// Negative: the gate becomes a required two-element tuple naming the missing -// runtime, and the call fails on arity. +// Negative: the marker becomes the `NO RUNTIME — …` sentence, which the module +// argument cannot satisfy, so the call fails to typecheck against it. // @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort. const _missingRuntime = start(RuntimelessApi, options); // The starter imported without its router provided: `http()`'s runtime // provider depends on the starter's own router port (the one // `HttpRouter(contract)({ name: Dep }, arm)` provides), so the composition carries it -// as an unmet need — di's gate, not the kernel's, and it rejects the module -// at `start` rather than at arity. +// as an unmet need — the `Needs` channel, not the kernel's marker, refused by +// `start`'s `Module` parameter, which names the port. const RouterlessApi = Module("RouterlessApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], exports: [HttpRuntime, Logger], @@ -75,9 +78,10 @@ const _unitUnmet = start(UnloggedApi, { ...options, unit: RequestModule }); // The real root minus its authenticator. `contract.orders` is marked // `authenticated`, so `HttpRouter` gave the router provider a dependency on -// the starter's `AuthenticatorPort` and nothing here discharges it. Same gate -// as `_missingRouter` above — di's, at `start`, not at `HttpModule(...)`, -// which is why the module below builds without complaint. +// the starter's `AuthenticatorPort` and nothing here discharges it. Same +// mechanism as `_missingRouter` above — the `Needs` channel, refused at +// `start`, not at `HttpModule(...)`, which is why the module below builds +// without complaint. const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ router: orderRouter, imports: [OrdersSlice, CustomersSlice, observability()], @@ -89,7 +93,7 @@ const _missingAuthenticator = start(UnauthenticatedApi, options); // The OTHER authenticator gate, and a different one: whether the authenticator // resolves what the handlers read. `AuthenticatorPort`'s service type is -// erased to `unknown`, so di sees the need discharged and would let this +// erased to `unknown`, so the needs channel sees it discharged and would let this // through — `HttpModuleOptions` compares the ROUTER's identity against the // authenticator's itself, at the `HttpModule(...)` call, which is why this // directive sits on the option and not on a `start` below it. The contract diff --git a/examples/order-application/src/module.ts b/examples/order-application/src/module.ts index 08adc93..647a113 100644 --- a/examples/order-application/src/module.ts +++ b/examples/order-application/src/module.ts @@ -16,8 +16,10 @@ import { findCustomerProvider, findOrderProvider, placeOrderProvider } from "./u * does not compile — an importing module must provide the repository and a * logger first. That arity error is the layering, enforced by the compiler * rather than by convention, and splitting the module sharpened it: each - * vertical's gate now names that vertical's own repository, so a graph cannot - * close the orders half with a customers adapter. + * vertical's gate now carries that vertical's own repository, so a graph + * cannot close the orders half with a customers adapter. Carries, not prints — + * the message is `Expected 5 arguments, but got 2` and nothing else; the + * repository is in the rest parameter's type, on hover. * * The logger is `@btravstack/observability`'s port, not one this layer * declares: a composition root imports `observability()` and the lines this diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index d1d9555..1be50eb 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -43,7 +43,10 @@ const customerRepository = Provider(CustomerRepository)({ const logger = Provider(Logger)({ value: createLogger(() => {}) }); // Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error naming the unmet need. +// two-element tuple and the call is an arity error. `Expected 5 arguments, but +// got 2` is the whole message — an arity error carries no type, so neither the +// label nor `OrderRepository` is in it; both are in the parameter's type, +// which an editor shows on hover. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("acme", "o-1", 1), diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index de452cc..8149ea0 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -5,7 +5,9 @@ import { start } from "@btravstack/core"; * runtime needs nothing from the application context — its activities reach * it as a port the starter depends on through di. So there is no * `UNSATISFIED RUNTIME NEEDS` arm to pin here, as there was when the runtime - * declared five `needs` of its own; what replaces it is di's gate. + * declared five `needs` of its own; what replaces it is the needs channel — + * refused by `start`'s `Module` parameter, which names the + * port, and NOT di's `UNSATISFIED DEPENDENCIES` arity gate. * * Two distinct negatives, at two distinct levels. `orderActivities`'s own * `deps` are the two pieces' PORTS (`fulfillOrder.port | chargeOrder.port`), @@ -74,7 +76,7 @@ const ActivitylessTemporal = Module("ActivitylessTemporal")({ exports: [TemporalRuntime], }); -// Negative, di's gate rather than the kernel's: `start` takes a +// Negative, the needs channel rather than the kernel's marker: `start` takes a // `Module`, and this one still owes the activities port. // @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port, which nothing provides. const _missingActivities = start(ActivitylessTemporal, options); diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 7a9941b..83c52a7 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -88,9 +88,11 @@ void _none; // The composition half: a marked contract needs an authenticator, and the // composition root is where the router and the authenticator meet. The two // gates below are DIFFERENT gates, and fire at different calls. Whether an -// authenticator is there at all is di's own `UNSATISFIED DEPENDENCIES` at -// `start` (7) — the same arm `examples/order-api/src/needs-gate.test-d.ts` -// pins for the router. Whether it resolves what the handlers read is this +// authenticator is there at all is an unmet need `start` refuses (7) — its +// `module` parameter takes only `Scope | Env` outstanding, so the diagnostic +// names the port; NOT di's `UNSATISFIED DEPENDENCIES` arity gate, which guards +// `Module.build`/`Module.scoped`. Same mechanism as +// `examples/order-api/src/needs-gate.test-d.ts` pins for the router. Whether it resolves what the handlers read is this // package's own options check at the `HttpModule(...)` call (8), because // `AuthenticatorPort`'s service type is erased to `AuthenticatorService< // unknown>`: the need cannot carry the identity, so only the options type @@ -115,11 +117,11 @@ const options = { signals: false, probes: false } as const; // 7. A marked router with no authenticator supplied carries the port as an // unmet need — the module builds, `start` refuses it. const MissingApi = HttpModule("Missing")({ router: markedRouter }); -// @ts-expect-error — UNSATISFIED DEPENDENCIES: nothing provides the authenticator port the marked router needs. +// @ts-expect-error — UNMET NEED: nothing provides the authenticator port the marked router needs. const _missing = start(MissingApi, options); // 8. An authenticator minted on a DIFFERENT identity is refused. Unlike 7, -// this one is NOT di's gate and does not wait for `start`: the +// this one is not the needs channel and does not wait for `start`: the // authenticator port's service type is erased to `unknown`, so di sees the // need discharged. The two identities meet on `HttpModule`'s own options — // `RouterIdentity` is inferred from the router — which is where it is caught. From 7d861a6edb799e3447c3062193b634202221ae27 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:32:35 +0200 Subject: [PATCH 12/21] docs: the READMEs say the arity gate carries the port, not prints it --- docs/how-to/run-a-temporal-worker.md | 5 +++-- examples/README.md | 5 +++-- examples/order-application/README.md | 4 +++- packages/di/README.md | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index 6e44722..0bb73c7 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -157,8 +157,9 @@ line on stdout, every line carrying the activity attempt's own trace id. The starter's runtime provider depends on its activities port through di, so a root whose imports do not cover what the provider declared (`FulfillmentModule` and `BillingModule` here — `chargeOrder`'s `PaymentService` comes from the -latter) is refused at `start` — di's gate, an arity error; a root with no -starter is refused against +latter) is refused at `start` — the `Needs` channel failing to assign against +`Env | Scope`, which names the port, not di's `UNSATISFIED DEPENDENCIES` arity +gate; a root with no starter is refused against `"NO RUNTIME — the module exports no port declared over RuntimePort"`. `activities` is typed against the module's own `contract`: a provider built for another contract is refused at the call. diff --git a/examples/README.md b/examples/README.md index 4fdcf12..b8877ec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -46,8 +46,9 @@ vocabulary — is declared by the caller that needs it, not by the database that happens to satisfy it. `OrderApplicationModule` therefore leaves that need **unmet**, which is not documentation but a type: `Module.scoped(OrderApplicationModule, …)` does not compile until an outer -module provides one. There is one such module per vertical, so the gate names -the repository that vertical actually uses. +module provides one. There is one such module per vertical, so each gate +carries the repository that vertical actually uses — carries, not prints: di's +gate is an arity error, and the port is in the parameter's type. ## The contract tier, which depends on nothing and is depended upon diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 6c61895..3215df6 100644 --- a/examples/order-application/README.md +++ b/examples/order-application/README.md @@ -63,7 +63,9 @@ the same reason from the other direction: it is `@btravstack/observability`'s port, not this layer's, so there is nothing here to provide and nothing to re-export. `Module.scoped(OrderApplicationModule, …)` is therefore a compile error — di's gate turns the module's remaining needs into a required argument -naming them (`src/needs-gate.test-d.ts` pins each vertical's gate separately). +that carries them (`src/needs-gate.test-d.ts` pins each vertical's gate +separately). The message itself is only `Expected 5 arguments, but got 2`: an +arity error prints no type, so the ports are on hover rather than in the line. The hole is not documentation; it is the type. An infrastructure module fills it, and only then does the graph build. diff --git a/packages/di/README.md b/packages/di/README.md index de82311..2260710 100644 --- a/packages/di/README.md +++ b/packages/di/README.md @@ -77,7 +77,7 @@ The five provider arms — `value`, `sync`, `make` (may fail, with a modeled error), `class`, `acquire`/`release` (a resource, released when the scope closes) — the private-by-default modules, `Module.forkScope` for a per-request scope, and the compile-time gates that -name what is missing are on the [documentation site](https://btravstack.github.io/start/reference/di/ports). +carry what is missing are on the [documentation site](https://btravstack.github.io/start/reference/di/ports). Under [`@btravstack/core`](https://btravstack.github.io/start/reference/core/start), `start(module)` is the one `Module.scoped` call a process makes. From a46909bb6b63b647cebbefb4b8ab439e8e3c1a01 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:58:15 +0200 Subject: [PATCH 13/21] docs: the arity gate's ports are spelled out, not hovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten passages told a reader to hover the call to see which port di's arity gate is missing. Nobody observed a tooltip — TS 7.0.2 ships no JS language service and the native binary answers no LSP initialize — so the instruction was the same unmeasured claim about compiler output this branch exists to remove. What is measured is the technique that prints them. The rest parameter is `[error: "UNSATISFIED DEPENDENCIES", missing: N]`, and a value neither slot accepts turns the arity error into an assignability one that answers a slot at a time: the label first, then the port. The order is load-bearing and now stated — a reader who passes two junk arguments sees only the label. Module.build(Resourceful, n, n) Argument of type 'number' is not assignable to parameter of type '"UNSATISFIED DEPENDENCIES"'. Module.build(Resourceful, "UNSATISFIED DEPENDENCIES", n) Argument of type 'number' is not assignable to parameter of type 'Scope'. Module.scoped(OrderApplicationModule, use, {}, "UNSATISFIED DEPENDENCIES", n) Argument of type 'number' is not assignable to parameter of type 'Logger | OrderRepository'. The last is `examples/order-application`'s own two open needs, which is what the example's four passages now quote. Hover survives in three of the ten places, demoted to what it is: the language service reads the same parameter type, so it would be expected to show the same ports — inferred, never observed here. `compile-time-wiring.md`'s "Hovering a large module shows real channel unions" goes the same way, to the half that a 400-character diagnostic already proves: the unions are real, and anything that prints one prints it at full width. `needs-gate.test-d.ts` is comment text only — the directive has not moved, and the gate still fires. --- docs/examples/order-application.md | 13 ++++++---- docs/explanation/compile-time-wiring.md | 24 ++++++++++------- docs/how-to/manage-a-resource.md | 4 ++- docs/how-to/swap-an-adapter.md | 7 +++-- docs/reference/di/entry-points.md | 26 +++++++++++++++---- examples/hexagonal-order-api/README.md | 13 +++++----- examples/order-application/README.md | 4 ++- examples/order-application/src/module.ts | 3 ++- .../src/needs-gate.test-d.ts | 11 ++++---- packages/di/CLAUDE.md | 9 +++++-- 10 files changed, 76 insertions(+), 38 deletions(-) diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index fc0d2fa..307b3a6 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -284,8 +284,8 @@ activities or handlers that implement it. `start`'s, and easy to conflate with it: ```ts -// Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error. +// Negative: nothing provides `OrderRepository`, so di's rest parameter is a +// required two-element tuple the call does not pass. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("o-1", 1), @@ -295,9 +295,12 @@ const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => What that prints is `error TS2554: Expected 5 arguments, but got 2.` and nothing else — an arity error carries no type, so neither the `UNSATISFIED DEPENDENCIES` label nor `OrderRepository` appears in it. Both are -in the rest parameter's type, which an editor shows on hover. `start`'s three -arms are the deliberate contrast: they ride the `module` parameter precisely so -their sentence prints. +in the rest parameter's type, and hand-spelling the phantom arguments is what +prints them: pass the label through as the fourth argument and the fifth reports +`Argument of type 'number' is not assignable to parameter of type +'Logger | OrderRepository'` — measured, this vertical's own two open needs. +`start`'s three arms are the deliberate contrast: they ride the `module` +parameter precisely so their sentence prints. Each vertical's gate is pinned separately, which is the split showing up in the type tests: a graph that provides `OrderRepository` still cannot scope diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index e2589b2..b73950c 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -87,12 +87,15 @@ That is the whole message. An arity error never prints a type, so neither the `"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` reach it: with `--pretty`, TypeScript adds related information pointing at the rest parameter's _declaration_ in `module.ts`, where a reader sees the labels but sees `N` -un-instantiated. The missing ports are in the parameter's type — an editor shows -them on hover, and spelling the phantom arguments out by hand surfaces them as -an ordinary assignability error (`Argument of type 'number' is not assignable to -parameter of type 'Scope'`). The label is a signpost for whoever goes looking, -not a sentence the compiler hands you. `start`'s gate below is the same idea -paying differently, and the difference is exactly this. +un-instantiated. The missing ports are in the parameter's type, and spelling the +phantom arguments out by hand is what gets them printed: a value the rest tuple +cannot accept turns the arity error into an assignability one naming each slot +in turn — the label first, then the port (measured, `Argument of type 'number' +is not assignable to parameter of type 'Scope'`). An editor's language service +reads that same type, so a hover would be expected to show them too. The label +is a signpost for whoever goes looking, not a sentence the compiler hands you. +`start`'s gate below is the same idea paying differently, and the difference is +exactly this. The same trick guards a related mistake at declaration time: an `exports` entry must be provided or imported, so a module cannot claim a surface it @@ -237,8 +240,9 @@ off the signature, the kernel's is one of three fixed sentences. The types work hard, and it shows at the edges: di's wiring mistakes surface as an arity error rather than a friendly sentence, and the kernel's surface as a -long assignability error whose readable half is its last line. Hovering a large -module shows real channel unions. The container is also deliberately small — one -construction family, one module algebra, three entry points, one name per -concept. Conditional registration DSLs, interceptors and property injection +long assignability error whose readable half is its last line. A large module's +channel unions are real types, and a diagnostic that has to print one prints it +at full width. The container is also deliberately small — one construction +family, one module algebra, three entry points, one name per concept. +Conditional registration DSLs, interceptors and property injection are not missing features; this is the wrong library for them on purpose. diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md index 60ef999..ea0e7d4 100644 --- a/docs/how-to/manage-a-resource.md +++ b/docs/how-to/manage-a-resource.md @@ -60,7 +60,9 @@ the scope **before its own result settles**. The close runs on every path: `Module.build` — no scope, no teardown — refuses the graph at compile time: `Expected 3 arguments, but got 1`. That arity line is the whole message; the `UNSATISFIED DEPENDENCIES` label and `Scope` as the missing piece live in the -rest parameter's type, which an editor shows on hover. +rest parameter's type. To get them printed, spell the phantom arguments out by +hand — a value the tuple cannot accept names each slot in turn, ending on +`Argument of type 'number' is not assignable to parameter of type 'Scope'`. ## Under `start`, the process is the scope diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md index b91becf..a0a20c3 100644 --- a/docs/how-to/swap-an-adapter.md +++ b/docs/how-to/swap-an-adapter.md @@ -127,8 +127,11 @@ await Module.build(makeAppModule(makePersistenceModule())); `Scope` is still in `Needs`, so the call's arity gate rejects it before anything runs. The message is the arity line and nothing more — the `UNSATISFIED DEPENDENCIES` label and the missing port are in the rest -parameter's type, which an editor shows on hover. A test that quietly wires the production adapter into a -scope-less build breaks at compile time, not in CI at midnight. Passing the +parameter's type, and hand-spelling the phantom arguments is what prints them +(`Argument of type 'number' is not assignable to parameter of type 'Scope'`, +once the label is passed through first). A test that quietly wires the +production adapter into a scope-less build breaks at compile time, not in CI at +midnight. Passing the in-memory module to `Module.scoped` is fine — `Scope` is simply absent from its `Needs`, and a scope that releases nothing is harmless. diff --git a/docs/reference/di/entry-points.md b/docs/reference/di/entry-points.md index b90472c..6d8c129 100644 --- a/docs/reference/di/entry-points.md +++ b/docs/reference/di/entry-points.md @@ -39,11 +39,27 @@ more. An arity error never prints a type, so neither the `"UNSATISFIED DEPENDENCIES"` label nor the ports in `missing` appear in it. With `--pretty`, TypeScript adds related information pointing at the rest parameter's declaration in `module.ts` — a reader sees the labels there, but -sees `N` un-instantiated. **To find out which port is missing, hover the call**: -the instantiated `missing: N` is in the parameter's type. (Spelling the phantom -arguments out by hand surfaces it as an ordinary assignability error — -`Argument of type 'number' is not assignable to parameter of type 'Scope'` — -which is a diagnostic technique, not an intended call form.) +sees `N` un-instantiated. **To find out which port is missing, spell the +phantom arguments out by hand**: the rest parameter is +`[error: "UNSATISFIED DEPENDENCIES", missing: N]`, so a value neither slot +accepts turns the arity error into an assignability one, which does print a +type. The first slot answers first: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type '"UNSATISFIED DEPENDENCIES"'. +``` + +Pass that label through as the first phantom argument and the second slot names +the port: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type 'Scope'. +``` + +Both measured, on a scratch file since deleted; it is a diagnostic technique, +not an intended call form. `missing: N` is the same type an editor's language +service reads, so a hover would be expected to show the same ports — an +inference from the parameter's type, not something observed here. `@btravstack/core`'s [`start`](/reference/core/start) answers this differently: its gate rides the `module` parameter so its sentence prints. The two are no diff --git a/examples/hexagonal-order-api/README.md b/examples/hexagonal-order-api/README.md index b8b999e..ae4e228 100644 --- a/examples/hexagonal-order-api/README.md +++ b/examples/hexagonal-order-api/README.md @@ -39,12 +39,13 @@ graph with `Module.build` is a compile error, not a runtime leak — the call's arity gate (the "UNSATISFIED DEPENDENCIES" rest parameter every unmet requirement produces) rejects it before anything runs. What it prints is the arity line alone, `Expected 3 arguments, but got 1`; the label and `Scope` are -in the rest parameter's type, which an editor shows on hover, not in the -message. `src/index.test-d.ts` -pins that with a `@ts-expect-error` of its own, right next to the privacy -one. `Module.scoped` is the one entry point that opens a scope and -discharges `Scope` — used in `src/index.spec.ts` against the production -adapter, closing the pool on every path out. +in the rest parameter's type, not in the message. Hand-spelling the phantom +arguments is what prints them — a value the tuple cannot accept names the label +first, then `Argument of type 'number' is not assignable to parameter of type +'Scope'`. `src/index.test-d.ts` pins that with a `@ts-expect-error` of its own, +right next to the privacy one. `Module.scoped` is the one entry point that +opens a scope and discharges `Scope` — used in `src/index.spec.ts` against +the production adapter, closing the pool on every path out. `InMemoryPersistenceModule` has nothing resourceful, so `makeAppModule` applied to it has `Needs = never` — `Module.build` accepts it directly, no diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 3215df6..2e79478 100644 --- a/examples/order-application/README.md +++ b/examples/order-application/README.md @@ -65,7 +65,9 @@ re-export. `Module.scoped(OrderApplicationModule, …)` is therefore a compile error — di's gate turns the module's remaining needs into a required argument that carries them (`src/needs-gate.test-d.ts` pins each vertical's gate separately). The message itself is only `Expected 5 arguments, but got 2`: an -arity error prints no type, so the ports are on hover rather than in the line. +arity error prints no type, so the ports are in the rest parameter rather than +in the line — hand-spelling the phantom arguments is what prints them, ending on +`not assignable to parameter of type 'Logger | OrderRepository'`. The hole is not documentation; it is the type. An infrastructure module fills it, and only then does the graph build. diff --git a/examples/order-application/src/module.ts b/examples/order-application/src/module.ts index 647a113..7e14869 100644 --- a/examples/order-application/src/module.ts +++ b/examples/order-application/src/module.ts @@ -19,7 +19,8 @@ import { findCustomerProvider, findOrderProvider, placeOrderProvider } from "./u * vertical's gate now carries that vertical's own repository, so a graph * cannot close the orders half with a customers adapter. Carries, not prints — * the message is `Expected 5 arguments, but got 2` and nothing else; the - * repository is in the rest parameter's type, on hover. + * repository is in the rest parameter's type, which hand-spelling the phantom + * arguments prints. * * The logger is `@btravstack/observability`'s port, not one this layer * declares: a composition root imports `observability()` and the lines this diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index 1be50eb..1dbe4bc 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -42,11 +42,12 @@ const customerRepository = Provider(CustomerRepository)({ const logger = Provider(Logger)({ value: createLogger(() => {}) }); -// Negative: nothing provides `OrderRepository`, so the gate becomes a required -// two-element tuple and the call is an arity error. `Expected 5 arguments, but -// got 2` is the whole message — an arity error carries no type, so neither the -// label nor `OrderRepository` is in it; both are in the parameter's type, -// which an editor shows on hover. +// Negative: nothing provides `OrderRepository`, so di's rest parameter is a +// required two-element tuple the call does not pass, and `Expected 5 arguments, +// but got 2` is the whole message. An arity error carries no type, so neither +// the label nor the ports are in it; hand-spelling the phantom arguments prints +// them, ending on `not assignable to parameter of type +// 'Logger | OrderRepository'`. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => ctx.get(PlaceOrder).execute("acme", "o-1", 1), diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index d13f43a..cae7935 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -64,8 +64,13 @@ missing: N]`. What that **prints** is the arity line alone — `error TS2554: Expected 3 arguments, but got 1.` — because an arity error never carries a type: neither the label nor the ports in `missing` reach the message, and `--pretty`'s related information points at the declaration in - `module.ts`, where `N` is still un-instantiated. Hover is where a reader gets - the port. `@btravstack/core`'s `start` answers this differently — its marker + `module.ts`, where `N` is still un-instantiated. Hand-spelling the phantom + arguments is how a reader gets the port printed: a value the rest tuple cannot + accept names the label first, then the port itself (measured, + `Argument of type 'number' is not assignable to parameter of type 'Scope'`). + An editor's language service reads the same instantiated type, so a hover + would be expected to show it — inferred, never measured here. + `@btravstack/core`'s `start` answers this differently — its marker rides the `module` parameter so its sentence prints — so **the two gates are no longer the same shape**; do not describe them as parallel. `exports` accepts an available **port class**, a **provider** for From af5be64ae939161de580f05c85faff8245d916a5 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:58:24 +0200 Subject: [PATCH 14/21] docs: keep-a-port-private quotes a gate that was actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page never passes its module to `start`, so `Type 'Pool' is not assignable to type 'Env | Scope'` was a plausible string nobody had seen. The pattern is measured five times over on the starters' own gates, so the claim now cites one of those — the captured `Type '"HttpRouter"' is not assignable to type '"@di/Scope"'` — instead of inventing the page's own port into a diagnostic. --- docs/how-to/keep-a-port-private.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/how-to/keep-a-port-private.md b/docs/how-to/keep-a-port-private.md index 566b12e..5a0281e 100644 --- a/docs/how-to/keep-a-port-private.md +++ b/docs/how-to/keep-a-port-private.md @@ -62,8 +62,9 @@ and surfaces as `UNSATISFIED DEPENDENCIES` at the entry point — the arity error, `Expected 3 arguments, but got 1`. Under [`start`](/reference/core/start) the same mistake is caught differently: the kernel's `module` parameter is `Module`, so the leftover -need fails to assign and the diagnostic **names the port** -(`Type 'Pool' is not assignable to type 'Env | Scope'`). +need fails to assign and the diagnostic **names the port** — measured on the +starters' own gates, where the last line is +`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. And on a built context: From e4bd9964fa42703e5f4413a5c25086a4deec6136 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 02:58:34 +0200 Subject: [PATCH 15/21] docs: the uncovered key was re-measured on the real contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `'"orderAudit"'` and `'"fulfillOrder"'` were the examples' keys substituted into a shape measured on a purpose-built 3-key scratch contract that actually printed `'"c"'`. Both re-captured against the shipped contracts, with a 2-element array covering one key twice so TypeScript lines the array up against the marker tuple positionally: AmqpHandlers(orderContract)([orderNotifications, orderNotifications]) … is not assignable to type '"orderAudit"'. TemporalActivities(orderContract)([chargeOrder, chargeOrder]) … is not assignable to type '"fulfillOrder"'. The strings were right; nothing had confirmed it. Each now says which contract it was measured against, so the next reader does not have to. --- docs/how-to/split-a-worker-into-slices.md | 8 ++++---- docs/reference/amqp.md | 8 ++++---- docs/reference/temporal.md | 9 +++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index c1b9e71..e438c9f 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -258,10 +258,10 @@ marker — the missing key itself is in neither message. The key **is** named once the array under test is as long as the marker tuple itself (2), a two-piece array missing one key being the common case: TypeScript then lines the array up against the tuple positionally and reports one error per element, -the trailing one being `is not assignable to type '"orderAudit"'` — the bare -key, as its own diagnostic, not folded into the marker's sentence. Below that -length it can no longer line them up and falls back to reporting the marker -alone. +the trailing one being — measured on this worker's own contract — +`is not assignable to type '"orderAudit"'`: the bare key, as its own +diagnostic, not folded into the marker's sentence. Below that length it can no +longer line them up and falls back to reporting the marker alone. This is why the composing arm is declared **last** in the intersection both packages build it from — di's builder first, the composer last — so diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index b16896a..26c1b8b 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -177,10 +177,10 @@ marker. The diagnostic is a three-line `TS2769` and the sentence is at the contract type — measured, and not shortenable from inside this package. The missing key itself is named too once the array's length matches that marker tuple's own length of 2: TypeScript then matches the array against the tuple -positionally and reports the trailing element separately, as -`is not assignable to type '"orderAudit"'` — the bare key, not the marker -tuple. A single-element array's diagnostic names the marker alone; a piece -built for another contract +positionally and reports the trailing element separately — measured against +this example's two-consumer contract, `is not assignable to type +'"orderAudit"'`: the bare key, not the marker tuple. A single-element array's +diagnostic names the marker alone; a piece built for another contract is refused too, structurally, since its port's service is that contract's handler for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index ee3a276..cf0f7ff 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -207,10 +207,11 @@ marker. The diagnostic is a three-line `TS2769` and the sentence is at the contract type — measured, and not shortenable from inside this package. The missing key itself is named too once the array's length matches that marker tuple's own length of 2: TypeScript then matches the array against the tuple -positionally and reports the trailing element separately, as -`is not assignable to type '"fulfillOrder"'` — the bare key, not the marker -tuple. A single-element array's diagnostic names the marker alone; a piece -built for another contract is refused too, structurally, since its port's service is +positionally and reports the trailing element separately — measured against +this example's two-workflow contract, `is not assignable to type +'"fulfillOrder"'`: the bare key, not the marker tuple. A single-element array's +diagnostic names the marker alone; a piece built for another contract is refused +too, structurally, since its port's service is that contract's activities for the key. `Uncovered` checks coverage, not injectivity, so two pieces claiming the same key still type-check together; di's duplicate-provider defect at build catches it only once **both** end up From 9af980d8fecef53a8ddd3f4cd14e0c9f4abd6dfa Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 03:08:48 +0200 Subject: [PATCH 16/21] docs: record what each gate printed before and after --- .changeset/gates-say-what-they-mean.md | 21 +++++++++++++++++++ docs/explanation/compile-time-wiring.md | 27 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/gates-say-what-they-mean.md diff --git a/.changeset/gates-say-what-they-mean.md b/.changeset/gates-say-what-they-mean.md new file mode 100644 index 0000000..f903677 --- /dev/null +++ b/.changeset/gates-say-what-they-mean.md @@ -0,0 +1,21 @@ +--- +"@btravstack/core": minor +"@btravstack/amqp": minor +"@btravstack/temporal": minor +"@btravstack/http": minor +"@btravstack/testing": minor +--- + +The compile-time gates name what is missing. `start`'s markers rode a phantom +rest tuple, whose failure is an arity error — and arity errors never print +types, so `NO RUNTIME` never reached a reader and TypeScript's related info +pointed at the wrong fix. They ride the module parameter now. + +`start`, `runMain` and `bootFixture` no longer take the trailing gate argument. +No call site passed one — it existed to be omitted — so this is a signature +change without a migration. + +The same widening reached the composers: `AmqpHandlers`'s/`TemporalActivities`'s +`UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` marker and `HttpRouter`'s +`UNDECLARED KEY` marker now say the rule in English and name the missing key, +where each used to end on a bare `"UNCOVERED HANDLERS"` or `never`. diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index b73950c..133bbca 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -246,3 +246,30 @@ at full width. The container is also deliberately small — one construction family, one module algebra, three entry points, one name per concept. Conditional registration DSLs, interceptors and property injection are not missing features; this is the wrong library for them on purpose. + +## The record + +Three gate mechanisms live in this repo, not two — and before this branch, +thirteen places across the documentation and the examples named one as +another. This table is the index. Where the full diagnostic is already +told above, the row points back rather than repeating it; where it is not, +the row carries the measured target — the type each diagnostic's last line +ends on, which is the payload of the whole message. + +| Mechanism | Case | Printed target, before | Printed target, after | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| di's own gate (a conditional rest tuple) | `Module.scoped`/`build`/`forkScope` | `Expected 3 arguments, but got 1.` — [the whole message](#the-gate-an-arity-error), unchanged | same — no task on this branch touched it | +| An unmet need at `start` (plain assignability) | a starter's own port, e.g. `AmqpHandlers` | `'"AmqpHandlers"' is not assignable to type '"@di/Scope"'` | same — this was always the best diagnostic in the repo; the thirteen corrections were to the documentation calling it di's gate, not to the gate | +| `start`'s `StartGate` — `NO RUNTIME` | [the Greeter example above](#the-kernels-own-gate) | `Expected 4 arguments, but got 1.` | ends on `"NO RUNTIME — the module exports no port declared over RuntimePort"` — [full example above](#the-kernels-own-gate) | +| `start`'s `StartGate` — `UNSATISFIED RUNTIME NEEDS` | a runtime's `needs` uncovered by the module's exports | `Expected 4 arguments, but got 1.` | ends on `"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"` | +| `start`'s `StartGate` — `UNSATISFIED UNIT NEEDS` | a unit module's needs uncovered | `Expected 4 arguments, but got 2.` | ends on `"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"` | +| amqp's/temporal's composer — `UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` | `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` missing a key | ends on `'"UNCOVERED HANDLERS"'` / `'"UNCOVERED ACTIVITIES"'` | ends on `'"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"'` / the `ACTIVITIES` twin; the missing key prints too, as a separate diagnostic on the trailing element, once the array is as long as the marker tuple (measured: `'"orderAudit"'`, `'"fulfillOrder"'`) | +| http's keyed router — `UNDECLARED KEY` | `HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` | + +No gate's behaviour moved: the same 82 `@ts-expect-error` directives fire +after this branch as before it — none added, removed, or moved to a +different line. What changed is which of these target strings a reader sees. +The row that did **not** change and is still the best diagnostic in the +repo — the unmet need at `start` — is the one this branch's own documentation +most often mislabelled as di's gate; naming it correctly here is the closing +half of that fix. From f26a4c9da9a0a1ede4d6bbb6f5ffcbef5e42b343 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 03:22:32 +0200 Subject: [PATCH 17/21] docs: the harness gate is measured, and it is the fourth mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Boot` lost its trailing gate argument in this branch; its reference page still published the old signature. It now matches `boot-fixture.ts`. `tapped` is still a conditional rest tuple, so `NOT EXPORTED` never reaches a reader — measured, `Expected 4 arguments, but got 2.` and nothing else, with the label and the port answering one hand-spelled slot at a time. The page states that the way di's arity gate already states it. That makes four mechanisms, not three. The record counts the three a composing application meets and names the harness's fourth; the testing reference says the reciprocal. --- docs/explanation/compile-time-wiring.md | 17 +++++--- docs/reference/testing.md | 55 +++++++++++++++++++++---- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index 133bbca..a418cf3 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -249,9 +249,13 @@ are not missing features; this is the wrong library for them on purpose. ## The record -Three gate mechanisms live in this repo, not two — and before this branch, -thirteen places across the documentation and the examples named one as -another. This table is the index. Where the full diagnostic is already +Three gate mechanisms live in this repo that a composing application meets, +not two — and before this branch, thirteen places across the documentation and +the examples named one as another. A **fourth** lives in the test harness: +`@btravstack/testing`'s `tapped` keeps di's conditional rest tuple, so a port +the module does not export is an arity error there too, and +[the testing reference measures it](/reference/testing#the-tap-gate-an-arity-error). +This table is the index of the three. Where the full diagnostic is already told above, the row points back rather than repeating it; where it is not, the row carries the measured target — the type each diagnostic's last line ends on, which is the payload of the whole message. @@ -267,8 +271,11 @@ ends on, which is the payload of the whole message. | http's keyed router — `UNDECLARED KEY` | `HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` | No gate's behaviour moved: the same 82 `@ts-expect-error` directives fire -after this branch as before it — none added, removed, or moved to a -different line. What changed is which of these target strings a reader sees. +after this branch as before it — none added or removed, and none now guards a +different call. (Four in `packages/core/src/start.test-d.ts` shifted line — +56→57, 66→67, 92→95, 129→131 — because the hand-spelled bypass calls below them +became `expectTypeOf` assertions; each still sits above the call it always +guarded.) What changed is which of these target strings a reader sees. The row that did **not** change and is still the best diagnostic in the repo — the unmet need at `start` — is the one this branch's own documentation most often mislabelled as di's gate; naming it correctly here is the closing diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 47994a2..647cd95 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -37,16 +37,17 @@ const bootFixture: ( ) => (ctx: object, use: (boot: Boot) => Promise) => Promise; type Boot = ( - module: Module, + module: Module & StartGate, options?: Omit, "signals">, - ...gate: StartGate ) => RunningApp>; type BootDefaults = Omit; ``` A `test.extend` fixture that hands the test a `Boot` — `start`, with the -same signature and the same phantom gate, minus `signals` — and **stops every +same signature and the same +[phantom marker on `module`](/reference/core/start#the-gate-startgate-x-unitneeds), +minus `signals` — and **stops every application it started once the test is over**, on every exit path, a failing assertion included. Wire it once, in the fixture module every spec imports: @@ -109,11 +110,49 @@ the running graph writes through — not a fresh one — has nothing to `ctx.get it with. `tapped` composes one more provider around `module`, depending on `ports`, and remembers what it was built with. -| Member | Semantics | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `module` | A `Module` exporting **exactly what `module` exports** — the kernel still finds the runtime, the gate still sees the same `X`. Boot this one instead of `module`. | -| `services()` | The service instances behind `ports`, in order, as a tuple typed by `ServicesOf

` (`const [repository] = tap.services()`). **Throws** before the graph has been built: reading a tap nobody booted is a bug in the test, not a modeled outcome, so it is loud rather than an `undefined`. | -| `...gate` | Phantom, at the call site: `NOT EXPORTED` names any port `module` does not export. An application-scope service is the only thing there is to tap; a unit-scoped port exists only while a unit is open. | +| Member | Semantics | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `module` | A `Module` exporting **exactly what `module` exports** — the kernel still finds the runtime, the gate still sees the same `X`. Boot this one instead of `module`. | +| `services()` | The service instances behind `ports`, in order, as a tuple typed by `ServicesOf

` (`const [repository] = tap.services()`). **Throws** before the graph has been built: reading a tap nobody booted is a bug in the test, not a modeled outcome, so it is loud rather than an `undefined`. | +| `...gate` | A conditional rest tuple, phantom, refusing at the call site any port `module` does not export — as an **arity error**; see [what it prints](#the-tap-gate-an-arity-error) below. An application-scope service is the only thing there is to tap; a unit-scoped port exists only while a unit is open. | + +### The tap gate: an arity error + +`tapped` keeps the mechanism [di's own entry points +use](/reference/di/entry-points#the-gate) — a conditional rest parameter, empty +when every port is exported and two required parameters +(`error: "NOT EXPORTED", missing: …`) when one is not — rather than the marker +[`start` intersects onto its `module`](/reference/core/start#the-gate-startgate-x-unitneeds). +It is the fourth gate mechanism in this repo, and the only one a **test** +meets rather than a composing application. + +So what it prints is an arity error, measured on a one-port tap of a module +that does not export that port: + +``` +src/__scratch.ts(15,1): error TS2554: Expected 4 arguments, but got 2. +``` + +That is the whole message. An arity error never prints a type, so neither the +`"NOT EXPORTED"` label nor the port in `missing` appears in it — the fix is +always to export the port, or to tap one the module already exports. **To find +out which port is unexported, spell the phantom arguments out by hand**, the +same technique di's gate documents; the slots answer one at a time, the first +one first: + +``` +error TS2345: Argument of type '0' is not assignable to parameter of type '"NOT EXPORTED"'. +``` + +Pass that label through as the first phantom argument and the second slot names +the port: + +``` +error TS2345: Argument of type 'number' is not assignable to parameter of type 'Secret'. +``` + +All three measured, on a scratch file since deleted; it is a diagnostic +technique, not an intended call form. The tap provider is not exported and nothing resolves it; di builds every provider in a graph, exported or not, which is what makes the capture work. From e04a7550822b0360e3c8049c22acc524d931e048 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 03:22:41 +0200 Subject: [PATCH 18/21] docs: the example fixtures name the marker, not the rest parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `start.ts` ships the gate intersected onto `module`. The reasoning each comment gives survives — a generic `X` leaves `StartGate` unresolved, so pinning is still required — only the mechanism clause was stale. Comment text only; no code and no directive moved. --- examples/order-amqp-worker/src/test-fixtures.ts | 6 +++--- examples/order-api/src/test-fixtures.ts | 4 ++-- examples/order-temporal-worker/src/test-fixtures.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index ac4969e..d547612 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -29,9 +29,9 @@ type ServeOptions = { readonly drainTimeoutMs: number }; /** * `X` is pinned to the ports the composition root exports rather than left - * generic: `start`'s gate is a phantom rest parameter proven at the call site, - * and no proof is available inside a helper generic in the module's own - * exports. `AmqpRuntime` is what `start` resolves; the rest is the writer's + * generic: `start`'s gate is a marker intersected onto `module`, proven at the + * call site, and no proof is available inside a helper generic in the module's + * own exports. `AmqpRuntime` is what `start` resolves; the rest is the writer's * surface, which the tap below reads. Spelled inline, like * `order-temporal-worker`'s: an alias for a port union reads like a domain * concept and is neither — the list IS the meaning. diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 4fc3bff..fff0dbd 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -210,8 +210,8 @@ export type ApiFixtures = { * through `boot`: its shutdown is the fixture's, on every exit path. * * The module's `X` is pinned to the two ports every composition here - * exports rather than left generic: `start`'s gate is a phantom rest - * parameter proven at the call site, and no proof is available inside a + * exports rather than left generic: `start`'s gate is a marker intersected + * onto `module`, proven at the call site, and no proof is available inside a * helper generic in the module's own exports. `HttpRuntime` is what `start` * resolves, and `Logger` is for the gate's OTHER half — `RequestModule`, * passed as `StartOptions.unit`, reads it out of the parent. diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index 4b19b91..cc61c5c 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -57,8 +57,8 @@ type Deployment = { /** * `X` is pinned to the four ports the activities provider depends on, plus - * `Logger` — rather than left generic: `start`'s gate is a phantom rest - * parameter proven at the call site, and no proof is available inside a + * `Logger` — rather than left generic: `start`'s gate is a marker intersected + * onto `module`, proven at the call site, and no proof is available inside a * helper generic in the module's own exports. `Logger` has to be in the union * because `serve` composes `BillingModule` beside `module` (see below), and * `BillingModule`'s own need for it is invisible past this type unless it is From ce14658b10e08e4df3b96ec764c3cdcfa6f8832d Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 03:22:49 +0200 Subject: [PATCH 19/21] docs: the record claims only what the diff shows The changeset said no call site passed the gate argument; `start.test-d.ts` passed it three times, as the documented hand-spelled bypass, before this branch deleted those calls. Two pages read "is an / surfaces as \`UNSATISFIED DEPENDENCIES\` error", which reads as the printed message. They link the gate the way the swept pages do. --- .changeset/gates-say-what-they-mean.md | 4 ++-- docs/explanation/scopes-and-resources.md | 3 ++- docs/reference/di/modules.md | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.changeset/gates-say-what-they-mean.md b/.changeset/gates-say-what-they-mean.md index f903677..dad3cab 100644 --- a/.changeset/gates-say-what-they-mean.md +++ b/.changeset/gates-say-what-they-mean.md @@ -12,8 +12,8 @@ types, so `NO RUNTIME` never reached a reader and TypeScript's related info pointed at the wrong fix. They ride the module parameter now. `start`, `runMain` and `bootFixture` no longer take the trailing gate argument. -No call site passed one — it existed to be omitted — so this is a signature -change without a migration. +No production call site passed one; the documented hand-spelled bypass went +with it, so this is a signature change without a migration. The same widening reached the composers: `AmqpHandlers`'s/`TemporalActivities`'s `UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` marker and `HttpRouter`'s diff --git a/docs/explanation/scopes-and-resources.md b/docs/explanation/scopes-and-resources.md index adcb09a..3f5da98 100644 --- a/docs/explanation/scopes-and-resources.md +++ b/docs/explanation/scopes-and-resources.md @@ -32,7 +32,8 @@ and open a real scope, run construction and your callback inside it, and close it before their own result settles — so they exclude `Scope` from the gate. `Module.build` opens nothing, so it excludes nothing, and a resourceful graph -reaching it is an `UNSATISFIED DEPENDENCIES` error at the call site. The leak +reaching it is refused at the call site by the +[`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). The leak is refused before it exists. Making `Scope` a port — rather than, say, a boolean flag on the module type — diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 8ee997b..bc54667 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -56,11 +56,11 @@ flat map at runtime, unnameable through the built `Context`'s type. `Module`: -| Channel | Computed as | -| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | -| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | -| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies propagates upward until some module satisfies it — or surfaces as `UNSATISFIED DEPENDENCIES` at the entry point. `Scope`, once introduced by a resourceful provider, propagates the same way and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | +| Channel | Computed as | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | +| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | +| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies propagates upward until some module satisfies it — or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, propagates the same way and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | The variance rule, shared with [`Provider`](/reference/di/providers#the-channels): From b4aa9a0e97c52c01af7a829c4ad6160f16a590fb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 03:31:04 +0200 Subject: [PATCH 20/21] docs: a seventh site, and one shared technique instead of two copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-an-application.md claimed the tap gate's NOT EXPORTED label reaches a reader at the call site; it does not — only the arity error does. Rewritten to link the measured account, matching how the two sites in ce14658 were corrected. testing.md's own account re-taught di's hand-spelling technique in ~15 lines duplicating entry-points.md#the-gate. Kept the three captured tsc strings and the gate's own one-slot-at-a-time ordering, cut the technique prose to one link. --- docs/how-to/test-an-application.md | 7 ++++--- docs/reference/testing.md | 17 ++++++----------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 6eb17df..0f8133f 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -111,9 +111,10 @@ it("broadcasts every committed write, end to end", async ({ serve }) => { }); ``` -The gate refuses a port the module does not export (`NOT EXPORTED`, at the -call site), and `services()` throws if read before the graph is built — a -bug in the test, kept loud rather than answered with an `undefined`. +A port the module does not export is refused at the call site by the +[tap gate](/reference/testing#the-tap-gate-an-arity-error), and `services()` +throws if read before the graph is built — a bug in the test, kept loud +rather than answered with an `undefined`. ## Read a running graph's log lines with a sink diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 647cd95..aec310f 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -126,19 +126,17 @@ when every port is exported and two required parameters It is the fourth gate mechanism in this repo, and the only one a **test** meets rather than a composing application. -So what it prints is an arity error, measured on a one-port tap of a module -that does not export that port: +What it prints, measured on a one-port tap of a module that does not export +that port: ``` src/__scratch.ts(15,1): error TS2554: Expected 4 arguments, but got 2. ``` -That is the whole message. An arity error never prints a type, so neither the -`"NOT EXPORTED"` label nor the port in `missing` appears in it — the fix is -always to export the port, or to tap one the module already exports. **To find -out which port is unexported, spell the phantom arguments out by hand**, the -same technique di's gate documents; the slots answer one at a time, the first -one first: +Reading that message, and finding the missing port by hand-spelling the +phantom arguments, is the same technique [di's own +gate](/reference/di/entry-points#the-gate) documents. The slots answer one at +a time, the first one first: ``` error TS2345: Argument of type '0' is not assignable to parameter of type '"NOT EXPORTED"'. @@ -151,9 +149,6 @@ the port: error TS2345: Argument of type 'number' is not assignable to parameter of type 'Secret'. ``` -All three measured, on a scratch file since deleted; it is a diagnostic -technique, not an intended call form. - The tap provider is not exported and nothing resolves it; di builds every provider in a graph, exported or not, which is what makes the capture work. Its port is declared once, so two `tapped` modules in one graph are di's From 06e90d3224d934fcb22fbf37a976bfcf79bb49a1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 13:02:35 +0200 Subject: [PATCH 21/21] docs: runMain's comment names the cast, not a tuple that is gone The rest tuple went with cbe8e24; the forwarding comment still called the discharge a tuple. bootFixture's equivalent already said cast. --- packages/core/src/run-main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/run-main.ts b/packages/core/src/run-main.ts index f8eb7b1..5c44aee 100644 --- a/packages/core/src/run-main.ts +++ b/packages/core/src/run-main.ts @@ -120,7 +120,7 @@ export const runMain = async ( ): Promise => { // The gate above proves the needs at the call site, but that proof is not // visible inside a body where `X` is still an unresolved type parameter — - // the same reason `bootFixture` discharges the tuple the same way. + // the same discharged-signature cast `bootFixture` makes, for the same reason. const boot = start as ( module: Module, options: StartOptions,