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/.changeset/gates-say-what-they-mean.md b/.changeset/gates-say-what-they-mean.md
new file mode 100644
index 0000000..dad3cab
--- /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 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
+`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/CLAUDE.md b/CLAUDE.md
index 40d5a6c..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**:
@@ -670,9 +676,13 @@ 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
- array's length matches the marker tuple's own length of 2), and two slices
+ one is refused at the call, against an `"UNCOVERED HANDLERS — …"` /
+ `"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/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..307b3a6 100644
--- a/docs/examples/order-application.md
+++ b/docs/examples/order-application.md
@@ -284,14 +284,24 @@ 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 naming the unmet need.
+// 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),
);
```
+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, 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
`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/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md
index f338caf..a418cf3 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,30 @@ 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, 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
@@ -93,7 +112,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
@@ -105,12 +143,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 +160,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 +217,66 @@ 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
-construction family, one module algebra, three entry points, one name per
-concept. Conditional registration DSLs, interceptors and property injection
+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. 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.
+
+## The record
+
+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.
+
+| 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 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
+half of that fix.
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/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/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/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/how-to/keep-a-port-private.md b/docs/how-to/keep-a-port-private.md
index 762d108..5a0281e 100644
--- a/docs/how-to/keep-a-port-private.md
+++ b/docs/how-to/keep-a-port-private.md
@@ -58,8 +58,13 @@ 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** — 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:
diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md
index d254de4..ea0e7d4 100644
--- a/docs/how-to/manage-a-resource.md
+++ b/docs/how-to/manage-a-resource.md
@@ -58,8 +58,11 @@ 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. 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/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..0bb73c7 100644
--- a/docs/how-to/run-a-temporal-worker.md
+++ b/docs/how-to/run-a-temporal-worker.md
@@ -157,8 +157,11 @@ 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` — 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.
`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 a278f9b..e438c9f 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 — 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/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md
index 74005d3..a0a20c3 100644
--- a/docs/how-to/swap-an-adapter.md
+++ b/docs/how-to/swap-an-adapter.md
@@ -120,12 +120,18 @@ 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
-scope-less build breaks at compile time, not in CI at midnight. Passing the
+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, 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/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/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
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/amqp.md b/docs/reference/amqp.md
index 60fea3a..26c1b8b 100644
--- a/docs/reference/amqp.md
+++ b/docs/reference/amqp.md
@@ -170,11 +170,17 @@ 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
-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
+missing one is refused at the call, against an
+`"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"`
+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 — 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/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..6d8c129 100644
--- a/docs/reference/di/entry-points.md
+++ b/docs/reference/di/entry-points.md
@@ -25,8 +25,45 @@ 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, 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
+longer the same shape.
| Entry point | Excludes from `Needs` before checking |
| ------------------ | ------------------------------------- |
@@ -102,7 +139,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/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):
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).
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