feat(pipeline): add the fact-record pipeline core - #474
Open
Menci wants to merge 8 commits into
Open
Conversation
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.
§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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
throughandintoare mutually exclusive because of what they mean, so five shapes arelegal and
defineStage's five overloads admit exactly those — the overload fixes whatnextaccepts, rather than a marker boolean or a conditional type. A stage that declared no way down
is handed no continuation at all.
transformis sugar in theexecuteslot:openfor state spanning both directions, then onefunction 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
providesis there and every key it declaredconsumes,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
consumesandprovidesis a modify, and that one rule covers bothdirections: 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 interioris 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.
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
runand at every handoff.Handover
Entering the record is a move.
Object.isFrozencannot be the gate, and both halves of thereason 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 satisfiesisFrozenwhile its children stay mutable. So aWeakSetof handed-over roots is the gate andObject.freezeis 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 onemore, because a tool's parameters are JSON Schema and
$schema,$defsand$refreally doarrive. 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
awaitinterleaves two concurrent runs — one dump lost, the other holding both, onestageIdissued twice — and a gateway serves concurrent requests by definition.The disposal sweep runs in
finally. A stage that throws must not abandon an open upstreambody: an aborted connection cannot be reused and leaves its billing unsettled.
Tests
54 tests plus two compile-time assertions (a
throughstage'snexttakes one argument; aprovider's own key is unreachable from a gateway-only pipeline). Among them the one property
nothing else in the system guards:
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.tsis deleted — thoseendpoints 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.