Skip to content

feat(pipeline): add the fact-record pipeline core - #474

Open
Menci wants to merge 8 commits into
mainfrom
feat/pipeline-core
Open

feat(pipeline): add the fact-record pipeline core#474
Menci wants to merge 8 commits into
mainfrom
feat/pipeline-core

Conversation

@Menci

@Menci Menci commented Aug 16, 2026

Copy link
Copy Markdown
Owner

The data plane is moving from an onion of nested interceptors to a list of stages over one
immutable record of facts. This is the first of three stacked Pull Requests and it adds the
package everything else will compose. There is no business logic in it — nothing here knows
what a model is — so nothing in the running gateway changes yet.

What a stage is

A stage declares where control may go, and it may declare more than one place. There is no
name for a kind of stage; the fields are the declaration.

return:  { provides }            // answer here, never go down
through: { request, response }   // go down the rest of this pipeline
into:    { request, response }   // hand off to another pipeline, so it is last

through and into are mutually exclusive because of what they mean, so five shapes are
legal and defineStage's five overloads admit exactly those — the overload fixes what next
accepts, rather than a marker boolean or a conditional type. A stage that declared no way down
is handed no continuation at all.

transform is sugar in the execute slot: open for state spanning both directions, then one
function per direction, either of which may be omitted. Omitting one is the common case —
today's interceptors call the continuation 107 times across 68 files and 50 of those are a bare
return run();.

Declarations are checked, never applied

After a stage hands on — in either direction, and on the answering path too — the runner
asserts that every key it declared provides is there and every key it declared consumes,
and did not also declare providing, is gone. Nothing is removed on a stage's behalf: a
declaration that acts cannot also be checked, and checking is the whole of what a declaration
is for.

A key in both consumes and provides is a modify, and that one rule covers both
directions: going down it is a translation taking the source and putting the target at another
key; coming up it is a fork taking ownership of every branch's disposable and handing one of
them onward.

What assembly catches that types cannot

compose(name, stages) reasons over declarations, which are strings — so a pipeline's interior
is not part of its type and a stage written against one fact space drops into a pipeline over
another with no variance question to lose.

compose(reentry):   cache needs in.text, which splitWords consumed above it
compose(bad):       handoff declares 'into' but is not last
compose(shortfall): answersNothing may answer here, but its short-circuit does not provide
                    out.result, which stages above it need
compose(swallowed): swallows consumes out.result on the way up, which a stage above it needs

The first is the load-bearing one: a key an earlier stage consumed cannot be needed below it,
so a translated request cannot re-enter its own chain, and assembly says so rather than a
runtime guard. The entry contract is derived from the stages and asserted when a request
arrives, at run and at every handoff.

Handover

Entering the record is a move. Object.isFrozen cannot be the gate, and both halves of the
reason are load-bearing: freezing a typed array that has elements throws, so the walk skips
them and a skipped value can never satisfy isFrozen; and a shallow builtin freeze satisfies
isFrozen while its children stay mutable. So a WeakSet of handed-over roots is the gate and
Object.freeze is what makes a later write throw.

A handed record is frozen in place rather than copied, so a stage that hands on what it
received hands on the same object — which is what the dump's folding reads.

The dump

Six events, carrying states and never differences, so a reader derives every change and the run
stores none. An object id is taken at first sight and before recursing, which is what makes
a cycle terminate on its second visit. A key that already begins with $ is written with one
more, because a tool's parameters are JSON Schema and $schema, $defs and $ref really do
arrive. Large strings are shared by value, which is the only handle left when a stage
deep-clones a payload. A secret is stored as {length, redacted, hash} and never as itself.

Two things worth reviewing closely

The run context is threaded, not ambient. A module-level variable saved and restored around
an await interleaves two concurrent runs — one dump lost, the other holding both, one
stageId issued twice — and a gateway serves concurrent requests by definition.

The disposal sweep runs in finally. A stage that throws must not abandon an open upstream
body: an aborted connection cannot be reused and leaves its billing unsettled.

Tests

54 tests plus two compile-time assertions (a through stage's next takes one argument; a
provider's own key is unreachable from a gateway-only pipeline). Among them the one property
nothing else in the system guards:

on a 49-message conversation with one message rewritten,
48 of 51 objects come back by identity — so the layer costs 3 objects, whatever the length

A stage rewritten to rebuild unconditionally drops that to zero while every other test passes.

What comes next

PR 2 migrates the six non-chat families, and is where passthrough-serve.ts is deleted — those
endpoints forward a body they have not parsed, and the architecture has no such concept. PR 3
migrates chat, which does not divide further: translation couples the four protocols, and the
two shims are elements of the interceptor array rather than things beside it.

Menci added 3 commits August 16, 2026 18:55
The data plane is moving from an onion of nested interceptors to a list of
stages over one immutable record of facts. This is the package everything
else will compose; nothing in it knows what a model is.

A stage declares where control may go and may declare more than one place —
`return`, `through`, `into` — and there is no name for a kind of stage: the
fields are the declaration. Five shapes are legal and `defineStage`'s five
overloads admit exactly those, with the overload fixing what `next` accepts
rather than a marker boolean or a conditional type. `transform` is sugar in
the `execute` slot, two layers deep, and an omitted direction passes through
unchanged — which is most of them, since today's interceptors call the
continuation 107 times across 68 files and 50 of those are a bare return.

Declarations are checked, never applied. After a stage hands on, in either
direction and on the answering path too, the runner asserts that what it
declared `provides` is there and what it declared `consumes` — and did not
also declare providing — is gone. A key in both is a modify: a translation
taking the source and putting the target elsewhere on the way down, a fork
taking ownership of every branch's disposable and handing one onward on the
way up. Nothing is removed on a stage's behalf, because a declaration that
acts cannot also be checked.

`compose` reasons over declarations, which are strings, so a stage written
against one fact space drops into a pipeline over another with no variance
question to lose. It catches what types cannot: an `into` stage that is not
last, a short-circuit that does not cover what the stages above it need, a
stage that can neither answer nor descend, and the load-bearing one — a key
an earlier stage consumed cannot be needed below it, so a translated request
cannot re-enter its own chain. The entry contract is derived from the stages
and asserted when the first request arrives.

Handover is enforced at runtime by one predicate at every place a value can
enter the record. `Object.isFrozen` cannot be that predicate: freezing a
typed array with elements throws, so the walk skips them, and a shallow
builtin freeze satisfies `isFrozen` while its children stay mutable. So a
WeakSet of handed-over roots is the gate and `Object.freeze` is what makes a
later write throw. A handed record is frozen in place rather than copied, so
a stage that hands on what it received hands on the same object.

The dump is the encoding in the design record: events carrying states and
never differences, an object id taken at first sight so a cycle terminates
on its second visit, `$$` escaping a key that already begins with `$` (tool
parameters are JSON Schema), large strings shared by value because a deep
clone defeats reference sharing, and secrets stored as length, redaction and
hash so a reader can see the same secret twice without ever holding one.

Two things the runner does that a reader should not have to discover: the
run context is threaded rather than ambient, because a module-level variable
saved around an await interleaves two concurrent runs and a gateway serves
concurrent requests by definition; and the disposal sweep runs in `finally`,
because a stage that throws must not abandon an open upstream body — an
aborted connection cannot be reused and leaves its billing unsettled.

Tests carry every property, including the one nothing else guards: on a
49-message conversation with one message rewritten, 48 of 51 objects come
back by identity, so a layer costs what its stage touched. A stage rewritten
to rebuild unconditionally drops that to zero while every other test passes.
…ails

An adversarial review of the first commit ran five lenses over the package and
reproduced every claim it made. Nine blocking defects survived refutation, and
they shared one cause: `run` treated the answer as the only channel out, so
everything else — the events, the open bodies — was reachable only through the
success return.

  · a run that 500s produced no dump at all, though a dump of a failing turn is
    the one an operator most wants and the current gateway does keep it;
  · every body opened below a throw was abandoned, because the sweep read a
    variable that is only assigned on success — and the test written for that
    case asserted the empty result as correct;
  · the sweep was awaited before `run` resolved, so a streaming family could
    never hand its stream back: the drain both blocked the handler and ate the
    frames it was supposed to deliver;
  · a body at a key a stage declared it consumes was released by nobody unless
    the stage happened to fork, though ownership is a declaration and not an
    arity;
  · a fork released a body the stage had handed up under a different key, out
    from under whoever was reading it.

Events now leave through a sink the prologue resolves, the moment they exist —
which also makes recording conditional without a mode flag, and is the seam a
live observer will attach to. What the run owns is tracked where the resource is
created rather than where it lands, so a body is known before the stack unwinds
past it. The drain is handed to the caller, because a streaming family's answer
*is* the stream; a run that threw has nothing left to hand back, so it drains
before the throw propagates.

Two encoding defects went the same way. A typed array was walked by index, so a
400 KiB image became 4.4 MB of NDJSON — an eleven-fold expansion of the value
most likely to be large, and enough to break the single-put sizing the storage
rests on. And `undefined` inside a fact vanished, which is precisely the failure
the design names when it explains why `undefined` is not a removal. Buffers,
`undefined`, `NaN`, the infinities and `bigint` each carry a tag now.

Assembly gained the two terminal checks it was missing: a last stage that can
only descend is unsatisfiable, and a stage that can only answer is where the
array ends. The handoff error names the caller as well as the target. The
handover gate covers the response direction and the answering path. `transform`
no longer repeats the runner's check with a worse message. A log line is
snapshotted where it is written, so a stored line is a state that existed.
`secret` takes a renderer whenever the value is not already a string, because
`String(bytes)` is not a rendering anybody would recognise.

The tests were rebuilt against the properties rather than against the outcome,
after the review showed several passed with the behaviour they named deleted.
Each is now checked by removing what it tests: dropping the drain, the handover
loop or the freeze each fails exactly the test that claims it.
A stage that carries both traits makes two statements, not one. The human's
ruling separates them in as many words — 「如果它短路返回,会 provides 哪些东西,
此时只有 provides」 against 「如果它要透传后面 stage 的 await next() 返回的东西
……它要 provides/consumes/needs 什么」 — and `compose` has always checked them
apart, testing a short-circuit's `provides` against what the stages above need
independently of the pass declaration.

`defineStage` had them sharing one type parameter, so a stage could not answer
with a key its descend path never carries. The first real family found it
immediately: a resolver that refuses a request no upstream can serve answers
with a refusal key, and passes through untouched when an upstream can — and the
compiler rejected the passing-through path for not carrying a key that exists
only when it did not pass through.

The reference example could not have found this. Both of its both-trait stages
answer with a key their descend path also carries, so the two slices coincide
and the conflation is invisible. The test added here is the shape where they
differ, and it fails to compile against the old signature.

The bad error message was the second half of the defect: overload resolution
fell through to the `into` shape and reported that `next` takes too few
arguments, which is not the problem.
Menci added 2 commits August 16, 2026 20:18
§1.4 listed this as the last shape it had not written, and called it "where the
load-bearing typing lives". It needs nothing: a pipeline's interior is not part
of its type, because `compose` takes stages whose slices are already erased, so a
provider composes stages typed over its own space and exports a `Pipeline` whose
type mentions only the gateway's.

The proof is a provider that is a separate module with a fact space of its own.
Its key is not closed over — it is created by one of its stages, travels in the
record where the dump can see it, and is consumed by another before the request
leaves. A caller cannot name it, because it cannot learn that it exists.

Three further shapes, because the claim is about composition and not about one
call: failover forking across the boundary from above it, two providers whose
spaces differ reached through one seal type, and the key's absence from what the
run answers with.

The reference example could not have settled this. Its provider closes the
credential over in a closure, so the key never enters the record and the question
of a travelling foreign key never arises.

This unblocks the chat migration, which is built entirely on handoffs of this
kind — four protocol chains that translate into one another, each ending in a
provider's sealed chain.
`Symbol.asyncDispose` is a release mechanism, and the language hands it out on
terms that do not match a gateway's in either direction. Measured on Node 24:

  Symbol.asyncDispose in (async function*(){})()   true    every async generator
  Symbol.asyncDispose in new ReadableStream()      false   what a body actually is

So the structural predicate was wrong twice over. It adopted every
generator-shaped fact as a run resource, which meant a stage consuming such a key
had its source released the moment it handed up — a transducer silently producing
zero frames — and a fork threw on receiving one at a key nobody declared
consuming, which is every failed-over streaming request. Meanwhile it missed the
upstream body the whole rule exists for.

`own(value, release)` is what says the run is answerable for a value. That is the
same thing `consumes` on the response side already declares, so declaration was
always the mechanism; this is the runner reading it rather than guessing.

Found by the first family that streams, which is the only way it could have been
found: the reference example's bodies are plain objects with a disposer, so both
halves of the mismatch are invisible there. The two tests added here are the two
halves — a generator the language marks and the run does not, and a stream the
language does not mark and the run does.
Menci added 3 commits August 17, 2026 05:43
A stage had no way to begin work without reaching past the record for a
scheduler, and anything it started that way escaped the run's own accounting.
Deferral is now a property of the value: a helper marks a promise, the marked
value enters the record like any other, and teardown waits for what this run
began.

Declared rather than sniffed. Testing for a then method would make a fact that
happens to hold a promise indistinguishable from one the run owes work to, and
it would be quietly awaited instead of reported.

Per run and not per root, because a discarded branch's facts are private to it
and a root-level sweep could never see them. Teardown has a deadline and
exceeding it is an error a reader can act on, since a value that never settles
would otherwise hold teardown open forever.
…a document

The dump's log-line test named itself after structured fields and asserted every
part of the line except them; removing the fields from the encoder left it
green.

Two comments pointed at design documents that are not in the repository, so a
reader following them finds nothing.
…nerator

Two assertions read `Symbol.asyncDispose` off an async generator and required it
to be there. That is a fact about the host rather than about this runner, and it
is false on the host CI runs and `apps/platform-node` supports:

                                                   Node 24   Node 22
  Symbol.asyncDispose in (async function*(){})()   true      false
  Symbol.asyncDispose in new ReadableStream()      false     false

So the suite passed on a developer's Node 24 and failed on Node 22, where the
product behaves identically: ownership is claimed through `own()` and nothing is
ever sniffed.

The variance is worth writing down rather than deleting, because it is a second
argument for claiming. On Node 24 a structural predicate adopts every
generator-shaped fact; on Node 22 it adopts nothing; neither column is what a run
needs. A predicate whose answer depends on the host cannot be what decides which
resources a gateway closes.

What the tests assert now is the run's own answer, which is the same everywhere.
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.

1 participant