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.
27 KiB
Dependency resolution in Chord v2: one model (decided, 2026-10-29)
Status: decided (owner, 2026-10-29); built for slice 7a (audit A1, 2026-10-30: installing is cut, staging being synchronous; A5: retry dedupe by a visited set and a hop budget; A11: parked_long for any long park) (§1-4 and §6-11 without what installs and reloads add:
§1's installing, §4's install-end and reload wakes, §3.9's reload handover and §5's removal, versions and rollout
are slice 7b's; folded into design.md §3, §5, §6, §7, §17, §21's start barrier, §22; slice-7a.md). The draft
history below is kept as it was. Sources: the owner's direction
("park until resolved"; "no difference between local and remote resolution: a link going is like a plugin
unloading"), the reviews (/tmp/dep-mechanism-*.md, /tmp/dep-graph-*.md, /tmp/barrier-*.md,
/tmp/model/review{1,2}-*.md), the code at pi 358f7d46a. Owner marks an open decision.
Draft 7 (round 6): (A) also retries in lockstep without finding the cycle (Fable); the probe's recovery after a
suppressed search becomes a choice between (B1) a re-search backoff for probes only (recommended) and (B2) a full
search lifecycle without timers, since dead notices alone need recovery state that eviction may not drop (Astra, Sol).
Causes are compared by value on wakes (Opus).
Round 7: a returned search is accepted while its initiator is parked, whatever its round (Astra: re-searches must not
invalidate searches under way); detection is eventual, not within one backoff period; the local walk stays (Opus).
Draft 6 (round 5): cycles across hosts become an owner choice between (A) the owner's slice 4b decision (simultaneous
starts across hosts time out, no probe message), narrowed to one deadline, and (B) the probe, completed with edge
tokens, no revisits, and dead notices back to the initiator (Astra: a check that dies silently can lose the only
search that survived dedupe). (A) turned out to either poll chains across hosts again or miss a ring of waiting
attempts, so (B) is recommended, pending its review.
Draft 5 (round 4, Sol's phantom cycle): the second round is replaced by a check along the recorded trail of
(attempt, edge generation) hops; a reported ring existed at one moment; probes grow with their path.
Draft 4 after round 4 (probe only): round numbers per attempt, forwarded only when newer than the stored one; probes honoured on parent and route links, dropped on child links; completeness stated under an eventually stable graph; the repair caveat broadened to any concurrent edge removal; a new edge generation resets confirmation; a bounded display trail.
Draft 4 changes (round 3): the probe starts a fresh round on every new or changed wait edge (a ring closes at its last edge, whose source finds it), keeps one seen entry per initiator, accepts only its current round, and states its cost honestly; every host drops probes from untrusted links; local forwarding is iterative; no-placement remote requests are re-placed when a host's first parent link connects; a cohort object that ends before its first outcome settles the barrier; the last-good refinement is marked for the owner.
Draft 3 changes (round 2): remote types without placement keep today's fallback (here if served, else the parent);
hydration stays before parking; no copied retryAt; a dependency ended breaks its dependent; a reload's handover
binds new requests before releasing old ones; retry passes only to broken and transient blockers, once per
operation; installing re-evaluated at every install end (commit or rejection); a registry's last good value dropped
when one of its contributors leaves; the probe with round ids, bounded seen sets, edge-generation triggers, attempt
ids on every non-live answer and relays dropping probes from untrusted links; one cycle mechanism, local and remote.
0. The problem
The same situation, "what I need is not there", has five outcomes today, depending only on how it is declared:
| Declared as | Provider missing | Today |
|---|---|---|
self.get(LocalType) |
no facet serves it | self.get throws, the factory throws: broken |
self.get(RemoteType) |
the worker does not serve it | the hold is refused broken; the dependent transient, polled 1-60 s forever |
self.registry(R) |
no provider | transient, woken at R's first value only |
self.registry(R), reader newer |
broken |
|
| a reload removes the type | holders get gone: ended for good |
And the startup barrier deadlocks an acyclic graph across two hosts (Sol, /tmp/dep-graph-sol-barrier.log): S's eager
A needs W's B, B needs S's C; W's hold for C waits for S's whole load, which waits for A. Only readyTimeout ends it.
1. Requests and one resolver
-
A request is what a reference stands for: type (or registry), params, principal, the consumer's contract version. Its holder makes it and owns it: an attempt (
self.get,self.registry, keyed pins), a call (ctx.get), outside code (host.get,host.hold,host.registry), a link's peer (hold). It ends only when its holder ends it (the attempt ends, the call settles,release(), a getter gives up, the peer's link or origin closes, revocation, the holding host closes), never because its provider is missing, went away or does not fit. -
The destination by address (round 1): a
localtype goes here; aremotetype goes to the address itshostAddress(computed from the consumer's own contract) gives: here if that is this host's address, whether served here or not, else over the route for that kind or the parent (design §3). Everyremotetype declares itshostAddress(owner, 2026-10-29: the optionalplacementof slice 3a is removed and the field renamed, and with it the fallback "here if served, otherwise the parent" and its re-placement at the first parent link). A hold arriving for an address that is not this host's is relayed or refused (invalid/host_address) as today; a relay never captures a hold because it serves the same name. -
Then the selected provider is checked (served, version or accepted version and fingerprint,
authorize, the provider's own host-address check). Version checks never look at a same-name registration elsewhere. -
The publication gate: a request made on this host, or arriving on its links, before the host's first publication (§6) is not placed yet; it shows
loadingand is placed by the publication. The gate lasts only for the synchronous setups, never for activation, so it cannot deadlock. -
Outcomes of the check:
The check finds Request a fitting provider bound (to the object here, or to the hold over a path) not published yet unbound unpublished:loadingnobody serves the name at its address unbound not_served:failed { waiting }no fitting version, a fingerprint that differs at the same version, a host address the provider computes elsewhere (code skew) unbound version:failed { waiting }the registry is served, but its first compose(or the reader's version mapping) threwunbound compose:failed { waiting }the provider host is installing a revision and the hold does not fit its current code unbound installingat the provider:loadingthe path has no link (a drop, a route resolving) on the path: loadingauthorizerefused, invalid paramsfailed { broken, forbidden | invalid }, as today, never indexedThe unbound reasons that wait for a name (
unpublished,not_served,version,compose) are indexed under it on the host that decided them: the consumer's host for a local request; the provider's host for a hold that arrived over a link (it already keeps such holds as refused:remote/hold.ts).installingis the provider host's own, re-evaluated at its install's end;no_linklives on the path, where the next link holds it again. Indexes drop an entry when its request ends; GC-tested.
2. States
ref.state keeps its four kinds; FailureCause gains one kind, waiting:
type RefState =
| { kind: "loading" }
| { kind: "live" }
| { kind: "failed"; cause: FailureCause; retryAt: number | undefined }
| { kind: "ended"; error: ChordError };
type FailureCause =
| { kind: "transient"; error: ChordError } // the object's own effect failed (an open, a follow): backoff
| { kind: "broken"; error: ChordError } // the object's own code, a cycle, or authority: retry() or a reload
| { kind: "waiting"; error: ChordError }; // not the object's fault: no provider, no fitting version, a
// dependency failed; retryAt undefined, woken by an event
| The request is | ref.state |
Calls |
|---|---|---|
| bound, its object activating or parked with no failed blocker | loading |
park, bounded |
unbound unpublished or installing, or its path has no link |
loading |
park, bounded |
unbound not_served, version or compose |
failed { waiting } |
reject at once |
| bound, its object failed | failed with the object's cause, as today |
reject at once |
| bound, its object live | live |
run |
| ended by its holder, revoked, or its host closed | ended |
reject |
Owner: waiting as a new cause kind (Opus, Fable; recommended: the cause says "not your fault, an event will wake
you" and a UI can say what is missing) or no new kind: transient with no retryAt (Astra; today's wire already
carries a transient cause without retryIn, remote-object.ts), or loading with a diagnostic (Sol; parks every
call on a missing plugin for its full readyTimeout).
3. Dependents: parked until their dependencies resolve
A dependency is what an attempt declares and needs before it goes live: self.get (local or remote),
self.registry, keyed pins (at(key) on another object's keyed member). self.follow is the attempt's own step (its
attach runs Durable's code), not a dependency; a follower declares retryOn on the registry it reads the harness
through, or recovers by its own backoff.
-
Order, as today up to the check: the factory runs; persisted singletons hydrate (the object's documents, kept across attempts); the requests are made. The attempt is parked while any dependency is not
live(hydrated):Its dependencies The dependent shows any loading, none failedloadingany failedfailed { waiting, "waiting for X: <X's cause>" },retryAtundefined; the message keeps the nearest blocker and the root cause within the 500-character ruleone failed with invalid/cycle(a dependent off the ring)brokenwith the same error: it would wait on a ring that never heals by itselfone failed with forbidden(anauthorizerefused it: authority, §3.6)brokenwith the same error: onlyretry()or a reload helps (slice 7a fix round 1)one ended(revoked, or its host closed)brokenwith that error: the request cannot come back;retry()or a reload makes new requests (a guard: a dependency ends only with its holder's principal or host, so the dependent ends too; Fable) -
The dependency check is the activation boundary: once every dependency is
live, the attempt runs its own steps (natives in order, follow attaches, member checks, derives), bounded by its ownreadyTimeoutdeadline, and goes live. A dependency that leavesliveafter the check is treated as after live (R18): the attempt goes on. This is R18's rule for live objects applied from the check on, not a rule of its own (owner, 2026-10-29: a dependency can go away at any point of an object's life; the object stays live, its calls through that reference meet its state). So a parked attempt holds no natives, and an attempt that opened natives is never parked. -
No timer while parked: no dependency deadline, no backoff. The attempt's deadline is paused while parked (as eager migrations pause it) and resumes with the budget left.
-
Wakes, one rule: the dependent re-evaluates on every state change of any of its dependencies (live, loading, failed, a new cause compared by value, ended; Opus: otherwise a ring of waiting attempts passes its causes round and round), updating what it shows; it leaves parking when all are live. The factory never re-runs while parked. Wakes are coalesced per object and run after the batch that caused them; a change during the attempt's own steps or its teardown is remembered. (As built in 7a, with the parent's note 5: a change while the parked attempt checks or loads its pinned keys is remembered and checked again; after the dependency check, during the own steps, a change is ignored, §3.2's R18 rule.)
-
Kept while parked: its documents (the object's), its declarations and requests, its seal token; no natives. Ended with the object (its last holder; a host-lifetime object when its plugin is removed or the host closes).
-
brokenonly for the object's own code (a throwing factory, a derive throwing at activation, hydration,migrate, a document fingerprint mismatch, anopenthrowingbroken(...)), cycles (§7) and authority (forbidden, an ended dependency). -
Retry passes on:
ref.retry()on afailed { waiting }object retries itsbrokenandtransientblockers, hop by hop (the existingretrymessage over links), once per retry operation (an id dedupes diamonds);waitingblockers pass it on; nothing else is touched. Retry of abrokenortransientobject starts a new attempt, as today. -
retryOnwakes failed objects as decided in slice 8 (a value call retries atransientorbrokenobject); while the object is parkedwaiting, a value call does nothing: its dependencies decide. -
Own-code reload of a parked object: its plugin's reload replaces the parked attempt with the new factory's, as one handover: the new requests are made and bound first, then the old ones released (round 2, Astra N4: an unchanged dependency held only by this object must not end in between; today's
#activatebinds before#releasePrevious). -
After live, R18 unchanged: a dependency leaving
liveleaves its dependent live.
4. Wakes: two events
| Event | Re-evaluates |
|---|---|
a target changes state: a dependency's object goes live, loading, fails, gets a new cause or ends; a hold's bound, loading, failed arrive, also after a re-hold on a new link |
the requests bound to it, and their dependents (§3.4) |
a name becomes usable, or an install ends, on a host: a publication (load's setups, a reload's commit, an install's commit) serves a type, a registry, a version; a registry composes or maps a value at a version; an install ends without a commit (rejected) |
the unbound requests indexed under those names; the provider's installing holds; a refused hold re-runs its start and sends loading, then bound or failed |
No new message kind for wakes: loading, bound, failed and retry exist. The wire's failed carries the new cause
kind waiting.
5. Removal, versions, rollout
- A reload that removes a type: its objects end as today (natives close, reason
end; callers rejected at once; handlers drain). Their requests are not ended: each becomes unboundnot_servedand binds again if a later commit serves the name.- Locally: a
Bindingwhose target ended by removal becomes unbound and accepts a secondbind: member sources re-bind by name (one frame from the last value, as on a restart), keyed pins re-pin, parked calls flush or reject, presence re-lists. The one new piece of machinery. - Over a link: a
failedon a bound hold unbinds it (Fable: one message, one rule; the consumer applies it as#startOverplusfailed); the provider detaches the hold's sources and drops its unsent pending ops and queuedbound; the ack slots ofbound/statemessages already sent stay counted until theirackor the link's loss; calls in flight on it are rejected and free their slots once.
- Locally: a
- A reload that stops accepting a version unbinds only the requests on that version (
version); the object stays (its own plugin's reload still restarts it if it has natives, §21). - A type moved to another host kind: the request carries the address its own consumer code computed; the old host
answers
not_served; the consumer's own reload makes a new request. No redirect. - A consumer whose code moves to a new version releases its old request and makes a new one (today).
- Rollout, without revision fields: each host knows only its own install. A provider host that installs keeps
holds that do not fit its current code
installing(loading) and re-evaluates them when the install ends, by its commit or by its rejection. A provider host that is queued but not installing yet answers a mismatchfailed { waiting, version }until its install commits and wakes it: a visible blip of at most one install, accepted (Opus). An install never waits for activations (a reverse-order rollout would deadlock); a host whose own install was rejected fails its own requests at once with the reason (design §21). The loader keeps its own duties (the desired code set, digests, serialized installs). - Old calls across a reload follow §21 unchanged: a pure swap lets them finish on old handles; a native restart drains them first; a removal rejects their callers at once and drains the handlers. Calls are never resent.
6. Startup: publication, then first outcomes
- Publication:
loadruns every facet's setup, then publishes all at once in one batch (types served, registries served and composed once, contributions in their tables), then wakes (§4) after that batch. Nothing activates inside it. - No gate beyond it: holds and
host.holdwait only for the publication, never for the barrier. This ends the startup deadlock: W's hold for C binds as soon as S's setups published; B goes live, then A. - The barrier (Owner: (a) or (b); recommended (a), three reviewers of four):
- (a):
loadresolves when every host-lifetime object of thisloadhas a first outcome:firstOutcome(o) = o.live || o.ownFailure || (o.parked && o.blockers.every((b) => !b.local || firstOutcome(b))), wherelocalmeans executing on this host (an object here, a keyed pin still loading here), not the contract's kind. SoCodeMode → Harnesswaits for the harness's outcome; a wait on another host or on a failed dependency ends the first pass. OnefirstOutcomesignal per object, separate from the readiness dependents wait for (Sol), counted once, never reopened by retries. An object of the cohort that ends before its first outcome (its plugin removed, the host closing) settles its entry as ended;host.closesettles the waitingload(Astra R3). - What (a) promises: every host-lifetime object on this host had its first try, and the contributions of those
that went live are in their tables. Not: every tool that will arrive (a parked or slow contributor, tools found
in
onLive, MCP; Sol's probe/tmp/barrier-sol-first-outcome.log). What a first turn needs is p3's explicit admission check. - (b):
loadresolves after the publication; Chord addshost.startup, a watchable of the host-lifetime objects' first outcomes with their causes; the app decides (Fable). The same tracker, awaited by the app. - p2 today resumes its harness without waiting for all host objects (
apps/p2/src/hosts/worker.ts), because its worker objects that need server types are reachable only aftercontrol.ready().
- (a):
7. Cycles (decided 2026-10-29: no probe)
On one host, cycles are found as today: at self.get, following the waiting edges of activating and parked attempts
(exact). Across hosts, a hold carries the activation chain it was made in, which finds every ring whose objects start
one after another. The remaining gap, a ring across hosts whose objects start at the same moment, is not detected:
without a dependency deadline it stays parked. It is made visible: an attempt parked longer than its readyTimeout on
a remote attempt that is itself only loading is reported once per parking episode as HostError parked_long { object, waitingFor, since }; nothing fails or retries. A cycle is a plugin bug; the guide gives plugin authors and
agents four rules: (1) a factory declares only what the object needs, never a type that needs it back; (2)
back-references and occasional lookups go into methods and routines (ctx.get); (3) data flows up through state and
registries, not mutual self.get; (4) host-lifetime objects on the server never declare worker types in their factory.
Rejected (owner, 2026-10-29): the probe (edge-chasing searches with a check along the recorded trail; seven review
rounds, /tmp/model/review*-*.md; complete only with a re-search timer or a full search lifecycle, the most intricate
code in Chord, for a plugin bug) and the slice 4b timeout narrowed (it polls chains across hosts again, misses a ring of
waiting attempts, and retries in lockstep without finding the ring).
8. Getters, holds, calls: one rule
host.holdreturns a reference at once; only invalid params throw (also beforeload). A missing local type givesfailed { waiting }after the publication,loadingbefore it.host.get,ctx.getandhost.registry(owner, 2026-10-29, decision 2): resolve when live; wait whileloading,failed { transient }orfailed { waiting }, up to theirreadyTimeout(default 60 s, the caller's choice), then reject with the last cause; reject at once only onfailed { broken }. On success, rejection or timeout their temporary wait goes;host.registryreturns theRegistryValuewith its ordinary watch ownership (norelease()). A UI holds and showsref.state; code that needs the object now gets it, with the patience it passes.- Calls on a
loadingreference park, each bounded by the reference'sreadyTimeoutfrom when it was made; on afailedone they reject at once with the cause; sent calls are never resent.
9. Registries
- The same resolver on one host: a reader's request for R at its version is bound while R is served and has a usable
value at that version (composed; mapped by
accepts[old].valuefor an older reader), otherwise unboundnot_served,versionorcompose, orloadingbefore the publication. Woken at every transition to usable (#valued's first-value latch goes). S2'sbrokenand S3's backoff go. - Version skew within one host: a reload of the registry's plugin alone leaves other plugins' contributors and
readers on their bundled versions;
accepts[old].contributionand.valuekeep them working, unsupported contributions are left out and reported, unsupported readers arewaiting(version). - A registry waits for a value, never for a particular contribution (an empty table composes). After live, a later
composefailure changes nothing for the reader:.valuekeeps the last good composition, but only while every contribution it was built from is still in the table (round 2, Astra N2): when one of them leaves (its contributor ended or was reloaded), the last good value is dropped and.valuethrows untilcomposesucceeds, so it never hands out a native whose contributor ended.composerepresents a missing contributor as a value. - Registries stay host-local (contributions are in-process values).
10. Keep-alive, presence, revocation, jobs
- A request over a link counts once in
host.activity.referencesfrom its arrival to its end, bound or unbound; presence (host.references) lists only bound references. - Revocation ends the requests of a revoked principal, bound and unbound, at every hop; nothing revives them.
gonestays only for revocation (a provider host closing closes its links and sends nothing; its consumers see a drop,loading, and hold again on the next link).- Persisted job runs of a parked host-lifetime object are loaded with its documents;
runandfinishstart only once it is live; the run count keeps the object as today.
11. Timeouts after the model
| Timeout or backoff | After | Why |
|---|---|---|
readyTimeout of an attempt |
only its own steps; paused while parked | a slow dependency is bounded where it runs |
readyTimeout of host.get, ctx.get, host.registry |
kept | a caller gets an answer |
readyTimeout of a parked call |
kept | an action must not run long after it was asked for |
| identity backoff 1-60 s | only for the object's own transient failures |
nothing else says the disk or network is back |
| dependency backoff, polling of refused holds | removed | recovery is an event |
| dependency deadline across links | removed | the simultaneous-cycle gap is a plugin bug, reported by parked_long |
parked_long report |
new: once per parking episode after readyTimeout on a remote attempt that is only loading |
a diagnostic; nothing fails or retries |
route backoff, heartbeat, closeTimeout, routine and finish backoffs, linger |
kept | not dependency waits |
| rollout install timeout (not built) | a watchdog on the install only | never a deadline for dependencies |
12. What changes in the design
R18 (dependency failures waiting, no backoff, the deadline paused while parked, the dependency check as boundary,
wakes on every change, an ended dependency breaks), S2 and S3 (waiting, woken at every usable value), slice 8's
last-good rule (dropped when a contribution leaves), §3's destination table (by address; the publication gate), §6 (the
simultaneous-cycle gap: no probe, parked_long), §7 (host.hold, getters, calls), §21 (no gone on
removal; a dropped version unbinds only its requests; installs never await activations; rollout without revision
fields; the reload handover), the start barrier (R17, W6), FailureCause (+ waiting), parked_long. Unchanged: what an object's own code reload does (§21), R18 after live,
retryOn outside parking, self.follow's R4, the call window and flow control, revocation, no client providers.
13. Decisions (owner, 2026-10-29)
- The barrier: (a),
loadresolves at the first outcomes of this host's host-lifetime objects (§6.3). - Getters wait through
waitinguntil theirreadyTimeout; calls onfailedstill reject (§8). waitingis a newFailureCausekind (§2).- A pure swap whose new factory declares a dependency that is not there parks the object (its gate closes; holders
see
loadingorfailed { waiting }; calls park or reject); no overlap of old and new code (§21). - The last good registry value is kept only while every contribution it was built from is still in the table (§9).
- Cycles across hosts: no probe;
parked_longand four authoring rules (§7).
1, 3, 4 and 5 are the recommendations, taken when the owner said "just build this" (2, 6 decided in discussion).