mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
The owner's audit decisions (decisions.md, "Simplification audit"): cut the reload-only installing state (staging is synchronous) and the static registry cycle edges (the runtime guard stays); retry propagation by a visited set and a hop budget (retry.hops) instead of operation ids and through lists; a pure swap sends holds a fresh bound; one rule after a live failure (every member followed until the admitted commits published); parked_long for any long park, naming the first blocker in declaration order with its state; one seal token per attempt. Keys follow the old instance during a restart's drain like members. src/next: about 255 code lines fewer.
194 lines
16 KiB
Markdown
194 lines
16 KiB
Markdown
# Slice 8: registries and `host.root` (spec)
|
|
|
|
Status: **built** (2026-10-28; build review and two fix rounds by the same four reviewers,
|
|
`/tmp/s8-build-review-*.md`, `/tmp/s8-build-rereview-*.md`). Before: ready to build (2026-10-27; D1-D3, the registry failures and S1-S3 decided by the owner; two spec reviews,
|
|
`/tmp/s8-review{,2}-{astra,fable,opus,sol}.md`, found no remaining blocker; S3 refined after the second: with no value
|
|
after `load`, a reader fails at once, as `self.get` of a type not served here)
|
|
|
|
8 builds the extension point between plugins (registries), in-process values that change (`self.writable`), wake
|
|
signals for failed objects (`retryOn`), and the call scope for code outside objects (`host.root`). The harness object
|
|
is p3's base code; Chord shows it only in an example.
|
|
|
|
## What an app can do after this slice
|
|
|
|
```ts
|
|
// the defining plugin (e.g. http): the token, exported for other plugins
|
|
export const Http = defineRegistry<HttpContribution, Router>("chord.http", { version: 2 }); // shared: identity only
|
|
export default defineFacet((host, self) => {
|
|
host.serve(Http, { // the one provider (Q8), as for object types
|
|
compose: (table, { report }) => buildRouter(table, report), // pure; always a value; conflicts reported
|
|
accepts: { 1: { contribution: (old: HttpContributionV1) => upgrade(old), value: (router) => routerV1(router) } },
|
|
});
|
|
host.serve(HttpServer, (_, self) => {
|
|
const router = self.registry(Http); // a NativeWatchable of the composed value
|
|
self.on(router, ({ value }, ctx) => …);
|
|
return { … };
|
|
});
|
|
});
|
|
|
|
// any plugin: a facet or a host-lifetime object contributes one value per registry
|
|
export default defineFacet((host, self) => {
|
|
self.contribute(Http, { routes: { "GET /notes/export": exportNotes } });
|
|
});
|
|
host.serve(McpStore, (_, self) => { // local, empty params (R21)
|
|
const ext = self.writable(mcpExtension([])); // an in-process value that changes
|
|
self.contribute(HarnessExtensions, ext);
|
|
self.on(mcp.tools, ({ value: tools }) => ext.set(mcpExtension(tools)));
|
|
self.retryOn(self.registry(HarnessModels)); // a model contribution wakes a failed attempt
|
|
return {};
|
|
});
|
|
|
|
// code outside objects (a Durable tool, a CLI): a call scope
|
|
await host.root(async (ctx) => (await ctx.get(Notes, { session })).add(note, ctx), context);
|
|
```
|
|
|
|
## 1. Registries
|
|
|
|
- **Token and provider (D1, owner, 2026-10-27):** the token `defineRegistry<C, V>(name, { version })` is shared, as a
|
|
contract: its name (as types, `[a-z0-9@/._~-]`), the contribution type `C`, the composed value type `V`, and the
|
|
version the bundling plugin was built against. The defining plugin's facet provides it with `host.serve(R, {
|
|
compose, accepts? })`, as it serves object types (adapters with the provider, never in the shared token, as
|
|
`checkAccepts` for contracts); a second serve on a host is refused; a reload replaces it (slice 7).
|
|
|
|
- **Contributors** (R21): facets (by plugin name) and host-lifetime objects (`local`, empty params: by plugin name and
|
|
type), at most one value per registry each; `self.contribute(R, value | Watchable | NativeWatchable)` in a facet setup
|
|
or such an object's factory (a type error elsewhere; a declaration error at runtime). A facet's contribution is in the
|
|
table while its plugin is loaded; an object's from `live` until it ends, fails or restarts.
|
|
- **The table:** an immutable list of `{ contributor, plugin, value }`, sorted by plugin name, then type (never load
|
|
order); a contribution carries its token's version: an older one is adapted once with the declaration's `accepts`
|
|
when the table is built, an older one without an adapter or a newer one is left out and reported (`HostError`
|
|
`contribution_refused { registry, plugin, version }`).
|
|
- **Contributions:** a plain value, or a Chord watchable (a `writable`, a derive, a document member; another
|
|
registry's value too, a cycle of registries reading each other refused when the second one serves or contributes (superseded by audit A2: caught at the first composition by the runtime guard, reported `contribution_failed`)), followed as a dependent of the table (its change recomposes), recognised by Chord's own mark; anything
|
|
else is a plain value, whatever methods it has (a harness, a conversation handle or a runtime is contributed as a plain
|
|
value, not followed; build review 2026-10-27, replacing "a foreign watchable is refused"). The table's entry is `{ plugin, type?, version, value }` (`type` for
|
|
an object contributor; served types carry their plugin's name).
|
|
- **`compose(table, { report })`:** pure, lazy as a derive: the registry's value is marked when its table changes (a
|
|
contributor goes live or ends, a watchable contribution changes) and composed when read or, while watched, in the
|
|
owner's flush (W4: never by another owner's adoption itself; the flush after a commit step is where it runs). The first
|
|
composition happens at the serve: the facets' setups of one `load` serve and contribute, then every served registry
|
|
composes once in one batch at the end of the setups (an empty table composes too), so a served registry always has
|
|
a value before any object activates. `report(conflict)` goes to `onError` as `registry_conflict { registry, message,
|
|
plugins }`, once per conflict episode.
|
|
- **Failures (owner, 2026-10-27):** contributions never fail the registry. A contribution that is a watchable and fails
|
|
is left out of the table and reported (`contribution_failed { registry, plugin, error }`), the table recomposes without
|
|
it, and it is back once it heals. A `compose` that throws (the owner's bug) is reported (`registry_failed { registry,
|
|
error }`), the composed value's watches get a `failed` call, and `.value` keeps the last good composition (an extension
|
|
point stays available; derives keep their own rule, `.value` throwing while failed); the next good composition
|
|
replaces it. Before any good composition `.value` throws the error.
|
|
- **Without a provider** (the defining plugin is not loaded): the registry has no value; contributions wait in the
|
|
table; its serve composes it.
|
|
- **Reading is a dependency (S3, owner, 2026-10-27: "isn't self.registry meant to wait like self.get?"):**
|
|
`self.registry(R)` in a factory declares the registry as a dependency of the attempt, as `self.get`: activation waits
|
|
until it has a value; once `load`'s setups ran, a registry with no value cannot get one before a reload (a provider
|
|
comes only with a plugin), so a reader then fails at once `transient` with `unavailable/undeclared` as the
|
|
dependency's cause (as `self.get` of a type not served here fails at once, and the start barrier waits for no
|
|
contribution) and is retried the moment the registry gets its first value (R18); a first `compose` that throws fails
|
|
its readers the same way. It returns a `RegistryValue<V>` (a native source: a derive source, an `on` source, a `retryOn` source; never a
|
|
state member); methods and routines read `.value` at use. Once the object is live, a later failure of the registry
|
|
(a throwing `compose`) changes nothing for it: `.value` keeps the last good composition. Code outside objects:
|
|
`await host.registry(R, ctx)` waits the same way.
|
|
- **Older readers (S2, owner, 2026-10-27):** a reader whose bundled token has an older version than the provider's gets
|
|
the composed value mapped back with `accepts[old].value` (computed once per composition and version, while such a
|
|
reader exists); without that mapping, or for a reader newer than the provider, its dependency fails
|
|
`contract_mismatch` (reported). `accepts: { [old]: { contribution?: (old) => current, value?: (current) => old } }`.
|
|
Adapters are the provider's code, guarded as `compose`: a `contribution` adapter that throws leaves that contribution
|
|
out (reported `contribution_failed`); a `value` adapter that throws fails only that version's value, by the rules of a
|
|
throwing `compose` (its readers keep the last good mapped value; before any, a reader of that version waiting at
|
|
activation fails `transient`, retried when a mapped value arrives); current readers are never affected.
|
|
- **Helpers:** `keyed(table, entriesOf, report)` (a map, a duplicate key reported through `report`, the first by plugin
|
|
name wins) and
|
|
`ordered(table, itemsOf, orderOf?)`.
|
|
- **Use sites guard contributions** (Q8): Chord's helpers keep each entry's plugin name; the guide shows catching a
|
|
contribution's throws and rejections, reporting the plugin, and continuing.
|
|
- **Registries carry natives** (design §17): a contribution may be any in-process value; an entry lives exactly as long
|
|
as its contributor.
|
|
|
|
## 2. `self.writable(initial)` and native sources
|
|
|
|
A `NativeWatchable<T>` with `set(value)` (identity compared: `set` of the same value notifies nobody; a new value is a
|
|
new frame), owned by the attempt (dropped with it; `set` after the attempt's seal throws `released`); never a state
|
|
member; usable as a contribution, a derive source and an `on` source. `set` inside a batch marks it there; outside, it
|
|
opens its own batch. `writable`, `RegistryValue` and `ref.state` (a `NativeWatchable` since slice 3b, until now not derivable: design audit,
|
|
2026-10-27) are native sources: one base (`NativeSource`) the batch marks and derives depend on; `self.derive` takes JSON and native sources (its type and the source resolver widen), while the
|
|
member check still refuses a native as a state member.
|
|
|
|
## 3. `self.retryOn(...sources)`
|
|
|
|
In any object's factory, anywhere: Chord follows each source with watches owned by the object (under its context,
|
|
replaced by each attempt's declaration, stopped at the object's end; GC-tested), installed right after the factory
|
|
returns, before natives open, so a failed open still has them; a `value` call of a source while the object is failed
|
|
(`transient` or `broken`) retries it as `ref.retry()` does (backoff skipped, an attempt now, holders told `loading`);
|
|
a call while it activates is remembered and retries it at once if that attempt fails (decision S1); ignored while live. Sources
|
|
are those that live independently of the attempt: a `RegistryValue`, `constant`; anything else (a reference's member,
|
|
a `writable` of the same attempt) is refused at runtime (declaration policy; TypeScript cannot tell them apart).
|
|
|
|
**Which calls retry (D2, owner, 2026-10-27):** only `value` calls: a new value of a source wakes a failed object; a
|
|
`failed` call (a throwing `compose`) does nothing, its next good composition retries.
|
|
|
|
## 4. `host.root(fn, context, options?)`
|
|
|
|
**Signature and guarantees (D3, owner, 2026-10-27; design §26 #25):** `host.root<T>(fn: (ctx: Ctx) => Promise<T>,
|
|
context: Context, options?: { principal?: P }): Promise<T>`. `fn` gets a call context like a handler's: the host's
|
|
principal (or `options.principal`, a trusted same-host caller's choice, as base code acting for an authenticated
|
|
user), this host's incarnation, `context`'s telemetry and abort signal linked until `fn` settles
|
|
(`withLinkedAbortSignal`), and `ctx.get`. When `fn` settles or `context` aborts, the call context is sealed:
|
|
`ctx.abortSignal` aborted, `ctx.get` rejects `released`, every reference it took released (their calls reject
|
|
`released`, their watches end), so an uncooperative `fn` fails at its next use of Chord, as a handler after its caller
|
|
cancelled; work outside Chord runs on until then. `ctx.get` before `load` resolved parks like any reference;
|
|
`host.close` seals every open root scope (the host keeps them), so an `fn` waiting in `sleep(ms, ctx)` ends; `host.root`
|
|
after `host.close` rejects `released`. No span of its own until slice 9.
|
|
|
|
- `chord(fn, context)` is p3's helper over `host.root` (design §24); Chord ships none.
|
|
|
|
## 5. Durable on registries (examples, p3's pattern)
|
|
|
|
- Durable's `HarnessOptions.registry` takes a `RegistryReader` (`snapshot()`, `subscribe(listener)`, the listener
|
|
called synchronously after every publication). A `RegistryValue` offers a synchronous observer of its compositions
|
|
(`observe(observer, ctx)`, as Durable's `observeXXX`: `advance` inside the flush that composed, record only; an
|
|
attached observer counts as watching, so the lazy compose runs for it); Durable's
|
|
`registryReader(value)` (in its `src`) reads `.value` in `snapshot()` and forwards `observe` to `subscribe`. The
|
|
harness object declares the extensions registry with `self.registry(...)`, so it activates (and opens Durable) only
|
|
once the registry has a value. p3's `compose` builds Durable's snapshot without throwing (a duplicate task name is
|
|
reported and the later extension left out).
|
|
- **Durable's views from other objects** (carry-over from 6b): the object that opens Durable contributes its harness to
|
|
a registry (`HarnessRegistry`, p3's); another object declares it in its factory and follows a view through its value:
|
|
|
|
```ts
|
|
const harness = self.registry(HarnessRegistry); // a dependency: activation waits for it
|
|
self.retryOn(harness); // the new harness's contribution wakes the failed follower
|
|
const view = self.follow((observer, ctx) => harness.value.observeConversation(id, observer, ctx));
|
|
```
|
|
|
|
When that harness closes, Durable ends the observer `failed` (S1, a Durable change in this slice: a Session close
|
|
ends observers `failed`, `ended` only when the observed owner ended), so the follower fails `transient` and its retry
|
|
follows the new harness.
|
|
- Examples: Chord's `29-registries.ts` (a router registry with two contributing plugins, a conflict reported, a
|
|
`writable` contribution, a missing provider healing) and `30-host-root.ts`; Durable's `test/examples/37-harness-
|
|
registry.ts` (a Durable harness on the store, its extensions from a registry, its views followed by a second object)
|
|
and the `registryReader` tests in Durable's tree. Durable's `subscribe` listeners then run inside Chord's flush:
|
|
they only wake Durable's scheduler (no Chord call; a reentrant one would throw `invalid/reentrant`), which the
|
|
`registryReader` test pins.
|
|
|
|
## 6. Carry-over
|
|
|
|
From `slices.md` "Slice 8": `host.root` (D3); `retryOn` (section 3); registries recomposing in the owner's flush (W4);
|
|
`host.activity` on the facet's host: not built (nothing needs it; dropped from the carry-over); Durable's views from
|
|
other objects (section 5). Left to slice 7: registries on reload (a reload replaces a contribution and a provider
|
|
atomically; staging composes nothing, decision D1 of "Review round 1"); `accepts` of contracts and jobs; a `writable`
|
|
across a pure swap; older registry readers across a reload of the provider.
|
|
|
|
**New names** (design §3, §5, the pinned reason list): the reason `unavailable/undeclared`; `HostError` kinds
|
|
`registry_conflict`, `registry_failed`, `contribution_failed`, `contribution_refused`, each reported once per episode.
|
|
|
|
## 7. Tests and cost
|
|
|
|
- One `describe` per mechanism: tokens and providers (two serves refused, a missing one healing), contributors
|
|
(R21's type and runtime refusals, facet and object lifetimes, one value each), the table (order by plugin, versions
|
|
adapted, refused versions reported), `compose` (in the flush, after a commit that changed a watchable contribution,
|
|
conflicts reported, a throw as a failure call and its heal), reading (`self.registry` in derives and `on`, `.value` at
|
|
use), `writable` (identity, batching, as a contribution), `retryOn` (a failed object retried by a contribution, value
|
|
calls only, sources installed before natives), `host.root` (principal, release at settle, abort, before load, after
|
|
close), `registryReader` with a Durable harness; GC for every table, writable and root scope.
|
|
- Cost guards: one compose per flush per changed table; no compose for an unchanged table; a `writable.set` of the same
|
|
value costs one comparison.
|