Skip to content

feat: the application kernel - #1

Merged
btravers merged 32 commits into
mainfrom
feat/kernel
Aug 11, 2026
Merged

feat: the application kernel#1
btravers merged 32 commits into
mainfrom
feat/kernel

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Builds @btravstack/start, the application kernel: it boots a @btravstack/di module into a running process with one runtime, drains in-flight work on SIGTERM, and closes the application scope on every path.

Implemented from the design and the plan, 13 tasks, each reviewed before the next began.

What it does

start is a thin wrapper over Module.scoped. It owns three things and nothing else: the application object, the lifecycle state machine, and the Runtime contract. It knows nothing about HTTP, AMQP or Temporal — those are future runtime packages.

  • One process, one runtime. Several runtime kinds will exist; a process boots exactly one.
  • Draining in three beats — readiness flips false, then a pre-drain delay, then the runtime stops accepting. The delay is deliberate: Kubernetes endpoint removal is eventually consistent, so a pod that stops accepting the instant SIGTERM lands rejects traffic the ingress is still routing to it.
  • Ambient carries data, Context carries capabilities. The AsyncLocalStorage store holds unitId/traceId/tenantId/deadline and nothing else — no services, so it cannot become a service locator.
  • start never throws and never calls process.exit. runMain is the single sanctioned place a process decides its fate.
  • The application's own error type passes through unwrapped: AsyncResult<ExitReport, E | RuntimeStartFailed>.

81 tests; 100% statements/functions/lines. Full gate green: format --check, lint, typecheck, knip, test, build. CI added.

Design changes made during implementation

The design document is kept as the historical record, annotated with > **Shipped as:** callouts and a summary table. The substantive changes:

  • Serving.drain returns AsyncResult<void, never>, not a DrainReport. Two competing drain reports was a design error — only the kernel sees the unit registry, so the kernel owns the accounting.
  • The runtime-needs check is a trailing phantom rest-tuple gate. As first written it did not exist at all: nothing related the runtime's Needs to the module's exports, while a comment claimed otherwise. A conditional type on an inference-bearing parameter was rejected because it collapses X/E to unknown. The gate is bypassable by a caller who deliberately hand-writes the phantom arguments — the same escape hatch di's own gate leaves, asserted rather than assumed.
  • RuntimeHost.ctx is Context<InstanceType<Needs>> — a runtime declares needs as port classes, di parameterises Context by port instance types.
  • Exit code 70 for an uncaught exception. The original table had no uncaught row, so a crashed process exited 0 — the kernel was turning a crash into a successful exit.
  • DrainReport fields have precise meanings, and completed is now a monotonic count. The original formula could go negative, tripping the abandoned-work exit code on a clean shutdown.

Known gap, not fixed here

The probe server binds 127.0.0.1 only. A Kubernetes httpGet probe dials the pod IP, so as shipped /livez and /readyz are unreachable by the orchestrator the drain design targets. This needs a host option before the package is useful in the deployment it was designed for. Documented factually rather than papered over.

Deliberately out of scope

@btravstack/start-http, -amqp, -temporal; observability; config loading; auth; CLI. Per-unit scope forking is deferred — RunUnit is typed for it and the fork site is marked.

Also add @unthrown/vitest to tsconfig's `types` so tsc picks up its
Matchers augmentation (toBeOkWith/toBeErrWith/toBeDefect) — needed
because this is the first spec file to use those matchers, and the
setupFiles registration alone only wires them at vitest runtime, not
for `tsc --noEmit`.
Task 5 review, finding 1: tsconfig.json's `types` array governs the
tsdown --dts build too, so adding "@unthrown/vitest" there tied the
published .d.ts to a devDependency and applied it repo-wide instead of
just to specs. Revert `types` to ["node"] and add an ambient
src/vitest.d.ts (`import type {} from "@unthrown/vitest";`), picked up
by `include: ["src/**/*"]` for `tsc --noEmit` but unreachable from
src/index.ts, so it never enters the bundled build.

Also, per review additions 2 and 3:
- strengthen the abortAll and awaitIdle tests to cover two concurrent
  units instead of one, so a first-or-last-only bug would be caught
- add a one-line comment on abortAll recording that the open set is
  iterated live, so a unit started from within an abort listener is
  aborted by the same pass
…compliant runtime

- Track a monotonic closed-unit count in the registry and derive `completed`
  from it instead of `inFlightAtStart - abandoned`, which could go negative
  when a unit started after the drain's opening sample.
- Sample `inFlightAtStart`/`closedAtStart` at the very start of the drain, so
  they line up with the `"draining"` event emitted from the same turn.
- Stop awaiting `Serving.drain` before the timeout race begins, and always
  release its signal once the race settles, so a runtime that treats that
  signal as its own cue to return can no longer deadlock the drain.
- `Serving.drain` now returns `void`: only the kernel can see the unit
  registry, so it alone owns the completed/abandoned accounting.
Copilot AI lite review requested due to automatic review settings August 10, 2026 14:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces @btravstack/start, a TypeScript “application kernel” that boots a @btravstack/di module into a single-runtime process, provides deterministic lifecycle/drain behavior (including SIGTERM handling), and exposes transport-agnostic probes/events while preserving the application’s modeled error type via unthrown Results.

Changes:

  • Adds the kernel implementation (start, lifecycle phases, draining, probes, signals/uncaught handling, events) plus the published public surface (index.ts) and runMain exit-code mapping.
  • Adds a full testing toolkit and invariant suite (testRuntime, createFakeClock, withApp, type-level docs compilation tests) with 100% coverage enforcement.
  • Scaffolds repo toolchain (pnpm workspace + turbo), CI workflow, changeset, and documentation/spec records.

Reviewed changes

Copilot reviewed 60 out of 63 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
turbo.json Turbo task graph for build/lint/test workflows.
README.md Root README documenting the kernel contract, lifecycle, probes, and exit codes.
pnpm-workspace.yaml Workspace + dependency catalog configuration.
packages/start/vitest.config.ts Vitest config with v8 coverage thresholds.
packages/start/tsconfig.test-d.json Dedicated tsconfig for *.test-d.ts type tests.
packages/start/tsconfig.json Package TS config (NodeNext / outDir / excludes).
packages/start/src/with-app.ts Test helper to run an app and guarantee teardown.
packages/start/src/vitest.d.ts Declares @unthrown/vitest matchers for tests.
packages/start/src/units.ts Unit registry + ambient record wiring around unit execution.
packages/start/src/units.spec.ts Unit registry behavior tests (tracking, nesting, aborting, idle).
packages/start/src/uncaught.ts Installs uncaught exception/rejection handlers (first-cause only).
packages/start/src/uncaught.spec.ts Tests uncaught handler reporting + disposal.
packages/start/src/testing.ts @btravstack/start/testing barrel export.
packages/start/src/test-runtime.ts In-memory runtime fixture for kernel/drain testing.
packages/start/src/test-runtime.spec.ts Tests for the test runtime behavior.
packages/start/src/start.ts Core kernel: boot, lifecycle state, draining integration, probes, signals.
packages/start/src/start.test-d.ts Type-level tests for the runtime-needs phantom gate.
packages/start/src/start.spec.ts Behavioral tests for start/stop/drain/signals/uncaught and teardown errors.
packages/start/src/signals.ts SIGTERM/SIGINT handler installation + disposal.
packages/start/src/signals.spec.ts Tests for signal handler behavior and cleanup.
packages/start/src/runtime.ts Runtime contract types + RuntimeStartFailed error.
packages/start/src/run-main.ts runMain exit-code mapping without process.exit().
packages/start/src/run-main.spec.ts Tests exit-code mapping and “never calls process.exit”.
packages/start/src/probes.ts Probe server implementation for /livez and /readyz.
packages/start/src/probes.spec.ts Probe server tests (health routes, 404, bind failure).
packages/start/src/phase.ts Monotonic phase tracker implementation.
packages/start/src/phase.spec.ts Phase tracker tests.
packages/start/src/invariants.spec.ts End-to-end invariants suite (ordering, readiness latch, disposal, bind failure).
packages/start/src/index.ts Public API surface for @btravstack/start.
packages/start/src/index.test-d.ts Type test for exported VERSION.
packages/start/src/index.spec.ts Runtime test for exported VERSION.
packages/start/src/fake-clock.ts Deterministic clock for lifecycle/drain timing tests.
packages/start/src/fake-clock.spec.ts Fake clock tests (advance, abortable sleep).
packages/start/src/events.ts Kernel event model + safe sink + stderr sink.
packages/start/src/events.spec.ts Tests for safe sink swallowing and stderr JSON output.
packages/start/src/drain.ts Three-beat drain implementation + accounting.
packages/start/src/drain.spec.ts Drain ordering, accounting, deadline/skip behavior tests.
packages/start/src/drain-report.ts DrainReport type and semantics documentation.
packages/start/src/docs-examples.test-d.ts Compiles README code samples + asserts type equality to shipped API.
packages/start/src/deferred.ts Idempotent deferred primitive for shutdown signaling.
packages/start/src/clock.ts Injectable Clock + systemClock with abortable sleep.
packages/start/src/clock.spec.ts Tests for system clock sleeping and abort behavior.
packages/start/src/ambient.ts AsyncLocalStorage ambient unit record.
packages/start/src/ambient.spec.ts Ambient record tests (scope, awaits, concurrency).
packages/start/README.md Package README (published-facing) aligned with root README.
packages/start/package.json Package manifest: dual CJS/ESM exports, peers, scripts.
packages/start/LICENSE Package-level MIT license.
package.json Root workspace manifest + scripts + engines + tooling deps.
LICENSE Root MIT license.
lefthook.yml Git hooks wiring and exclusions.
knip.json Dead-code / unused dep configuration.
docs/superpowers/specs/2026-08-09-btravstack-start-design.md Design doc updated with “shipped as” callouts and delta table.
docs/superpowers/plans/2026-08-10-btravstack-start.md Plan annotated as executed and linked to final deltas.
commitlint.config.js Conventional commits enforcement via shared config.
CLAUDE.md Authoritative internal spec and invariants record for the repo.
.oxlintrc.json oxlint configuration (incl. unthrown plugin rules).
.oxfmtrc.json oxfmt configuration and overrides.
.node-version Dev Node pin.
.gitignore Repo ignore patterns (including agent scratch).
.github/workflows/ci.yml CI workflow using reusable workflow + node version matrix.
.changeset/initial-kernel.md Initial changeset describing the published kernel.
.changeset/config.json Changesets configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/start/src/probes.ts Outdated
Comment thread .changeset/config.json Outdated
…ajor

startProbeServer left its bind-failure `once("error", ...)` listener
attached after a successful listen, so a later probe-server error would
resolve an already-settled promise and vanish silently. Detach it once
the bind succeeds, so a post-bind error surfaces as an uncaught exception
and is handled by this kernel's own uncaughtException path instead.

Also point .changeset/config.json's $schema at the installed
@changesets/config major (3) instead of a stale patch pin (3.1.1 vs the
installed 3.1.4), matching knip.json's major-pin convention.
Comment thread packages/start/src/drain.spec.ts
Two workspace packages, one per clean-architecture layer, because the
package boundary is what makes the dependency direction a build error
rather than a convention.

order-domain depends on unthrown and nothing else: an Order, the rule
that a quantity is positive, and the three tagged errors every outer
layer names failures with. src/layering.test-d.ts asserts the
wrong-direction import does not resolve, so adding the application layer
to this package's dependencies fails test:types on the now-unused
@ts-expect-error.

order-application declares the ports the use cases need — OrderRepository
in the domain's own error vocabulary — and ApplicationModule deliberately
does not provide OrderRepository, leaving it an unmet need that di
propagates into a call-site arity error until infrastructure supplies
one. Its specs run the use cases against a stub repository from a
test-only module, with no database, no HTTP and no kernel booted.
currentUnit() is the single kernel touchpoint, read fresh per call so
trace ids differ per unit.
Comment thread examples/order-domain/src/order.ts Outdated
…int rule

`unthrown` ships `OkAsync` / `ErrAsync` precisely so `Ok(v).toAsync()` need
never be written, and `OkAsync()` mirrors `Ok()`'s no-arg `void` overload so
`Ok(undefined)` need never be written either. 50 occurrences across the kernel,
its specs, the examples and both READMEs are rewritten; the one surviving
`.toAsync()` (`use-cases.ts`) lifts a `Result` that already exists, which is
what the method is for.

`start.ts`'s probe branch was the one non-mechanical rewrite: a bare `OkAsync()`
is `AsyncResult<void, …>` and `void` is not assignable to `undefined`, so the
sibling branch's `map`-used-for-effect-then-`return undefined` becomes the
`tap` + `discard` it always was, and the internal channel type follows.

Bump `unthrown`, `@unthrown/oxlint` and `@unthrown/vitest` to 5.2.0, and enable
all eight plugin rules rather than the five in `recommended` — `no-throw`,
`prefer-ensure` and 5.2.0's new `no-get-or-throw`. `prefer-ensure` reports
nothing. The six surviving `throw`s each carry a targeted disable naming why:
three are loud test fixtures whose failure means the test is buggy, and three
are throws that ARE the subject under test (a `Defect` has no public
constructor, so `run-main.spec.ts` has no other way to mint one).
`no-get-or-throw` is switched off for the spec glob — the exemption the rule's
own diagnostic prescribes — and stays on everywhere else.

No public behaviour change: 91 tests, coverage unmoved.
The domain layer hand-rolled its entity: a structural `type Order` plus a
`placeOrder` that checked `quantity > 0` inline. @btravstack/entity is the
btravstack library for exactly this layer, and these examples exist to show the
stack composing, so the domain should use it.

`Order` is now an entity with branded `OrderId`/`Quantity` fields, an
immutable `id`, and the quantity rule declared as an `Entity.invariant` — so
it is re-checked on every path that produces an `Order` (`make`, `update`),
not only the one the old check was written on. `placeOrder` keeps its exact
signature and names the entity's `InvalidEntity` as `InvalidQuantity`, which
is what the outer layers already speak.

No export was renamed: order-application compiles and its five specs pass
untouched. The domain's specs go 4 -> 9, asserting what the entity promises —
runtime immutability, update re-running the invariant, an immutable field
refused when smuggled past the type, and `toJSON` never carrying `_tag`.

`OrderId` deliberately carries no length rule: it keeps the
`InvalidEntity` -> `InvalidQuantity` translation total, so the error never
stands in for a failure it does not name.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 84 out of 89 changed files in this pull request and generated no new comments.

Suppressed comments (1)

packages/start/src/drain.ts:56

  • drainApp races Promise.all([drainStopped, registry.awaitIdle()]) against the timeout, but it never inspects the Result produced by serving.drain(...). If Serving.drain settles as a Defect, Promise.all still resolves and the drain is reported as successful, silently ignoring the defect. This makes runtime drain failures invisible and can produce a misleading DrainReport.

…splits

Three pairs/trios of tightly-coupled files existed only because the 13-task
plan that built the kernel produced one file per task rather than one file
per concern:

- drain-report.ts (13-line DrainReport type) merges into drain.ts, its only
  non-trivial consumer's neighbour
- signals.ts + uncaught.ts merge into process-handlers.ts: both install and
  dispose process-level handlers, both are consumed only by start.ts at the
  same two dispose sites
- ambient.ts merges into units.ts, its only importer

No behaviour, signatures, or exported names changed — index.ts and
testing.ts export the identical 24 public names, just from consolidated
source files. Specs merged verbatim (same names, same assertions). 19
source files (1079 lines) become 16 (1076 lines); 96 tests still pass with
100% line/function coverage.
@btravers
btravers merged commit 7f383a9 into main Aug 11, 2026
13 checks passed
@btravers
btravers deleted the feat/kernel branch August 11, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants