A Complete Surface Runtime
The plan of record for the surface-framework consolidation: the kernel of runtime, membership, and mirror moves, then the reactive-bridge spine, sequenced as one PR list.
Surface declarations are the source of truth for data shape, and they stop one layer too early. Kolu, Drishti, and Odu each receive a typed declaration and then rebuild some combination of router finalization, procedure clients, membership identity, failure vocabulary, or protocol projection by hand. The cures land where the volatility already lives: every move completes an existing package — no new package — and the derivation-shaped remainder is the reactive bridge’s spine, scheduled here as the same campaign’s back half.
What a user gets
Almost nothing visible — deliberately. This is a pure consolidation, and its invariant governs: every PR is byte-identical on screen, pinned by the e2e suite and the seal test — and its dual is the sorting rule: a feature (any user-visible behavior change) never rides one of these PRs; it ships standalone. What a user gets is defect classes dying rather than defects being fixed one at a time:
- A remote host that is removed and re-added (or whose authority restarts) can never resurrect a stale subscription — the class kolu currently suppresses with a hand-rolled generation rearm.The rearm:
createRejoinKeyedSubin packages/client/src/host/connectionRearm.ts:35-64 bumps a generation signal on membership re-join and re-keys the subscription effect. It works; the point of SR3 is that no consumer should have to know to write it. - A failed host chip can never read a cause the framework invented: today an absent cause is filled with
"other"by generic map code.packages/surface-map/src/server.ts:200 and a second site at packages/surface-map/src/server.ts:301. - A registry writer can never forget to republish a derived fact (urgency, alerts, overview counts) — the bridge tracks the edge instead of trusting a convention.
- The costs stay honest: the bridge’s ten named costs are carried into this plan unchanged, and its open question 4 (a permanently broken derivation looks healthy) remains open — srid rules on it inside the bridge track, not here.
The kernel — six moves, each where the volatility already lives
The structural verdict: none of these needs a new package. Each move completes a receptacle that already owns the volatility, and each is proven by what it lets a consumer delete.
SR1 — A runtime you can serve
implementSurface still returns an intermediate fragment — { router: { surface: … }, ctx } — and every consumer must know the one correct oRPC finalization step.The fragment return: packages/surface/src/server.ts:1958-1962. The three re-learnings: kolu finalizes via implement(servedContract) in packages/server/src/surface.ts:85-99; drishti wraps the fragment in packages/agent/src/main.ts:404-409; odu does it twice, in src/runner/runner.ts:339 and src/coordinator/run.ts:469. Odu already reaches implementSurface, so its finalization is down to one line per site — but it is still a caller obligation the framework should own. The runtime becomes a directly servable, supervised resource:
interface SurfaceRuntime<S extends SurfaceSpec> {
readonly router: Router; // final top-level router
readonly ctx: SurfaceCtx<S>;
readonly done: Promise<void>; // rejects on an owned runtime fault
close(): Promise<void>; // idempotent; releases every owned source
// package-private mount material for composition
}
Async cell connectors receive an abort signal and return a disposer; reactor subscriptions, map subscriptions, and re-served pumps all join done and close. A close that ignores today’s cell.connect, reactor, or reServeSurface.done would be ceremonial and is not acceptable. The ordinary constructor owns inMemoryChannelByName() internally; kolu’s shared publisher stays a distinct, explicit constructor (implementSurfaceOnPublisher) because its cross-channel microtask order is load-bearing — two ownership promises, not an override knob.
SR2 — Procedures join the typed dual
Cells, collections, streams, and events receive bound client capabilities; procedures alone remain behind raw rpc, so every consumer casts.The asymmetry: packages/surface/src/solid/surfaceClient.ts:309-324 — four bound member kinds plus a generic rpc field. Kolu recovers PadiRpc with a cast in packages/client/src/wire.ts:202-207 (a second cast at line 402); drishti casts HostRpc and AdminScopedRpc slices in packages/app/src/client/wire.ts:124-155. Every declared procedure appears at client.procedures.<namespace>.<verb> and on a map entry; the casts and their copied callable shapes delete.
SR3 — Membership is time, and time is a fact
A key that leaves and returns is a new member even when its spelling is unchanged. serveSurfaceMap stamps crypto.randomUUID() as an opaque membershipId on every add — never reused across map-server restart, published on every status arm — and clients key every cached owner on {encodedKey, membershipId}, so same-key remove/re-add and authority restart rebuild subscriptions by construction:
type EntryStatus<Failure> =
| { kind: "warming"; membershipId: string }
| { kind: "connected"; membershipId: string; clockOffset: number | null }
| { kind: "failed"; membershipId: string; failure: Failure };
Readiness is link-liveness, not clock-measured: an entry is connected the moment its link is live. The clock offset is a separate fact on that arm, with honest null-absence — clockOffset: number | null, where null has one meaning (not-yet-measured; the reader renders “—”). A connected entry with clockOffset: null stays fully connected, never demoted; the offset probe surfaces loudly and retries on its own cadence until it lands, so a failed probe never silently strands a null. And the padi-specific control.core.clockNow stays frozen-forever beside the new framework-reserved system.clockNow — the frozen control core never versions, so old binders keep crossing skew on it while new kolu measures via system.clockNow only.
W11 already proved the doctrine’s sibling on the server side: the remembered fleet became its own ordered fact (persistedMembership), never derived from the live pool’s keys, exactly because retire deliberately desyncs live from remembered.packages/surface-remote/src/hostFanout.ts:486 inside buildRemotePool, shipped by W11 (#1775). Membership identity is the client-side twin of the same insight: identity is a fact in its own right, not a projection of key spelling. This PR kills kolu’s createRejoinKeyedSub and its test outright.
Two riders land here because they touch the same admit/connection seam:
- The typed connection key. Kolu reconnects its map by the string
"padi"and drishti by"hosts"— a stringly sibling key at every connection site.Kolu: packages/client/src/wire.ts:158; drishti: packages/app/src/client/wire.ts:97. The server-side dual — the widenedas anycontract splice — is kolu’s packages/server/src/surface.ts:85-99 plus the router splice in packages/server/src/index.ts:484-516, repeated by drishti in packages/app/src/server/admin-router.ts:187-208. This was the deferred suite package’s one real client-side move; it lands here as a typed key derived from the declaration, without the package. clockNowas a framework-reserved member.makeSessionmeasures the clock offset at admit automatically (the reserved-member patternsystem.identityalready shipped), theoffsetOfinjection point ceases to be a caller obligation, and drishti’soffsetOf: () => 0stub deletes. It rides the membership PR because both rework the same admit seam.
SR4 — Failure is domain data, never fabricated
The map’s TypeScript cause narrows while its wire schema accepts any string, and the server fills an absent cause with "other".The narrowing: packages/surface-map/src/define.ts:70-73 against the loose wire arm at packages/surface-map/src/define.ts:94-102 (cause: z.string() at line 100). The fabrications: packages/surface-map/src/server.ts:200 and packages/surface-map/src/server.ts:301. SurfaceMap takes a real failure schema, and failureOf is required and total:
const padiHostMap = defineSurfaceMap({
key: HostKeySchema,
codec: hostKeyCodec,
entry: padiEntrySurface,
failure: PadiEntryFailureSchema,
});
serveHostMap(padiHostMap, pool, {
// A verdict, not a fabrication: a schema-valid failure, or `null` = "this down
// state is not a standing failure — keep the entry warming" (a live host's normal
// reconnect window). A terminal give-up that yields `null` is a classification
// defect and fails loud (`UnclassifiedHostFailureError`), never a bucketed catch-all.
failureOf(host, session, downState): PadiEntryFailure | null {
return classifyPadiFailure(host, session, downState);
},
});
The migration starts from Padi’s real producers — contract skew, cross-supervisor, the two drv failures, unconverged, link failure, and the structural cases currently collapsed into other — and gives every structural producer a named arm. failureOf is total but not exhaustively non-null: its null is a single-meaning “keep warming” verdict for a transient drop (never a fabricated cause), while an unclassifiable terminal failure throws — neither Padi nor the framework may publish a renamed catch-all. A schema validates this value; it never derives one.
SR5 — One protocol across the wire
A collection should stay one protocol when it crosses a network. Drishti’s process fact is both a Surface collection and a second snapshot/delta stream because the remote mirror fans collections into keys plus one get stream per key.The fan-out: packages/surface/src/mirrorRemoteSurface.ts:415-443 — and the sweep confirms no collection-level deltas verb is consumed anywhere yet. Drishti’s resulting dual: the collection and the parallel processesSnapshot stream declared together in packages/common/src/surface.ts:259-291, the extra stream implemented in packages/agent/src/main.ts:252-262, the parent reducer in packages/app/src/server/router.ts:409-434. mirrorRemoteSurface selects a collection’s declared deltas verb and folds the initial snapshot plus batches into its existing sink; reServeSurface inherits that path and returns a normal supervised SurfaceRuntime; relay loss crosses oRPC as a named retryable transport end.
Parent-owned additions remain causally separate from mirroring: extendSurface(mirroredSurface(agentSurface), historySurface) composes a local runtime onto a re-served one, with post-commit observation instead of a second mirror — so drishti deletes its inert agent-side metricHistory stub and keeps its retention as local policy.The stub, marked inert in its own comment: packages/agent/src/main.ts:263-278, declared at packages/common/src/surface.ts:292-298; the parent’s real ring + bus serve it at packages/app/src/server/router.ts:193-216.
This PR reworks the re-serve internals, so two standing cleanups in them ride along as side effects: the opaque-client navigation casts in reServeSurface.ts collapse into one typed helper with one justification, and the hand-rolled additive in-memory collection shape gets a single implementation.
SR6 — Adopt before you mint
Some cleanup is adoption rather than invention, and it needs no new framework API. One-shot reads keep the existing non-empty first-frame contract (firstFrameOrThrow) instead of open-coding iterator advancement; socket acceptance, connection presentation, attention, and combined health all have existing primitives consumers should reach before the framework grows another concept.firstFrameOrThrow: packages/surface/src/firstFrame.ts:42-48. The primitives to adopt: acceptSurfaceSocket in packages/surface-app/src/server.ts:597-640, presentingDown in packages/surface-app/src/solid/index.ts:674-677, setAttention in packages/surface-app/src/solid/index.ts:584-602; drishti already computes combined health from its two siblings via surfaceClientsHealth (packages/app/src/client/App.tsx:461-466). This is consumer-side; it shipped as its own PR (#1829), and the grounded sweep found most named sites already adopted — the sequence-table row records the honest per-site outcome.
Deliberately not in this plan
Several shapes that could plausibly belong here are out by design. The rule: a concept earns a slot only where its promise cannot be expressed by what is already ratified or shipped. The reasoning is recorded so it is not relitigated per-PR.
| not in the plan | why | where the residue lives |
|---|---|---|
a Feed member kind — a state-owning frame producer with a snapshot | delta wire union |
it is the bridge’s scan wearing a wire protocol |
feedState({initial, reduce}) + commit ≡ scan(source, initial, step) published via derived.cell — one atomic update-and-publish; the delta projection ≡ derived.collection’s diff-by-equals → wire patches. The genuinely unserved residue — the append-heavy wire member (terminal bytes, CI logs) — is a named bridge-track design question, not a fourth member kind |
@kolu/surface-suite |
a thin composition leaf earns no receptacle | its two real moves were never suite-shaped: the final router is SR1, the typed connection key is SR3. Sole revival condition: @kolu/surface-app needing to depend on a suite value (the dependency-direction question) — that reopens the package question with a real consumer in hand |
a framework lease stack — SurfaceLease, LeaseOpen, surfaceClientFromLeases, projectSurface, SurfacePort |
five concepts for one caller fails prove-then-extract | Odu fixes its MCP projection with these shapes in its own tree (they are the right shapes: the stable client is projected Surface B, every A-lease pinned and released, unavailability a typed arm); they graduate to the framework at a second consumer. SurfacePort — a TS2590 workaround — dies with the stack |
createLiveQuery — a string-keyed client query adapter |
W9’s per-host ownership shape supersedes it | one retained query world per host, paused/resumed/disposed by membership, in right-panel/hostCodeTab.ts. One instinct survives inside that ownership shape: prefer createResource over a hand-rolled async engine |
notificationSurfaceApp / bootSurfaceApp |
the notify seam already exists | createNotify with a feature-detected worker discriminant (shipped in W5), consumed by kolu and drishti. A server-authored capability discriminant (boot parses a no-store payload; workerless can never produce the notify arm) remains a candidate — it must be grounded against that shipped seam before any PR is cut |
Three of these deserve their sentence of reasoning in full:
The Feed↔scan equivalence, stated. A state-owning feedState makes “mutate, then remember to publish” unspellable as a paired obligation — which is exactly scan’s promise, with the stop-hold error doctrine already specified in the bridge’s law table. The wire union (snapshot | delta) as a member kind adds nothing for state-shaped data — the bridge’s derived.collection already promises “membership changes become wire frames.” For append-shaped data (a terminal’s bytes, a CI log) a signal is the wrong substrate by the bridge’s own law — a signal conflates same-batch frames, and an append consumer must see every one. So the append-heavy wire member is real, unserved, and off the graph by design: snapshot-then-append with a bounded per-subscriber queue, abort-on-overflow so retry restarts from a fresh snapshot, and a retryable relay end. The live defect class it must kill is in the tree three ways today: odu’s log tail yields its snapshot before installing the subscriber, kolu re-arms terminal attach with a client-local reattach loop, and the padi relay’s forwarding policy is a hand-maintained per-member table.Odu: src/common/logTail.ts:59-66 — yield snapshot at 64 before the bus subscribe at 65, the lost-update window. Kolu client: consumeReattachingStream in packages/client/src/terminal/reattachingStream.ts:21-48. The table: PADI_FORWARDING_POLICY at packages/padi/src/surface.ts:939. Whether the answer is a new off-graph member kind or a completion of the existing stream role is precisely the design question — decided inside the bridge track where the other wire laws live. The standing framework-backpressure question closes with this design if it ships a framework-owned subscriber bound: today the re-serve bounds its per-subscriber channels (HWM 4096, loud overflow) but the framework’s underlying oRPC receive queue is unbounded for an unpaced sender — gated on a measured slow-consumer incident or a load test, either way.
Why there is no suite package, though its moves ship. Kolu’s splice is real and ugly — a server-only widened contract with as any casts, a hand-spliced router, and a string key at the client — and drishti repeats all three. But every line of that ugliness is addressing, not topology: once the runtime returns a final router (SR1) and the map connection key is typed from the declaration (SR3), the splice collapses without any value describing “the topology” existing anywhere. A defineSurfaceSuite would then be three lines of object literal wearing a package — the thin-composition-leaf shape the electricity tests exist to reject.
Why no client query adapter. The Code tab’s queries live in a per-host owner (buildHostCodeTab), born inside scopedByEntry, paused while backgrounded, resumed from the held value, disposed on membership exit — and the old free-floating constructors were retired so component-owned retention is unspellable. Only two createPolledQuery instances remain, both inside that owner.packages/client/src/right-panel/hostCodeTab.ts:85 (buildHostCodeTab), the shared owner at line 258; the two surviving createPolledQuery call sites at lines 119 and 197. A string-keyed singleton cache — stable keys, abort-latest, stream-driven invalidation, held outside any owner — would reintroduce the class that ownership shape deleted. The surviving insight — prefer Solid’s createResource to a hand-rolled async engine — applies inside the owner, if at all, and the existing createPolledQuery regression suite stays the acceptance bar for touching it.
The PR sequence
Kernel first (track prefix SR — surface runtime) — each PR independently landable, each proven by a deletion, sequenced so no PR depends on an unshipped sibling. Then the bridge spine, whose technical content lives in the bridge note and whose work is numbered here, as SR7–SR10 — the one numbering scheme. Every @kolu/surface* PR ships with a paired drishti PR pinned to final kolu HEAD per .claude/rules/surface.md. Odu is not schedulable from this repo: it pins kolu by npins, so its adoptions land at its pin bumps — the campaign’s bump shipped as odu#44 (pin → 07397fa2, #1836/SR9, with the SR-series adaptations including SR1’s two hand-finalization deletions); the standing accounting for future drift is the per-PR odu-impact verdict + the ledger (odu#43) per .claude/rules/surface.md.
| id | what lands | the deletion that proves it | gate |
|---|---|---|---|
| SR1 (#1805) | supervised SurfaceRuntime — final router, done, idempotent close; implementSurfaceOnPublisher stays distinct (with an implementSurfacesOnPublisher plural twin) |
kolu’s implement(servedContract) surface-finalization step; drishti’s fragment wrap; odu’s two wraps (landed at the bump, odu#44). Done only when every serving site observes runtime.done and owns shutdownThe headline shrank in the build, the negative property did not. kolu’s implement(servedContract) is not fully deleted — it stays as the app-router builder (binding server/daemon/hosts and re-adapting the re-served padi map fragment for the wire matcher, which requires a contract that declares surface.padi). What deleted is its surface-finalization role, which moved into implementSurfacesOnPublisher. So the campaign property holds verbatim — “no production consumer imports oRPC implement merely to finalize a Surface” — because kolu’s remaining implement is the app-router builder, not mere finalization. This was learned the hard way: CI’s e2e caught a deterministic wire-matcher 404 (serveHostMap returns a matcher-meta-less fragment; a padi-less builder drops every /surface/padi/* route) that the directLink-based padiBinding integration test structurally could not see — directLink navigates the router object, never the matcher. The lesson is now a pin: packages/server/src/router.test.ts asserts the assembled appRouter’s StandardRPCMatcher tree routes /surface/padi/*, the evidence class structural navigation cannot provide. |
drishti pair |
| SR2 #1815 | bound procedures at client.procedures.<ns>.<verb> and on map entries; plus BoundStream.unenrolled / BoundCollection.unenrolledKeys for the un-enrolled carve-out reach |
kolu’s PadiRpc casts (both sites); drishti’s HostRpc/AdminScopedRpc casts |
drishti pair |
| SR3 #1811 | opaque membershipId per map add; typed map connection key; clockNow reserved member + offset-at-admit |
connectionRearm.ts + test; the "padi"/"hosts" string keys and the as any contract/router splices; drishti’s offsetOf: () => 0 stub. Done only when same-key remove/re-add and authority restart rebuild subscriptions keyed on {encodedKey, membershipId} |
drishti pair |
| SR4 #1804 | failure schema on defineSurfaceMap; required, total failureOf |
both "other" fabrication sites in the map server; the loose z.string() cause on the wire |
drishti pair |
| SR5 #1822 | mirror + reServeSurface consume declared collection deltas; named retryable relay end; extendSurface for parent-owned additions (the re-serve cast/collection dedup rides along) |
drishti’s processesSnapshot stream, parent reducer, and inert metricHistory stub; the mirror’s per-key stream fan-out for delta-declaring collections |
drishti pair |
| SR6 (#1829) | adoption sweep — firstFrameOrUndefined at two open-coded one-shot reads (kaval-tui readExitCode over the exit stream, server readPadiMemoryOnce over processMemory), pinned by an AST guard; kaval-tui consumeExit stays open-coded as a documented non-adoption (its settle must fire before the awaited iterator-close in a Promise.all race); the other named primitives (acceptSurfaceSocket, presentingDown, setAttention, surfaceClientsHealth) grounded as already-adopted or deliberate divergences, not force-adopted |
the open-coded iterator advances (consumer-side; rode no sibling branch) | none |
| SR7 (#1823) | bridge: the $ read face, computed, batch, post-equals mirror pokes |
padi urgency’s publishUrgency + both riders + the prose invariant (worked example 1) |
drishti pair |
| SR8 | bridge: derived.collection + source’s poll shape; the poll-on-change client core moves into @kolu/surface beside pollOnEvent as its named dualThe move’s shape: a framework-free core (subscribe to the {seq} pulse → re-run the procedure → equality-dedupe → emit) exported beside pollOnEvent; the SolidJS ergonomics (.pending(), the #818 selection-stability guard) stay in packages/client. Done when the core is importable from @kolu/surface with zero Solid imports and the Code tab is byte-identical (the existing e2e + the #818 guard test pin it). The once-planned writeWrappedValue copy-deletion is already satisfied — the copy was consolidated in #805 before SR8; L1’s live deliverable is the core extraction alone. Named obligation (from SR7): resolve the urgency compose-fold regression — SR7 wired urgency: derived.cell(($) => recomputeUrgency($.terminals())) off the composed collection, so every firehose poke re-composes every live terminal (O(M)→O(M²)/cycle vs the pre-SR7 raw-registry reads; see SR7 #1823). The fix, precisely located (the keyed reconciler is a wire dedup — it diffs a whole-map read, after the recompose): the incremental $ sibling read — the framework maintains the composed per-key map in the existing wrappedUpsert/wrappedRemove, so a cycle costs M composes, one per poke — proven by a compose-count gate (24 vs 600/cycle at M=24). SR8 is that regression’s proving case and cannot ship without it. |
two of the three hand-rolled samplers (padi memory, hostInventory; kolu-server parked as SR8.a); drishti’s keyed poll-reconciles; the urgency compose-fold regression (SR7) | drishti pair |
| SR9 (#1836) | bridge: reactiveFamily + derived.registry — the serveHostMap reshape (worked example 4), extended: one connection authority. Both connection views — the dot’s EntryStatus and the word — derive from the one reactiveFamily<SessionState> source; the fine connection payload rides the entry (equals-gated), the connection cell / connectionPipe.ts / the mirroredSurface seam are deleted from the tree, and the word derives client-side via the exported pure projectConnection. The joint invariant — for any SessionState, EntryStatus === connected ⟺ connection.phase === connected — is enforced server-side pre-publication and pinned by a contract test plus a joint-render pin at drishti’s sole entry.state() seam (drishti has no browser harness by design; the delivery-divergence class died with the second channel, so the pin realizes the “settled frame agreement” done-criterion). Fixed the live bug (srid/drishti#102, green dot + permanent “connecting”) — srid live-confirmed on a real deployment before merge |
~60 lines of serveHostMap plumbing (drishti’s clone already died in #92); the second independent connection projection/subscription — connectionPipe.ts and the dot-vs-word split (fixed srid/drishti#102, pair srid/drishti#105) |
drishti pair |
| SR10 | the padi registry as signalMap — the campaign’s one adjudicated row; the full question, the defect in code, and the decision live in its own section below |
per the adjudication | per the adjudication |
Ordering constraints, honestly stated: SR1–SR6 are mutually independent (land in any order); SR7 needs only shipped phase 0; SR8 needs 7 ($ and the poke seams); SR9 needs 8 (the collection wire form); SR10 needs 7–9 (it is their evidence). The named design questions that are deliberately not PRs here: the append-heavy wire member (above, where the framework-backpressure question also closes if it ships a bound); the bridge’s open questions, ruled inside its track; the server-authored notify discriminant (needs grounding first). One unratified candidate stands beside the plan, srid’s to revive: a repeatable typing-latency lane as this plan’s net — the keystroke→echo pipe these PRs rebuild was measured once at W2.2 (+1.3 ms p99 against a +5 ms budget, method in the baseline note), and a just bench-style lane rerunning that method before/after would replace a months-old one-off with a standing budget check. The parked padi-area cleanups (each with its own gate) live in the padi note and are untouched by this plan.
SR8.a — serve after boot, then the third sampler
SR8 (#1832) converted two of the three hand-rolled samplers; kolu-server’s processMemory is this parked continuation. The blocker is ordering: kolu’s surface is served eagerly at surface.ts module load, while the sampler’s read (readPadiMemoryOnce) and its padiSession.onState force-resample exist only after index.ts’s async boot — a derived poll cell can’t wire the resample, and losing it is a real regression (the rail freezes a stale MB for up to ~5 s on a padi drop). The late-bind registrar seam that would force it into SR8 was rejected as an override-knob. The work, one PR: move the kolu surface serve into index.ts post-padiSession (a router/serve assembly restructure), then convert the sampler. Build-time grounding found a second kolu-server hand-roll of the identical shape — startDaemonInventorySampler (padi/daemonInventory.ts), outside the campaign’s three-sampler ledger — and it converts in the same PR, so the negative property ships with zero allowlist: no hand-rolled setInterval sampler remains in kolu-server. Gate: none beyond scheduling it — the gate is the restructure. Provenance: issue #1831, closed into this entry.
SR8.b — the incremental per-key urgency fold
SR8 resolved its named obligation (compose count O(M²)→O(M), gate-proven 600→24/cycle at M=24), and its wall-clock evidence disclosed the honest remainder: recomputeUrgency still scans the whole terminals map per poke — a cheap residual O(M²); the win plateaus at ~14× (278→19 ms/cycle at M=1536) instead of growing. The work: a lawful incremental fold — only the poked key updates its urgency contribution, surviving removes — never a cached scan. Gate: a real workload where the scan matters (live M is ~16–24 today, where it is noise) — with SR10 declined, the “keyed machinery makes it free” branch closes; a SR10 revival reopens it. Provenance: issue #1834, closed into this entry; evidence on #1832.
SR10 — the padi registry as signalMap: the adjudication
Padi’s terminal registry is an ordinary in-memory Map (terminal-registry.ts:95), and the registry IS the store — the terminals collection is served straight off it. When code changes a terminal it must remember to call one of five named publish seams (metadata.ts:96-243, all funneling into two ctx writes) so the change becomes a wire frame. SR10 asks: rebuild the registry as a signalMap — a writable reactive store — so publishing is automatic and forgetting has no spelling?
The defect the convention leaves expressible, in code. Nothing rejects this today — no type, no lint, no crash:
// any future padi feature, any file:
const entry = requireMutableTerminal(id);
// ^ the registry's accessor for mutation handlers (terminal-registry.ts:277):
// looks the id up and returns the LIVE TerminalProcess object itself —
// whoever holds it can assign to entry.meta/entry.snapshot directly.
entry.meta.lastActivityAt = Date.now(); // mutates the registry in place
// ...forgot: publishComposedTerminal(id) ← the convention. Nothing enforces it.
Two consequences, the second subtler: the tile shows stale info forever, and materializeSiblingView’s per-key cache (SR8’s perf fix, sole-writer-coupled at servePadi.ts:305-314) still holds the old composed record — so server-side derived readers (urgency) go stale too. The cache made a forgotten publish quieter, not louder. Under signalMap, mutation and publish are the same act, so the forgetting is unspellable:
// there is no bare entry to mutate; this is the ONLY write:
terminals.update(id, (t) => ({ ...t, meta: { ...t.meta, lastActivityAt: now } }));
What SR7–SR9 already banked (the marginal-value ledger from the SR10-ADJ decision package), in code:
// SR7 — a DERIVED fact can't be forgotten: the graph tracks the edge, no
// writer calls anything (servePadi.ts:251):
urgency: derived.cell(($) => recomputeUrgency($.terminals())),
// SR8 — the fold stays O(M) on the firehose: $.terminals() reads a per-key
// materialized cache instead of re-composing all M (servePadi.ts:314):
terminals: { /* … */ materializeSiblingView: true },
// gate-proven: urgencyComposeCost.test.ts — 24 composes/cycle at M=24, vs 600.
// SR8 + SR9 — the keyed machinery exists:
processes: derived.collection(source({ read, install })), // wire reconciler
const hosts = reactiveFamily({ members, attach, onEvict }); // serveHostMap (SR9)
What remains open only because the registry is imperative: the five seams as a named-call convention (the snippet above), and SR8.b’s incremental fold staying parked. Everything else the row once implied is in the ledger above.
The cost, honestly — what a conversion must re-path, in code. SR9 reshaped a projection of an external authority (the host pool); the registry is the authority:
// the store to replace IS the source of truth (terminal-registry.ts:95):
const terminals = new Map<TerminalId, TerminalProcess>();
// in-place mutation on the ~150 ms firehose, once per live terminal
// (metadata.ts:154) — every such site must route through the new store's
// write so the change edge fires:
entry.snapshot = observation;
// ~17 synchronous readers that must keep working WITHOUT reactivity —
// they need a pull face (keys/has/get), not signals:
getTerminal(id); registryMap(compose); terminalEntries(); snapshotFor(id);
parkedTerminalIds(); requireActiveTerminal(); drainTerminals(); /* … */
// park/restore/sleep/wake walk the raw map (terminal-registry.ts:149-173) —
// the restore's parked→active flip is the restore-idempotency token and must
// survive a store whose writes now also emit wire frames:
hasParkedTerminals();
…plus signalMap itself must be built (reactor.ts:19 — “Still ahead: SR10’s signalMap”) and served — and SR8.c’s live evidence is that even a two-cell conversion surfaced type-acceptance subtleties.Why a new primitive — doesn’t Solid ship one? Client-side, yes (createStore/reconcile, @solid-primitives’ reactive map) and kolu uses them there. But padi is a Node daemon where Solid is banned by a pinned test (noSolidInDaemon.test.ts — “a UI reactive framework has no business in a PTY daemon’s closure”; padi imports zero solid-js), and the server graph’s ratified engine is @preact/signals-core behind reactor.ts (@solidjs/signals is the named swap target, not a today option) — two reactive graphs in one process is the complecting the reactor boundary exists to prevent. And the genuinely new part isn’t the reactive map (small; reactiveFamily’s in-place-latest + version-signal pattern is the precedent) — it’s the wire half no library can ship: served as derived.collection with per-key frames and equals gating, the synchronous pull face, the poke form, the boot-walk connect, the durability laws. Roughly 20% map, 80% kolu’s own wire contract.
What the converted world would look like (the after, sketched honestly):
// terminal-registry.ts — the registry IS the reactive store now:
const terminals = signalMap<TerminalId, TerminalProcess>();
// the 17 synchronous readers survive on the pull face, unchanged signatures:
export const getTerminal = (id: TerminalId) => terminals.get(id);
export const terminalEntries = () => terminals.entries();
// requireMutableTerminal is DELETED — the raw-entry door the defect walks
// through cannot exist: nothing hands out a live object to mutate silently.
// metadata.ts — the five seams SURVIVE as the domain vocabulary; only the
// ritual line dies. Cold paths use the immutable update:
export function updateMemory(id: TerminalId, facts: MemoryFacts) {
terminals.update(id, (t) => ({ ...t, meta: { ...t.meta, ...facts } }));
// no publishComposedTerminal(id) — the write IS the wire frame
}
// the ~150 ms firehose CANNOT afford a spread per poke (the alloc cost SR7/SR8
// fought), so the hot seam uses the in-place poke form — mutate, then the store
// bumps that key's version (the reactiveFamily precedent: in-place `latest` +
// a version-signal change edge):
export function commitSnapshot(id: TerminalId, observation: Snapshot) {
terminals.poke(id, (t) => { t.snapshot = observation; });
}
// servePadi.ts — the collection is declared FROM the store; the wire member
// stops being convention-published:
terminals: derived.collection(terminals.map(composePadiTerminal)),
// downstream derivations unchanged:
urgency: derived.cell(($) => recomputeUrgency($.terminals())),
Three honest observations about the after: the seams don’t vanish — updateMemory/commitSnapshot/etc. survive as the domain verbs, and what vanishes is the forgettable line inside them plus requireMutableTerminal (the door itself); the store must ship both write forms (update for cold paths, poke for the firehose) or the conversion trades the forgotten-publish class for an allocation regression; and terminals.map(composePadiTerminal) has to subsume materializeSiblingView’s per-key caching internally — compose-once-per-poke, gate-pinned by the existing urgencyComposeCost harness.
Adjudicated — DECLINED, dated 2026-07-15 (srid’s explicit call, after the full code-first review of this section: the defect snippet, the banked ledger, the cost re-path, the converted-world sketch, the second-consumer sweep, and the engine question). Not a permanent no — a decline with two teeth:
- The confinement pin ships now (rides SR8.c as one small commit): an AST guard in the SR6 pattern pinning registry-entry mutation happens only inside
metadata.ts— so a future write path is forced into the one file where the seams live, or fails CI. The declined class stays expressible but becomes un-sprawlable: bounded to one reviewed file, not loose in the tree. - Revive when any one holds: a second consumer needs a writable reactive keyed store (prove-then-extract — checked 2026-07-15, none exists: drishti’s one instance of the shape already rides the graduated
serveHostMap; odu’s two near-misses both fail honestly — the MCPLogsStoreis a projection of the coordinator’s stream, and the runner’sPipelineState.nodesis a true keyed store whose writes are already atomic set+publish through one helper,runner.ts:89-97— the forgotten-publish hazard does not occur in either repo; the most plausible future consumer is a liveodu runs --watchover the on-disk ledger, if that feature is ever built); OR SR8.b’s workload gate trips (live M grows past where the scan is noise); OR a silently-stale-tile incident lands from a forgotten seam.
The convention-published members persist alongside derived ones — the bridge’s honest cost #10, accepted with a fence rather than denied. The deleted-world doc sweep’s residue rides SR8.c (the plan’s last-shipping PR) as its tail commit.Comments and READMEs across the stack still cite the deleted packages/pulam* packages as context (31 non-test src files, re-verified 2026-07-15; rg -i 'pulam' packages/*/src --type ts -g '!*test*' plus package READMEs). Update each to the padi-era truth or delete; Atlas notes are exempt (historical record). Done when the grep returns only deliberate historical references, each commented as such. Hygiene never ships standalone — it rides the last PR as one tail commit.
SR8.c — members have one home
Shipped (#1838, pin-pair srid/drishti#106): all three moves + the SR10 confinement pin (registryMutationConfinement.test.ts, zero outside mutations found) + the pulam doc-sweep tail. With it, every row of this plan is resolved — shipped, parked-with-gate, or declined-dated.
SR8.a shipped with a composition defect srid caught post-merge: implementKoluSurface(pollCells: KoluDerivedCells) splits kolu’s member table across two files by dependency timing — index.ts’s boot builds the framework’s derived.cell(source(...)) inline and injects the constructed artifacts, while store cells wire in surface.ts. The design that cures it was lens-run before authoringTwo Workflow runs over the proposed shape — wf_4d7b4b25-995 (C2/C3/C6 + adversarial refute, 15 raw → 4 confirmed) and wf_f51bacc4-d06 (overlay-parameterized C3 ladder + C1). The runs refuted the initial framework diagnosis: implementSurfaces has carried per-entry spec types since #1197/#1201 (SurfaceDepsFor<S>, tsc-probed — full inline inference, zero casts); the “any-specs / cast at the entry boundary” story survives only in a stale comment (packages/server/src/surface.ts:217-220) contradicted five lines down, and Omit<ImplementSurfaceDeps<…>, "channel"> is a provable no-op (channel left the interface in #1805). The originally-proposed framework signature change + drishti pair would have been churn on a healthy API. — three moves, one PR:
- Hygiene — delete the fossils. The two vestigial
Omit<ImplementSurfaceDeps<…>, "channel">spells (packages/server/src/surface.ts:223-226andpackages/padi/src/daemonBoot/controlCore.ts:21-24— line-wrapped, a naive single-line grep misses both); the lying “any-specs / cast at the entry boundary” comment; the unnecessary{ input: … }hand-annotations inimplementSurfaces.test.ts. Out-of-line deps consts keep a cleanImplementSurfaceDeps<typeof spec>annotation (structurally required by TS for a standalone const — no builder API chases this). - The inversion — one home, one writer.
implementKoluSurfacetakes plain domain-named dependencies (readPadiMemoryOnce/readDaemonInventoryOnce/ a payload-typedonState/padiIdentity) and builds all members insidesurface.ts— the frameworksource(...)nodes are assembled there (never a kolu-named parallel poll-spec type — that would duplicatePollSourceOptionsone layer up).padiLink+processStartedAtconvert to push-source derived cells on the singleonStatesubscription, retiring their hand-rolled in-memory stores and the exported-ctx write path (index.ts:999/:1005) — “graph is the one writer” completed. Explicitly NOT the deferred poll arm: the reactor defers poll reads by a microtask, the exact stale-read hazardcaptureLatestexists for — verify push-emit timing is synchronous at build, and STOP if it is not.KoluDerivedCellsdies;index.tsimports no reactor primitives. - The one genuinely-lower move (the real framework half, drishti-paired): graduate the cadence fuse — kolu-server’s
everyMsOrOnState(pollCadence.ts:29) and padi’s byte-sameeveryMsOrOnDaemonChange(servePadi.ts:168) — to@kolu/surface’s reactor besideeveryMs(two proven consumers, zero kolu concepts; the SR8.a-recorded follow-up lands here). Both twins delete;ref-surface.mdxupdates. Do not ship SR8.c with the duplicate pair still standing.
captureLatest was deliberately kept at app policy through this move — one consumer; its deeper cure (surface-remote’s honest liveness during connecting) shipped separately as #1852: the seam grew Session.currentState(), the honest synchronous point-read twin of onState, and the memory-rail gate reads phase === "connected" off it, so captureLatest and pollCadence.ts are gone. The corrected mechanism there also falsified this note’s implicit framing — onState delivery is microtask-deferred, so captureLatest’s “same synchronous frame” guarantee was an accident of ordering, and the retired currentClient() !== null gate actually leaked stale-live through whole reconnect backoff windows, not just one microtask. The build’s gauntlet surfaced two further reactor-depth candidates, recorded with gates, not done: a hot push-source (replay the current level to a late subscriber — would retire the shared-source seed invariant the build instead enforces fail-fast at boot; gate: a second late-subscriber consumer, or that boot assert ever firing in the wild) and poll-seed totality lifted into the reactor (log-skip-continue on the T+0 read, retiring per-cell total-wrappers; gate: a second total-wrapper appearing). Both are @kolu/surface changes with drishti drag — prove-then-extract holds them. Done-criteria, grep-shaped: no Omit<ImplementSurfaceDeps spelling anywhere (multiline-aware grep); no local cadence-fuse in kolu-server or padi (the reactor export is the only spelling); index.ts imports no derived/source; behavior-neutral — e2e + seal. Gate: none — schedulable. Provenance: srid’s post-merge finding on #1835 + the two lens runs.
SR11 — a member declares its client policy (shipped — #1847, merged 2026-07-16)
The campaign gave the producer side its DSL — a member declares its schema, its equals, its derivation, and the framework mints the serving. The consumer side never got the same treatment: client policy — which onError, floor-on-liveness or not, authority, coalescing, an initial — is still spelled per use-site, ~22 sites today, and the code confesses the pattern (useDaemonStatus.ts:192: “Same singleton app.cells.X.use(...) pattern as processMemory”).Surfaced by the LLM-and-DSLs discussion (the Fowler article read): kolu’s honest app-glue DSL opening is not a string mini-language or a codegen step — the spec and the interpreter already live in the same language; what’s missing is the declaration carrying the consumer half. Grounded 2026-07-15 against wire.ts, HostDaemonChips.tsx, useDaemonStatus.ts.
Before — real sites, quoted. Every consumer re-decides policy at the call:
// wire.ts:277 — preferences
const preferences = app.cells.preferences.use({
authority: "local",
initial: DEFAULT_PREFERENCES,
coalesceMs: 150, // the #1041 drag-frame debounce
onError: (err) => toast.error(`Preferences error: ${err.message}`),
});
// HostDaemonChips.tsx:146 — processMemory, per host
return padiMap.entry(host).cells.processMemory.use({
onError: (err) => toast.error(`Padi/kaval memory error: ${err.message}`),
});
After — the policy moves into the declaration (common/surface.ts, beside schema + equals), and the framework mints the hook:
// common — policy is DATA: a closed discriminated union, never a string.
// (A toast FUNCTION can't live in common; the declaration carries the typed
// intent, the client owns the one interpreter of each arm.)
type ClientErrorPolicy =
| { kind: "toast"; label: string } // label is payload, kind is checked
| { kind: "log" } // observable but silent to the user
| { kind: "membership" }; // routes to the host-membership handler
processMemory: {
schema: ProcessMemorySchema,
equals: processMemoryMbEqual,
client: { onError: { kind: "toast", label: "Padi/kaval memory" } },
},
preferences: {
schema: PreferencesSchema,
client: { authority: "local", initial: DEFAULT_PREFERENCES,
coalesceMs: 150,
onError: { kind: "toast", label: "Preferences" } },
},
// client — ONE interpreter per arm, registered once (satisfies-never fenced,
// so a new policy kind FORCES a decision here):
const interpretErrorPolicy = (p: ClientErrorPolicy, err: Error): void => {
switch (p.kind) {
case "toast": toast.error(`${p.label} error: ${err.message}`); return;
case "log": log.warn(err); return;
case "membership": onHostMembershipError(err); return;
default: p satisfies never;
}
};
// the use-site shrinks to the read; policy is unrepeatable:
const mem = padiMap.entry(host).cells.processMemory.use();
What it deletes: the 22-site policy class, the same way derived.cell deleted the publish seams — a future consumer can’t re-decide a member’s error routing or floor, because there is no options bag left to spell it in. The open design questions a lens run must settle before this is a PR: the exact arms of the policy union and their interpreter’s home (the sketch’s shape — typed union in common, one satisfies never-fenced interpreter in the client — is the P4-correct skeleton; an earlier draft spelled it as a "toast:…" string, which srid caught as one string doing two jobs, discriminant + payload, unrejectable by the checker — the union is the fix, recorded here so it isn’t re-litigated); per-site legitimate divergence (a site that genuinely wants a different floor must become a declared variant, not an override knob); and whether initial/authority are policy or wiring. Gate: lens-run-first (the design-bearing trigger applies in full) + srid’s ratification; drishti-paired (a @kolu/surface declaration-shape change). Until ruled, the 22 sites stand — they are correct, just repetitious.
The campaign is complete only when these negative properties hold — each is a grep or a test, not a judgment call:
- No production consumer imports oRPC
implementmerely to finalize a Surface; no serving API accepts private mount material; child failure reachesdone; repeatedcloseis harmless. - No production consumer casts a declared Surface procedure or copies its callable client shape.
- Kolu and Drishti contain no
as anycontract/map splice and no string sibling key at any connection site. - A map member cannot enter failed state without a schema-valid domain failure; no framework fallback cause exists.
- A map add always gets a never-reused membership id; every owner and cache switches on it;
connectionRearm.tsdoes not exist. - A failed map entry never collapses an app-wide gate — readiness stays per entry.
- Drishti has one process protocol and no inert parent-owned member on the agent.
- A derived member has no ctx entry and no write verbs (the bridge’s one-writer law, held from phase 0 on).
Each framework PR updates the affected package README and reference note with the change that introduces its API (and the pass verifies the two daemon/supervisor README dependency claims against their real consumerspackages/surface-daemon/README.md:8-9 and packages/surface-daemon-supervisor/README.md:8-9 both claim zero kolu-* dependencies; the claims are unverified against the code that consumes them, not known-false — the docs pass settles them.); each runs package tests and full CI, and every drishti pair runs its own CI against the exact kolu commit it pins.