mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
The next object runtime moves from the private repository into pi: src/next (contracts, hosts and objects, documents, derives, links and relays, routines and jobs, the store) with its tests and examples in test/next, src/next-node (Node compression), and its design in docs/next (design.md, the decision log decisions.md, the slice plan slices.md, slice specs, books and benchmarks). AGENTS.md gets its code style. Not exported: the API changes with every slice until it has an entry point.
5409 lines
422 KiB
HTML
5409 lines
422 KiB
HTML
<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Chord v2: a guide to building apps that reload</title>
|
||
<style>
|
||
|
||
:root{--paper:#fbf8f2;--ink:#2a2723;--muted:#7b7468;--rule:#e3dbcc;--accent:#b4472f;--blue:#2e6a8a;--sand:#c2b18c;--soft:#f4eee2}
|
||
*{box-sizing:border-box}
|
||
html{background:#efe9dd}
|
||
body{margin:0;color:var(--ink);font:19px/1.68 'Iowan Old Style','Palatino Linotype',Palatino,'Book Antiqua',Georgia,serif;
|
||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||
.book{max-width:860px;margin:0 auto;background:var(--paper);box-shadow:0 0 0 1px #e1d9c8,0 30px 80px -40px rgba(60,40,10,.35);padding:0 0 4rem}
|
||
.inner{padding:0 90px}
|
||
h1,h2,h3{font-weight:400;line-height:1.15}
|
||
h2{font-size:2.1rem;margin:.2em 0 .3em;letter-spacing:-.01em}
|
||
h3{font-size:1.05rem;text-transform:uppercase;letter-spacing:.14em;margin:2.4em 0 .6em;color:var(--ink);font-weight:600;
|
||
font-family:'Avenir Next','Helvetica Neue',sans-serif}
|
||
p{margin:0 0 1.05em;hyphens:auto;text-align:left}
|
||
code{font:.8em/1 'SF Mono',Menlo,Consolas,monospace;background:#f1ebde;padding:.1em .35em;border-radius:3px}
|
||
pre{font:.74rem/1.55 'SF Mono',Menlo,Consolas,monospace;background:#f4eee2;border-left:3px solid var(--sand);padding:1rem 1.2rem;margin:1.4rem 0 1.8rem;
|
||
overflow-x:auto;border-radius:0 4px 4px 0;white-space:pre;tab-size:2}
|
||
pre code{background:none;padding:0;font-size:1em}
|
||
em{font-style:italic}
|
||
a{color:var(--blue)}
|
||
.cover{min-height:92vh;display:flex;flex-direction:column;justify-content:center;padding:90px;background:
|
||
radial-gradient(circle at 85% 15%,rgba(180,71,47,.13),transparent 40%),radial-gradient(circle at 10% 90%,rgba(46,106,138,.12),transparent 45%),var(--paper);
|
||
border-bottom:1px solid var(--rule)}
|
||
.kicker{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
|
||
.cover h1{font-size:5.2rem;line-height:.98;margin:.35em 0 .25em;letter-spacing:-.025em}
|
||
.subtitle{font-size:1.45rem;font-style:italic;color:#4a453e;max-width:30em;text-align:left}
|
||
.cover-stats,.stats{display:flex;flex-wrap:wrap;gap:1.2rem 2.4rem;margin:2.2rem 0}
|
||
.stats{margin:1.6rem 0 1.8rem;padding:1.1rem 0;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule)}
|
||
.stat b{display:block;font-size:2.1rem;font-weight:400;line-height:1.1;font-variant-numeric:lining-nums}
|
||
.stat span{font:.78rem/1.3 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);letter-spacing:.03em;display:block;max-width:14em}
|
||
.cover-note{font-size:.9rem;color:var(--muted);max-width:34em;text-align:left;margin-top:2rem}
|
||
.toc{padding:4rem 0 2rem}
|
||
.toc ol{list-style:none;padding:0;margin:1rem 0;columns:2;column-gap:3rem}
|
||
.toc li{padding:.3rem 0;border-bottom:1px dotted var(--rule);break-inside:avoid}
|
||
.toc a{color:var(--ink);text-decoration:none}
|
||
.toc a:hover{color:var(--accent)}
|
||
.preface{padding-top:2rem}
|
||
.findings{padding-left:1.4em}
|
||
.findings li{margin:.55em 0;padding-left:.3em}
|
||
.findings li::marker{color:var(--accent);font-weight:600}
|
||
.chapter{padding-top:4.5rem;margin-top:3rem;border-top:1px solid var(--rule)}
|
||
.opener{text-align:center;margin-bottom:2.6rem}
|
||
.numeral{font-size:4.2rem;color:var(--accent);line-height:1;font-variant:small-caps;letter-spacing:.05em}
|
||
.opener h2{font-size:2.5rem;margin:.25em 0}
|
||
.lede{font-style:italic;color:#5a544a;font-size:1.12rem;max-width:30em;margin:0 auto;text-align:center}
|
||
.dropcap::first-letter{float:left;font-size:4.1em;line-height:.82;padding:.06em .09em 0 0;color:var(--accent)}
|
||
.fig{margin:2rem 0 2.2rem}
|
||
.fig.wide{margin-left:-50px;margin-right:-50px}
|
||
.fig svg{display:block;margin:0 auto}
|
||
figcaption{font:.8rem/1.45 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:.7rem;padding-left:.8rem;border-left:2px solid var(--rule)}
|
||
.fignum{color:var(--ink);font-weight:600}
|
||
.note{font:.86rem/1.55 'Avenir Next','Helvetica Neue',sans-serif;color:#5b5549;background:#f4eee2;border-left:3px solid var(--sand);
|
||
padding:.9rem 1.1rem;margin:1.8rem 0;border-radius:0 4px 4px 0}
|
||
.note.review{border-left-color:var(--accent)}
|
||
.note b.tag{font-weight:600;letter-spacing:.12em;text-transform:uppercase;font-size:.72rem;color:var(--accent);display:block;margin-bottom:.3rem}
|
||
.note code{font-size:.85em}
|
||
table{width:100%;border-collapse:collapse;margin:1.4rem 0 1.8rem;font:.8rem/1.4 'Avenir Next','Helvetica Neue',sans-serif}
|
||
th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-size:.68rem;border-bottom:1.5px solid var(--ink);padding:.45rem .5rem}
|
||
td{padding:.42rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
||
td code{font-size:.92em}
|
||
table.num td:not(:first-child),table.num th:not(:first-child){text-align:right}
|
||
.epilogue p{font-size:1.04em}
|
||
.appendix{font-size:.92em}
|
||
.appendix ul,.chapter ul{padding-left:1.2em}
|
||
.appendix li,.chapter li{margin:.4em 0}
|
||
.colophon{text-align:center;font:.78rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--muted);margin-top:4rem;letter-spacing:.05em}
|
||
@media (max-width:760px){.inner{padding:0 22px}.cover{padding:40px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}
|
||
.toc ol{columns:1}p{text-align:left}body{font-size:17px}}
|
||
@media print{html{background:#fff}.book{box-shadow:none}.chapter{break-before:page}.fig{break-inside:avoid}}
|
||
.transcript p b{font:600 .78rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:.3em}
|
||
pre .tk-c{color:#8d8576;font-style:italic}pre .tk-s{color:#5f7a3d}pre .tk-n{color:#a0662a}pre .tk-k{color:#b4472f}pre .tk-t{color:#2e6a8a}pre .tk-f{color:#7a3f6a}
|
||
h4{font:600 .82rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.06em;margin:1.8em 0 .5em;color:var(--muted)}
|
||
.inner ul,.inner ol:not(.findings){padding-left:1.2em}
|
||
.inner li{margin:.35em 0}
|
||
blockquote{margin:1.4rem 0;padding:.2rem 0 .2rem 1.2rem;border-left:3px solid var(--rule);color:#5a544a;font-style:italic}
|
||
.fig svg text{dominant-baseline:auto}
|
||
.fig .replay{font:600 .7rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);
|
||
background:none;border:1px solid var(--rule);border-radius:3px;padding:.2rem .55rem;margin:0 0 0 1rem;cursor:pointer;float:right}
|
||
.fig .replay:hover{border-color:var(--accent)}
|
||
.anim.armed .step{opacity:0}
|
||
.anim.armed.play .step{animation:stepIn .45s ease-out both;animation-delay:calc(var(--i) * var(--pace, .45s))}
|
||
.anim.armed.play .step.edge{animation-name:edgeIn}
|
||
@keyframes stepIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||
@keyframes edgeIn{from{opacity:0}to{opacity:1}}
|
||
.steps{margin:2rem 0 2.2rem;border:1px solid var(--rule);border-radius:6px;background:#fffdf8}
|
||
.steps-head{display:flex;align-items:center;gap:.8rem;padding:.6rem 1rem;border-bottom:1px solid var(--rule);
|
||
font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
|
||
.steps-head .title{flex:1}
|
||
.steps-head .count{color:var(--muted);letter-spacing:.06em}
|
||
.steps-head button{font:600 .72rem 'Avenir Next','Helvetica Neue',sans-serif;color:var(--ink);background:var(--soft);border:1px solid var(--rule);
|
||
border-radius:3px;padding:.25rem .65rem;cursor:pointer}
|
||
.steps-head button:disabled{opacity:.35;cursor:default}
|
||
.step-panel{padding:1rem 1.2rem .4rem}
|
||
.step-panel>h4:first-child{margin-top:.2rem;color:var(--ink)}
|
||
.steps.js .step-panel{display:none}
|
||
.steps.js .step-panel.on{display:block}
|
||
.steps:not(.js) .steps-head button,.steps:not(.js) .steps-head .count{display:none}
|
||
.steps:not(.js) .step-panel+.step-panel{border-top:1px dashed var(--rule)}
|
||
.fig figcaption{display:flow-root}
|
||
.fig svg{max-width:100%;height:auto}
|
||
table{max-width:100%}
|
||
@media (max-width:760px){.inner{padding:0 20px}.cover{padding:48px 22px}.cover h1{font-size:3.2rem}.fig.wide{margin-left:0;margin-right:0}table{display:block;overflow-x:auto}.toc ol{columns:1}}
|
||
</style>
|
||
|
||
|
||
</head>
|
||
<body><div class="book">
|
||
<section class="cover">
|
||
<div class="kicker">A guide · Chord v2 target design · 2026-10-20</div>
|
||
<h1>Chord v2<br>A Guide</h1>
|
||
<p class="subtitle">How to build an app like p2 from nothing on Chord v2 and pi-durable, one object at a time, so that new plugin code takes over while the agent keeps running</p>
|
||
<div class="cover-stats"><div class="stat"><b>11</b><span>layers in the rebuild, L1 to L11</span></div><div class="stat"><b>~1,300</b><span>lines built so far (L1 contracts)</span></div><div class="stat"><b>6–7k</b><span>target size of Chord v2, without tests</span></div><div class="stat"><b>0.004 ms</b><span>Delta's apply per small change</span></div></div>
|
||
<div class="cover-note"><p>This guide teaches the <strong>designed</strong> API of Chord v2 (<code>packages/chord/src/next</code>, export <code>@earendil-works/chord/next</code>)
|
||
from <code>packages/chord/docs/next/design.md</code> and the newer decision log <code>packages/chord/docs/next/decisions.md</code>, which wins
|
||
where the two differ. Most of it is <strong>not built yet</strong>: a first kernel of about 10k lines is frozen as a reference and
|
||
Chord v2 is being rebuilt layer by layer; only the L1 contract layer exists (<code>src/next/contracts/</code>, commit
|
||
<code>7d2bb8ea8</code>). Code in this guide is therefore the target, not something you can run today. Every chapter says what is
|
||
built. API names are working names. Notes marked <em>review</em> are points that are undecided, or where the sources
|
||
disagree; they name the files. The reader is assumed to know pi-durable; Chord is explained from the start.</p>
|
||
</div>
|
||
</section><div class="inner">
|
||
<nav class="toc"><h2>Contents</h2><ol>
|
||
<li><a href="#before-we-begin">Before we begin</a></li>
|
||
<li><a href="#shape">I. The Shape of an App</a></li>
|
||
<li><a href="#first-object">II. A First Object</a></li>
|
||
<li><a href="#principals">III. Who Is Calling</a></li>
|
||
<li><a href="#shared-store">IV. One Store, Many Views</a></li>
|
||
<li><a href="#documents">V. Writing It Down</a></li>
|
||
<li><a href="#references">VI. Holding On</a></li>
|
||
<li><a href="#revocation">VII. Taking Access Away</a></li>
|
||
<li><a href="#derives">VIII. Computed Values</a></li>
|
||
<li><a href="#natives">IX. Things the OS Gives You</a></li>
|
||
<li><a href="#routines">X. Standing Behaviour</a></li>
|
||
<li><a href="#jobs">XI. Work That Outlives the Call</a></li>
|
||
<li><a href="#flows">XII. Asking a Person</a></li>
|
||
<li><a href="#registries">XIII. Extension Points</a></li>
|
||
<li><a href="#agent">XIV. The Agent</a></li>
|
||
<li><a href="#reload">XV. Reload and Rollout</a></li>
|
||
<li><a href="#contracts">XVI. Changing Contracts</a></li>
|
||
<li><a href="#wire">XVII. On the Wire</a></li>
|
||
<li><a href="#p2-core">XVIII. Building p2: the Core</a></li>
|
||
<li><a href="#p2-sessions">XIX. Sessions and Conversations</a></li>
|
||
<li><a href="#p2-machines">XX. Machines, Files, Models</a></li>
|
||
<li><a href="#compared">XXI. Other Ways to Do It</a></li>
|
||
<li><a href="#what-s-next">What's Next</a></li>
|
||
<li><a href="#api">Appendix A: API Reference</a></li>
|
||
<li><a href="#defaults">Appendix B: Timeouts and Defaults</a></li>
|
||
<li><a href="#versions">Appendix C: Versions</a></li>
|
||
<li><a href="#errors">Appendix D: Errors and States</a></li>
|
||
<li><a href="#rejected">Appendix E: Rejected Designs</a></li>
|
||
<li><a href="#glossary">Appendix F: Glossary</a></li>
|
||
<li><a href="#sources">Appendix G: Sources</a></li>
|
||
</ol></nav>
|
||
|
||
<section class="preface" id="before-we-begin">
|
||
<h2>Before we begin</h2>
|
||
<p class="dropcap">It is late in the evening. Mario is on his phone, connected to his laptop at home through the Radius relay. In session
|
||
S1 the agent is twenty minutes into a refactoring; right now it is running a test suite through <code>bash</code>. A colleague,
|
||
Ann, follows the session read-only from her own browser. Mario notices that the notes panel sorts notes the wrong way
|
||
round. He fixes one line in the notes plugin on the laptop and presses <strong>Reload</strong>.</p>
|
||
<p>What should happen is easy to say. The notes panel on both screens shows the new order. The test run keeps running. The
|
||
agent's turn continues as if nothing happened. Ann's view does not flicker into an error. Nothing that was written is
|
||
lost, and no process restarts.</p>
|
||
<p>The obvious ways to get there fail. <strong>Restarting the processes</strong> loses the turn in flight, the open terminal and every
|
||
connection; pi-durable would resume the turn, but the test run and the connections are gone. <strong>Hot-swapping modules</strong>
|
||
in place, as front-end tooling does, leaves the old code alive in closures, timers, event listeners and sockets that
|
||
nobody knows about: the old sort function keeps running inside a callback someone stored, a file watcher opened by the
|
||
old code is never closed, and two versions of the plugin now write the same data without any order.</p>
|
||
<p>Chord v2 is the runtime that makes the first outcome normal. It does so by being strict about one thing: <strong>every piece
|
||
of state, every open OS handle and every piece of running work has exactly one owner, an object, and objects only
|
||
declare what they need.</strong> Chord itself activates, connects, reloads and ends them. Because Chord knows every timer,
|
||
watcher, transaction and reference an object has, it can swap the object's code while its data stays, restart only
|
||
what touches the operating system, and route calls between browser tabs, the server and per-session worker processes
|
||
without the plugin author writing any of that.</p>
|
||
<p>This guide teaches Chord v2 by building an app called <strong>Studio</strong>: a small version of p2 (Pi Mobile), with a server,
|
||
one worker process per session, browser tabs as clients, and an agent from pi-durable on each worker. The first
|
||
chapters grow one plugin, <code>@studio/notes</code>, until it uses every concept; the last three chapters assemble the rest of a
|
||
p2-like app from the same parts.</p>
|
||
<div class="note review"><b class="tag">Review: what exists today</b><p>Nothing in this guide runs yet except the contract layer. <code>packages/chord/src/next/contracts/</code> (L1 part 1: <code>remote</code>,
|
||
<code>local</code>, <code>method</code>, <code>evolve</code>, <code>checkAccepts</code>, the schema helpers) is built and reviewed. The rest of L1 (inputs,
|
||
argument checks, principals, errors) is a draft spec (<code>packages/chord/docs/next/layer-1.md</code>). The frozen kernel in
|
||
<code>src/next/*.ts</code> implements an older version of the design (host values, a development freeze, JSONL storage,
|
||
descriptions on the wire); do not read it as the target. Build on the design, not on the frozen code.</p>
|
||
</div>
|
||
<h3 id="ten-things-to-know">Ten things to know</h3>
|
||
<ol class="findings">
|
||
<li><strong>An app is a tree of hosts.</strong> One server, any number of workers it starts on demand, any number of clients (tabs,
|
||
TUIs) that connect to it. Clients reach workers only through the server, which relays.</li>
|
||
<li><strong>Objects are the unit of everything.</strong> An object type is a contract (name, input, placement, members, version); an
|
||
object is one instance per input and caller. The first <code>get</code> creates it, later gets share it, the last release ends
|
||
it.</li>
|
||
<li><strong>Factories declare, Chord activates.</strong> A factory is synchronous: it declares documents, references, derived
|
||
values, OS handles, routines and jobs through <code>self</code>, and returns the members. Chord opens, binds and starts them,
|
||
and undoes them in reverse.</li>
|
||
<li><strong>Links stamp who is calling.</strong> A caller's data never says who it is; the link it arrived on does. Each caller gets
|
||
its own object and the variant of members it may use.</li>
|
||
<li><strong>Shared data lives once, behind views.</strong> A <code>local</code> shared store holds the data; per-caller <code>remote</code> views expose
|
||
what each caller may see. Only stable identities persist documents.</li>
|
||
<li><strong>State changes only in transactions.</strong> Documents are JSON values written in <code>self.tx</code>; commits are atomic for
|
||
every viewer and travel as small operations, never as whole values.</li>
|
||
<li><strong>Failures look like disconnects.</strong> A reference survives link drops, restarts and failures: calls park or fail,
|
||
values stay readable, and Chord retries by itself. There are no request ids.</li>
|
||
<li><strong>Everything long-lived has a declared form.</strong> Standing behaviour is a routine, work that outlives a call is a job,
|
||
input from a person is a flow, behaviour from other plugins comes through a registry. Nothing else may hold a timer
|
||
or a callback.</li>
|
||
<li><strong>Reload swaps pure objects in place and restarts native ones.</strong> Objects without OS handles get the new code with
|
||
their data and running work intact; objects with OS handles close and reopen them. A broken change leaves the whole
|
||
host on its old code.</li>
|
||
<li><strong>pi-durable runs inside a core object.</strong> The agent harness is a host-lifetime object on each worker; models,
|
||
environments and tools reach it through registries, and tools reach Chord through <code>chord(fn, context)</code>.</li>
|
||
</ol>
|
||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||
<p>A <strong>host</strong> is one participant of the app: a process, a browser tab or a Durable Object, identified by a logical
|
||
identity and an <strong>incarnation</strong> that changes on every start. A <strong>link</strong> connects two hosts. The <strong>server</strong> is the one
|
||
addressable root host; a <strong>worker</strong> is a host the server starts for one session; a <strong>client</strong> is a tab or TUI, not
|
||
addressable. A <strong>plugin</strong> is a directory with a <code>package.json</code>; its code for one kind of host is a <strong>facet</strong>. <strong>Base
|
||
code</strong> is the app's own code (Chord calls its facet the core facet); it restarts the hosts when it changes, while
|
||
plugins reload. An <strong>object type</strong> (or <strong>contract</strong>) describes objects; an <strong>object</strong> is one instance; its <strong>input</strong> is
|
||
its key among objects of that type, and its <strong>placement</strong> computes from the input on which host it lives. A <strong>factory</strong> builds an
|
||
object; <strong><code>self</code></strong> is what a factory declares through. A <strong>principal</strong> is who calls, stamped by the link; a
|
||
<strong>variant</strong> is the set of members a principal gets. A <strong>reference</strong> is what <code>get</code> returns. A <strong>readable</strong> is a value
|
||
plus its ordered changes. A <strong>document</strong> is a named JSON value an object owns, written in a <strong>transaction</strong>. A
|
||
<strong>derive</strong> is a value computed from readables. A <strong>native</strong> is an OS-level value (a socket, a watcher, a process)
|
||
opened with <code>self.open</code>; an object with natives is a <strong>native object</strong>, one without is a <strong>pure object</strong>. A
|
||
<strong>routine</strong> is standing behaviour, a <strong>job</strong> is owned work that outlives its call, a <strong>flow</strong> is input into running
|
||
work. A <strong>registry</strong> is an owner's extension point, filled with <strong>contributions</strong>. <strong><code>ctx</code></strong> is what every call
|
||
receives: cancellation, caller, call-scoped references. A <strong>reload</strong> swaps a plugin's code inside running hosts; a
|
||
<strong>restart</strong> ends a process; a <strong>revision</strong> names the complete code set the hosts run.</p>
|
||
<div class="note"><b class="tag">Reading the code in this guide</b><p>Imports are shown once per file and then omitted. <code>obj</code>, <code>str</code>, <code>arr</code> and friends are Chord's schema helpers (they
|
||
return ordinary TypeBox schemas). Helper functions that belong to the app (<code>authenticate</code>, <code>watchDir</code>, <code>uuidv7</code>) are
|
||
named and not shown. Where the design gives no name, the guide says it uses a working name.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="shape"><header class="opener"><div class="numeral">I</div><h2>The Shape of an App</h2><p class="lede">Which processes a Chord app has, how they find each other, and the start code that connects them.</p></header>
|
||
<p class="dropcap">Before any plugin exists, Studio needs processes and connections between them. In Chord each participant is a host,
|
||
and the app's own start code creates every host, connects them and decides how workers start. Chord deliberately does
|
||
none of that itself: a laptop app forks child processes, a test runs everything in one process, a Cloudflare
|
||
deployment routes through the edge. This chapter writes Studio's start code: <code>server.ts</code>, <code>worker.ts</code>, the browser
|
||
entry, and a test setup.</p>
|
||
<h3 id="three-roles">Three roles</h3>
|
||
<p>A Node app on Chord has one shape:</p>
|
||
<figure class="fig wide"><svg viewBox="0 0 720 363" width="100%" role="img" aria-label="The three roles. Clients connect to the one server; the server starts workers and relays between them." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f1m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f1a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f1b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#7b7468" text-anchor="start" font-weight="600" letter-spacing="2">CLIENTS: NOT ADDRESSABLE</text><text x="16" y="144" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">SERVER: EXACTLY ONE</text><text x="16" y="266" font-size="10" fill="#2e6a8a" text-anchor="start" font-weight="600" letter-spacing="2">WORKERS: ONE PER SESSION, ON DEMAND</text><rect x="19" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="124" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">browser tab</text><text x="124" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">owner, local</text><rect x="255" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">browser tab</text><text x="360" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">guest Ann</text><rect x="491" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="596" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">TUI</text><text x="596" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">owner</text><rect x="255" y="158" width="210" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="360" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="360" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle" font-family="Menlo,monospace">Server()</text><rect x="19" y="280" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="124" y="299.3" font-size="11" fill="#2a2723" text-anchor="middle">worker</text><text x="124" y="313.3" font-size="9.5" fill="#7b7468" text-anchor="middle" font-family="Menlo,monospace">Session({ id: "S1" })</text><rect x="255" y="280" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="360" y="299.3" font-size="11" fill="#2a2723" text-anchor="middle">worker</text><text x="360" y="313.3" font-size="9.5" fill="#7b7468" text-anchor="middle" font-family="Menlo,monospace">Session({ id: "S2" })</text><rect x="491" y="280" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae" stroke-dasharray="3 3"/><text x="596" y="299.3" font-size="11" fill="#2a2723" text-anchor="middle">worker</text><text x="596" y="313.3" font-size="9.5" fill="#7b7468" text-anchor="middle" font-family="Menlo,monospace">Session({ id: "S3" })</text><path d="M124 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="246" y="112" width="55.4" height="13" fill="#fbf8f2" opacity=".92"/><text x="250" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">WebSocket</text><path d="M360 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="364" y="112" width="55.4" height="13" fill="#fbf8f2" opacity=".92"/><text x="368" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">WebSocket</text><path d="M596 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="482" y="112" width="55.4" height="13" fill="#fbf8f2" opacity=".92"/><text x="486" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">WebSocket</text><path d="M360 202 L124 278" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="246" y="234" width="23.8" height="13" fill="#fbf8f2" opacity=".92"/><text x="250" y="244" font-size="9.5" fill="#7b7468" text-anchor="start">IPC</text><path d="M360 202 L360 278" fill="none" stroke="#7b7468" marker-end="url(#f1m)"/><rect x="364" y="234" width="23.8" height="13" fill="#fbf8f2" opacity=".92"/><text x="368" y="244" font-size="9.5" fill="#7b7468" text-anchor="start">IPC</text><text x="16" y="348" font-size="10.5" fill="#7b7468" text-anchor="start">A worker for S3 does not exist until something asks for an object that lives there.</text></svg><figcaption><span class="fignum">Figure 1.</span> The three roles. Clients connect to the one server; the server starts workers and relays between them.</figcaption></figure>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Role</th>
|
||
<th>Addressable</th>
|
||
<th>Who it is comes from</th>
|
||
<th>Lifecycle</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>client</td>
|
||
<td>no</td>
|
||
<td>the authentication of its link</td>
|
||
<td>connects by itself</td>
|
||
</tr>
|
||
<tr>
|
||
<td>server</td>
|
||
<td>yes, exactly one</td>
|
||
<td>its own declaration</td>
|
||
<td>runs</td>
|
||
</tr>
|
||
<tr>
|
||
<td>worker</td>
|
||
<td>yes, many, by a typed address</td>
|
||
<td>stamped by the server, its parent</td>
|
||
<td>started on demand by the app</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The <strong>server</strong> holds what all sessions share (the session list, access, settings) and starts workers. A <strong>worker</strong>
|
||
runs one session: its agent, its files, its terminal connections. <strong>Clients</strong> are browser tabs and TUIs. Nobody can
|
||
send a request <em>to</em> a client, so a client can only serve objects that nobody outside it needs; chapter IV names them
|
||
<code>local</code> types.</p>
|
||
<p>Things that look like hosts but are not: a code mode sandbox (a worker thread inside a worker, owned by an object), a
|
||
remote environment's daemon (it speaks its own protocol, owned by an object), the TUI's sandbox behind its launcher.
|
||
A detached process such as a terminal host could become another kind of child of the server.</p>
|
||
<h3 id="host-kinds-and-addresses">Host kinds and addresses</h3>
|
||
<p>Every addressable host has a typed address. The app declares its <strong>host kinds</strong> once, in base code:</p>
|
||
<pre><code><span class="tk-c">// studio/src/hosts.ts</span>
|
||
<span class="tk-k">import</span> { hostKind, obj, str } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Server</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"server"</span>);
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Session</span> = <span class="tk-f">hostKind</span>(<span class="tk-s">"session"</span>, <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() }));
|
||
|
||
<span class="tk-t">Server</span>(); <span class="tk-c">// the address of the server</span>
|
||
<span class="tk-t">Session</span>({ id: <span class="tk-s">"S1"</span> }); <span class="tk-c">// the address of the worker of session S1</span></code></pre>
|
||
<p>There are no host-name strings anywhere in app code. Chord serializes addresses internally; your code only calls the
|
||
kinds. An object type names its host with a placement function from its input to an address (chapter II), so the
|
||
type checker knows that <code>Session({ id: session })</code> needs a <code>session</code> field.</p>
|
||
<h3 id="links">Links</h3>
|
||
<p>A <strong>link</strong> is an ordered, two-way channel of messages between two hosts; it only delivers. Chord ships three: a memory
|
||
pair for tests, IPC for a Node child process forked with <code>serialization: "advanced"</code>, and WebSocket. All three carry
|
||
the same binary message format and the same flow control (chapter XVII). Recovery policy belongs to the link adapter:
|
||
IPC needs no heartbeat, a WebSocket through a relay needs one, because TCP does not notice that a laptop went to sleep.</p>
|
||
<p>Each link has a <strong>direction</strong>. A host has at most one link to its parent (<code>connect</code>) and any number of links from
|
||
below (<code>accept</code> for clients, <code>route</code> for workers). Requests arriving on a link act as the principal the link was given;
|
||
chapter III explains why that matters.</p>
|
||
<h3 id="the-server">The server</h3>
|
||
<pre><code><span class="tk-c">// studio/src/server.ts</span>
|
||
<span class="tk-k">import</span> { createHost, ipcLink, webSocketLink } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
<span class="tk-k">import</span> { fork } <span class="tk-k">from</span> <span class="tk-s">"node:child_process"</span>;
|
||
<span class="tk-k">import</span> { <span class="tk-t">WebSocketServer</span> } <span class="tk-k">from</span> <span class="tk-s">"ws"</span>;
|
||
<span class="tk-k">import</span> { <span class="tk-t">Server</span>, <span class="tk-t">Session</span> } <span class="tk-k">from</span> <span class="tk-s">"./hosts.ts"</span>;
|
||
<span class="tk-k">import</span> { coreServerFacet } <span class="tk-k">from</span> <span class="tk-s">"./core/server.ts"</span>;
|
||
<span class="tk-k">import</span> <span class="tk-k">type</span> { P } <span class="tk-k">from</span> <span class="tk-s">"./principal.ts"</span>;
|
||
|
||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span><P>({
|
||
address: <span class="tk-t">Server</span>(),
|
||
principal: { kind: <span class="tk-s">"server"</span> }, <span class="tk-c">// who this host's own code acts as</span>
|
||
storage: <span class="tk-k">await</span> <span class="tk-f">openServerStorage</span>(dataDir), <span class="tk-c">// persisted documents (chapter V); memory if omitted</span>
|
||
onError: (e) => { <span class="tk-k">if</span> (e.kind === <span class="tk-s">"storage_failed"</span>) process.<span class="tk-f">exit</span>(<span class="tk-n">75</span>); <span class="tk-k">else</span> <span class="tk-f">log</span>(e); },
|
||
});
|
||
|
||
<span class="tk-k">await</span> server.<span class="tk-f">load</span>([
|
||
{ name: <span class="tk-s">"@studio/core"</span>, facet: <span class="tk-f">coreServerFacet</span>(config) }, <span class="tk-c">// base code, under a fixed name</span>
|
||
...(<span class="tk-k">await</span> <span class="tk-f">loadPlugins</span>(<span class="tk-s">"server"</span>)), <span class="tk-c">// [{ name, facet }] from the plugin loader</span>
|
||
]);
|
||
|
||
<span class="tk-k">const</span> wss = <span class="tk-k">new</span> <span class="tk-t">WebSocketServer</span>({ server: http, path: <span class="tk-s">"/chord"</span>, maxPayload: <span class="tk-n">64</span> * <span class="tk-n">1024</span> * <span class="tk-n">1024</span> });
|
||
wss.<span class="tk-f">on</span>(<span class="tk-s">"connection"</span>, (ws, req) => {
|
||
<span class="tk-k">const</span> principal = <span class="tk-f">authenticate</span>(req); <span class="tk-c">// app code: cookie, loopback, device token</span>
|
||
<span class="tk-k">if</span> (!principal) <span class="tk-k">return</span> ws.<span class="tk-f">close</span>(<span class="tk-n">4401</span>);
|
||
server.<span class="tk-f">accept</span>(<span class="tk-f">webSocketLink</span>(ws), { principal });
|
||
});
|
||
|
||
server.<span class="tk-f">route</span>(<span class="tk-t">Session</span>, {
|
||
authorize: (p, { id }) =>
|
||
p.kind === <span class="tk-s">"owner"</span> || p.kind === <span class="tk-s">"server"</span> || (p.kind === <span class="tk-s">"guest"</span> && p.session === id),
|
||
resolve: <span class="tk-k">async</span> ({ id }, { signal }) => {
|
||
<span class="tk-k">const</span> principal: P = { kind: <span class="tk-s">"worker"</span>, session: id };
|
||
<span class="tk-k">const</span> child = <span class="tk-f">fork</span>(<span class="tk-s">"worker.ts"</span>, [], { serialization: <span class="tk-s">"advanced"</span>, signal });
|
||
child.<span class="tk-f">send</span>({ id, principal, init: <span class="tk-k">await</span> <span class="tk-f">initFor</span>(id, signal) }); <span class="tk-c">// the app's own start message</span>
|
||
<span class="tk-k">return</span> { link: <span class="tk-f">ipcLink</span>(child), principal }; <span class="tk-c">// Chord stamps `principal` on it</span>
|
||
},
|
||
});</code></pre>
|
||
<p>Each call has one job:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Call</th>
|
||
<th>Meaning</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>createHost<P>({ address?, principal?, ...options })</code></td>
|
||
<td>with an address: an addressable host whose own code acts as <code>principal</code>; without: a client. <code>P</code> is the app's principal type</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>load([{ name, facet }])</code></td>
|
||
<td>the facets this host runs, each with its plugin's <code>package.json</code> name; duplicates and invalid names are refused. Base code passes its core facet the same way</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>accept(link, { principal })</code></td>
|
||
<td>a link from a client; every request on it acts as <code>principal</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>route(Kind, { authorize, resolve })</code></td>
|
||
<td>how to reach hosts of a kind. <code>authorize(principal, address)</code> runs before anything starts; <code>resolve(address, { signal })</code> returns <code>{ link, principal }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>connect(link, { principal })</code></td>
|
||
<td>the link to the parent, at most one, replaced after a reconnect; <code>principal</code> says who the parent is</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>root(fn, { principal?, signal? })</code></td>
|
||
<td>a call scope for code outside objects (a CLI command): gives <code>fn</code> a <code>ctx</code> (chapter VI)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Chord has no <code>listen</code>. Entry points differ too much: a loopback WebSocket server, a tunnel, the Radius relay
|
||
delivering channels, a Durable Object edge, a memory link in a test. Each entry point authenticates and calls
|
||
<code>accept</code>. Radius, for example, is one more line:</p>
|
||
<pre><code>radius.<span class="tk-f">onChannel</span>((ws) =>
|
||
server.<span class="tk-f">accept</span>(<span class="tk-f">webSocketLink</span>(ws), { principal: { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"relay"</span> } }));</code></pre>
|
||
<h3 id="the-worker">The worker</h3>
|
||
<pre><code><span class="tk-c">// studio/src/worker.ts</span>
|
||
<span class="tk-k">const</span> { id, principal, init } = <span class="tk-k">await</span> <span class="tk-f">startMessage</span>(process); <span class="tk-c">// app code: the first IPC message</span>
|
||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({
|
||
address: <span class="tk-t">Session</span>({ id }),
|
||
principal, <span class="tk-c">// { kind: "worker", session: id }</span>
|
||
storage: <span class="tk-k">await</span> <span class="tk-f">openSessionStorage</span>(init.dataDir, id), <span class="tk-c">// one storage per session</span>
|
||
});
|
||
<span class="tk-k">await</span> worker.<span class="tk-f">load</span>([
|
||
{ name: <span class="tk-s">"@studio/core"</span>, facet: <span class="tk-f">coreWorkerFacet</span>(init) }, <span class="tk-c">// serves the start data (chapter IX)</span>
|
||
...(<span class="tk-k">await</span> <span class="tk-f">loadPlugins</span>(<span class="tk-s">"worker"</span>)),
|
||
]);
|
||
worker.<span class="tk-f">connect</span>(<span class="tk-f">ipcLink</span>(process), { principal: { kind: <span class="tk-s">"server"</span> } });</code></pre>
|
||
<h3 id="a-browser-tab">A browser tab</h3>
|
||
<pre><code><span class="tk-c">// studio/web/main.tsx</span>
|
||
<span class="tk-k">const</span> client = <span class="tk-f">createHost</span><P>(); <span class="tk-c">// no address: a client</span>
|
||
<span class="tk-k">await</span> client.<span class="tk-f">load</span>(<span class="tk-k">await</span> <span class="tk-f">loadPlugins</span>(<span class="tk-s">"web"</span>));
|
||
client.<span class="tk-f">connect</span>(<span class="tk-f">webSocketLink</span>(<span class="tk-k">new</span> <span class="tk-t">WebSocket</span>(<span class="tk-s">"/chord"</span>)), { principal: { kind: <span class="tk-s">"server"</span> } });
|
||
<span class="tk-k">const</span> notes = <span class="tk-k">await</span> client.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, { session: <span class="tk-s">"S1"</span> }); <span class="tk-c">// tab → server → worker S1</span></code></pre>
|
||
<h3 id="the-first-get-step-by-step">The first <code>get</code>, step by step</h3>
|
||
<p>The tab asks for the notes of S1. No worker for S1 runs yet.</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 526" width="100%" role="img" aria-label="The first get for an object on a worker that is not running. The server authorizes, the app starts the worker, Chord stamps the link." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f2m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f2a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f2b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="102" y1="52" x2="102" y2="518" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="23" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="102" y="27" font-size="11" fill="#2a2723" text-anchor="middle">browser tab</text><text x="102" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">owner</text><line x1="274" y1="52" x2="274" y2="518" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="195" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="274" y="30" font-size="11" fill="#2a2723" text-anchor="middle">server</text><line x1="446" y1="52" x2="446" y2="518" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="367" y="10" width="158" height="42" rx="4" fill="#efe9dd" stroke="#c9bfae"/><text x="446" y="27" font-size="11" fill="#2a2723" text-anchor="middle">route(Session)</text><text x="446" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">app code</text><line x1="618" y1="52" x2="618" y2="518" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="539" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="618" y="30" font-size="11" fill="#2a2723" text-anchor="middle">worker S1</text><g class="step edge" style="--i:1"><path d="M102 72 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f2m)"/><text x="144" y="82" font-size="10" fill="#2a2723" text-anchor="start">placement: Session({ id: "S1" })</text></g><g class="step edge" style="--i:2"><line x1="102" y1="118" x2="271" y2="118" stroke="#7b7468" marker-end="url(#f2m)"/><text x="188" y="112" font-size="10" fill="#2a2723" text-anchor="middle">hold Notes { session: "S1" }</text></g><g class="step edge" style="--i:3"><path d="M274 144 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f2m)"/><text x="316" y="154" font-size="10" fill="#2a2723" text-anchor="start">not mine: route(Session)</text></g><g class="step edge" style="--i:4"><line x1="274" y1="190" x2="443" y2="190" stroke="#7b7468" marker-end="url(#f2m)"/><text x="360" y="184" font-size="10" fill="#2a2723" text-anchor="middle">authorize(owner, { id: "S1" })</text></g><g class="step edge" style="--i:5"><line x1="446" y1="222" x2="277" y2="222" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f2m)"/><text x="360" y="216" font-size="10" fill="#2a2723" text-anchor="middle">true</text></g><g class="step edge" style="--i:6"><line x1="274" y1="254" x2="443" y2="254" stroke="#b4472f" marker-end="url(#f2a)"/><text x="360" y="248" font-size="10" fill="#b4472f" text-anchor="middle">resolve({ id: "S1" })</text></g><g class="step edge" style="--i:7"><line x1="446" y1="286" x2="615" y2="286" stroke="#7b7468" marker-end="url(#f2m)"/><text x="532" y="280" font-size="10" fill="#2a2723" text-anchor="middle">fork + start message</text></g><g class="step edge" style="--i:8"><path d="M618 312 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f2m)"/><text x="576" y="322" font-size="10" fill="#2a2723" text-anchor="end">createHost, load, connect</text></g><g class="step edge" style="--i:9"><line x1="446" y1="358" x2="277" y2="358" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f2m)"/><text x="360" y="352" font-size="10" fill="#2a2723" text-anchor="middle">{ link, principal: worker S1 }</text></g><g class="step edge" style="--i:10"><line x1="274" y1="390" x2="615" y2="390" stroke="#b4472f" marker-end="url(#f2a)"/><text x="446" y="384" font-size="10" fill="#b4472f" text-anchor="middle">hold (stamped: owner)</text></g><g class="step edge" style="--i:11"><path d="M618 416 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f2m)"/><text x="576" y="426" font-size="10" fill="#2a2723" text-anchor="end">placement check, run factory</text></g><g class="step edge" style="--i:12"><line x1="618" y1="462" x2="277" y2="462" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f2m)"/><text x="446" y="456" font-size="10" fill="#2a2723" text-anchor="middle">bound: snapshot</text></g><g class="step edge" style="--i:13"><line x1="274" y1="494" x2="105" y2="494" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f2m)"/><text x="188" y="488" font-size="10" fill="#2a2723" text-anchor="middle">bound: snapshot</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 2.</span> The first get for an object on a worker that is not running. The server authorizes, the app starts the worker, Chord stamps the link.</figcaption></figure>
|
||
<p><code>resolve</code> runs once per address while its link lives. When the link drops (the worker crashed or the supervisor
|
||
stopped it) and references to that worker still exist, Chord calls <code>resolve</code> again by itself, with a per-address
|
||
backoff, so a tab that only watches recovers without a click. A worker start that already produced a link finishes
|
||
even if the request that triggered it went away.</p>
|
||
<p><strong>Incarnations</strong> make restarts visible. Chord mints a fresh id (a uuidv7) on every host start and sends it in the link
|
||
handshake; the other side sees a restart as a new incarnation of the same identity, and references to it re-hold
|
||
(chapter VI).</p>
|
||
<h3 id="tests-everything-in-one-process">Tests: everything in one process</h3>
|
||
<p>The same code runs in a test with memory links. <code>resolve</code> builds the worker host in memory and returns one end of the
|
||
pair:</p>
|
||
<pre><code>server.<span class="tk-f">route</span>(<span class="tk-t">Session</span>, {
|
||
authorize: () => <span class="tk-k">true</span>,
|
||
resolve: <span class="tk-k">async</span> ({ id }) => {
|
||
<span class="tk-k">const</span> [up, down] = <span class="tk-f">memoryLinkPair</span>();
|
||
<span class="tk-k">const</span> principal: P = { kind: <span class="tk-s">"worker"</span>, session: id };
|
||
<span class="tk-k">const</span> worker = <span class="tk-f">createHost</span><P>({ address: <span class="tk-t">Session</span>({ id }), principal });
|
||
<span class="tk-k">await</span> worker.<span class="tk-f">load</span>([{ name: <span class="tk-s">"@studio/core"</span>, facet: <span class="tk-f">coreWorkerFacet</span>({ id, dataDir }) }, notesWorker]);
|
||
worker.<span class="tk-f">connect</span>(up, { principal: { kind: <span class="tk-s">"server"</span> } });
|
||
<span class="tk-k">return</span> { link: down, principal };
|
||
},
|
||
});</code></pre>
|
||
<p>Memory links deliver the same encoded messages asynchronously, so a test runs the production protocol path.</p>
|
||
<h3 id="what-stays-app-code">What stays app code</h3>
|
||
<p>Starting processes, the start message, authentication, supervising and stopping idle workers are app code. Chord
|
||
provides one readable for the last one: <code>host.activity</code>, with <code>{ references, calls, jobs, lingering }</code> (viewers, calls
|
||
in flight, unsettled jobs, objects kept warm after their last reference). Studio's core reports it to the server
|
||
together with the agent's running tasks, and the server's supervisor stops a worker when everything has been zero for
|
||
five minutes, as p2 does. Chord counts; it has no idle policy.</p>
|
||
<h3 id="host-options-and-timeouts">Host options and timeouts</h3>
|
||
<p><code>createHost</code> takes the options introduced later in this guide: <code>storage</code> (V), <code>principalKey</code> and <code>revokedBy</code> (III,
|
||
VII), <code>telemetry</code> (XVII) and <code>onError</code>. <code>onError</code> receives background errors and <code>{ kind: "storage_failed" }</code>; every
|
||
app must handle the latter, because Chord never ends the process. Studio exits with its own code so its supervisor
|
||
restarts it.</p>
|
||
<p>Timeouts are host defaults with per-thing overrides; the full table is in the appendix. The two you meet first:
|
||
<code>readyTimeout</code> (60 s: how long an activation and an awaited <code>get</code> wait) and the park deadline (60 s: how long a call
|
||
waits through a link drop or a restart before failing <code>unavailable</code>).</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>Nothing in this chapter is built in Chord v2 yet: hosts, links and routes are layers L6 and L7. The frozen kernel has
|
||
working versions with the same shape (<code>createHost</code>, <code>connect</code>, <code>accept</code>, <code>route</code>, <code>load</code>), which the rebuild
|
||
simplifies.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="first-object"><header class="opener"><div class="numeral">II</div><h2>A First Object</h2><p class="lede">A plugin, a contract, a factory, and a browser tab that calls it on another process.</p></header>
|
||
<p class="dropcap">Studio's first feature is session notes: a list of short notes per session, shown in a panel, that anyone allowed can
|
||
add to. Several places need "the notes of S1": the owner's tab, a guest's tab, later a download route and an agent
|
||
tool. If each built its own copy, the copies would drift apart and nobody would know when to clean them up. In Chord
|
||
there is <strong>one object</strong> for "the notes of S1", everyone asks for it by its type and input, and Chord routes them all to it.</p>
|
||
<h3 id="the-plugin-39-s-files">The plugin's files</h3>
|
||
<p>A plugin is a directory with a <code>package.json</code>. Its <code>name</code> (npm style, globally unique and stable) is the plugin's
|
||
identity: Chord finds its stored documents, reports its errors and orders its contributions by it, never by a
|
||
directory name or load order.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>File</th>
|
||
<th>Runs on</th>
|
||
<th>Holds</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>package.json</code></td>
|
||
<td>read by the loader</td>
|
||
<td><code>"name": "@studio/notes"</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>contract.ts</code></td>
|
||
<td>every host that imports it</td>
|
||
<td>object types: names, shapes, method signatures; no running code</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>worker.ts</code></td>
|
||
<td>every session worker</td>
|
||
<td>the worker facet</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>server.ts</code></td>
|
||
<td>the server</td>
|
||
<td>the server facet (later: the download route)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>web.tsx</code></td>
|
||
<td>browser tabs</td>
|
||
<td>the web facet: the panel</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="the-contract">The contract</h3>
|
||
<p>An object type is a contract: a name, an <strong>input</strong> (the object's key among objects of this type), a <strong>placement</strong>
|
||
(on which host it lives, computed from the input), the <strong>members</strong> (state you can read and methods you can call) and a <strong>version</strong>.</p>
|
||
<pre><code><span class="tk-c">// notes/contract.ts</span>
|
||
<span class="tk-k">import</span> { arr, int, method, obj, remote, str, <span class="tk-k">type</span> <span class="tk-t">Static</span> } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
<span class="tk-k">import</span> { <span class="tk-t">Session</span> } <span class="tk-k">from</span> <span class="tk-s">"@studio/core/hosts"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Note</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), text: <span class="tk-f">str</span>({ max: <span class="tk-n">2000</span> }), by: <span class="tk-f">str</span>(), at: <span class="tk-f">int</span>() });
|
||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Note</span> = <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">Note</span>>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notes</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
input: <span class="tk-f">obj</span>({ session: <span class="tk-f">str</span>() }), <span class="tk-c">// the key: { session: "S1" }</span>
|
||
placement: ({ session }) => <span class="tk-t">Session</span>({ id: session }), <span class="tk-c">// input → the host's address</span>
|
||
variants: {
|
||
editor: {
|
||
state: { notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">Note</span>) }) },
|
||
methods: {
|
||
add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>({ min: <span class="tk-n">1</span>, max: <span class="tk-n">2000</span> }) }), <span class="tk-f">str</span>()), <span class="tk-c">// returns the new id</span>
|
||
remove: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })), <span class="tk-c">// no result</span>
|
||
},
|
||
},
|
||
},
|
||
});</code></pre>
|
||
<p><code>remote</code> means objects of this type can be reached from any host, across links (chapter IV introduces <code>local</code>). The
|
||
schema helpers (<code>obj</code>, <code>str</code>, <code>int</code>, <code>arr</code>, <code>map</code>, <code>opt</code>, <code>lit</code>, <code>oneOf</code>, <code>union</code>, <code>bool</code>, <code>num</code>, <code>nul</code>, <code>bytes</code>,
|
||
<code>unknown</code>) return ordinary TypeBox schemas; short options such as <code>{ min, max }</code> bound lengths or values. Schemas must
|
||
describe data: no callbacks, no class instances, no custom codecs.</p>
|
||
<p><code>method()</code> has four forms: <code>method()</code> (no arguments, no result), <code>method(args)</code>, <code>method(args, result)</code> and
|
||
<code>method(undefined, result)</code>.</p>
|
||
<p>A contract contains no code that runs, so every host can import it. When you declare it, Chord materializes its
|
||
description and computes a <strong>fingerprint</strong>, a hash of its input, variants and members. Two hosts compare contracts by
|
||
version and fingerprint (chapter XVI). Placement is not part of the fingerprint.</p>
|
||
<h3 id="placement">Placement</h3>
|
||
<p>The input says <em>which</em> object; <code>placement</code> says <em>where</em> it lives. The caller runs the placement function on the input and sends the resulting address with its request; the server
|
||
routes by it. The provider runs the function too and refuses an object that is not its own with <code>invalid/placement</code>.
|
||
So a worker for S2 never builds "Notes for S1", even if a caller has an outdated rule. Because placement is a typed
|
||
function, a <code>Session</code> placement on a type whose input has no <code>session</code> field does not compile.</p>
|
||
<p>Inputs are <strong>normalized</strong> before anything else: defaults are applied, unknown fields removed, and a canonical key
|
||
computed. <code>{ session: "S1" }</code> and <code>{ session: "S1", extra: 1 }</code> are the same object.</p>
|
||
<h3 id="the-facet-and-the-factory">The facet and the factory</h3>
|
||
<p>A <strong>facet</strong> is a plugin's setup for one kind of host. It runs once when the plugin loads on that host and tells the
|
||
host which object types it serves:</p>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">import</span> { defineState } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@studio/core"</span>; <span class="tk-c">// Studio's defineFacet: principals typed as Studio's P (chapter III)</span>
|
||
<span class="tk-k">import</span> { <span class="tk-t">Note</span>, <span class="tk-t">Notes</span> } <span class="tk-k">from</span> <span class="tk-s">"./contract.ts"</span>;
|
||
|
||
<span class="tk-k">const</span> <span class="tk-t">NoteList</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.list"</span>, { <span class="tk-c">// a document (chapter V); in memory for now</span>
|
||
schema: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">Note</span>) }),
|
||
version: <span class="tk-n">1</span>,
|
||
initial: () => ({ items: [] }),
|
||
checkpointWhen: () => <span class="tk-k">true</span>,
|
||
});
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => {
|
||
<span class="tk-k">const</span> list = self.<span class="tk-f">state</span>(<span class="tk-t">NoteList</span>); <span class="tk-c">// THE note list of this object</span>
|
||
<span class="tk-k">return</span> {
|
||
variant: <span class="tk-s">"editor"</span>,
|
||
notes: list, <span class="tk-c">// a document handle is a readable: expose it as state</span>
|
||
add: ({ text }, ctx) =>
|
||
self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||
<span class="tk-k">const</span> id = <span class="tk-f">uuidv7</span>();
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(list)).items.<span class="tk-f">push</span>({ id, text, by: <span class="tk-s">"someone"</span>, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() });
|
||
<span class="tk-k">return</span> id;
|
||
}, ctx),
|
||
remove: ({ id }, ctx) =>
|
||
list.<span class="tk-f">change</span>((d) => { d.items = d.items.<span class="tk-f">filter</span>((n) => n.id !== id); }, ctx),
|
||
};
|
||
});
|
||
});</code></pre>
|
||
<p>Read <code>host.serve(Notes, factory)</code> as "this host builds <code>Notes</code> objects like this". The factory receives the
|
||
normalized input, the caller's principal (chapter III) and <code>self</code>, through which it declares what the object needs.
|
||
It returns the members of one variant, tagged with <code>variant</code>.</p>
|
||
<p><strong>Factories declare; Chord activates.</strong> The factory is synchronous and has no side effects. It does not await, open
|
||
files or start timers. <code>self.state(NoteList)</code> does not load anything: it declares that the object has this document
|
||
and returns a <strong>handle</strong> that stays valid for the object's whole life. Chord then performs the activation: it loads the
|
||
document, binds references, opens OS handles, starts routines, and only then lets calls in. The rule exists for
|
||
reload: a reload runs the factory again, and anything the factory did itself would happen twice.</p>
|
||
<h3 id="calling-it">Calling it</h3>
|
||
<pre><code><span class="tk-c">// notes/web.tsx</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-t">NotesPanel</span>({ session }: { session: string }) {
|
||
<span class="tk-k">const</span> notes = <span class="tk-f">useRef</span>(<span class="tk-t">Notes</span>, { session }); <span class="tk-c">// app-side hook over host.get / release</span>
|
||
<span class="tk-k">const</span> items = <span class="tk-f">useReadable</span>(notes?.notes)?.items ?? [];
|
||
<span class="tk-k">return</span> (
|
||
<section>
|
||
{items.<span class="tk-f">map</span>((n) => <p key={n.id}>{n.text}</p>)}
|
||
<<span class="tk-t">AddBox</span> onAdd={(text) => notes?.<span class="tk-f">add</span>({ text })} />
|
||
</section>
|
||
);
|
||
}</code></pre>
|
||
<p>Underneath the hooks is the reference API:</p>
|
||
<pre><code><span class="tk-k">const</span> notes = <span class="tk-k">await</span> client.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, { session: <span class="tk-s">"S1"</span> }); <span class="tk-c">// live and hydrated once awaited</span>
|
||
notes.notes.value.items; <span class="tk-c">// the current list</span>
|
||
notes.notes.<span class="tk-f">subscribe</span>((v) => <span class="tk-f">render</span>(v.items)); <span class="tk-c">// every committed change</span>
|
||
<span class="tk-k">const</span> id = <span class="tk-k">await</span> notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"check the flaky test"</span> });
|
||
notes.<span class="tk-f">release</span>(); <span class="tk-c">// the object may end now</span></code></pre>
|
||
<p>An awaited reference is <strong>live and hydrated</strong>: every state member has a value. State members are <strong>readables</strong>:
|
||
<code>.value</code> is the current value, <code>subscribe</code> is called after every commit. The first <code>get</code> for <code>{ session: "S1" }</code> runs
|
||
the factory; every later <code>get</code> for the same input and caller shares that object. The object ends when the last
|
||
reference is released (a type may keep it a little longer with <code>linger</code>).</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 332" width="100%" role="img" aria-label="A call to add. Arguments are checked once at the provider; the commit reaches the viewer as one small change." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f3m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f3a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f3b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="102" y1="42" x2="102" y2="324" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="23" y="10" width="158" height="32" rx="4" fill="#fff" stroke="#c9bfae"/><text x="102" y="30" font-size="11" fill="#2a2723" text-anchor="middle">browser tab</text><line x1="274" y1="42" x2="274" y2="324" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="195" y="10" width="158" height="32" rx="4" fill="#efe9dd" stroke="#c9bfae"/><text x="274" y="30" font-size="11" fill="#2a2723" text-anchor="middle">server</text><line x1="446" y1="42" x2="446" y2="324" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="367" y="10" width="158" height="32" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="446" y="30" font-size="11" fill="#2a2723" text-anchor="middle">worker S1</text><line x1="618" y1="42" x2="618" y2="324" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="539" y="10" width="158" height="32" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="618" y="30" font-size="11" fill="#2a2723" text-anchor="middle">Notes { S1 }</text><g class="step edge" style="--i:1"><line x1="102" y1="68" x2="271" y2="68" stroke="#7b7468" marker-end="url(#f3m)"/><text x="188" y="62" font-size="10" fill="#2a2723" text-anchor="middle">call add { text }</text></g><g class="step edge" style="--i:2"><line x1="274" y1="100" x2="443" y2="100" stroke="#7b7468" marker-end="url(#f3m)"/><text x="360" y="94" font-size="10" fill="#2a2723" text-anchor="middle">relay (header only)</text></g><g class="step edge" style="--i:3"><line x1="446" y1="132" x2="615" y2="132" stroke="#7b7468" marker-end="url(#f3m)"/><text x="532" y="126" font-size="10" fill="#2a2723" text-anchor="middle">strip + validate args</text></g><g class="step edge" style="--i:4"><path d="M618 158 h-34 v14 h32" fill="none" stroke="#b4472f" marker-end="url(#f3a)"/><text x="576" y="168" font-size="10" fill="#b4472f" text-anchor="end">self.tx: push note, commit</text></g><g class="step edge" style="--i:5"><line x1="618" y1="204" x2="449" y2="204" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f3m)"/><text x="532" y="198" font-size="10" fill="#2a2723" text-anchor="middle">result: id</text></g><g class="step edge" style="--i:6"><line x1="446" y1="236" x2="277" y2="236" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f3m)"/><text x="360" y="230" font-size="10" fill="#2a2723" text-anchor="middle">state: ops of the commit</text></g><g class="step edge" style="--i:7"><line x1="446" y1="268" x2="277" y2="268" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f3m)"/><text x="360" y="262" font-size="10" fill="#2a2723" text-anchor="middle">result</text></g><g class="step edge" style="--i:8"><line x1="274" y1="300" x2="105" y2="300" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f3m)"/><text x="188" y="294" font-size="10" fill="#2a2723" text-anchor="middle">state, then result</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 3.</span> A call to add. Arguments are checked once at the provider; the commit reaches the viewer as one small change.</figcaption></figure>
|
||
<h3 id="two-rules-for-members">Two rules for members</h3>
|
||
<p><strong>Methods return data, never objects.</strong> A method that wants to point you to another object returns its address
|
||
(<code>{ session: "S2" }</code>), and you <code>get</code> it yourself. That keeps every reference owned by someone who releases it.</p>
|
||
<p><strong>No callbacks as arguments.</strong> A callback carries code, and everything it closes over, from one object into another.
|
||
Suppose an inbox object passed <code>onFile</code> to another object, which stored it. After a reload of the inbox's plugin, the
|
||
other object still calls the <em>old</em> <code>onFile</code>; after the inbox ends, it calls into an object whose file watcher is
|
||
already closed. Chord forbids this by construction: members are data (JSON plus bytes). Behaviour from elsewhere goes
|
||
through registries (chapter XIII), multi-step work through jobs (XI), input from a person through flows (XII).</p>
|
||
<h3 id="what-is-wrong-with-this-version">What is wrong with this version</h3>
|
||
<p>This first version works for one person. It has two problems, which the next two chapters fix. Everyone gets the
|
||
<code>editor</code> variant, including a read-only guest. And, less obvious, the owner and a guest do not see the same notes: as
|
||
chapter III shows, a remote object exists per caller, so each caller's <code>Notes</code> object has its own <code>NoteList</code>.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p><code>remote</code>, <code>method</code>, the schema helpers, <code>Static</code> and fingerprints are built (<code>src/next/contracts/</code>). Serving, <code>get</code>,
|
||
references and documents are layers L3 to L5.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="principals"><header class="opener"><div class="numeral">III</div><h2>Who Is Calling</h2><p class="lede">Principals: how Chord knows who calls, why a caller cannot lie about it, and how the answer picks the object and its members.</p></header>
|
||
<p class="dropcap">The owner may add and remove notes. Ann, a read-only guest, may only read them and should not even see who wrote
|
||
what. The object must therefore know who is calling, and the caller must not be able to claim to be someone else. In
|
||
Chord the answer is a <strong>principal</strong>, a small JSON value the app defines and the links stamp.</p>
|
||
<h3 id="the-app-39-s-principal-type">The app's principal type</h3>
|
||
<p>Chord defines no principal kinds; the app does. Every variant has a <code>kind</code>. Studio uses p2's set:</p>
|
||
<pre><code><span class="tk-c">// studio/src/principal.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">type</span> P =
|
||
| { kind: <span class="tk-s">"owner"</span>; access: <span class="tk-s">"local"</span> | <span class="tk-s">"relay"</span> | <span class="tk-s">"device"</span>; device?: string }
|
||
| { kind: <span class="tk-s">"guest"</span>; session: string; mode: <span class="tk-s">"read"</span> | <span class="tk-s">"write"</span>; grant: string; revision: number }
|
||
| { kind: <span class="tk-s">"server"</span> }
|
||
| { kind: <span class="tk-s">"worker"</span>; session: string };</code></pre>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th><code>kind</code></th>
|
||
<th>Who</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>owner</code></td>
|
||
<td>the person running the app, on loopback, through the relay, or on a paired device</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>guest</code></td>
|
||
<td>someone invited into one session, with a mode and the grant (and its revision) that let them in</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>server</code></td>
|
||
<td>the server's own code calling a worker</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>worker</code></td>
|
||
<td>a session worker calling the server</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="links-stamp-principals">Links stamp principals</h3>
|
||
<p>A principal never comes from a request's data. The server authenticates a client once, when its WebSocket connects,
|
||
and stamps the result on the whole link (<code>server.accept(link, { principal })</code>). The server tells Chord who each worker
|
||
is when it resolves it (<code>return { link, principal }</code>). When the server relays a client's request to a worker, Chord
|
||
writes the client's principal into the forwarded request; a principal field coming from a client is always
|
||
overwritten, and only a trusted parent link may carry one.</p>
|
||
<p>Where principals come from is app code. Studio's sharing feature creates a share link per session and mode; a guest
|
||
who opens it gets a guest record (grant <code>g1</code>) and a cookie; <code>authenticate(req)</code> turns the cookie into
|
||
<code>{ kind: "guest", session: "S1", mode: "read", grant: "g1", revision: 1 }</code> on every new connection. Chord never sees
|
||
cookies or grants, only the finished principal.</p>
|
||
<h3 id="the-caller-is-part-of-the-identity">The caller is part of the identity</h3>
|
||
<p>A <code>remote</code> object exists once per <strong>(type, input, principal key)</strong>. The <strong>principal key</strong> is a string computed from
|
||
the principal; by default its canonical JSON (sorted keys). The app may pass its own <code>principalKey</code> to <code>createHost</code>,
|
||
and must pass the same function on every host, or identities split silently.</p>
|
||
<pre><code><span class="tk-k">const</span> principalKey = (p: P) =>
|
||
p.kind === <span class="tk-s">"owner"</span> ? <span class="tk-s">`owner:${p.access}:${p.device ?? ""}`</span>
|
||
: p.kind === <span class="tk-s">"guest"</span> ? <span class="tk-s">`guest:${p.grant}:r${p.revision}`</span>
|
||
: p.kind === <span class="tk-s">"worker"</span> ? <span class="tk-s">`worker:${p.session}`</span>
|
||
: p.kind;</code></pre>
|
||
<p>The design wants the key to include how the client is connected and the grant's revision, so that two access paths
|
||
never share objects and a changed grant never reuses the old grant's object:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Caller</th>
|
||
<th>Principal key</th>
|
||
<th><code>Notes { S1 }</code> object</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>owner, tab A, local</td>
|
||
<td><code>owner:local:</code></td>
|
||
<td>1</td>
|
||
</tr>
|
||
<tr>
|
||
<td>owner, tab B, local</td>
|
||
<td><code>owner:local:</code></td>
|
||
<td>1 (shared with tab A)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>owner through the relay</td>
|
||
<td><code>owner:relay:</code></td>
|
||
<td>2</td>
|
||
</tr>
|
||
<tr>
|
||
<td>guest g1, revision 1</td>
|
||
<td><code>guest:g1:r1</code></td>
|
||
<td>3</td>
|
||
</tr>
|
||
<tr>
|
||
<td>guest g1 after a change to write mode, revision 2</td>
|
||
<td><code>guest:g1:r2</code></td>
|
||
<td>4 (new)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="variants">Variants</h3>
|
||
<p>The contract lists named sets of members; the factory receives the principal and returns the variant that principal
|
||
may use:</p>
|
||
<pre><code><span class="tk-c">// notes/contract.ts (version 1, final form for now)</span>
|
||
variants: {
|
||
editor: {
|
||
state: { notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">Note</span>) }) },
|
||
methods: { add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>({ min: <span class="tk-n">1</span>, max: <span class="tk-n">2000</span> }) }), <span class="tk-f">str</span>()), remove: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })) },
|
||
},
|
||
reader: {
|
||
state: { notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">PublicNote</span>) }) }, <span class="tk-c">// no `by`: authors are hidden</span>
|
||
},
|
||
},</code></pre>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => {
|
||
<span class="tk-k">if</span> (<span class="tk-f">canWrite</span>(principal, session)) <span class="tk-k">return</span> { variant: <span class="tk-s">"editor"</span>, notes: …, add: …, remove: … };
|
||
<span class="tk-k">return</span> { variant: <span class="tk-s">"reader"</span>, notes: … };
|
||
});</code></pre>
|
||
<p>A reader has no <code>add</code> at all. Chord refuses a call to a member that is not in the caller's variant, so <code>add</code> needs no
|
||
check of its own. Three rules keep variants compatible: a member name has the same kind (state or method) in every
|
||
variant that has it; a method has the same arguments and result in every variant; only state schemas may differ per
|
||
variant (the reader's notes have no authors). <code>remote(...)</code> enforces them at the type level, and at runtime for
|
||
contracts that arrive as data.</p>
|
||
<h3 id="how-a-facet-knows-p">How a facet knows <code>P</code></h3>
|
||
<p><code>P</code> is Studio's type, not Chord's, and every host is created with it (<code>createHost<P></code>). A plugin's facets are written
|
||
and compiled separately, then handed to <code>host.load</code> as values, so the type checker cannot infer <code>P</code> from the host into
|
||
the factory's <code>principal</code>. Studio's base code therefore exports its own <code>defineFacet</code>, already bound to <code>P</code>, and every
|
||
facet of every plugin imports that one instead of Chord's:</p>
|
||
<pre><code><span class="tk-c">// studio/src/facet.ts (base code, exported as @studio/core)</span>
|
||
<span class="tk-k">import</span> { bindFacet } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
<span class="tk-k">import</span> <span class="tk-k">type</span> { P } <span class="tk-k">from</span> <span class="tk-s">"./principal.ts"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> defineFacet = <span class="tk-f">bindFacet</span><P>(); <span class="tk-c">// a facet's principals are Studio's P</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">type</span> { P, <span class="tk-t">Revocation</span> } <span class="tk-k">from</span> <span class="tk-s">"./principal.ts"</span>;</code></pre>
|
||
<pre><code><span class="tk-c">// notes/worker.ts (server.ts, web.tsx and tui.ts import the same defineFacet)</span>
|
||
<span class="tk-k">import</span> { defineFacet } <span class="tk-k">from</span> <span class="tk-s">"@studio/core"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => { <span class="tk-c">// principal: P</span>
|
||
<span class="tk-k">if</span> (principal.kind === <span class="tk-s">"guest"</span> && principal.mode === <span class="tk-s">"read"</span>) <span class="tk-k">return</span> { variant: <span class="tk-s">"reader"</span>, notes: … };
|
||
…
|
||
});
|
||
});</code></pre>
|
||
<p>The import is cheap: <code>@studio/core</code> is a host package plugins already import at runtime (the contract imports <code>Session</code>
|
||
from it), <code>P</code> is a type-only import, and <code>bindFacet</code> is a one-line function. The host checks the pairing: <code>load</code>
|
||
accepts only facets typed for its own <code>P</code>, so a facet compiled against another app's principal type, or one built with
|
||
Chord's untyped <code>defineFacet</code>, does not compile into Studio's start code. Client facets (<code>web.tsx</code>, <code>tui.ts</code>) use the
|
||
same <code>defineFacet</code>; they rarely see a principal, because clients serve only <code>local</code> types.</p>
|
||
<p>None of this is checked at runtime, and none of it needs to be: the principal a factory receives is the one the link
|
||
stamped.</p>
|
||
<div class="note review"><b class="tag">Review: binding facets to the app's principal type (open)</b><p>The design shows <code>defineFacet((host, self) => …)</code> without saying how <code>P</code> reaches the factory. The frozen kernel takes
|
||
it as an explicit type parameter, <code>defineFacet<P>(…)</code>, defaulting to <code>{ kind: string }</code>
|
||
(<code>packages/chord/src/next/facet.ts</code>), and the playground writes <code>defineFacet<AppPrincipal>(…)</code>. The pre-bound
|
||
<code>defineFacet</code> above (<code>bindFacet</code> is a working name) is the owner's preferred direction, recorded as an open point in
|
||
<code>packages/chord/docs/next/layer-1.md</code> §4.</p>
|
||
</div>
|
||
<h3 id="narrowing-on-the-caller-39-s-side">Narrowing on the caller's side</h3>
|
||
<p>Owner and guest run the same <code>web.tsx</code>, and which variant they get is known only at runtime. The types force every
|
||
caller to narrow before using variant members:</p>
|
||
<pre><code><span class="tk-k">const</span> notes = <span class="tk-k">await</span> client.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, { session: <span class="tk-s">"S1"</span> });
|
||
notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"hi"</span> }); <span class="tk-c">// type error: `add` exists only in "editor"</span>
|
||
<span class="tk-k">if</span> (<span class="tk-f">inVariant</span>(notes, <span class="tk-s">"editor"</span>)) <span class="tk-k">await</span> notes.<span class="tk-f">add</span>({ text: <span class="tk-s">"hi"</span> });
|
||
<span class="tk-f">expectVariant</span>(notes, <span class="tk-s">"editor"</span>); <span class="tk-c">// or assert (throws)</span>
|
||
<span class="tk-k">function</span> <span class="tk-t">Editor</span>({ notes }: { notes: <span class="tk-t">RefIn</span><<span class="tk-k">typeof</span> <span class="tk-t">Notes</span>, <span class="tk-s">"editor"</span>> }) { … }</code></pre>
|
||
<p>The protection is not the type checker; it is Chord refusing the call at the provider. A client that ignores the
|
||
types gets the same error.</p>
|
||
<h3 id="admission-before-a-worker-starts">Admission before a worker starts</h3>
|
||
<p>Principals also decide whether a caller may reach a host at all. <code>route(Session, { authorize })</code> runs before Chord
|
||
resolves an address, so a guest of S1 asking for anything in S2 is refused at the server, and no worker starts for
|
||
them. Finer authority lives in the per-principal view's methods; there is no asynchronous "may this caller activate
|
||
this object" guard. An object whose address carries authority (say, a login flow by id) may exist; what a caller can
|
||
see and do is decided by the variant its factory picks.</p>
|
||
<h3 id="inside-methods">Inside methods</h3>
|
||
<p><code>ctx.principal</code> is the caller of the current call. In Studio's notes the editor's <code>add</code> uses it to record the
|
||
author:</p>
|
||
<pre><code>add: ({ text }, ctx) => store.<span class="tk-f">add</span>({ text, by: <span class="tk-f">authorOf</span>(ctx.principal) }, ctx),</code></pre>
|
||
<p>Where principals act, in summary:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Purpose</th>
|
||
<th>Where</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>identity: which object a caller gets</td>
|
||
<td>principal key</td>
|
||
</tr>
|
||
<tr>
|
||
<td>variant: which members</td>
|
||
<td>the factory</td>
|
||
</tr>
|
||
<tr>
|
||
<td>admission: whether a host is reachable</td>
|
||
<td><code>route.authorize</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>attribution and fine checks</td>
|
||
<td><code>ctx.principal</code> in methods</td>
|
||
</tr>
|
||
<tr>
|
||
<td>revocation</td>
|
||
<td><code>revokedBy</code> (chapter VII)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="the-bug-from-chapter-ii">The bug from chapter II</h3>
|
||
<p>Now the bug is visible. The owner's <code>Notes { S1 }</code> and Ann's <code>Notes { S1 }</code> are two objects (rows 1 and 3 above), and
|
||
each declared its own <code>NoteList</code>. The owner's notes are not in Ann's list. Per-caller objects must not hold the data;
|
||
they must be views over something that exists once. That is the next chapter.</p>
|
||
</section>
|
||
|
||
<section class="chapter" id="shared-store"><header class="opener"><div class="numeral">IV</div><h2>One Store, Many Views</h2><p class="lede">Local types, and the pattern every Chord plugin uses: shared data once in a store, per-caller views in front of it.</p></header>
|
||
<p class="dropcap">Every caller has its own <code>Notes</code> object, which is what makes per-caller variants possible. But data must exist once.
|
||
The fix is the most common pattern in Chord: a <strong>shared store</strong>, a <code>local</code> object that holds the data and does the
|
||
heavy work once, behind thin per-principal <strong>views</strong>, the <code>remote</code> objects callers get.</p>
|
||
<h3 id="local-types"><code>local</code> types</h3>
|
||
<pre><code><span class="tk-c">// notes/contract.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteStore</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.store"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
input: <span class="tk-f">obj</span>({ session: <span class="tk-f">str</span>() }),
|
||
members: {
|
||
notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">Note</span>) }), <span class="tk-c">// state: a schema</span>
|
||
<span class="tk-k">public</span>: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">PublicNote</span>) }),
|
||
add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>(), by: <span class="tk-f">str</span>() }), <span class="tk-f">str</span>()), <span class="tk-c">// methods: method(...)</span>
|
||
remove: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })),
|
||
},
|
||
});</code></pre>
|
||
<p>A <code>local</code> contract has flat <code>members</code> and no variants. The differences:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th><code>remote</code></th>
|
||
<th><code>local</code></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>reachable from</td>
|
||
<td>any host, across links</td>
|
||
<td>its own host only</td>
|
||
</tr>
|
||
<tr>
|
||
<td>identity</td>
|
||
<td>(type, input, principal key)</td>
|
||
<td>(type, input)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>principals, variants</td>
|
||
<td>yes</td>
|
||
<td>no</td>
|
||
</tr>
|
||
<tr>
|
||
<td>factory</td>
|
||
<td><code>(input, principal, self)</code></td>
|
||
<td><code>(input, self)</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>arguments</td>
|
||
<td>stripped to the provider's schema and validated, for every caller</td>
|
||
<td>see the review note below</td>
|
||
</tr>
|
||
<tr>
|
||
<td>values on the same host</td>
|
||
<td>passed by reference, never copied</td>
|
||
<td>passed by reference, never copied</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ctx.principal</code></td>
|
||
<td>the caller</td>
|
||
<td>none</td>
|
||
</tr>
|
||
<tr>
|
||
<td>may persist documents</td>
|
||
<td>no (chapter V)</td>
|
||
<td>yes</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>A local object has no principal: it is trusted code on its own host, acting for the host. So attribution ("who wrote
|
||
this note") is passed as data (<code>by</code>), by the view that knows who called. Clients serve only local types, because
|
||
nobody can address a client.</p>
|
||
<h3 id="the-pattern">The pattern</h3>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, ({ session }, self) => {
|
||
<span class="tk-k">const</span> list = self.<span class="tk-f">state</span>(<span class="tk-t">NoteList</span>);
|
||
<span class="tk-k">const</span> pub = self.<span class="tk-f">derive</span>(<span class="tk-s">"public"</span>, [list], ([l]) => ({ items: l.items.<span class="tk-f">map</span>(stripAuthor) }));
|
||
<span class="tk-k">return</span> {
|
||
notes: list,
|
||
<span class="tk-k">public</span>: pub, <span class="tk-c">// computed once, for every reader</span>
|
||
add: ({ text, by }, ctx) =>
|
||
self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||
<span class="tk-k">const</span> id = <span class="tk-f">uuidv7</span>();
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(list)).items.<span class="tk-f">push</span>({ id, text, by, at: <span class="tk-t">Date</span>.<span class="tk-f">now</span>() });
|
||
<span class="tk-k">return</span> id;
|
||
}, ctx),
|
||
remove: ({ id }, ctx) => list.<span class="tk-f">change</span>((d) => { d.items = d.items.<span class="tk-f">filter</span>((n) => n.id !== id); }, ctx),
|
||
};
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Notes</span>, ({ session }, principal, self) => {
|
||
<span class="tk-k">const</span> store = self.<span class="tk-f">get</span>(<span class="tk-t">NoteStore</span>, { session }); <span class="tk-c">// a reference, declared: held for the view's life</span>
|
||
<span class="tk-k">if</span> (<span class="tk-f">canWrite</span>(principal, session)) <span class="tk-k">return</span> {
|
||
variant: <span class="tk-s">"editor"</span>,
|
||
notes: store.notes, <span class="tk-c">// the store's readable, exposed as-is</span>
|
||
add: ({ text }, ctx) => store.<span class="tk-f">add</span>({ text, by: <span class="tk-f">authorOf</span>(principal) }, ctx),
|
||
remove: (args, ctx) => store.<span class="tk-f">remove</span>(args, ctx),
|
||
};
|
||
<span class="tk-k">return</span> { variant: <span class="tk-s">"reader"</span>, notes: store.<span class="tk-k">public</span> };
|
||
});
|
||
});</code></pre>
|
||
<figure class="fig wide"><svg viewBox="0 0 720 241" width="100%" role="img" aria-label="One store, three views. The redaction runs once in the store; both readers read the same value." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f4m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f4a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f4b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#2e6a8a" text-anchor="start" font-weight="600" letter-spacing="2">PER-PRINCIPAL VIEWS (REMOTE)</text><text x="16" y="144" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">SHARED STORE (LOCAL)</text><rect x="19" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="124" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">Notes { S1 }</text><text x="124" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">owner · editor</text><rect x="255" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="360" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">Notes { S1 }</text><text x="360" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">guest Ann · reader</text><rect x="491" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="596" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">Notes { S1 }</text><text x="596" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">guest Bob · reader</text><rect x="255" y="158" width="210" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="360" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">NoteStore { S1 }</text><text x="360" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">notes, public (a derive)</text><path d="M124 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f4m)"/><rect x="246" y="112" width="50.1" height="13" fill="#fbf8f2" opacity=".92"/><text x="250" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">self.get</text><path d="M360 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f4m)"/><rect x="364" y="112" width="50.1" height="13" fill="#fbf8f2" opacity=".92"/><text x="368" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">self.get</text><path d="M596 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f4m)"/><rect x="482" y="112" width="50.1" height="13" fill="#fbf8f2" opacity=".92"/><text x="486" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">self.get</text><text x="16" y="226" font-size="10.5" fill="#7b7468" text-anchor="start">Authority is the view's: the reader variant has no add. The store does not check; it is trusted and acts for the host.</text></svg><figcaption><span class="fignum">Figure 4.</span> One store, three views. The redaction runs once in the store; both readers read the same value.</figcaption></figure>
|
||
<p><code>self.get(NoteStore, { session })</code> in a factory declares a <strong>reference</strong> that the view holds for its whole life; Chord
|
||
binds it during activation and waits until the store is live (chapter VI). The view exposes the store's readables
|
||
directly. Owner, Ann and Bob now give three <code>Notes</code> objects, one <code>NoteStore</code>, one note list and one redacted list.</p>
|
||
<p>Any heavy or shared part belongs in a store like this: a pi-durable watch, a file watcher, a sandbox, a search index.
|
||
Base code uses the same pattern with the same primitives; nothing about it is special to core.</p>
|
||
<h3 id="derived-values-briefly">Derived values, briefly</h3>
|
||
<p><code>self.derive(name, sources, fn)</code> computes a value from readables and recomputes it when a source changes; viewers get
|
||
only the difference. The reader's list above is one. Chapter VIII covers derives in depth, including the one footgun
|
||
that matters: <code>stripAuthor</code> must return the <em>same</em> object for an unchanged note, or every commit costs the size of the
|
||
whole list. The tool for that is <code>mapShared</code>.</p>
|
||
<h3 id="arguments-are-checked-at-the-provider">Arguments are checked at the provider</h3>
|
||
<p>Trusted code is not type-checked code: plugins are bundled with esbuild without type checks, and agents write
|
||
JavaScript plugins. So the provider checks arguments itself (decision R7): every method's arguments are stripped to
|
||
the <strong>provider's own</strong> schema (unknown fields removed) and validated with it, whoever the caller is, and never with a
|
||
schema a peer sent. Schemas are compiled lazily on first use and cached per schema object. Bytes are checked as leaves
|
||
(<code>instanceof Uint8Array</code> plus an optional length bound), never walked. Stripping copies only when a field was removed;
|
||
the caller's value is never mutated (proposal Q12, in the L1 draft). Results and state are not validated: they come
|
||
from the provider.</p>
|
||
<p>On the same host nothing is copied: arguments and results are passed by reference. Values are <strong>immutable by
|
||
convention</strong> (Delta's rule, as in Durable): nobody mutates a value it received, and Chord neither copies nor freezes
|
||
values to enforce it. There is no development mode that would freeze them; one behaviour everywhere (R9).</p>
|
||
<div class="note review"><b class="tag">Review: are local arguments validated? (Q15, open)</b><p>Design §5's table says <code>local</code> arguments are "neither validated nor copied"; §3 and R7 say arguments are validated
|
||
"for every caller". Review round 3 raised this as Q15 (apply R7 to <code>local</code> too, or keep <code>local</code> as the one exception),
|
||
and <code>layer-1.md</code> lists it as open. Until it is decided, write local methods so that they do not depend on either
|
||
answer: trust the shape, but never trust content (lengths, ids) that came from a remote caller without the view
|
||
checking it. <code>chord-next-walkthrough.md</code> concept 6 still describes the older rule ("validated only when the caller is
|
||
not a host principal"), which R7 replaced.</p>
|
||
</div>
|
||
<h3 id="choosing-between-the-two">Choosing between the two</h3>
|
||
<p>Use a <code>remote</code> type for what callers address: views, things with per-caller authority. Use a <code>local</code> type for what
|
||
exists once per input on one host: stores, caches, owners of OS handles, and the core objects a host provides
|
||
(chapter IX). If a type is reached across a link, it is remote; if a type persists documents, it is local (or a
|
||
host-lifetime object, chapter V).</p>
|
||
</section>
|
||
|
||
<section class="chapter" id="documents"><header class="opener"><div class="numeral">V</div><h2>Writing It Down</h2><p class="lede">Documents and transactions: how an object stores data, changes it atomically, survives restarts and migrates it.</p></header>
|
||
<p class="dropcap">Notes must be stored, changed safely while others read them, sent to viewers as small changes, and survive a worker
|
||
restart. An object's data lives in <strong>documents</strong>: named JSON values the object owns, changed only in
|
||
<strong>transactions</strong>. The design is a port of pi-durable's store, so most rules will look familiar; this chapter states
|
||
them for Chord.</p>
|
||
<h3 id="defining-a-document">Defining a document</h3>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">const</span> <span class="tk-t">NoteList</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.list"</span>, {
|
||
schema: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">Note</span>) }),
|
||
version: <span class="tk-n">1</span>,
|
||
initial: () => ({ items: [] }),
|
||
persist: <span class="tk-k">true</span>, <span class="tk-c">// stored; default: memory only</span>
|
||
checkpointWhen: (_value, _ops, info) => info.deltasSinceBase > <span class="tk-n">64</span>,
|
||
});
|
||
|
||
<span class="tk-k">const</span> <span class="tk-t">Draft</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.draft"</span>, { <span class="tk-c">// one unfinished note per author</span>
|
||
schema: <span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>() }),
|
||
version: <span class="tk-n">1</span>,
|
||
initial: () => ({ text: <span class="tk-s">""</span> }),
|
||
keyed: <span class="tk-k">true</span>, <span class="tk-c">// many per object, by key</span>
|
||
persist: <span class="tk-k">true</span>,
|
||
checkpointWhen: () => <span class="tk-k">true</span>, <span class="tk-c">// small: a full base per write</span>
|
||
});</code></pre>
|
||
<p><code>checkpointWhen</code> is required on every definition, with no default (R14, as in Durable since pi <code>5c1736f82</code>): it says
|
||
when a commit stores a complete base instead of a delta, which bounds how much is replayed on load. Small documents
|
||
checkpoint on every write; growing ones after a number of deltas.</p>
|
||
<p>A document's root is always a JSON object (lists live in a field). Documents hold JSON only; bytes are a value and wire
|
||
type, not a document type.</p>
|
||
<h3 id="singleton-and-keyed">Singleton and keyed</h3>
|
||
<pre><code>host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, ({ session }, self) => {
|
||
<span class="tk-k">const</span> list = self.<span class="tk-f">state</span>(<span class="tk-t">NoteList</span>); <span class="tk-c">// THE note list of this object</span>
|
||
<span class="tk-k">const</span> drafts = self.<span class="tk-f">state</span>(<span class="tk-t">Draft</span>); <span class="tk-c">// all drafts of this object</span>
|
||
…
|
||
});</code></pre>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>singleton (no <code>keyed</code>)</th>
|
||
<th>keyed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>how many</td>
|
||
<td>one per object</td>
|
||
<td>many per object, by a short key</td>
|
||
</tr>
|
||
<tr>
|
||
<td>loaded</td>
|
||
<td>at activation, kept in memory</td>
|
||
<td>on demand</td>
|
||
</tr>
|
||
<tr>
|
||
<td>read</td>
|
||
<td><code>list.value</code> (synchronous)</td>
|
||
<td><code>await drafts.get(key)</code>: the value or <code>undefined</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>list</td>
|
||
<td>(it is one value)</td>
|
||
<td><code>for await (const key of drafts.keys())</code> pages keys</td>
|
||
</tr>
|
||
<tr>
|
||
<td>expose as state</td>
|
||
<td>yes: the handle is a readable</td>
|
||
<td>no: expose a method or a singleton index</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>There is no live key list. A collection shown live is a singleton (an object holding a list or a map); a large
|
||
collection is a method returning pages.</p>
|
||
<h3 id="transactions">Transactions</h3>
|
||
<p>All writes go through <code>self.tx(fn, ctx)</code>. Inside, <code>await tx.draft(handle, key?, { tags? })</code> returns a draft of the
|
||
current document, or a draft over <code>initial()</code> if it is absent. Drafts record operations (assignments, <code>push</code>,
|
||
<code>Object.assign</code>); asking for the same document twice in one transaction returns the same draft. <code>tx.retire(handle, key?)</code> removes a document. There is no <code>tx.get</code>: reading is drafting, and a draft is an overlay over the current
|
||
value, so nothing is copied. A draft you do not change commits nothing, so reading never stores an absent document.</p>
|
||
<pre><code>saveDraft: ({ author, text }, ctx) =>
|
||
self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(drafts, author)).text = text; }, ctx),</code></pre>
|
||
<p>Shorthands for one document: <code>handle.change(fn, ctx)</code> (keyed: <code>handle.change(key, fn, ctx)</code>) (loads the document, then runs the synchronous <code>fn</code> on
|
||
its draft) and <code>handle.retire(ctx)</code> (keyed: <code>handle.retire(key, ctx)</code>).</p>
|
||
<h4>Four patterns</h4>
|
||
<p>Two decisions (R12 and R13, both as in Durable) shape how transactions are written. <strong>Drafts are revoked when the
|
||
transaction settles</strong>, so a transaction returns at most plain values, never something taken from a draft. And
|
||
<strong>there is no existence check inside a transaction.</strong></p>
|
||
<pre><code><span class="tk-c">// 1. Return data: write in the transaction, read after the commit.</span>
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(list)).items.<span class="tk-f">push</span>(note); }, ctx);
|
||
<span class="tk-k">return</span> list.value.items.length; <span class="tk-c">// the committed revision, or a newer one</span>
|
||
|
||
<span class="tk-c">// 2. Remove if present: retire of an absent document is a no-op.</span>
|
||
<span class="tk-k">await</span> drafts.<span class="tk-f">retire</span>(author, ctx);
|
||
|
||
<span class="tk-c">// 3. Create with tags if missing: the draft creates it, only if it changes.</span>
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(drafts, author, { tags: [<span class="tk-t">SessionTag</span>(session)] })).text = text;
|
||
}, ctx);
|
||
|
||
<span class="tk-c">// 4. Branch on existence: read first, outside the transaction.</span>
|
||
<span class="tk-k">if</span> ((<span class="tk-k">await</span> drafts.<span class="tk-f">get</span>(author)) === <span class="tk-k">undefined</span>) { … }</code></pre>
|
||
<h4>Durable's rule</h4>
|
||
<p>Inside the callback, only <code>await tx.*</code>. No other objects, no network, no models, no processes, no second <code>self.tx</code>, no
|
||
reads past <code>tx</code>. Breaking it is not an error but a hang: the object's line is held while the callback waits. Fetch
|
||
first; decide and write inside.</p>
|
||
<pre><code><span class="tk-c">// Wrong: a fetch inside the transaction holds the object's line.</span>
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(list)).items.<span class="tk-f">push</span>({ …, title: <span class="tk-k">await</span> <span class="tk-f">fetchTitle</span>(url) }); }, ctx);
|
||
<span class="tk-c">// Right: fetch, then write.</span>
|
||
<span class="tk-k">const</span> title = <span class="tk-k">await</span> <span class="tk-f">fetchTitle</span>(url, ctx.signal);
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(list)).items.<span class="tk-f">push</span>({ …, title }); }, ctx);</code></pre>
|
||
<h3 id="what-a-commit-does">What a commit does</h3>
|
||
<figure class="fig wide anim"><svg viewBox="0 0 720 241" width="100%" role="img" aria-label="The commit sequence, Durable's. Nothing is copied, frozen or revalidated; viewers get one batch per commit." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f5m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f5a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f5b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">INSIDE SELF.TX</text><text x="16" y="144" font-size="10" fill="#2e6a8a" text-anchor="start" font-weight="600" letter-spacing="2">AFTER THE WRITE</text><g class="step" style="--i:1"><rect x="16" y="36" width="152.5" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="92.3" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">wait for the line</text><text x="92.3" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">per object</text></g><g class="step" style="--i:3"><rect x="194.5" y="36" width="152.5" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="270.8" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">run the callback</text><text x="270.8" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">drafts record ops</text></g><g class="step" style="--i:5"><rect x="373" y="36" width="152.5" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="449.3" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">prepare</text><text x="449.3" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">Delta checks placed values</text></g><g class="step" style="--i:7"><rect x="551.5" y="36" width="152.5" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="627.8" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">storage batch</text><text x="627.8" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">deltas or a base</text></g><g class="step" style="--i:9"><rect x="16" y="158" width="152.5" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="92.3" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">adopt</text><text x="92.3" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">pointer swap</text></g><g class="step" style="--i:11"><rect x="194.5" y="158" width="152.5" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="270.8" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">notify locally</text><text x="270.8" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">once, with the ops</text></g><g class="step" style="--i:13"><rect x="373" y="158" width="152.5" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="449.3" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">send</text><text x="449.3" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">one batch per link</text></g><g class="step" style="--i:15"><rect x="551.5" y="158" width="152.5" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="627.8" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">revoke drafts</text><text x="627.8" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">touching one throws</text></g><g class="step edge" style="--i:2"><path d="M168.5 58 L192.5 58" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><g class="step edge" style="--i:4"><path d="M347 58 L371 58" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><g class="step edge" style="--i:6"><path d="M525.5 58 L549.5 58" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><g class="step edge" style="--i:8"><path d="M627.8 80 L92.3 156" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/><rect x="364" y="112" width="39.6" height="13" fill="#fbf8f2" opacity=".92"/><text x="368" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">stored</text></g><g class="step edge" style="--i:10"><path d="M168.5 180 L192.5 180" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><g class="step edge" style="--i:12"><path d="M347 180 L371 180" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><g class="step edge" style="--i:14"><path d="M525.5 180 L549.5 180" fill="none" stroke="#7b7468" marker-end="url(#f5m)"/></g><text x="16" y="226" font-size="10.5" fill="#7b7468" text-anchor="start">An error or an abort before the storage write discards everything. Storage commits are serialized per host.</text></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 5.</span> The commit sequence, Durable's. Nothing is copied, frozen or revalidated; viewers get one batch per commit.</figcaption></figure>
|
||
<p>Lines are per object, never across objects; reads outside a transaction do not take the line. Subscribers on the same
|
||
host get the new value and the commit's operations by reference. Remote viewers get the commit's operations, never the
|
||
document again; chapter XVII shows how one commit touching several holds still arrives as one message.</p>
|
||
<h3 id="tags-and-deleting-by-tag">Tags and deleting by tag</h3>
|
||
<p>Tags are typed tokens set when a document is created:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">SessionTag</span> = <span class="tk-f">defineTag</span><string>(<span class="tk-s">"studio.session"</span>); <span class="tk-c">// base code, shared by every plugin</span>
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(drafts, author, { tags: [<span class="tk-t">SessionTag</span>(session)] })).text = text; }, ctx);
|
||
<span class="tk-c">// when the session is deleted</span>
|
||
<span class="tk-k">await</span> store.<span class="tk-f">retireByTag</span>(<span class="tk-t">SessionTag</span>, <span class="tk-s">"S1"</span>); <span class="tk-c">// every document tagged S1</span></code></pre>
|
||
<p>The design names the operation <code>store.retireByTag(tag, value)</code> without saying who holds the store; session delete is
|
||
one of the app decisions still open (#18, chapter XIX).</p>
|
||
<h3 id="who-may-persist">Who may persist</h3>
|
||
<p><code>persist: true</code> is allowed only in <code>local</code> objects and <code>lifetime: "host"</code> objects: identities without a principal. A
|
||
<code>remote</code> object's identity contains the principal key, which changes with the access path and the grant revision. Its
|
||
stored documents would scatter across access paths and be orphaned after a reconnect. The rule is enforced at the type
|
||
level (<code>self.state</code> in a remote factory accepts only non-persisted definitions) and at declaration ("persist needs a
|
||
stable identity: put the documents into a local shared store"). This is the second reason for the shared store.</p>
|
||
<p>What survives a host teardown (a crash, a restart) is exactly what is persisted. In-memory documents live as long as
|
||
their object: they survive reloads and native restarts of a live object, and go when it ends. Everything else
|
||
(natives, derives, routines, references) is rebuilt on activation.</p>
|
||
<h3 id="facet-documents">Facet documents</h3>
|
||
<p>A facet has its own identity (its plugin's name) and may hold documents too, for plugin-wide data such as
|
||
preferences. Objects the facet serves may read and write their facet's documents, and one transaction may span the
|
||
object's own documents and its facet's (lock order: own line, then the facet's). The facet never touches a served
|
||
object's documents.</p>
|
||
<h3 id="ownership-across-plugins">Ownership across plugins</h3>
|
||
<p>No plugin reads or writes another plugin's documents, Chord or Durable. Data crosses plugins only through state and
|
||
methods. Data that core enforces lives in a format core owns and is handed to core through its API when the data is
|
||
created (as with Durable's <code>createConversation({ init })</code>), never written into core's documents.</p>
|
||
<h3 id="versions-and-migration">Versions and migration</h3>
|
||
<p>A change of shape or meaning raises <code>version</code> and adds a pure <code>migrate(old, from)</code>:</p>
|
||
<pre><code><span class="tk-k">const</span> <span class="tk-t">NoteList</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.list"</span>, {
|
||
schema: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">NoteV2</span>) }), <span class="tk-c">// NoteV2 adds `pinned`</span>
|
||
version: <span class="tk-n">2</span>,
|
||
migrate: (old, <span class="tk-k">from</span>) => ({ items: old.items.<span class="tk-f">map</span>((n) => ({ ...n, pinned: <span class="tk-k">false</span> })) }),
|
||
initial: () => ({ items: [] }),
|
||
persist: <span class="tk-k">true</span>,
|
||
checkpointWhen: (_v, _o, info) => info.deltasSinceBase > <span class="tk-n">64</span>,
|
||
});</code></pre>
|
||
<p>Documents migrate on access by the new definition: singletons during activation (hydration counts as access), keyed
|
||
ones on their next access. The migrated base is persisted with the next commit; right after a bump, a read-only
|
||
transaction writes it.</p>
|
||
<p>Chord does not walk values against schemas. Instead, each definition computes a <strong>schema fingerprint</strong> once
|
||
(descriptions and titles ignored), and every stored record keeps the fingerprint it was written with. On access Chord
|
||
compares them: the same version with a different fingerprint fails the object <code>broken</code> ("schema changed without a
|
||
version bump"). So every structural change needs a bump, an additive one with a trivial <code>migrate</code>. The one exception
|
||
to "no value checks" (R8): the result of <code>migrate</code> and of <code>initial()</code> is validated against the new schema once, when
|
||
produced. A failed check, a throwing <code>migrate</code>, or old code meeting a newer document refuses the access, keeps the
|
||
stored data and fails the owning object. A changed meaning without a schema change cannot be detected; that is on
|
||
you.</p>
|
||
<h3 id="storage">Storage</h3>
|
||
<p>Behind the store is a <code>DocumentStorage</code>, passed as <code>createHost({ storage })</code>; memory by default. The server has one
|
||
shared storage, each worker one for its session.</p>
|
||
<pre><code><span class="tk-k">interface</span> <span class="tk-t">DocumentStorage</span> {
|
||
<span class="tk-f">commit</span>(writes: <span class="tk-k">readonly</span> <span class="tk-t">DocumentWrite</span>[]): <span class="tk-t">Promise</span><<span class="tk-t">Seq</span>>; <span class="tk-c">// create/change/retire, atomic</span>
|
||
<span class="tk-f">find</span>(address: <span class="tk-t">DocumentAddress</span>): <span class="tk-t">Promise</span><<span class="tk-t">DocumentRecord</span> | <span class="tk-k">undefined</span>>;
|
||
<span class="tk-f">load</span>(id: string): <span class="tk-t">Promise</span><<span class="tk-t">StoredDocument</span> | <span class="tk-k">undefined</span>>; <span class="tk-c">// version, value, deltasSinceBase</span>
|
||
<span class="tk-f">scan</span>(query: { owner: string; definition: string }, limit: number, cursor?: string):
|
||
<span class="tk-t">Promise</span><{ keys: string[]; next?: string }>;
|
||
<span class="tk-f">byTag</span>(tag: string, value: string, limit: number, cursor?: string): <span class="tk-t">Promise</span><{ ids: string[]; next?: string }>;
|
||
<span class="tk-f">mintId</span>(): <span class="tk-t">Promise</span><string>;
|
||
}</code></pre>
|
||
<p>Chord ships a memory storage and a SQLite storage (over <code>@earendil-works/chord/sqlite</code>, the driver layer that moved
|
||
from Durable; Node and Durable Object drivers). Storages check only metadata (incarnation, version, sequence, address
|
||
occupancy, fingerprint) and write the supplied delta or base; they never rebuild or validate content on a commit.
|
||
Chord documents never commit together with Durable transcripts: Chord has its own storage, not an adapter over
|
||
Durable's. Your own storage can prove itself with <code>registerDocumentStorageConformance(runner, name, withStorage)</code> from
|
||
<code>@earendil-works/chord/testing</code>.</p>
|
||
<p>Keyed persisted documents that nobody reads, writes or watches for 10 minutes leave memory and reload on the next
|
||
access. A storage error ends the store and closes the host; <code>onError</code> receives <code>{ kind: "storage_failed" }</code> and the
|
||
app decides (chapter I).</p>
|
||
<h3 id="kinds-of-state">Kinds of state</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Kind</th>
|
||
<th>For</th>
|
||
<th>API</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>document</td>
|
||
<td>data the object owns</td>
|
||
<td><code>self.state</code>, <code>self.tx</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>derive</td>
|
||
<td>computed values</td>
|
||
<td><code>self.derive</code> (chapter VIII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>source readable</td>
|
||
<td>external data: Durable views, files, logs</td>
|
||
<td>value plus ordered changes; feeds derives</td>
|
||
</tr>
|
||
<tr>
|
||
<td>paged method</td>
|
||
<td>large or queryable collections</td>
|
||
<td>a method returning pages (chat history)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>plugin database</td>
|
||
<td>full-text search, large blobs</td>
|
||
<td>a native opened by its owner (chapter IX)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>App state lives in Chord documents. Only transcript-bound data, which must fork and rewind with a conversation, lives
|
||
in Durable.</p>
|
||
<div class="note review"><b class="tag">Review: does an absent singleton read <code>undefined</code> or <code>initial()</code>? (Q11, open)</b><p>Design §9 says "whether a document exists is read outside the transaction (<code>handle.value</code> / <code>handle.get</code> give
|
||
<code>undefined</code> for an absent one)" and also "a singleton that does not exist yet reads <code>initial()</code> through its handle;
|
||
retiring resets it to <code>initial()</code>". Review round 3 proposes: singletons always read <code>initial()</code>, only absent keyed
|
||
documents read <code>undefined</code>, and singleton existence needs no API. This guide follows the proposal (<code>list.value</code> is
|
||
never <code>undefined</code>); it is not decided.</p>
|
||
</div>
|
||
<div class="note"><b class="tag">Built so far</b><p>Documents, transactions and storages are layer L3, a port of Durable's session store; not started. The frozen kernel
|
||
has a working version with a JSONL storage, which the design rejected.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="references"><header class="opener"><div class="numeral">VI</div><h2>Holding On</h2><p class="lede">References, the object lifecycle and ctx: what a caller sees while objects start, fail, restart and move, and why nobody writes retry code.</p></header>
|
||
<p class="dropcap">A tab shows the notes of S1 for an hour. During that hour the laptop sleeps, the worker is stopped for idleness and
|
||
started again, the notes plugin reloads twice and the store's storage hiccups once. The panel should show the notes the
|
||
whole time, say "reconnecting" when it cannot reach them, and recover by itself. In Chord this is what a
|
||
<strong>reference</strong> does; plugin code contains no retry loops and no request ids.</p>
|
||
<h3 id="three-ways-to-hold">Three ways to hold</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Where</th>
|
||
<th>API</th>
|
||
<th>Released</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>a factory</td>
|
||
<td><code>self.get(Type, input)</code></td>
|
||
<td>when the object ends</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a call</td>
|
||
<td><code>await ctx.get(Type, input, { state? })</code></td>
|
||
<td>when the call ends</td>
|
||
</tr>
|
||
<tr>
|
||
<td>outside objects</td>
|
||
<td><code>await host.get(Type, input, { signal, state?, parkTimeout? })</code></td>
|
||
<td><code>ref.release()</code></td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Use <code>self.get</code> for what an object needs its whole life (the view's store), <code>ctx.get</code> for what one call needs, and
|
||
<code>host.get</code> only in UI code, tests and entry points. <code>{ state: false }</code> binds for methods only, without receiving state.
|
||
A reference keeps its object alive while held.</p>
|
||
<h3 id="the-object-lifecycle">The object lifecycle</h3>
|
||
<pre class="plain"><code>(get) → activating → live → ending → ended
|
||
└→ failed (backoff) → activating …</code></pre>
|
||
<p><strong>Activating:</strong> Chord binds the declared references and waits until they are live and hydrated, loads singleton
|
||
documents, opens natives (one after another, in declaration order), computes derives and starts routines. The object
|
||
goes live when all of that succeeded. Calls wait behind its <strong>gate</strong> until then.</p>
|
||
<p><strong>Ending:</strong> routines stop, the object's calls and jobs are aborted as their rules say, natives close in reverse order,
|
||
references are released. An object with <code>lifetime: "referenced"</code> (the default) ends when its last reference goes,
|
||
after the type's <code>linger</code> if it has one. A <code>lifetime: "host"</code> object (only for types without a required input) lives
|
||
as long as the host. A running, finishing or blocked job counts as a reference (chapter XI).</p>
|
||
<h3 id="ref-state"><code>ref.state</code></h3>
|
||
<p>Every reference reports what is going on as a tagged value; listeners get each new one:</p>
|
||
<pre><code><span class="tk-k">type</span> <span class="tk-t">RefState</span> =
|
||
| { kind: <span class="tk-s">"loading"</span> } <span class="tk-c">// first hold, reconnecting, restarting</span>
|
||
| { kind: <span class="tk-s">"live"</span> }
|
||
| { kind: <span class="tk-s">"failed"</span>; cause: <span class="tk-t">FailureCause</span>; retryAt: number | <span class="tk-k">undefined</span> } <span class="tk-c">// undefined: no automatic retry</span>
|
||
| { kind: <span class="tk-s">"released"</span> };</code></pre>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Situation</th>
|
||
<th>Calls</th>
|
||
<th>Values</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>link drop</td>
|
||
<td>park until reconnected</td>
|
||
<td>readable, <code>loading</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>object loading or restarting</td>
|
||
<td>park until live</td>
|
||
<td>readable, <code>loading</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>rollout: provider ahead or installing</td>
|
||
<td>park until installed</td>
|
||
<td>readable</td>
|
||
</tr>
|
||
<tr>
|
||
<td>object <code>failed</code></td>
|
||
<td>reject at once with the cause</td>
|
||
<td><code>failed</code>, with cause and <code>retryAt</code></td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Values stay readable through all of this: the panel keeps showing the last notes. Parked calls share one deadline (60
|
||
s by default, <code>parkTimeout</code> per <code>get</code>), then fail <code>unavailable</code>. Otherwise a call ends only through its caller's
|
||
signal; there is no per-call deadline. A call that was running on an instance that ends fails with an error and is
|
||
not resent: Chord cannot know whether it took effect.</p>
|
||
<p><strong>Errors look like disconnects.</strong> A consumer handles a failed call exactly as a dropped link: show the error, let the
|
||
user retry. Work that must not run twice carries its own idempotency key as data (Durable's <code>requestId</code>, a job's
|
||
<code>key</code>).</p>
|
||
<div class="steps"><div class="steps-head"><span class="title">The worker restarts while a tab watches the notes</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Watching</h4><p>The tab holds <code>Notes { S1 }</code>. <code>ref.state</code> is <code>live</code>; <code>notes.notes.value</code> has 40 notes.</p>
|
||
</div><div class="step-panel"><h4>The worker goes away</h4><p>The supervisor stops worker S1 (or it crashes). The server's IPC link to it drops. The server tells the tab that its
|
||
hold is gone; the tab's reference goes <code>loading</code>. The 40 notes stay readable.</p>
|
||
</div><div class="step-panel"><h4>A click meanwhile</h4><p>Ann's colleague clicks <strong>Add</strong>. The call <strong>parks</strong> on the reference; nothing is sent yet. The deadline is 60 s.</p>
|
||
</div><div class="step-panel"><h4>Chord resolves again</h4><p>Holds on S1 still exist, so Chord calls <code>route(Session).resolve({ id: "S1" })</code> again, with the per-address backoff.
|
||
The app forks a new worker: a new <strong>incarnation</strong> of <code>Session({ id: "S1" })</code>.</p>
|
||
</div><div class="step-panel"><h4>Re-hold</h4><p>The server re-holds every hold on S1 over the new link. The new worker activates <code>NoteStore { S1 }</code> (its notes load
|
||
from storage) and the views; it sends fresh snapshots. The tab's reference goes <code>live</code>.</p>
|
||
</div><div class="step-panel"><h4>The parked call runs</h4><p>The parked <code>add</code> is sent now and succeeds. The panel shows 41 notes. Nobody wrote recovery code.</p>
|
||
</div></div>
|
||
<h3 id="failure-and-retry">Failure and retry</h3>
|
||
<p>Any broken part fails the <strong>whole</strong> object: a half-working object could break rules that rely on its documents
|
||
changing together. Chord tags the cause by where it failed:</p>
|
||
<pre><code><span class="tk-k">type</span> <span class="tk-t">FailureCause</span> =
|
||
| { kind: <span class="tk-s">"transient"</span>; error: <span class="tk-t">ChordError</span> } <span class="tk-c">// an open threw, a dependency was unreachable: automatic backoff</span>
|
||
| { kind: <span class="tk-s">"broken"</span>; error: <span class="tk-t">ChordError</span> }; <span class="tk-c">// factory, derive, hydration or migrate threw: retried only by a trigger</span></code></pre>
|
||
<p><strong>Transient</strong> causes get a backoff per address: 1 s, doubling to 60 s; concurrent gets share one attempt; success
|
||
resets it. <strong>Broken</strong> causes would fail the same way again (a code bug, invalid data), so they are retried only by a
|
||
<strong>trigger</strong>: a plugin reload, <code>ref.retry()</code> (a Retry button), or a readable the factory declared with
|
||
<code>self.retryOn(...readables)</code>. Only an opener may declare its own failure broken, with <code>throw broken(error)</code>, for
|
||
example for invalid configuration. A failed reference stays held; on success it goes <code>loading</code>, then <code>live</code>.</p>
|
||
<pre><code>host.<span class="tk-f">serve</span>(<span class="tk-t">EnvConnection</span>, ({ env }, self) => {
|
||
<span class="tk-k">const</span> config = self.<span class="tk-f">get</span>(<span class="tk-t">EnvConfig</span>, {}); <span class="tk-c">// a facet or registry value, outlives the attempt</span>
|
||
self.<span class="tk-f">retryOn</span>(config.envs); <span class="tk-c">// a config change retries a broken open</span>
|
||
<span class="tk-k">const</span> ssh = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> ({ signal }) => {
|
||
<span class="tk-k">const</span> c = config.envs.value[env];
|
||
<span class="tk-k">if</span> (!c) <span class="tk-k">throw</span> <span class="tk-f">broken</span>(<span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">`unknown environment ${env}`</span>));
|
||
<span class="tk-k">return</span> <span class="tk-f">connect</span>(c, signal);
|
||
}, (s) => s.<span class="tk-f">close</span>());
|
||
…
|
||
});</code></pre>
|
||
<p><code>retryOn</code> sources must live independently of the attempt (facet documents, registry values); a member of one of the
|
||
object's own references is a type error, since it goes away with the attempt.</p>
|
||
<h3 id="waiting-for-dependencies">Waiting for dependencies</h3>
|
||
<p>An object whose references are not live yet stays <code>loading</code> and goes live the moment they are, bounded by
|
||
<code>readyTimeout</code> (60 s). The decision for the failure case (R18) is what makes recovery automatic:</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 417" width="100%" role="img" aria-label="Dependency recovery. The view fails transient with the store's cause, keeps its reference, and is retried the moment the store goes live." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f6m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f6a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f6b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="42" x2="130.7" y2="409" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="32" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="30" font-size="11" fill="#2a2723" text-anchor="middle">tab</text><line x1="360" y1="42" x2="360" y2="409" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="32" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="360" y="30" font-size="11" fill="#2a2723" text-anchor="middle">Notes { S1 }</text><line x1="589.3" y1="42" x2="589.3" y2="409" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="32" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="589.3" y="30" font-size="11" fill="#2a2723" text-anchor="middle">NoteStore { S1 }</text><g class="step edge" style="--i:1"><line x1="130.7" y1="68" x2="357" y2="68" stroke="#7b7468" marker-end="url(#f6m)"/><text x="245.3" y="62" font-size="10" fill="#2a2723" text-anchor="middle">hold</text></g><g class="step edge" style="--i:2"><line x1="360" y1="100" x2="586.3" y2="100" stroke="#7b7468" marker-end="url(#f6m)"/><text x="474.7" y="94" font-size="10" fill="#2a2723" text-anchor="middle">self.get (activation)</text></g><g class="step edge" style="--i:3"><path d="M589.3 126 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f6m)"/><text x="547.3" y="136" font-size="10" fill="#2a2723" text-anchor="end">open fails: disk not mounted</text></g><g class="step edge" style="--i:4"><line x1="589.3" y1="172" x2="363" y2="172" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f6m)"/><text x="474.7" y="166" font-size="10" fill="#2a2723" text-anchor="middle">failed (transient)</text></g><g class="step edge" style="--i:5"><line x1="360" y1="204" x2="133.7" y2="204" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f6m)"/><text x="245.3" y="198" font-size="10" fill="#2a2723" text-anchor="middle">failed: transient, cause inside</text></g><g class="step" style="--i:6"><rect x="255.3" y="222" width="438.7" height="23" rx="3" fill="#f4eee2" stroke="#c2b18c"/><text x="474.7" y="236" font-size="10" fill="#5b5549" text-anchor="middle">the view keeps its reference; its state is the retry trigger</text></g><g class="step edge" style="--i:7"><path d="M589.3 267 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f6m)"/><text x="547.3" y="277" font-size="10" fill="#2a2723" text-anchor="end">backoff retry: open succeeds</text></g><g class="step edge" style="--i:8"><line x1="589.3" y1="313" x2="363" y2="313" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f6m)"/><text x="474.7" y="307" font-size="10" fill="#2a2723" text-anchor="middle">live</text></g><g class="step edge" style="--i:9"><path d="M360 339 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f6m)"/><text x="402" y="349" font-size="10" fill="#2a2723" text-anchor="start">retried at once (backoff cancelled)</text></g><g class="step edge" style="--i:10"><line x1="360" y1="385" x2="133.7" y2="385" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f6m)"/><text x="245.3" y="379" font-size="10" fill="#2a2723" text-anchor="middle">live</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 6.</span> Dependency recovery. The view fails transient with the store's cause, keeps its reference, and is retried the moment the store goes live.</figcaption></figure>
|
||
<p>A dependency that fails, or does not become live in time, fails the dependent <strong>transient</strong> with the dependency's
|
||
cause inside, never broken: the cause is the dependency's. The dependent keeps its references while failed, and Chord
|
||
uses their state as a retry trigger. Factory authors write <code>self.get</code> and know none of this. After an object is live,
|
||
a dependency leaving <code>live</code> does not change it: its calls that use the dependency get the dependency's error, or park
|
||
while it loads.</p>
|
||
<p><strong>Cycles are rejected</strong> (Q5). If A's activation gets B and B's gets A, neither would ever lose its last reference.
|
||
Every object instance has a uuidv7 instance id; on one host Chord follows the waiting edges of activating objects, and
|
||
across hosts a hold made during an activation carries the chain of instance ids. A cycle fails the activation
|
||
<code>broken</code>, naming it ("A → B → A"), and keeps no references.</p>
|
||
<h3 id="no-optional-references">No optional references</h3>
|
||
<p><code>get</code> has no <code>optional</code> flag. When a dependency's plugin may be missing, turn the dependency around: the dependent
|
||
owns a registry and the other plugin contributes to it. Radius uses forwards from the ports plugin, but ports is
|
||
optional, so radius owns <code>radius.forwards</code> and ports contributes its forwards; without ports the table is empty and
|
||
radius works (chapter XIII).</p>
|
||
<h3 id="ctx"><code>ctx</code></h3>
|
||
<p>Every call gets a <code>ctx</code> as its last argument:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Field</th>
|
||
<th>Meaning</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>signal</code></td>
|
||
<td>fires when the caller cancelled or the object ended; pass it to everything you wait on</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>principal</code></td>
|
||
<td>the caller (remote types only)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>incarnation</code></td>
|
||
<td>the calling host's incarnation, for calls that arrived over a link (chapter X)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>get</code></td>
|
||
<td>references released when the call ends</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>telemetry</code>, <code>trace(name, attributes, fn)</code></td>
|
||
<td>the current span; a child span with a child <code>ctx</code> (chapter XVII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>jobId</code>, <code>progress</code></td>
|
||
<td>in a job's <code>run</code> (chapter XI)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<pre><code>exportText: <span class="tk-k">async</span> ({}, ctx) => {
|
||
<span class="tk-k">const</span> catalog = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Catalog</span>, {}); <span class="tk-c">// released when the call ends</span>
|
||
<span class="tk-k">const</span> title = catalog.sessions.value[session]?.title ?? session;
|
||
<span class="tk-k">const</span> res = <span class="tk-k">await</span> <span class="tk-f">fetch</span>(renderUrl, { method: <span class="tk-s">"POST"</span>, body: <span class="tk-f">render</span>(title, list.value), signal: ctx.signal });
|
||
<span class="tk-k">return</span> <span class="tk-k">await</span> res.<span class="tk-f">text</span>();
|
||
},</code></pre>
|
||
<p>There are no code versions and no request ids in <code>ctx</code>. Durable's <code>Context</code> is the same module (<code>chord/context</code>):
|
||
<code>ctx</code> extends it, so passing <code>ctx</code> into pi-durable needs no conversion. Code outside objects gets a <code>ctx</code> from
|
||
<code>host.root(fn, { principal?, signal? })</code>; inside Durable tools it uses <code>chord(fn, context)</code> (chapter XIV).</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>References, <code>ctx</code> and the lifecycle are layers L4 and L5. <code>ChordError</code> and <code>FailureCause</code> are specified in the L1
|
||
draft (<code>layer-1.md</code>), with codes <code>invalid</code>, <code>contract_mismatch</code>, <code>forbidden</code>, <code>gone</code>, <code>not_ready</code>, <code>released</code>,
|
||
<code>too_large</code>, <code>unavailable</code> and <code>aborted</code>, and a finer <code>reason</code> such as <code>invalid/args</code>.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="revocation"><header class="opener"><div class="numeral">VII</div><h2>Taking Access Away</h2><p class="lede">Revocation: how removing a guest ends their objects on every host at once, and why reconnecting does not bring access back.</p></header>
|
||
<p class="dropcap">The owner removes Ann from S1. Her tab still has a WebSocket open and a <code>Notes { S1 }</code> view on worker S1. Both must end
|
||
now, on the server and on the worker, and her tab's automatic reconnect must not restore anything. Chord provides the
|
||
mechanism; the app decides who is blocked and remembers it.</p>
|
||
<h3 id="labels-not-predicates">Labels, not predicates</h3>
|
||
<p>The app declares, once, which <strong>revocation labels</strong> each principal carries. Revoking means revoking one label;
|
||
everything carrying it is hit.</p>
|
||
<pre><code><span class="tk-c">// studio/src/principal.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Revocation</span> = { grant: string } | { device: string } | { session: string };
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> revokedBy = (p: P): <span class="tk-t">Revocation</span>[] =>
|
||
p.kind === <span class="tk-s">"guest"</span> ? [{ grant: p.grant }, { session: p.session }]
|
||
: p.kind === <span class="tk-s">"owner"</span> && p.access === <span class="tk-s">"device"</span> && p.device ? [{ device: p.device }]
|
||
: [];
|
||
|
||
<span class="tk-k">const</span> server = <span class="tk-f">createHost</span><P, <span class="tk-t">Revocation</span>>({ address: <span class="tk-t">Server</span>(), principal: { kind: <span class="tk-s">"server"</span> }, revokedBy });
|
||
<span class="tk-c">// every worker is created with the same revokedBy</span></code></pre>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Principal</th>
|
||
<th>Labels</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>guest Ann (grant g1) in S1</td>
|
||
<td><code>{ grant: "g1" }</code>, <code>{ session: "S1" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>guest Bob (grant g2) in S1</td>
|
||
<td><code>{ grant: "g2" }</code>, <code>{ session: "S1" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>owner on the paired phone</td>
|
||
<td><code>{ device: "phone" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>owner, local</td>
|
||
<td>none</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Call</th>
|
||
<th>Hits</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>server.revoke({ grant: "g1" })</code>: remove Ann</td>
|
||
<td>Ann</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>server.revoke({ session: "S1" })</code>: archive S1</td>
|
||
<td>Ann and Bob</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>server.revoke({ device: "phone" })</code>: sign out the phone</td>
|
||
<td>the phone</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>A label is plain data because revocation is an event that must travel from the server to the workers; a predicate
|
||
function could not be sent.</p>
|
||
<h3 id="what-revoke-does">What <code>revoke</code> does</h3>
|
||
<p><code>host.revoke(r)</code> ends every object and closes every link whose principal <code>p</code> has <code>r</code> in <code>revokedBy(p)</code> (compared by
|
||
canonical JSON; never the host's own principal), and passes <code>r</code> to its children, which apply their own <code>revokedBy</code>.</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 398" width="100%" role="img" aria-label="Removing guest Ann. The label travels down; her objects end on every host; her reconnect is refused by the app's own record." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f7m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f7a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f7b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="102" y1="52" x2="102" y2="390" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="23" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="102" y="30" font-size="11" fill="#2a2723" text-anchor="middle">owner tab</text><line x1="274" y1="52" x2="274" y2="390" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="195" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="274" y="30" font-size="11" fill="#2a2723" text-anchor="middle">server</text><line x1="446" y1="52" x2="446" y2="390" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="367" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="446" y="30" font-size="11" fill="#2a2723" text-anchor="middle">worker S1</text><line x1="618" y1="52" x2="618" y2="390" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="539" y="10" width="158" height="42" rx="4" fill="#efe9dd" stroke="#c9bfae"/><text x="618" y="27" font-size="11" fill="#2a2723" text-anchor="middle">Ann's tab</text><text x="618" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">guest g1</text><g class="step edge" style="--i:1"><line x1="102" y1="78" x2="271" y2="78" stroke="#7b7468" marker-end="url(#f7m)"/><text x="188" y="72" font-size="10" fill="#2a2723" text-anchor="middle">Sharing.remove({ grant: "g1" })</text></g><g class="step edge" style="--i:2"><path d="M274 104 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f7m)"/><text x="316" y="114" font-size="10" fill="#2a2723" text-anchor="start">app: mark g1 blocked (document)</text></g><g class="step edge" style="--i:3"><path d="M274 144 h34 v14 h-32" fill="none" stroke="#b4472f" marker-end="url(#f7a)"/><text x="316" y="154" font-size="10" fill="#b4472f" text-anchor="start">revoke({ grant: "g1" })</text></g><g class="step edge" style="--i:4"><line x1="274" y1="190" x2="615" y2="190" stroke="#7b7468" marker-end="url(#f7m)"/><text x="446" y="184" font-size="10" fill="#2a2723" text-anchor="middle">close link</text></g><g class="step edge" style="--i:5"><line x1="274" y1="222" x2="443" y2="222" stroke="#b4472f" marker-end="url(#f7a)"/><text x="360" y="216" font-size="10" fill="#b4472f" text-anchor="middle">revoke { grant: "g1" }</text></g><g class="step edge" style="--i:6"><path d="M446 248 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f7m)"/><text x="488" y="258" font-size="10" fill="#2a2723" text-anchor="start">end Notes { S1 } as g1</text></g><g class="step edge" style="--i:7"><line x1="618" y1="294" x2="277" y2="294" stroke="#7b7468" marker-end="url(#f7m)"/><text x="446" y="288" font-size="10" fill="#2a2723" text-anchor="middle">reconnect</text></g><g class="step edge" style="--i:8"><path d="M274 320 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f7m)"/><text x="316" y="330" font-size="10" fill="#2a2723" text-anchor="start">authenticate: g1 blocked</text></g><g class="step edge" style="--i:9"><line x1="274" y1="366" x2="615" y2="366" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f7m)"/><text x="446" y="360" font-size="10" fill="#2a2723" text-anchor="middle">refused (4401)</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 7.</span> Removing guest Ann. The label travels down; her objects end on every host; her reconnect is refused by the app's own record.</figcaption></figure>
|
||
<p>On worker S1, for <code>revoke({ grant: "g1" })</code>:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Object</th>
|
||
<th>Principal</th>
|
||
<th>Hit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>Notes { S1 }</code></td>
|
||
<td>owner, local</td>
|
||
<td>no</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>Notes { S1 }</code></td>
|
||
<td>guest g1</td>
|
||
<td><strong>yes</strong>: ends</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>Notes { S1 }</code></td>
|
||
<td>guest g2</td>
|
||
<td>no</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>NoteStore { S1 }</code></td>
|
||
<td>none (local)</td>
|
||
<td>no</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Ending Ann's view releases its reference to the store, which keeps serving the others.</p>
|
||
<h3 id="chord-persists-nothing-here">Chord persists nothing here</h3>
|
||
<p><code>revoke</code> cuts live access only. Lasting denial lives in the app's data (the sharing plugin's guest records), which
|
||
<code>authenticate</code> checks on every new connection. After a server restart nothing live exists to revoke; every client
|
||
authenticates again and blocked grants are refused there.</p>
|
||
<p><strong>No re-stamping.</strong> A changed grant (Ann moves from read to write) is a revocation of the old grant. Her link closes,
|
||
her tab reconnects and authenticates with the new grant revision, which gives a new principal key, and her references
|
||
re-hold by themselves, now as an editor. Reconnecting never restores revoked authority.</p>
|
||
<p><strong>Revocation beats jobs</strong> (R11). Normally a running job keeps its object alive after the last reference is released
|
||
(a closed tab does not abort an import). Revoking a principal ends its objects at once regardless; their jobs get the
|
||
abort signal and the normal close timeout.</p>
|
||
<h3 id="work-in-shared-stores">Work in shared stores</h3>
|
||
<p>Revocation ends objects whose principal carries the label. A shared store has no principal, so work it runs <em>on
|
||
behalf of</em> someone (a question waiting for Ann's answer, a shell command a guest started) is not ended by <code>revoke</code>.
|
||
Such a store keeps the grant as data with the work and reconciles from the grant owner's live state with a routine
|
||
(chapter X):</p>
|
||
<pre><code><span class="tk-c">// in a shared store that runs work for guests</span>
|
||
<span class="tk-k">const</span> sharing = self.<span class="tk-f">get</span>(<span class="tk-t">Grants</span>, {}); <span class="tk-c">// the sharing plugin's live grants</span>
|
||
self.routines.<span class="tk-f">on</span>(sharing.live, <span class="tk-k">async</span> (live, ctx) => {
|
||
<span class="tk-k">for</span> (<span class="tk-k">const</span> run <span class="tk-k">of</span> shell.running.value)
|
||
<span class="tk-k">if</span> (run.args.grant && !live[run.args.grant]) shell.<span class="tk-f">abort</span>(run.id); <span class="tk-c">// idempotent</span>
|
||
});</code></pre>
|
||
<p>There is no revocation hook: a hook can be missed (a crash between revoke and hook), a routine over current state
|
||
cannot. The window between the revocation and the routine's run is accepted (R10); a step started in it still runs.</p>
|
||
<h3 id="who-does-what">Who does what</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Chord</th>
|
||
<th>The app</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>stamps principals on links</td>
|
||
<td>what a grant is; share links, cookies, <code>authenticate</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>keeps one object per principal key</td>
|
||
<td>defines <code>Revocation</code> and <code>revokedBy</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>on <code>revoke(r)</code>: ends matching objects and links, passes <code>r</code> down</td>
|
||
<td>decides when to revoke; stores who is blocked</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter" id="derives"><header class="opener"><div class="numeral">VIII</div><h2>Computed Values</h2><p class="lede">Derives, the one footgun that makes them expensive, mapShared, and presence: who is looking.</p></header>
|
||
<p class="dropcap">The reader's notes without authors, a note count, the number of people viewing: values computed from other values.
|
||
Storing copies would go stale; recomputing them on every read would be wasteful and could not be watched. A
|
||
<strong>derive</strong> is computed once per change and sent to viewers as a difference.</p>
|
||
<h3 id="self-derive"><code>self.derive</code></h3>
|
||
<pre><code><span class="tk-k">const</span> count = self.<span class="tk-f">derive</span>(<span class="tk-s">"count"</span>, [list], ([l]) => l.items.length);
|
||
<span class="tk-k">const</span> summary = self.<span class="tk-f">derive</span>(<span class="tk-s">"summary"</span>, [list, store.pinned], ([l, pinned], previous) => ({
|
||
total: l.items.length,
|
||
pinned: pinned.length,
|
||
}));</code></pre>
|
||
<p><code>self.derive(name, sources, (values, previous) => value)</code>. Sources can be documents, readables, other derives and
|
||
state members of references. The value is recomputed when a source changes and diffed into operations for the wire.
|
||
Derived values are adopted before ordinary observers run and share their source commit's wire batch, so a viewer
|
||
never sees a note list and a count from different commits.</p>
|
||
<p><code>previous</code> is the derive's last value, kept across factory re-runs (reloads) by name. It is a cache, empty after a
|
||
host teardown, so a derive must be correct without it. A derive that throws fails its whole owner <code>broken</code>.</p>
|
||
<h3 id="how-the-difference-is-found">How the difference is found</h3>
|
||
<p>Chord compares the new value with the previous one using Delta's <code>diffRevisions</code>. Parts that are the <strong>same objects</strong>
|
||
as last time cost one <code>===</code> each. Parts that are new objects must be compared by content.</p>
|
||
<p><strong>The footgun.</strong> A derive that builds new objects for unchanged data makes every commit cost the size of the whole
|
||
value, per view. The reader list from chapter IV, written naively:</p>
|
||
<pre><code>self.<span class="tk-f">derive</span>(<span class="tk-s">"public"</span>, [list], ([l]) => ({ items: l.items.<span class="tk-f">map</span>((n) => ({ id: n.id, text: n.text, at: n.at })) }));
|
||
<span class="tk-c">// one note added to 2,000: 2,001 new objects, every one compared by content</span></code></pre>
|
||
<p><strong><code>mapShared(f)</code></strong> keeps one result per source element: <code>f</code> runs only for new or changed elements, unchanged ones
|
||
return their previous result object.</p>
|
||
<pre><code><span class="tk-k">import</span> { mapShared } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/delta"</span>;
|
||
|
||
<span class="tk-k">const</span> stripAuthor = <span class="tk-f">mapShared</span>((n: <span class="tk-t">Note</span>): <span class="tk-t">PublicNote</span> => ({ id: n.id, text: n.text, at: n.at }));
|
||
self.<span class="tk-f">derive</span>(<span class="tk-s">"public"</span>, [list], ([l]) => ({ items: l.items.<span class="tk-f">map</span>(stripAuthor) }));
|
||
<span class="tk-c">// one note added: one new object, 2,000 identical references; the diff emits one insert</span></code></pre>
|
||
<div class="note review"><b class="tag">Review: mapShared is planned, not available</b><p>Design §12 introduces <code>mapShared</code> as coming "from <code>@earendil-works/chord/delta</code>", but nothing in <code>packages/chord/src</code>
|
||
defines it yet, and Delta does not yet treat bytes as atomic leaves in <code>diffRevisions</code>. Design §28 lists both as the
|
||
step "Delta in pi", before the layered rebuild. Until then, keep a <code>Map</code> from source element to result yourself, which
|
||
is what <code>mapShared</code> will do.</p>
|
||
</div>
|
||
<h3 id="cookbook">Cookbook</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Case</th>
|
||
<th>How</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>select a field, look up by key, a small aggregate</td>
|
||
<td>compute it; the value is small</td>
|
||
</tr>
|
||
<tr>
|
||
<td>filter or map a list</td>
|
||
<td><code>mapShared</code> over the elements</td>
|
||
</tr>
|
||
<tr>
|
||
<td>redaction</td>
|
||
<td>preferably separate documents per visibility; otherwise <code>mapShared</code>, computed once in the shared store, never per view</td>
|
||
</tr>
|
||
<tr>
|
||
<td>join two sources</td>
|
||
<td><code>mapShared</code> plus the looked-up source's revision</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a window over a large list</td>
|
||
<td>take a section of the source elements without rebuilding them; cache per-element measurements by identity; keep the window's start stable so changes arrive as appends</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a large view combined from several sources</td>
|
||
<td>re-path the sources' operations, as Durable's <code>ConversationView</code> does; application code never interprets ops</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Container width</strong> (R24): structural sharing is O(change) along a path, not across a container's width. Changing one
|
||
item of a flat array copies the array's slots, and <code>mapShared</code> visits every element once (one identity comparison
|
||
each). Large collections are keyed documents or bounded windows. A benchmark of long arrays is to set the documented
|
||
limit.</p>
|
||
<h3 id="readables-that-are-not-documents">Readables that are not documents</h3>
|
||
<p>Three small readables round out the toolkit:</p>
|
||
<ul>
|
||
<li><code>constant(value)</code>: never changes. Start data uses it (chapter IX).</li>
|
||
<li><code>self.writable(initial)</code>: a host-local readable of any in-process value (functions included), compared by identity,
|
||
with <code>set</code>. It never goes on the wire; it exists for registry contributions that change (chapter XIII).</li>
|
||
<li>Source readables from elsewhere: a Durable view or document watch is already a Chord readable, because Durable and
|
||
Chord v2 share the readable type (<code>chord/src/state.ts</code>), so it can feed a derive or be exposed as state directly.</li>
|
||
</ul>
|
||
<div class="note review"><b class="tag">Review: exposing a writable (Q14, open)</b><p>A <code>writable</code> may hold functions; nothing yet stops a factory from returning one as a state member, which would reach
|
||
the wire encoder. Review round 3 proposes that only members with a JSON schema in the contract are exposable, checked
|
||
when the object is served. Undecided.</p>
|
||
</div>
|
||
<h3 id="presence-who-is-looking">Presence: who is looking</h3>
|
||
<p><code>host.references(Type)</code> is a readable of who holds references to objects of a type on this host: input, principal and
|
||
client. Client holds carry an opaque origin id, so two tabs are two clients.</p>
|
||
<pre><code>host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, ({ session }, self) => {
|
||
<span class="tk-k">const</span> viewers = self.<span class="tk-f">derive</span>(<span class="tk-s">"viewers"</span>, [host.<span class="tk-f">references</span>(<span class="tk-t">Notes</span>)], ([refs]) =>
|
||
refs.<span class="tk-f">filter</span>((r) => r.input.session === session).length);
|
||
<span class="tk-k">return</span> { …, viewers };
|
||
});</code></pre>
|
||
<p>A link drop is a departure: accurate and simple. Smoothing (a phone switching networks makes the count flicker) is the
|
||
UI's job, for example a debounced count. Counts per person are derived by the app from principals and origins. There
|
||
is no consumer-side linger: a closed tab closes its link and keeps nothing. The provider's <code>linger</code> keeps a
|
||
just-closed object warm, and that also keeps the worker alive through <code>host.activity</code>.</p>
|
||
<p><strong>"Leaving" is never derived from an unmount.</strong> A UI reload unmounts and remounts components; a component unmounted by
|
||
a reload has not left. Leaving is explicit navigation, or presence (a dropped reference). This replaces p2's presence,
|
||
viewer and login-abandon leases with one mechanism.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>Readables and derives are layer L2 (a readable is today's <code>replicatedState</code>, derives reuse today's derived cells over
|
||
it). Not started in the rebuild.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="natives"><header class="opener"><div class="numeral">IX</div><h2>Things the OS Gives You</h2><p class="lede">Start data, host-lifetime objects and natives: how a plugin opens a file watcher or a socket so that it is closed exactly once, also across reloads.</p></header>
|
||
<p class="dropcap">Studio's notes get an inbox: a text file dropped into the session's <code>inbox/</code> directory becomes a note. That needs a
|
||
file watcher, a <strong>native</strong>: an OS-level value (a file handle, a socket, a process, a database connection) that cannot
|
||
be JSON, cannot be copied, must be opened, and must be closed exactly once. It also needs the session's data
|
||
directory, which the plugin does not know. This chapter covers both.</p>
|
||
<h3 id="start-data-is-an-object">Start data is an object</h3>
|
||
<p>A worker knows things before any plugin loads: its session id, the data directory, init data from the server's start
|
||
message. There is no special mechanism for them. Start data is an ordinary <code>local</code> object with <code>lifetime: "host"</code> and
|
||
constant state, served by base code's core facet:</p>
|
||
<pre><code><span class="tk-c">// studio/src/core/contract.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Init</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), dataDir: <span class="tk-f">str</span>(), cwd: <span class="tk-f">str</span>(), env: <span class="tk-f">opt</span>(<span class="tk-f">str</span>()) });
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">SessionInfo</span> = <span class="tk-f">local</span>(<span class="tk-s">"studio.session-info"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
input: <span class="tk-f">obj</span>({}),
|
||
members: { init: <span class="tk-t">Init</span> },
|
||
});
|
||
|
||
<span class="tk-c">// studio/src/core/worker.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> coreWorkerFacet = (init: <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">Init</span>>) => <span class="tk-f">defineFacet</span>((host) => {
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">SessionInfo</span>, () => ({ init: <span class="tk-f">constant</span>(init) }), { lifetime: <span class="tk-s">"host"</span> });
|
||
<span class="tk-c">// … the Harness and other core objects (chapter XIV)</span>
|
||
});</code></pre>
|
||
<p>Any plugin on the worker declares a reference and reads it <strong>after activation</strong>: in an opener, a derive, a routine or
|
||
a method, never synchronously in the factory.</p>
|
||
<pre><code><span class="tk-k">const</span> info = self.<span class="tk-f">get</span>(<span class="tk-t">SessionInfo</span>, {});
|
||
<span class="tk-c">// later: info.init.value.id, info.init.value.dataDir</span></code></pre>
|
||
<p>Paths are derived by the plugin from generic start data (<code>join(dataDir, id, "notes-inbox")</code>), never provided per
|
||
plugin. Platform bindings work the same way: on a Durable Object, a core object's <code>self.open</code> returns the binding
|
||
(<code>state</code>, <code>env</code>) and its close does nothing.</p>
|
||
<h3 id="self-open"><code>self.open</code></h3>
|
||
<pre><code><span class="tk-k">const</span> watcher = self.<span class="tk-f">open</span>(
|
||
<span class="tk-k">async</span> ({ signal }) => <span class="tk-f">watchDir</span>(path, { signal }), <span class="tk-c">// open: during activation</span>
|
||
(w, reason) => w.<span class="tk-f">close</span>(), <span class="tk-c">// close: when the object ends or restarts</span>
|
||
{ closeTimeout: <span class="tk-n">10_000</span> }, <span class="tk-c">// optional; host default 30 s</span>
|
||
);
|
||
watcher.value; <span class="tk-c">// the watcher; reading it before activation throws not_ready</span></code></pre>
|
||
<p><code>self.open</code> returns a handle with <code>.value</code> (type <code>Native<T></code>), not a proxy. The rules:</p>
|
||
<ul>
|
||
<li><strong>Natives live only in objects.</strong> Not at module level, not in the facet. A module-level watcher would survive a
|
||
reload with nobody to close it.</li>
|
||
<li><strong>Order:</strong> natives open one after another in declaration order (a later opener may read an earlier handle's
|
||
<code>.value</code>) and close in reverse. The object goes live after all opens succeeded.</li>
|
||
<li><strong>The value never changes during an attempt.</strong> No replacement, no versions, no stale-handle logic. To change a live
|
||
native, the object restarts.</li>
|
||
<li><strong>A failed open</strong> makes the object <code>failed</code>: transient with backoff by default, broken if the opener says so
|
||
(<code>throw broken(error)</code>). A partly failed activation closes the natives already open, with reason <code>end</code>; the next
|
||
attempt opens all of them anew.</li>
|
||
<li><strong><code>close</code> gets a reason:</strong> <code>{ kind: "reload" }</code> (the plugin reloads and this native object restarts), <code>{ kind: "stop" }</code> (the host shuts down) or <code>{ kind: "end" }</code> (the last reference ended, or the type was removed). Each
|
||
<code>close</code> is bounded by <code>closeTimeout</code>, then reported as a possible leak while the rest continues. A throwing <code>close</code>
|
||
does not stop the others; <code>host.close()</code> collects the errors, background ends report to <code>onError</code>.</li>
|
||
<li><strong>Behaviour around a native goes into routines</strong> (chapter X). Listeners on natives outside routines are forbidden.</li>
|
||
</ul>
|
||
<p>An object with at least one <code>self.open</code> is a <strong>native object</strong>; one without is a <strong>pure object</strong>. The difference
|
||
decides what a reload does (chapter XV): pure objects are swapped in place, native objects restart.</p>
|
||
<h3 id="the-inbox">The inbox</h3>
|
||
<p>The inbox must run as long as the worker, whether anyone looks at the notes or not. So it is a <code>lifetime: "host"</code>
|
||
object. The facet cannot <code>self.get</code> a type it serves, and the inbox has no meaningful input, so it is an object
|
||
without input that reads its session from the start data:</p>
|
||
<pre><code><span class="tk-c">// notes/contract.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteInbox</span> = <span class="tk-f">local</span>(<span class="tk-s">"notes.inbox"</span>, { version: <span class="tk-n">1</span>, input: <span class="tk-f">obj</span>({}), members: {} });
|
||
|
||
<span class="tk-c">// notes/worker.ts</span>
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteInbox</span>, (_input, self) => {
|
||
<span class="tk-k">const</span> info = self.<span class="tk-f">get</span>(<span class="tk-t">SessionInfo</span>, {});
|
||
<span class="tk-k">const</span> watcher = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> ({ signal }) => {
|
||
<span class="tk-k">const</span> { id, dataDir } = info.init.value; <span class="tk-c">// read in the opener: after activation</span>
|
||
<span class="tk-k">return</span> <span class="tk-f">watchDir</span>(<span class="tk-f">join</span>(dataDir, id, <span class="tk-s">"notes-inbox"</span>), { signal });
|
||
}, (w) => w.<span class="tk-f">close</span>());
|
||
|
||
self.routines.<span class="tk-f">loop</span>(<span class="tk-s">"import"</span>, <span class="tk-k">async</span> (ctx) => { <span class="tk-c">// chapter X</span>
|
||
<span class="tk-k">const</span> store = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">NoteStore</span>, { session: info.init.value.id });
|
||
<span class="tk-k">for</span> <span class="tk-k">await</span> (<span class="tk-k">const</span> file <span class="tk-k">of</span> watcher.value.<span class="tk-f">files</span>(ctx.signal)) {
|
||
<span class="tk-k">await</span> store.<span class="tk-f">add</span>({ text: <span class="tk-k">await</span> <span class="tk-f">readFile</span>(file, <span class="tk-s">"utf8"</span>), by: <span class="tk-s">"inbox"</span> }, ctx);
|
||
<span class="tk-k">await</span> <span class="tk-f">rm</span>(file);
|
||
}
|
||
});
|
||
<span class="tk-k">return</span> {};
|
||
}, { lifetime: <span class="tk-s">"host"</span> });</code></pre>
|
||
<p>Note <code>ctx.get(NoteStore, …)</code> in the routine rather than <code>self.get</code> in the factory. The factory runs before the
|
||
session id is known (start data is read after activation), so a host-lifetime object without input gets
|
||
session-scoped objects with <code>ctx.get</code> in a routine or method (R22). A <code>get</code> whose input is computed after activation is
|
||
to be added only if a real case needs it.</p>
|
||
<h3 id="natives-with-work-in-flight">Natives with work in flight</h3>
|
||
<p>A restart is sequential (R16): close the old native, then open the new one. When the old one still has work running,
|
||
use one of two patterns:</p>
|
||
<ul>
|
||
<li><strong>Drain in the background.</strong> <code>close</code> detaches the old resource and returns at once; it disposes of it when its work
|
||
finishes, or after <code>closeTimeout</code> at most. Chord opens the new one immediately. Code mode's sandbox does this: a
|
||
script running during a reload finishes on the old sandbox (for up to 30 s, Q7; a longer one is aborted and its turn
|
||
fails), new scripts start on the new one.</li>
|
||
<li><strong>Swap through a registry.</strong> The resource is a registry contribution (chapter XIII). Readers always look up the
|
||
current one at use; a reader that must wait for one uses a cancellable "wait until present" with the park deadline.</li>
|
||
</ul>
|
||
<p>Chord aborts only the object's own calls and jobs on close. Other work that uses the native (Durable tool calls on a
|
||
sandbox) is the owner's business: <code>close</code> may drain instead of tearing down. What an old native does after its owner
|
||
restarts is also the owner's <code>close</code>: a model runtime keeps working until collected, a closed SSH connection fails its
|
||
in-flight <code>exec</code>.</p>
|
||
<h3 id="natives-that-follow-a-relocation">Natives that follow a relocation</h3>
|
||
<p>A session can move to another directory. The inbox watcher must follow. Restarting the object on every move would be
|
||
heavy-handed; instead the native is a stable manager opened once, and a routine on the session location switches its
|
||
internal resource and bumps a generation, so late events from the old location are dropped:</p>
|
||
<pre><code><span class="tk-k">const</span> watcher = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> ({ signal }) => <span class="tk-k">new</span> <span class="tk-t">SwitchableWatcher</span>({ signal }), (w) => w.<span class="tk-f">close</span>());
|
||
self.routines.<span class="tk-f">on</span>(location.cwd, (cwd) => watcher.value.<span class="tk-f">switchTo</span>(<span class="tk-f">join</span>(cwd, <span class="tk-s">".notes-inbox"</span>)));</code></pre>
|
||
<h3 id="call-scoped-natives">Call-scoped natives</h3>
|
||
<p>A native that lives exactly as long as one call (a temporary SSH session for a directory listing, a file handle for a
|
||
download) is opened and closed inside the method with <code>try/finally</code>. There is no <code>ctx.open</code>:</p>
|
||
<pre><code>list: <span class="tk-k">async</span> ({ path }, ctx) => {
|
||
<span class="tk-k">const</span> session = <span class="tk-k">await</span> ssh.<span class="tk-f">open</span>(config, ctx.signal);
|
||
<span class="tk-k">try</span> { <span class="tk-k">return</span> <span class="tk-k">await</span> session.<span class="tk-f">readDir</span>(path, ctx.signal); }
|
||
<span class="tk-k">finally</span> { session.<span class="tk-f">close</span>(); }
|
||
},</code></pre>
|
||
<h3 id="core-vs-plugin">Core vs. plugin</h3>
|
||
<p><code>SessionInfo</code> comes from base code, <code>NoteInbox</code> from a plugin; both use the same primitives. Where code lives decides
|
||
which it is. <strong>Base code</strong> (the app's <code>src/</code>, imported statically by the start code under a fixed name such as
|
||
<code>@studio/core</code>) is always there, not switchable, and a change restarts the hosts. <strong>A plugin</strong> (its own directory,
|
||
found by listing, named by its <code>package.json</code>) is switchable and reloads. Core is what the app always needs and
|
||
everything builds on: the harness, the session list, access, the session location. Optional features are plugins;
|
||
code mode is a plugin.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>Natives and host-lifetime objects are layer L4. <code>constant</code> is decided (R23), not built.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="routines"><header class="opener"><div class="numeral">X</div><h2>Standing Behaviour</h2><p class="lede">Routines, reconciling instead of notifying, and how two owners keep a rule between them without a shared transaction.</p></header>
|
||
<p class="dropcap">Notes older than 30 days should go every hour. Inbox files should be imported as they arrive. Notes of an archived
|
||
session should become read-only. Each of these is behaviour that stands, not a call someone makes. Written as bare
|
||
<code>setInterval</code>s and event listeners, it would keep running old code after a reload and touch state from nowhere. Chord
|
||
has one declared form for it: the <strong>routine</strong>.</p>
|
||
<h3 id="four-kinds">Four kinds</h3>
|
||
<pre><code>self.routines.<span class="tk-f">every</span>(<span class="tk-s">"cleanup"</span>, HOUR, (ctx) => <span class="tk-f">dropOlderThan</span>(<span class="tk-n">30</span> * DAY, ctx)); <span class="tk-c">// due time survives reloads</span>
|
||
self.routines.<span class="tk-f">on</span>(settings, <span class="tk-k">async</span> (value, ctx) => <span class="tk-f">applyLimit</span>(value, ctx)); <span class="tk-c">// a readable or event source</span>
|
||
self.routines.<span class="tk-f">loop</span>(<span class="tk-s">"import"</span>, <span class="tk-k">async</span> (ctx) => { <span class="tk-c">// runs until the object ends</span>
|
||
<span class="tk-k">while</span> (!ctx.signal.aborted) <span class="tk-k">await</span> <span class="tk-f">importNext</span>(ctx);
|
||
});
|
||
self.routines.<span class="tk-f">custom</span>(<span class="tk-s">"ticker"</span>, ({ signal, run }) => { <span class="tk-c">// anything else</span>
|
||
<span class="tk-k">const</span> timer = <span class="tk-f">setInterval</span>(() => <span class="tk-f">run</span>(tick), <span class="tk-n">1000</span>);
|
||
<span class="tk-k">return</span> () => <span class="tk-f">clearInterval</span>(timer); <span class="tk-c">// cleanup undoes the setup</span>
|
||
});</code></pre>
|
||
<p>Routines start when the object goes live, stop when it ends, and restart with the new code on every reload of their
|
||
plugin. Each run gets its own <code>ctx</code>; errors are caught and reported, never uncaught. Routines are named (<code>on</code>
|
||
optionally by its source) so Chord recognizes them across reloads; that is how <code>every</code> keeps its due time.</p>
|
||
<h3 id="serialized-per-routine">Serialized per routine</h3>
|
||
<p>The new instance of a routine starts only after the old handler in flight has settled; only that routine waits, for at
|
||
most the routine handover timeout (10 s), after which it starts anyway and the stuck handler is reported.</p>
|
||
<p>The reason, as a trace: a supervisor routine reconciles pending worker restarts. The old handler is midway through
|
||
<code>restart(W1)</code> when the plugin reloads. Without serialization the new routine starts at once, sees W1 still pending, and
|
||
restarts it a second time.</p>
|
||
<h3 id="reconcile-don-39-t-track">Reconcile, don't track</h3>
|
||
<p>A routine <strong>reconciles from the current value</strong>: it makes the world match what it sees now. It writes no "in
|
||
progress" flags, and it starts a job for long work (chapter XI). A handler that only cares about the latest value
|
||
compares with the current value itself; there is no <code>overlap</code> option. Subscribing to the object's own documents is
|
||
gapless: every commit is seen.</p>
|
||
<p>No bare timers or callbacks that touch state outside routines, jobs or calls. That is what lets Chord stop all of an
|
||
object's behaviour when it ends or reloads.</p>
|
||
<h3 id="rules-between-two-owners">Rules between two owners</h3>
|
||
<p>Every thing has exactly one owner, the smallest object it belongs to. Some rules span two: "notes of an archived
|
||
session are read-only" involves the catalog (which knows archiving) and <code>NoteStore</code> (which holds notes). Two tempting
|
||
designs are wrong:</p>
|
||
<ul>
|
||
<li><strong>A cross-owner transaction</strong> (archive and lock the notes atomically) would make the catalog know every plugin and
|
||
couple their storage.</li>
|
||
<li><strong>A notification</strong> (<code>catalog</code> calls <code>notes.archive(session)</code>) can be lost: the worker is not running, the link drops,
|
||
the process crashes between the archive and the call.</li>
|
||
</ul>
|
||
<p>The design's rule: <strong>the owner of the dependent data enforces the rule</strong> by reconciling from the other owner's state
|
||
with an idempotent routine.</p>
|
||
<pre><code><span class="tk-c">// NoteStore: an archived session's notes are read-only</span>
|
||
<span class="tk-k">const</span> flags = self.<span class="tk-f">state</span>(<span class="tk-t">NoteFlags</span>); <span class="tk-c">// { archived: boolean }, NoteStore's own document</span>
|
||
<span class="tk-k">const</span> catalog = self.<span class="tk-f">get</span>(<span class="tk-t">Catalog</span>, {}); <span class="tk-c">// the server's session list: another owner</span>
|
||
self.routines.<span class="tk-f">on</span>(catalog.sessions, <span class="tk-k">async</span> (sessions, ctx) => {
|
||
<span class="tk-k">const</span> archived = sessions[session]?.archived ?? <span class="tk-k">false</span>;
|
||
<span class="tk-k">if</span> (flags.value.archived !== archived) <span class="tk-k">await</span> flags.<span class="tk-f">change</span>((d) => { d.archived = archived; }, ctx);
|
||
});
|
||
<span class="tk-c">// add and remove refuse while flags.value.archived</span></code></pre>
|
||
<p>After a crash the routine runs again with the current value. The catalog does not know the notes plugin exists. The
|
||
same pattern closes a forward of an archived session in the ports plugin, aborts a job whose grant was revoked
|
||
(chapter VII), and keeps any invariant between plugins.</p>
|
||
<h3 id="reading-without-starting-a-worker">Reading without starting a worker</h3>
|
||
<p>The session list in the sidebar shows each session's note count. Getting <code>NoteStore { S1 }</code> for every session would
|
||
start every worker. The answer is plain publishing between two objects of one plugin, no Chord mechanism: the worker
|
||
side reports a summary to a server-side object by a method call from a routine, and clients read the server object.</p>
|
||
<pre><code><span class="tk-c">// notes/contract.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteCounts</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes.counts"</span>, {
|
||
version: <span class="tk-n">1</span>, input: <span class="tk-f">obj</span>({}), placement: () => <span class="tk-t">Server</span>(),
|
||
variants: {
|
||
owner: { state: { counts: <span class="tk-f">map</span>(<span class="tk-f">int</span>()) } },
|
||
worker: { methods: { report: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ session: <span class="tk-f">str</span>(), count: <span class="tk-f">int</span>() })) } },
|
||
},
|
||
});
|
||
|
||
<span class="tk-c">// notes/server.ts</span>
|
||
<span class="tk-k">const</span> <span class="tk-t">Counts</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.counts"</span>, {
|
||
schema: <span class="tk-f">obj</span>({ bySession: <span class="tk-f">map</span>(<span class="tk-f">obj</span>({ count: <span class="tk-f">int</span>(), incarnation: <span class="tk-f">str</span>() })) }),
|
||
version: <span class="tk-n">1</span>, initial: () => ({ bySession: {} }), persist: <span class="tk-k">true</span>, <span class="tk-c">// survives a server restart</span>
|
||
checkpointWhen: (_v, _o, info) => info.deltasSinceBase > <span class="tk-n">64</span>,
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteCountStore</span>, (_input, self) => { <span class="tk-c">// local, lifetime "host"</span>
|
||
<span class="tk-k">const</span> counts = self.<span class="tk-f">state</span>(<span class="tk-t">Counts</span>);
|
||
<span class="tk-k">return</span> {
|
||
counts: self.<span class="tk-f">derive</span>(<span class="tk-s">"counts"</span>, [counts], ([c]) => <span class="tk-f">mapValues</span>(c.bySession, (e) => e.count)),
|
||
report: ({ session, count, incarnation }, ctx) =>
|
||
counts.<span class="tk-f">change</span>((d) => {
|
||
<span class="tk-k">const</span> last = d.bySession[session];
|
||
<span class="tk-k">if</span> (last && last.incarnation > incarnation) <span class="tk-k">return</span>; <span class="tk-c">// uuidv7: an older worker, ignore</span>
|
||
d.bySession[session] = { count, incarnation };
|
||
}, ctx),
|
||
};
|
||
}, { lifetime: <span class="tk-s">"host"</span> });
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteCounts</span>, (_input, principal, self) => {
|
||
<span class="tk-k">const</span> store = self.<span class="tk-f">get</span>(<span class="tk-t">NoteCountStore</span>, {});
|
||
<span class="tk-k">if</span> (principal.kind === <span class="tk-s">"worker"</span>) <span class="tk-k">return</span> {
|
||
variant: <span class="tk-s">"worker"</span>,
|
||
report: ({ session, count }, ctx) => {
|
||
<span class="tk-k">if</span> (session !== principal.session) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>(); <span class="tk-c">// a worker reports only itself</span>
|
||
<span class="tk-k">return</span> store.<span class="tk-f">report</span>({ session, count, incarnation: ctx.incarnation }, ctx);
|
||
},
|
||
};
|
||
<span class="tk-k">return</span> { variant: <span class="tk-s">"owner"</span>, counts: store.counts };
|
||
});
|
||
|
||
<span class="tk-c">// notes/worker.ts, inside NoteStore</span>
|
||
<span class="tk-k">const</span> counts = self.<span class="tk-f">get</span>(<span class="tk-t">NoteCounts</span>, {}); <span class="tk-c">// a reference to the server</span>
|
||
self.routines.<span class="tk-f">on</span>(count, (n, ctx) => <span class="tk-f">inVariant</span>(counts, <span class="tk-s">"worker"</span>) && counts.<span class="tk-f">report</span>({ session, count: n }, ctx));</code></pre>
|
||
<p>The server object keeps the latest value per session (in a document if it must survive a server restart) and
|
||
<strong>ignores reports from an older worker incarnation</strong> (incarnations are uuidv7, so they order by start time). A worker that was replaced may still deliver a late report; the
|
||
fence is the caller's incarnation, which Chord stamps on every call from the link handshake (R20): <code>ctx.incarnation</code>,
|
||
next to <code>ctx.principal</code>, not part of the principal or its key, never sent as data, so it cannot be forged. Session
|
||
status, subagent lists, unread marks and artifact lists in p2 all follow this pattern. The store is local and has no
|
||
caller incarnation of its own, so the view passes it on as data.</p>
|
||
<div class="note review"><b class="tag">Review: <code>ctx.incarnation</code> across a relay (Q13, open)</b><p>A worker's call to the server arrives directly, so <code>ctx.incarnation</code> is the worker's. But a call relayed through the
|
||
server (a tab's call to a worker) carries the server's incarnation on the worker's side, because the worker's
|
||
handshake is with the server. Review round 3 proposes that relays stamp the origin host's incarnation, as they stamp
|
||
the principal. Undecided; the pattern above only needs the direct case.</p>
|
||
</div>
|
||
<div class="note"><b class="tag">Built so far</b><p>Routines are layer L8. The design treats <code>every</code> as one of four forms over one runner; review round 3 proposed cutting
|
||
to <code>on</code> and <code>loop</code> only (C4), not decided.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="jobs"><header class="opener"><div class="numeral">XI</div><h2>Work That Outlives the Call</h2><p class="lede">Jobs: owned work started by a method that keeps running after its caller leaves, with cancel, dedupe and a recorded outcome.</p></header>
|
||
<p class="dropcap">Importing a 50 MB text file into notes takes a minute. The tab that started it may close; the import must go on, be
|
||
cancellable, not run twice when the tab retries after a link blip, and its result must be recorded. A method cannot do
|
||
this (it dies with its caller), a routine is standing behaviour rather than a request, and a Durable task is for work
|
||
that must resume after a crash. Chord's form for it is the <strong>job</strong>, built in pi-durable's sandwich: <strong>intent</strong> (a
|
||
transaction), <strong>effect</strong> (anything), <strong>outcome</strong> (a transaction in the current code).</p>
|
||
<h3 id="defining-and-running-a-job">Defining and running a job</h3>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">const</span> <span class="tk-t">ImportJob</span> = <span class="tk-f">defineJob</span>(<span class="tk-s">"notes.import"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
args: <span class="tk-f">obj</span>({ path: <span class="tk-f">str</span>() }),
|
||
progress: <span class="tk-f">obj</span>({ lines: <span class="tk-f">int</span>() }),
|
||
result: <span class="tk-f">obj</span>({ added: <span class="tk-f">int</span>() }),
|
||
concurrency: <span class="tk-n">1</span>, <span class="tk-c">// one import at a time per NoteStore</span>
|
||
});
|
||
|
||
<span class="tk-k">const</span> <span class="tk-t">ImportResults</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.import-result"</span>, {
|
||
schema: <span class="tk-f">obj</span>({ outcome: <span class="tk-f">union</span>([<span class="tk-f">obj</span>({ added: <span class="tk-f">int</span>() }), <span class="tk-f">obj</span>({ error: <span class="tk-f">str</span>() })]) }),
|
||
version: <span class="tk-n">1</span>, initial: () => ({ outcome: { added: <span class="tk-n">0</span> } }), keyed: <span class="tk-k">true</span>,
|
||
checkpointWhen: () => <span class="tk-k">true</span>,
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteStore</span>, ({ session }, self) => {
|
||
<span class="tk-k">const</span> list = self.<span class="tk-f">state</span>(<span class="tk-t">NoteList</span>);
|
||
<span class="tk-k">const</span> results = self.<span class="tk-f">state</span>(<span class="tk-t">ImportResults</span>);
|
||
<span class="tk-k">const</span> imports = self.<span class="tk-f">job</span>(<span class="tk-t">ImportJob</span>, {
|
||
run: <span class="tk-k">async</span> ({ path }, ctx) => { <span class="tk-c">// 2. effect</span>
|
||
<span class="tk-k">let</span> lines = <span class="tk-n">0</span>;
|
||
<span class="tk-k">for</span> <span class="tk-k">await</span> (<span class="tk-k">const</span> line <span class="tk-k">of</span> <span class="tk-f">readLines</span>(path, ctx.signal)) {
|
||
<span class="tk-k">await</span> <span class="tk-f">addNote</span>({ id: <span class="tk-s">`${ctx.jobId}:${lines}`</span>, text: line, by: <span class="tk-s">"import"</span> }, ctx); <span class="tk-c">// keyed by job id</span>
|
||
ctx.<span class="tk-f">progress</span>({ lines: ++lines });
|
||
}
|
||
<span class="tk-k">return</span> { added: lines };
|
||
},
|
||
finish: <span class="tk-k">async</span> (outcome, tx) => { <span class="tk-c">// 3. outcome, current code</span>
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(results, outcome.id)).outcome =
|
||
outcome.kind === <span class="tk-s">"done"</span> ? outcome.result : { error: outcome.error };
|
||
},
|
||
});
|
||
<span class="tk-k">return</span> {
|
||
…,
|
||
imports: imports.running, <span class="tk-c">// live list of runs</span>
|
||
startImport: ({ path, requestId }, ctx) =>
|
||
imports.<span class="tk-f">start</span>({ path }, ctx, { key: requestId }), <span class="tk-c">// 1. intent (refined below)</span>
|
||
abortImport: ({ id }) => imports.<span class="tk-f">abort</span>(id),
|
||
};
|
||
});</code></pre>
|
||
<p><code>defineJob(name, { version, args, progress, result, concurrency?, persist?, accepts? })</code>; <code>progress</code> and the outcome
|
||
are typed by the schemas.</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 395" width="100%" role="img" aria-label="The job sandwich. The intent commits before anything runs; the effect runs outside any transaction; the outcome commits in the current code." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f8m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f8a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f8b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="52" x2="130.7" y2="387" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="30" font-size="11" fill="#2a2723" text-anchor="middle">tab</text><line x1="360" y1="52" x2="360" y2="387" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="360" y="30" font-size="11" fill="#2a2723" text-anchor="middle">NoteStore { S1 }</text><line x1="589.3" y1="52" x2="589.3" y2="387" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="589.3" y="27" font-size="11" fill="#2a2723" text-anchor="middle">run</text><text x="589.3" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">effect</text><g class="step edge" style="--i:1"><line x1="130.7" y1="78" x2="357" y2="78" stroke="#7b7468" marker-end="url(#f8m)"/><text x="245.3" y="72" font-size="10" fill="#2a2723" text-anchor="middle">startImport({ path, requestId })</text></g><g class="step edge" style="--i:2"><path d="M360 104 h34 v14 h-32" fill="none" stroke="#b4472f" marker-end="url(#f8a)"/><text x="402" y="114" font-size="10" fill="#b4472f" text-anchor="start">1. intent: busy check, dedupe by key, commit</text></g><g class="step edge" style="--i:3"><line x1="360" y1="150" x2="133.7" y2="150" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f8m)"/><text x="245.3" y="144" font-size="10" fill="#2a2723" text-anchor="middle">{ id }</text></g><g class="step edge" style="--i:4"><line x1="360" y1="182" x2="586.3" y2="182" stroke="#7b7468" marker-end="url(#f8m)"/><text x="474.7" y="176" font-size="10" fill="#2a2723" text-anchor="middle">start after the commit</text></g><g class="step edge" style="--i:5"><path d="M589.3 208 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f8m)"/><text x="547.3" y="218" font-size="10" fill="#2a2723" text-anchor="end">read lines, add notes (keyed by jobId)</text></g><g class="step edge" style="--i:6"><line x1="589.3" y1="254" x2="363" y2="254" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f8m)"/><text x="474.7" y="248" font-size="10" fill="#2a2723" text-anchor="middle">progress { lines } (throttled)</text></g><g class="step" style="--i:7"><rect x="26" y="272" width="438.7" height="23" rx="3" fill="#f4eee2" stroke="#c2b18c"/><text x="245.3" y="286" font-size="10" fill="#5b5549" text-anchor="middle">the tab may close here: the job counts as a reference</text></g><g class="step edge" style="--i:8"><line x1="589.3" y1="323" x2="363" y2="323" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f8m)"/><text x="474.7" y="317" font-size="10" fill="#2a2723" text-anchor="middle">settles: { kind: "done", result }</text></g><g class="step edge" style="--i:9"><path d="M360 349 h34 v14 h-32" fill="none" stroke="#b4472f" marker-end="url(#f8a)"/><text x="402" y="359" font-size="10" fill="#b4472f" text-anchor="start">3. record outcome, finish(outcome, tx), ack</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 8.</span> The job sandwich. The intent commits before anything runs; the effect runs outside any transaction; the outcome commits in the current code.</figcaption></figure>
|
||
<h3 id="the-three-steps">The three steps</h3>
|
||
<p><strong>1. Intent.</strong> <code>job.start(args, ctx, { key? })</code> is shorthand for <code>tx.start(job, args, { key? })</code> inside a
|
||
transaction, which may contain other writes. The busy check (<code>concurrency</code>) and deduplication (the same key returns the
|
||
existing run) are atomic and in the same commit; <code>run</code> starts only after the commit. Run ids are uuidv7.</p>
|
||
<p><strong>2. Effect.</strong> <code>run(args, ctx)</code> may do anything: processes, network, other objects. It writes no document itself; it
|
||
returns the outcome. External writes carry a dedupe key derived from <code>ctx.jobId</code> (above: note ids), so an interrupted
|
||
attempt cannot apply them twice. On a reload <code>run</code> keeps its old code.</p>
|
||
<p><strong>3. Outcome.</strong> When <code>run</code> settles, Chord records the outcome in its own record, then calls the <strong>current</strong> code's
|
||
<code>finish(outcome, tx)</code> and acknowledges the run in the same commit. The outcome is tagged:</p>
|
||
<pre><code><span class="tk-k">type</span> <span class="tk-t">Outcome</span><R> = { id: string } & ({ kind: <span class="tk-s">"done"</span>; result: R } | { kind: <span class="tk-s">"failed"</span>; error: <span class="tk-s">"threw"</span> | <span class="tk-s">"aborted"</span> | <span class="tk-s">"lost"</span> });</code></pre>
|
||
<p>A throwing <code>finish</code> changes nothing and is retried with a backoff (1 s to 60 s), and after the next reload. <code>finish</code>
|
||
is optional and follows Durable's transaction rule.</p>
|
||
<h3 id="dedupe-ends-with-the-run">Dedupe ends with the run</h3>
|
||
<p>Deduplication covers <strong>unsettled runs only</strong> (Q9). Once <code>finish</code> has committed, Chord drops the run's record and key
|
||
and releases its waiters' references; it keeps nothing beyond a run's lifetime. Dedupe after completion is the
|
||
owner's. Studio records which run a request started, in the same commit as the intent, and <code>finish</code> records the
|
||
result by run id (<code>ImportResults</code> above):</p>
|
||
<pre><code><span class="tk-k">const</span> <span class="tk-t">Requests</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"notes.import-request"</span>, {
|
||
schema: <span class="tk-f">obj</span>({ run: <span class="tk-f">str</span>() }), version: <span class="tk-n">1</span>, initial: () => ({ run: <span class="tk-s">""</span> }), keyed: <span class="tk-k">true</span>, persist: <span class="tk-k">true</span>,
|
||
checkpointWhen: () => <span class="tk-k">true</span>,
|
||
});
|
||
|
||
startImport: <span class="tk-k">async</span> ({ path, requestId }, ctx) => {
|
||
<span class="tk-k">const</span> prior = <span class="tk-k">await</span> requests.<span class="tk-f">get</span>(requestId); <span class="tk-c">// read outside the transaction (R13)</span>
|
||
<span class="tk-k">if</span> (prior) <span class="tk-k">return</span> { id: prior.run }; <span class="tk-c">// started before, maybe finished long ago</span>
|
||
<span class="tk-k">return</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => {
|
||
<span class="tk-k">const</span> run = <span class="tk-k">await</span> tx.<span class="tk-f">start</span>(imports, { path }, { key: requestId }); <span class="tk-c">// dedupes a concurrent retry</span>
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(requests, requestId, { tags: [<span class="tk-t">SessionTag</span>(session)] })).run = run.id;
|
||
<span class="tk-k">return</span> { id: run.id };
|
||
}, ctx);
|
||
},</code></pre>
|
||
<p>Two concurrent retries both miss <code>prior</code>, but the key makes <code>tx.start</code> return the same unsettled run. The client
|
||
watches <code>imports</code> for progress and reads the result by run id once it is gone from <code>running</code>.</p>
|
||
<p>Why Chord does not keep completed runs itself: either 100,000 keyed imports would stay in memory forever, or a
|
||
retention limit would let a retried key rerun its effect. The owner knows which results matter.</p>
|
||
<h3 id="watching-waiting-aborting">Watching, waiting, aborting</h3>
|
||
<ul>
|
||
<li><strong><code>running</code></strong> is a readable of <code>{ id, version, args, progress, phase }</code>. <code>phase</code> is <code>running</code>, <code>finishing</code> (outcome
|
||
recorded, <code>finish</code> not yet committed) or <code>blocked</code> (with the reason). <code>concurrency</code> counts only active runs. The
|
||
panel shows a progress bar from it.</li>
|
||
<li><strong>Progress</strong> is in memory, throttled and sent as deltas. For output, use <code>OutputBuffer</code> and <code>Progress</code> from
|
||
<code>@earendil-works/chord/output</code>: bounded windows, adaptive throttling, no write amplification.</li>
|
||
<li><strong>Waiting:</strong> <code>start</code> returns a run with <code>outcome(ctx)</code>. The caller's abort ends only its wait, never the run.</li>
|
||
<li><strong>Abort is a request:</strong> <code>job.abort(id)</code> fires the run's <code>ctx.signal</code>; the run decides what it returns (a result
|
||
that already arrived stays a success). A run that ignores the signal keeps its slot until it ends. <code>finish</code> runs in
|
||
Chord's own context.</li>
|
||
</ul>
|
||
<h3 id="lifetime-persistence-revocation">Lifetime, persistence, revocation</h3>
|
||
<p><strong>Jobs keep their owner alive.</strong> A running, finishing or blocked job counts as a reference to its object, which ends
|
||
only once all its jobs are settled. Starting an import and closing the tab does not abort it. Unsettled runs also
|
||
count as activity for the worker's keep-alive.</p>
|
||
<p><strong>Revocation takes precedence</strong> (R11). Revoking a principal ends its objects at once, whatever jobs remain; the jobs
|
||
get the abort signal and the normal close timeout. A job runs as its object's principal.</p>
|
||
<p><strong>Persistence:</strong> jobs are in memory by default and gone with the process. <code>persist: true</code> (only for stable
|
||
identities) keeps the record: a run live at a crash ends <code>lost</code> at the next activation and <code>finish</code> records it.
|
||
Nothing is rerun automatically.</p>
|
||
<p><strong>Versions:</strong> a run started under an older job version keeps its version; <code>finish</code> and <code>running</code> see it through pure
|
||
<code>accepts</code> adapters, and stored outcomes are never rewritten. A version bump without an adapter does not refuse the
|
||
reload: the old outcome is <code>blocked</code>, kept and reported until a later reload brings the adapter.</p>
|
||
<p><strong>Native objects:</strong> a job in a native object ends <code>aborted</code> when the object restarts on a reload (chapter XV). In a
|
||
pure object it keeps running on its old <code>run</code> code.</p>
|
||
<h3 id="what-is-not-a-job">What is not a job</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Work</th>
|
||
<th>Use</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>request and response that may die with its caller</td>
|
||
<td>a method</td>
|
||
</tr>
|
||
<tr>
|
||
<td>input into running work (a pasted code, an approval)</td>
|
||
<td>a flow (chapter XII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>work that must resume after a crash</td>
|
||
<td>a Durable task</td>
|
||
</tr>
|
||
<tr>
|
||
<td>standing behaviour</td>
|
||
<td>a routine</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<div class="note review"><b class="tag">Review: jobs are the largest open simplification (#9, C3)</b><p>Decision #9 (deferred until login is ported): a login or a long relocation is a job whose state consumers want to
|
||
address and watch (<code>LoginFlow { id }</code>). Options: (a) jobs stay records inside their owner, watched through <code>running</code>;
|
||
(b) a job is an object of its own, addressed by its id, living until done. Separately, review round 3 proposed cut C3:
|
||
shrink jobs to a runner over documents (no job versions, <code>accepts</code>, <code>blocked</code>, <code>persist</code>) or move them out of the
|
||
kernel. Not decided. <code>chord-next-walkthrough.md</code> concept 17 still shows the older outcome shape <code>{ ok: true, result }</code>;
|
||
the design tags outcomes by <code>kind</code>.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="flows"><header class="opener"><div class="numeral">XII</div><h2>Asking a Person</h2><p class="lede">Flows and the Questions service: how running work waits for an answer from someone who may be on any tab, may reconnect, and may answer twice.</p></header>
|
||
<p class="dropcap">A provider login asks for a pasted code, then an account choice. A dangerous tool call waits for the owner's
|
||
approval. The agent asks everyone present a question. In each case running work waits for a person, who may be on any
|
||
tab, may lose the connection halfway, may answer late or twice. Two patterns cover this: a <strong>flow</strong> kept as its
|
||
owner's state, and the <strong>Questions</strong> service, a shared store for questions.</p>
|
||
<h3 id="a-flow-is-state-not-a-call">A flow is state, not a call</h3>
|
||
<p>The tempting design is a long-held server call, <code>step()</code>, that blocks until the next step. A link drop fails it, and
|
||
the consumer cannot tell whether it missed a step. Chord's answer is that the owner keeps the flow as <strong>state</strong>, which
|
||
re-holds by itself after any drop:</p>
|
||
<pre><code><span class="tk-c">// models/contract.ts (sketch from the design)</span>
|
||
start: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ provider: <span class="tk-f">str</span>() }), <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })) <span class="tk-c">// uuidv7</span>
|
||
answer: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), step: <span class="tk-f">int</span>(), value: <span class="tk-f">str</span>() }), <span class="tk-t">Answered</span>) <span class="tk-c">// ok | "step no longer active"</span>
|
||
cancel: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() }))
|
||
|
||
<span class="tk-c">// LoginFlow { id }: state</span>
|
||
<span class="tk-k">type</span> <span class="tk-t">FlowState</span> =
|
||
| { kind: <span class="tk-s">"running"</span>; step?: { id: number; prompt: <span class="tk-t">LoginPrompt</span> }; notices: <span class="tk-t">Notice</span>[] }
|
||
| { kind: <span class="tk-s">"done"</span> | <span class="tk-s">"failed"</span> | <span class="tk-s">"cancelled"</span>; notices: <span class="tk-t">Notice</span>[] };</code></pre>
|
||
<p>The UI watches the state and renders the current step. A late or second answer to a step that is gone is refused. A
|
||
promise-based callback inside the work (pi-ai's <code>prompt</code> during a login) stays <strong>inside</strong> the owner: its job keeps the
|
||
resolver, and nothing crosses an object boundary.</p>
|
||
<p>Imperative consumers (tests, scripts, a CLI, an agent tool) use a generic client helper over the same state, not a
|
||
second protocol:</p>
|
||
<pre><code><span class="tk-k">async</span> <span class="tk-k">function</span>* <span class="tk-f">steps</span>(flow: <span class="tk-t">LoginFlowRef</span>, ctx: <span class="tk-t">Ctx</span>) {
|
||
<span class="tk-k">let</span> last = <span class="tk-n">0</span>;
|
||
<span class="tk-k">for</span> (;;) {
|
||
<span class="tk-k">const</span> s = <span class="tk-k">await</span> <span class="tk-f">until</span>(flow.state, (v) => v.kind !== <span class="tk-s">"running"</span> || (v.step !== <span class="tk-k">undefined</span> && v.step.id > last), ctx);
|
||
<span class="tk-k">if</span> (s.kind !== <span class="tk-s">"running"</span>) <span class="tk-k">return</span> s.kind;
|
||
last = s.step!.id;
|
||
<span class="tk-k">yield</span> s.step!;
|
||
}
|
||
}
|
||
|
||
<span class="tk-k">for</span> <span class="tk-k">await</span> (<span class="tk-k">const</span> step <span class="tk-k">of</span> <span class="tk-f">steps</span>(<span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">LoginFlow</span>, { id }), ctx))
|
||
<span class="tk-k">await</span> login.<span class="tk-f">answer</span>({ id, step: step.id, value: <span class="tk-f">answerFor</span>(step) }, ctx);</code></pre>
|
||
<p><code>last</code> keeps steps from being lost or repeated across re-holds.</p>
|
||
<div class="note review"><b class="tag">Review: where a flow lives (#11, deferred)</b><p>Decision #11, deferred until login is ported: (a) a keyed document of its owner, or (b) an object <code>LoginFlow { id }</code>
|
||
living until done; tied to #9 (jobs as objects). The <code>LoginFlow { id }</code> address above assumes (b). Also undecided is
|
||
how "leaving" ends a login (p2 cancels when the starting owner leaves everywhere); with presence it is a dropped
|
||
reference, never an unmount (chapter VIII).</p>
|
||
</div>
|
||
<h3 id="the-questions-service">The Questions service</h3>
|
||
<p>The Questions service (<code>apps/p2/docs/questions.md</code>) is a p2 design, not a Chord feature, built from Chord's
|
||
primitives: a shared store with per-principal views. The asker gets <code>ask</code>; viewers get the open questions and
|
||
<code>answer</code>.</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Question</span> =
|
||
| { kind: <span class="tk-s">"text"</span>; message: string; secret?: boolean; help?: string }
|
||
| { kind: <span class="tk-s">"select"</span>; message: string; options: <span class="tk-k">readonly</span> { id: string; label: string }[] }
|
||
| { kind: <span class="tk-s">"confirm"</span>; message: string; allow: string; deny: string };
|
||
|
||
<span class="tk-k">type</span> <span class="tk-t">Policy</span> = { mode: <span class="tk-s">"first"</span> } | { mode: <span class="tk-s">"quorum"</span>; count: number } | { mode: <span class="tk-s">"all"</span>; join?: <span class="tk-s">"live"</span> | <span class="tk-s">"snapshot"</span> };
|
||
|
||
<span class="tk-f">ask</span>(question, {
|
||
audience, <span class="tk-c">// a readable or a list of principals</span>
|
||
policy,
|
||
key?, <span class="tk-c">// stable per asking flow: a re-ask reattaches to the pending question</span>
|
||
deadline?, <span class="tk-c">// then resolve with what is there; `missing` names who did not answer</span>
|
||
}, ctx): <span class="tk-t">Promise</span><<span class="tk-t">Result</span>>;</code></pre>
|
||
<p>Its rules:</p>
|
||
<ul>
|
||
<li><strong>The store is the truth.</strong> Pending questions and their resolvers live in the store; <code>open</code> is a readable over it,
|
||
filtered per viewer. Nothing is a document, so a reload or migration cannot leave a ghost question.</li>
|
||
<li><strong>One settlement point.</strong> Register the waiter and the abort listener before publishing the question; an answer, an
|
||
abort or the deadline atomically takes the entry out before resolving. A late or second answer gets "no longer
|
||
active". Answers are validated against the stored question (a select id must be one of the options). Never await an
|
||
answer inside a transaction.</li>
|
||
<li><strong>Policies over a live audience.</strong> <code>first</code> resolves with the first answer; <code>quorum(n)</code> after n answers; <code>all</code> when
|
||
every member of the audience has answered. With <code>join: "live"</code> (the default), someone who joins becomes required and
|
||
someone who leaves drops out.</li>
|
||
<li><strong>Authorization.</strong> The asker is stamped by the host (a worker asks only in its own session's scope; secret questions
|
||
only from server askers). Each question stores who may answer it; a guest's send access does not imply approval
|
||
rights. Secret answers never appear in <code>open</code>, documents or logs.</li>
|
||
<li><strong>Link drops and crashes.</strong> A rejected <code>ask</code> is not a denial. Askers retry with the same <code>key</code> until their own signal
|
||
aborts; a pending <code>ask</code> counts as an active call for keep-alive.</li>
|
||
<li><strong>Where it lives.</strong> On the host where the asking work runs: session questions on the session's worker, login
|
||
questions on the server. UIs reach both through the usual relay.</li>
|
||
</ul>
|
||
<p>The store in Chord v2 terms:</p>
|
||
<pre><code>host.<span class="tk-f">serve</span>(<span class="tk-t">QuestionStore</span>, ({ scope }, self) => {
|
||
<span class="tk-k">const</span> pending = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> () => <span class="tk-k">new</span> <span class="tk-t">PendingQuestions</span>(), (p) => p.<span class="tk-f">rejectAll</span>(<span class="tk-s">"store closed"</span>));
|
||
<span class="tk-k">const</span> open = self.<span class="tk-f">derive</span>(<span class="tk-s">"open"</span>, [pending.value.readable], ([all]) => all); <span class="tk-c">// data only, no resolvers</span>
|
||
<span class="tk-k">return</span> { open, ask: (args, ctx) => pending.value.<span class="tk-f">ask</span>(args, ctx), answer: (args, ctx) => pending.value.<span class="tk-f">answer</span>(args, ctx) };
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Questions</span>, ({ scope }, principal, self) => {
|
||
<span class="tk-k">const</span> store = self.<span class="tk-f">get</span>(<span class="tk-t">QuestionStore</span>, { scope });
|
||
<span class="tk-k">if</span> (<span class="tk-f">isHost</span>(principal)) <span class="tk-k">return</span> { variant: <span class="tk-s">"asker"</span>, ask: (args, ctx) => store.<span class="tk-f">ask</span>({ ...args, asker: principal }, ctx) };
|
||
<span class="tk-k">if</span> (!<span class="tk-f">mayView</span>(principal, scope)) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>();
|
||
<span class="tk-k">return</span> {
|
||
variant: <span class="tk-s">"viewer"</span>,
|
||
open: self.<span class="tk-f">derive</span>(<span class="tk-s">"mine"</span>, [store.open], ([all]) => <span class="tk-f">openFor</span>(all, principal)),
|
||
answer: ({ id, value }, ctx) => store.<span class="tk-f">answer</span>({ id, value, by: principal }, ctx),
|
||
};
|
||
});</code></pre>
|
||
<p>The pending map with its resolvers is in-process state with promises, so it is a native of the store (opened and
|
||
closed with it). Its <code>close</code> rejects every waiter; askers retry with their key against the new store after a reload.</p>
|
||
<div class="note review"><b class="tag">Review: questions.md predates the v2 rules</b><p><code>apps/p2/docs/questions.md</code> writes the store as <code>self.resource(...)</code> with a <code>replace</code> hand-over, which the design
|
||
rejected ("resources replaced in place"), and opens Chord call scopes in Durable hooks with <code>host.root</code>, which Q6
|
||
replaced with <code>chord(fn, context)</code> (chapter XIV). Design §20 says the Questions store "must live in an object (it
|
||
predates natives only in objects)". The sketch above is this guide's translation, with the derive per viewer; filtering
|
||
per viewer in the store, once per variant, may be cheaper for large audiences. The <code>ask</code> signature passes typed
|
||
objects as arguments, which is fine (data), but the <code>audience</code> readable form cannot cross a link: across links,
|
||
audience must be data (principals) or computed by the store.</p>
|
||
</div>
|
||
<h3 id="choosing">Choosing</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Case</th>
|
||
<th>Use</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>a multi-step login in one owner</td>
|
||
<td>a flow: the owner's job keeps the resolver, state shows the steps</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a tool needs the owner's approval</td>
|
||
<td>Questions, <code>first</code>, audience the owner</td>
|
||
</tr>
|
||
<tr>
|
||
<td>the agent asks everyone present</td>
|
||
<td>Questions, <code>all</code>, audience from presence</td>
|
||
</tr>
|
||
<tr>
|
||
<td>deploy needs 2 of 3 approvals</td>
|
||
<td>Questions, <code>quorum(2)</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>a notice (auth URL, device code, progress)</td>
|
||
<td>not a question: the asking work's own progress state</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter" id="registries"><header class="opener"><div class="numeral">XIII</div><h2>Extension Points</h2><p class="lede">The facet, registries and the HTTP component: how plugins add behaviour to each other without callbacks, and how the owner stays in charge.</p></header>
|
||
<p class="dropcap">The notes plugin wants a download route, <code>GET /s/:session/notes.txt</code>. A logs plugin wants to time every request.
|
||
Several plugins add model providers, tools for the agent, commands for the TUI. Someone must combine these
|
||
contributions, decide conflicts, and replace one plugin's part as a whole when it reloads. Between plugins exactly two
|
||
things pass: object types and <strong>registries</strong>.</p>
|
||
<h3 id="the-facet-completed">The facet, completed</h3>
|
||
<p>The facet has appeared since chapter II as the place that serves types. It is also itself a <strong>pure object</strong> with the
|
||
host's lifetime:</p>
|
||
<pre><code><span class="tk-c">// notes/server.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
<span class="tk-k">const</span> prefs = self.<span class="tk-f">state</span>(<span class="tk-t">NotesPrefs</span>); <span class="tk-c">// plugin-wide documents</span>
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, { routes: { <span class="tk-s">"GET /s/:session/notes.txt"</span>: exportNotes } });
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteCountStore</span>, …, { lifetime: <span class="tk-s">"host"</span> });
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NoteCounts</span>, …);
|
||
});</code></pre>
|
||
<ul>
|
||
<li>It may hold documents, references, routines and contributions; <strong>no natives</strong> (those need an object with its own
|
||
lifetime).</li>
|
||
<li>Its identity is the plugin's name, so its persisted documents survive restarts and reloads.</li>
|
||
<li>Served objects go live after the facet and may read and write its documents (chapter V).</li>
|
||
<li><code>host.serve</code> and <code>self.contribute</code> are valid only while the setup runs. The facet never <code>self.get</code>s a type it
|
||
serves.</li>
|
||
</ul>
|
||
<h3 id="registries">Registries</h3>
|
||
<p>A registry is an extension point owned by someone (a Chord component, the app, a plugin). Each contributor
|
||
contributes <strong>at most one value</strong> per registry; the table is an immutable, ordered list of <code>(contributor, value)</code>;
|
||
the registry's <code>compose</code> turns it into the value consumers use.</p>
|
||
<pre><code><span class="tk-c">// owned by the HTTP component</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Http</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">HttpContribution</span>, <span class="tk-t">Router</span>>(<span class="tk-s">"chord.http"</span>, {
|
||
version: <span class="tk-n">2</span>,
|
||
accepts: { <span class="tk-n">1</span>: (old: <span class="tk-t">HttpContributionV1</span>): <span class="tk-t">HttpContribution</span> => <span class="tk-f">upgrade</span>(old) },
|
||
compose: (table, { report }) => <span class="tk-f">buildRouter</span>(table, report), <span class="tk-c">// pure; always delivers a value</span>
|
||
});
|
||
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, { routes: { … }, wraps: [ … ] }); <span class="tk-c">// a contributor</span>
|
||
<span class="tk-k">const</span> router = host.<span class="tk-f">registry</span>(<span class="tk-t">Http</span>).value; <span class="tk-c">// a consumer: live</span></code></pre>
|
||
<p><strong><code>compose</code> runs at runtime, whenever the table changes:</strong> after a reload's commit, when a contributor goes live or
|
||
ends, when a contribution changes. It is not a staging check: contributions can be dynamic (known only once a native
|
||
is open), so no check before a reload could be complete. <code>compose</code> always delivers a value with its fallback built in
|
||
(e.g. the first by plugin name wins) and reports conflicts through <code>report</code>, which become owner notices and telemetry
|
||
events. Readers get a plain value and never handle conflicts. Only if <code>compose</code> throws is the registry in an error
|
||
state, and readers get an error when they use it. Work that started earlier keeps the value it read.</p>
|
||
<p><strong>Rules:</strong></p>
|
||
<ul>
|
||
<li><strong>Who contributes</strong> (R21): only facets and <code>lifetime: "host"</code> objects; only their <code>self</code> has <code>contribute</code> (a type
|
||
error elsewhere). Every object may read registries.</li>
|
||
<li><strong>A contribution is in the table exactly while its contributor is live:</strong> a facet's while its plugin is loaded; an
|
||
object's from <code>live</code> until it ends, fails or restarts.</li>
|
||
<li><strong>Order</strong> is sorted by plugin name: deterministic on every host and across reloads, and deliberately meaningless.
|
||
Meaningful order comes from the owner's composition (HTTP phases, below). Helpers: <code>keyed(table, entriesOf)</code>,
|
||
<code>ordered(table, itemsOf, orderOf?)</code>.</li>
|
||
<li><strong>One authoritative declaration</strong> (Q8): only the defining plugin's facet declares the registry to the host
|
||
(<code>compose</code>, version, <code>accepts</code>). Other plugins' tokens only identify it by name and types. A reload of the defining
|
||
plugin replaces the declaration atomically and the table recomposes. If the defining plugin is not loaded, the
|
||
registry has no value (readers get an error like a failed reference) and contributions wait in the table.</li>
|
||
<li><strong>Versions</strong> (Q8): a contribution carries the registry version its plugin was built against; older ones are adapted
|
||
by the defining facet's pure <code>accepts</code>. One without a matching version or adapter is left out and reported naming
|
||
plugin, registry and versions; its plugin keeps working. Contributions are in-process values (often functions) and
|
||
are not schema-checked.</li>
|
||
<li><strong>Use sites guard contributions.</strong> A contribution is untrusted code (third-party plugins are a goal). Every use site
|
||
catches throws and rejections when calling an entry, reports naming the contributor, and continues with the others.</li>
|
||
<li><strong>The owner decides</strong> conflicts, order and selection. Registries add behaviour; they never replace an owner's checks.</li>
|
||
</ul>
|
||
<h3 id="natives-through-registries">Natives through registries</h3>
|
||
<p>Registries are the only way a native reaches another plugin. An entry lives exactly as long as its contributor;
|
||
consumers look up at each use (or follow the composed value) and keep nothing from an entry beyond a use. The models
|
||
plugin's host-lifetime object contributes its runtime; the harness looks it up at every request. After a models reload
|
||
the next request gets the new runtime, while a running stream finishes on the old one.</p>
|
||
<p>A handler that stores <code>const r = entry()</code> in a module variable and uses it for days has a bug: after the contributor
|
||
restarts it holds a closed native.</p>
|
||
<h3 id="contributions-that-change">Contributions that change</h3>
|
||
<p>Some contributions are known only at runtime: MCP tools appear when a server connects. A contribution is either fixed
|
||
for its contributor's life (dynamic parts behind handles read at use) or a <strong>readable</strong>, which Chord subscribes to;
|
||
on every change Chord replaces that entry and runs <code>compose</code> again. For in-process values, <code>self.writable(initial)</code> is
|
||
a host-local readable of any value (no JSON, no wire, compared by identity) with <code>set</code>:</p>
|
||
<pre><code><span class="tk-k">const</span> ext = self.<span class="tk-f">writable</span>(<span class="tk-f">mcpExtension</span>([])); <span class="tk-c">// a Durable Extension, no tools yet</span>
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">HarnessExtensions</span>, ext);
|
||
self.routines.<span class="tk-f">on</span>(mcp.tools, (tools) => ext.<span class="tk-f">set</span>(<span class="tk-f">mcpExtension</span>(tools)));</code></pre>
|
||
<p>There is no remove call. An entry leaves when its contributor ends, or after a reload whose new code no longer
|
||
contributes. A live contributor with nothing to offer sets its readable to the registry's empty value (an extension
|
||
without tools, empty routes); the table keeps exactly one entry per live contributor.</p>
|
||
<h3 id="optional-dependencies">Optional dependencies</h3>
|
||
<p>Registries also replace optional references (chapter VI). Radius forwards connections to ports the ports plugin
|
||
manages, but ports is optional. So radius owns <code>radius.forwards</code> and ports contributes its forwards; without ports the
|
||
table is empty and radius works.</p>
|
||
<h3 id="the-http-component">The HTTP component</h3>
|
||
<p>HTTP is an optional Chord component (<code>@earendil-works/chord/next/http</code>) built on a registry. A route gets a plain
|
||
request summary (method, path params, query, principal, provenance such as local or relayed, selected headers, parsed
|
||
and capped JSON bodies, uploads in chunks) and a <code>ctx</code> whose <code>principal</code> is the request's, so <code>ctx.get(Notes, …)</code> gives
|
||
exactly the view that person may see. The route returns a description; the component does the HTTP work once (ranges
|
||
with 206 and 416, HEAD, download headers with a safe filename, <code>nosniff</code>, CSP and its nonce, caching, streaming, abort
|
||
on disconnect):</p>
|
||
<pre><code><span class="tk-k">type</span> <span class="tk-t">HttpResponse</span> =
|
||
| { kind: <span class="tk-s">"json"</span>; value: <span class="tk-t">JsonValue</span> } | { kind: <span class="tk-s">"page"</span>; title: string; body: string }
|
||
| { kind: <span class="tk-s">"bytes"</span>; name: string; mime: string; size: number; <span class="tk-f">read</span>(offset: number, length: number): <span class="tk-t">Promise</span><<span class="tk-t">Uint8Array</span>> }
|
||
| { kind: <span class="tk-s">"redirect"</span>; to: string } | { kind: <span class="tk-s">"error"</span>; status: number };
|
||
|
||
<span class="tk-c">// notes/server.ts</span>
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||
routes: {
|
||
<span class="tk-s">"GET /s/:session/notes.txt"</span>: <span class="tk-k">async</span> (req, ctx) => {
|
||
<span class="tk-k">const</span> notes = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Notes</span>, { session: req.params.session }); <span class="tk-c">// the requester's view</span>
|
||
<span class="tk-k">const</span> text = <span class="tk-k">new</span> <span class="tk-t">TextEncoder</span>().<span class="tk-f">encode</span>(<span class="tk-f">render</span>(notes.notes.value));
|
||
<span class="tk-k">return</span> { kind: <span class="tk-s">"bytes"</span>, name: <span class="tk-s">"notes.txt"</span>, mime: <span class="tk-s">"text/plain"</span>, size: text.byteLength,
|
||
read: <span class="tk-k">async</span> (offset, length) => text.<span class="tk-f">subarray</span>(offset, offset + length) };
|
||
},
|
||
},
|
||
});</code></pre>
|
||
<p>Routes compose with <code>keyed</code>: two contributors with one pattern is a reported conflict, and the first by plugin name
|
||
wins. <strong>Wraps</strong> are middleware in phases the owner defines (<code>observe</code>, <code>headers</code>, <code>transform</code>, outermost first),
|
||
within a phase in contributor order. No numeric priorities: plugins have no single registration order, so Fastify's
|
||
lifecycle phases and encapsulation are the model, not Express.</p>
|
||
<pre><code><span class="tk-c">// logs/server.ts</span>
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Http</span>, {
|
||
wraps: [{
|
||
name: <span class="tk-s">"access-log"</span>, phase: <span class="tk-s">"observe"</span>, scope: <span class="tk-s">"all"</span>, <span class="tk-c">// default scope "own": only own routes</span>
|
||
wrap: <span class="tk-k">async</span> (req, next, ctx) => {
|
||
<span class="tk-k">const</span> t = <span class="tk-t">Date</span>.<span class="tk-f">now</span>();
|
||
<span class="tk-k">const</span> res = <span class="tk-k">await</span> <span class="tk-f">next</span>(req);
|
||
<span class="tk-f">log</span>(req, res, <span class="tk-t">Date</span>.<span class="tk-f">now</span>() - t);
|
||
<span class="tk-k">return</span> res;
|
||
},
|
||
}],
|
||
});</code></pre>
|
||
<p><code>route</code> narrows a wrap to one route; a <code>route</code> that does not exist is a reported conflict. Wraps may add a security
|
||
boundary on top of an owner's checks, never instead of them. A request uses the router as it was when it started.
|
||
Request resolvers (who sent a request: cookie, loopback, relay) are hooks over the summary.</p>
|
||
<h3 id="channels-and-big-bytes">Channels and big bytes</h3>
|
||
<p><strong>Channels</strong> are WebSocket byte channels (<code>send</code>, <code>onData</code>, <code>close</code>) for terminals, separate from object links and
|
||
with their own acknowledged windows, so a large state message never holds up keystrokes.</p>
|
||
<p><strong>Big transfers are chunked by the owner,</strong> not by Chord: bytes are a schema type (<code>bytes()</code>), carried natively
|
||
(never base64), and a 50 MB upload is a protocol of plain calls:</p>
|
||
<pre><code><span class="tk-f">beginUpload</span>({ name, size }, ctx) → { upload }
|
||
<span class="tk-f">append</span>({ upload, offset, bytes }, ctx) <span class="tk-c">// about 1 MiB per chunk; the awaited call is the backpressure</span>
|
||
<span class="tk-f">finishUpload</span>({ upload }, ctx) → { id }
|
||
<span class="tk-f">read</span>({ id, offset, length }, ctx) → bytes</code></pre>
|
||
<p>After a link drop the <code>append</code> in flight fails like any call; the writer asks the owner for the stored offset and
|
||
resumes. Unfinished uploads expire through the owner's routine. Chord has no stream type: it would need its own rules
|
||
for drops, cancellation, relays and restarts, which this offset protocol already survives.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>Registries are layer L9. The HTTP component, channels and the chunked-transfer helper are deferred until after the
|
||
kernel and a first real proof (the harness, one tool with an approval pause, a code mode reload); until then the
|
||
playground serves its pages with plain app code. Their design stays the target.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="agent"><header class="opener"><div class="numeral">XIV</div><h2>The Agent</h2><p class="lede">pi-durable inside Chord: the harness as a core object, models, environments and tools through registries, and how a tool reaches Chord objects and people.</p></header>
|
||
<p class="dropcap">Each Studio worker runs a pi-durable harness for its session. The harness must run for the worker's whole life,
|
||
whether anyone watches or not. Its models come from the models plugin, its execution environments from the envs
|
||
plugin, its tools from every plugin, and all of those reload independently. This chapter assumes you know pi-durable
|
||
and shows only how it plugs into Chord.</p>
|
||
<h3 id="shared-foundations">Shared foundations</h3>
|
||
<p>Durable and Chord v2 share modules: Durable imports Chord's <code>context</code>, <code>delta</code>, <code>json</code>, <code>output</code> and <code>sqlite</code>, and v2
|
||
imports the same, so <code>Context</code>, <code>Draft</code> and <code>JsonValue</code> are one type each. A v2 readable is today's
|
||
<code>replicatedState</code> (<code>chord/src/state.ts</code>), so Durable's <code>viewState()</code> and document watches can be exposed as Chord state
|
||
members without conversion. Only plain data crosses between Durable and Chord objects.</p>
|
||
<h3 id="the-harness-is-a-core-object">The harness is a core object</h3>
|
||
<pre><code><span class="tk-c">// studio/src/core/harness.ts (base code)</span>
|
||
<span class="tk-k">import</span> { <span class="tk-t">Harness</span> <span class="tk-k">as</span> <span class="tk-t">DurableHarness</span> } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/pi-durable"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">ChordHostKey</span> = createContextKey<<span class="tk-t">Host</span><P>>(<span class="tk-s">"studio.chord-host"</span>);
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">HarnessModels</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">Models</span>, <span class="tk-t">Models</span>>(<span class="tk-s">"studio.harness-models"</span>, { version: <span class="tk-n">1</span>, compose: firstModels });
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">HarnessEnvironments</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">EnvFactory</span>, <span class="tk-t">EnvFactory</span>>(<span class="tk-s">"studio.harness-envs"</span>, { version: <span class="tk-n">1</span>, compose: firstEnv });
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">HarnessExtensions</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">Extension</span>, <span class="tk-t">RegistrySnapshotSource</span>>(<span class="tk-s">"studio.harness-extensions"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
compose: (table, { report }) => <span class="tk-f">extensionSnapshot</span>(table, report), <span class="tk-c">// one Durable Extension per plugin</span>
|
||
});
|
||
<span class="tk-k">const</span> <span class="tk-t">HarnessRuntime</span> = defineRegistry<() => <span class="tk-t">DurableHarness</span>, () => <span class="tk-t">DurableHarness</span>>(<span class="tk-s">"studio.harness-runtime"</span>, {
|
||
version: <span class="tk-n">1</span>, compose: onlyEntry, <span class="tk-c">// core-private: one contributor</span>
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Harness</span>, (_input, self) => {
|
||
<span class="tk-k">const</span> info = self.<span class="tk-f">get</span>(<span class="tk-t">SessionInfo</span>, {});
|
||
<span class="tk-k">const</span> harness = self.<span class="tk-f">open</span>(<span class="tk-k">async</span> ({ signal }) => {
|
||
<span class="tk-k">const</span> { id, dataDir } = info.init.value;
|
||
<span class="tk-k">const</span> base = <span class="tk-f">withContextValue</span>(<span class="tk-t">ChordHostKey</span>, host, <span class="tk-f">withAbortSignal</span>(signal, BACKGROUND_CONTEXT));
|
||
<span class="tk-k">return</span> <span class="tk-t">DurableHarness</span>.<span class="tk-f">open</span>(<span class="tk-k">await</span> <span class="tk-f">openNodeSqliteStorage</span>(<span class="tk-f">join</span>(dataDir, id, <span class="tk-s">"durable.sqlite"</span>)), {
|
||
models: () => host.<span class="tk-f">registry</span>(<span class="tk-t">HarnessModels</span>).value, <span class="tk-c">// read at each use</span>
|
||
env: (target) => host.<span class="tk-f">registry</span>(<span class="tk-t">HarnessEnvironments</span>).<span class="tk-f">value</span>(target),
|
||
settings: () => <span class="tk-f">currentSettings</span>(),
|
||
registry: <span class="tk-f">durableRegistry</span>(host.<span class="tk-f">registry</span>(<span class="tk-t">HarnessExtensions</span>)), <span class="tk-c">// a RegistryReader over the composed value</span>
|
||
}, base);
|
||
}, (h) => h.<span class="tk-f">close</span>(BACKGROUND_CONTEXT));
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">HarnessRuntime</span>, () => harness.value); <span class="tk-c">// for core's conversation objects</span>
|
||
<span class="tk-k">return</span> { … };
|
||
}, { lifetime: <span class="tk-s">"host"</span> });</code></pre>
|
||
<ul>
|
||
<li><strong>Lifetime <code>"host"</code>:</strong> it runs for the worker's whole life, also while nobody holds it, because Durable tasks
|
||
continue.</li>
|
||
<li><strong>Base code:</strong> it lives in the core facet, so a plugin reload never restarts it; only a base-code change (a host
|
||
restart) does.</li>
|
||
<li><strong>Registries, looked up at each use:</strong> Durable's <code>models</code> and <code>settings</code> accept functions (pi <code>7ec1f648e</code>), so each
|
||
generation gets the current value. After a models reload the next request uses the new runtime; a running stream
|
||
finishes on the old one.</li>
|
||
<li><strong>Durable's storage is its own.</strong> Chord documents never commit together with Durable transcripts.</li>
|
||
</ul>
|
||
<p>The design writes the call as <code>Durable.open(storage, { … })</code>; pi-durable's actual entry point is
|
||
<code>Harness.open(storage, options, context)</code>, used here as <code>DurableHarness</code> to keep it apart from the core object
|
||
<code>Harness</code>.</p>
|
||
<h3 id="start-order">Start order</h3>
|
||
<p>At host start Chord loads every facet and waits until every facet and every <code>lifetime: "host"</code> object <strong>other than the
|
||
harness</strong> is live or failed: the <strong>start barrier</strong>. Then the registries hold the contributions of every plugin that
|
||
loaded, and the harness opens and resumes scheduling (R17). An object that must contribute at start, like the model
|
||
runtime, is therefore <code>lifetime: "host"</code>. Nothing waits for specific contributions; what is missing shows where it is
|
||
used:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Missing</th>
|
||
<th>What happens</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>a tool</td>
|
||
<td>its call gets an error result the model sees</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a model</td>
|
||
<td>the generation reports "model unavailable" and waits, not fails, until a model appears</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a task definition</td>
|
||
<td>the task stays blocked (<code>missing_task</code>) until the plugin is installed or the task is aborted</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a plugin that failed</td>
|
||
<td>reported with its error; a facet in backoff contributes later and the registries recompose</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The same holds when a session's database is copied to an instance with other plugins: missing tools, sections and
|
||
hooks are skipped, documents of missing plugins stay untouched, stored selections are never rewritten. The UI must
|
||
show blocked tasks with their reason and offer to abort them; a turn waiting on one hangs until then.</p>
|
||
<div class="note review"><b class="tag">Review: the harness exception to the barrier (C17, R17)</b><p>R17 makes the harness start after the barrier, a special case in the barrier's rule. Review round 3 proposed cut C17:
|
||
no harness-specific exception; the harness is an ordinary object the app's start code holds after <code>load</code> resolves.
|
||
Not decided. <code>chord-next-walkthrough.md</code> concept 28 still says the harness's activation <em>is</em> the start barrier, which
|
||
R17 replaced.</p>
|
||
</div>
|
||
<h3 id="tools-that-call-chord">Tools that call Chord</h3>
|
||
<p>Durable tools, hooks and sections receive Durable's <code>ToolExecutionApi</code> (or <code>HookApi</code>) and a <code>Context</code>, never a Chord
|
||
<code>ctx</code> (Q6). Core put the host into the harness's base context under <code>ChordHostKey</code>, and every phase context inherits
|
||
it. A core helper opens a Chord call scope from any Durable context:</p>
|
||
<pre><code><span class="tk-c">// studio/src/core/chord.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">chord</span><T>(context: <span class="tk-t">Context</span>, fn: (ctx: <span class="tk-t">Ctx</span>) => <span class="tk-t">Promise</span><T>): <span class="tk-t">Promise</span><T> {
|
||
<span class="tk-k">const</span> host = context.<span class="tk-f">value</span>(<span class="tk-t">ChordHostKey</span>);
|
||
<span class="tk-k">if</span> (!host) <span class="tk-k">throw</span> <span class="tk-k">new</span> <span class="tk-t">Error</span>(<span class="tk-s">"not inside the Studio harness"</span>);
|
||
<span class="tk-k">return</span> host.<span class="tk-f">root</span>(fn, { signal: context.abortSignal, telemetry: context.telemetry });
|
||
}</code></pre>
|
||
<p>The scope runs as the host principal; attribution is data; references taken with <code>ctx.get</code> are released when <code>fn</code>
|
||
settles. There is no wrapping of tools and no app-specific <code>defineTool</code>. The notes plugin gives the agent a tool:</p>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">const</span> addNote = <span class="tk-f">defineTool</span>({
|
||
name: <span class="tk-s">"notes_add"</span>, <span class="tk-c">// tools are named <plugin>_<name></span>
|
||
description: <span class="tk-s">"Add a note to this session's notes panel"</span>,
|
||
parameters: <span class="tk-t">Type</span>.<span class="tk-t">Object</span>({ text: <span class="tk-t">Type</span>.<span class="tk-t">String</span>({ maxLength: <span class="tk-n">2000</span> }) }),
|
||
execute: (args, api, context) => <span class="tk-f">chord</span>(<span class="tk-k">async</span> (ctx) => {
|
||
<span class="tk-k">const</span> info = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">SessionInfo</span>, {});
|
||
<span class="tk-k">const</span> store = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">NoteStore</span>, { session: info.init.value.id });
|
||
<span class="tk-k">const</span> id = <span class="tk-k">await</span> store.<span class="tk-f">add</span>({ text: args.text, by: <span class="tk-s">`agent:${api.conversationId}`</span> }, ctx);
|
||
<span class="tk-k">return</span> { output: [{ <span class="tk-k">type</span>: <span class="tk-s">"text"</span>, text: <span class="tk-s">`added note ${id}`</span> }] };
|
||
}, context),
|
||
});
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">HarnessExtensions</span>, <span class="tk-f">defineExtension</span>({ name: <span class="tk-s">"@studio/notes"</span>, tools: [addNote] }));
|
||
…
|
||
});</code></pre>
|
||
<p>A reload of the notes plugin replaces its extension in the table; Durable pins a registry snapshot per task phase, so
|
||
a tool call already running finishes on the old code and the next phase uses the new one.</p>
|
||
<h3 id="tools-that-need-a-person">Tools that need a person</h3>
|
||
<p>Clients are not addressable, so a tool cannot call the user's tab. A tool that needs the user goes through an owner's
|
||
state: the Questions service (chapter XII). The model-facing <code>ask_user</code> tool and an approval hook both call
|
||
<code>Questions.ask</code>; tabs render the open questions and answer them.</p>
|
||
<pre><code><span class="tk-f">hook</span>(<span class="tk-t">ToolTask</span>, {
|
||
beforeTool: <span class="tk-k">async</span> (call, api, context) => {
|
||
<span class="tk-k">if</span> (!<span class="tk-f">needsApproval</span>(call)) <span class="tk-k">return</span> <span class="tk-k">undefined</span>;
|
||
<span class="tk-k">const</span> decided = <span class="tk-k">await</span> api.<span class="tk-f">memo</span><boolean>(<span class="tk-s">"approval"</span>, context); <span class="tk-c">// a rerun does not ask again</span>
|
||
<span class="tk-k">if</span> (decided !== <span class="tk-k">undefined</span>) <span class="tk-k">return</span> decided ? <span class="tk-k">undefined</span> : { block: <span class="tk-s">"Denied"</span> };
|
||
<span class="tk-k">const</span> allowed = <span class="tk-k">await</span> <span class="tk-f">chord</span>(<span class="tk-k">async</span> (ctx) => {
|
||
<span class="tk-k">const</span> q = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Questions</span>, { scope: { session } });
|
||
<span class="tk-k">for</span> (;;) {
|
||
<span class="tk-k">try</span> {
|
||
<span class="tk-k">const</span> r = <span class="tk-k">await</span> q.<span class="tk-f">ask</span>(
|
||
{ kind: <span class="tk-s">"confirm"</span>, message: <span class="tk-s">`Run ${call.name}?`</span>, allow: <span class="tk-s">"Allow"</span>, deny: <span class="tk-s">"Deny"</span> },
|
||
{ audience: approvers, policy: { mode: <span class="tk-s">"first"</span> }, key: api.taskId },
|
||
ctx,
|
||
);
|
||
<span class="tk-k">return</span> r.answer;
|
||
} <span class="tk-k">catch</span> (error) {
|
||
<span class="tk-k">if</span> (ctx.signal.aborted) <span class="tk-k">throw</span> error; <span class="tk-c">// a link blip is not a denial: retry</span>
|
||
<span class="tk-k">await</span> <span class="tk-f">backoff</span>(ctx);
|
||
}
|
||
}
|
||
}, context);
|
||
<span class="tk-k">await</span> api.<span class="tk-f">memo</span>(<span class="tk-s">"approval"</span>, allowed, context);
|
||
<span class="tk-k">return</span> allowed ? <span class="tk-k">undefined</span> : { block: <span class="tk-s">"Denied by the user"</span> };
|
||
},
|
||
});</code></pre>
|
||
<p>Durable runs <code>beforeTool</code> in the tool task's call phase; after the execute checkpoint a crash retries execution
|
||
without hooks, so the stored decision covers that retry. The approval must cover the final arguments: put the
|
||
approval hook after hooks that rewrite arguments.</p>
|
||
<h3 id="conversations-for-the-ui">Conversations for the UI</h3>
|
||
<p>Chat panels need a conversation's live view, submit, steer, stop. The design leaves p3's conversation API to the p3
|
||
design; one construction that follows the rules: a core <code>ConversationStore { conversation }</code> (local) opens the Durable
|
||
watch as its own native, getting the Durable harness from the core-private <code>HarnessRuntime</code> registry contribution (a
|
||
native may reach another object only through a registry entry), and exposes a bounded window as a derive. Per-principal
|
||
<code>Conversation { session, conversation }</code> views in front of it check who may send (chapter XIX).</p>
|
||
<h3 id="code-mode">Code mode</h3>
|
||
<p>Code mode is a plugin. It owns its <code>pi-codemode</code> sandbox as a native; its tool entry looks up the <strong>current</strong> sandbox
|
||
when <code>execute()</code> starts, so a call pinned by a phase before a reload (waiting in an approval hook, say) starts on the
|
||
new sandbox. On <code>reload</code> and <code>stop</code> the old sandbox is sealed and drained in <code>close</code>: running scripts finish or hit
|
||
their deadline, for at most 30 s (Q7). After a worker stop Durable replays as it does today.</p>
|
||
<h3 id="what-lives-where">What lives where</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Data</th>
|
||
<th>Where</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>transcript-bound: entries, documents that fork and rewind with a conversation</td>
|
||
<td>Durable</td>
|
||
</tr>
|
||
<tr>
|
||
<td>app state: session list, notes, drafts, grants, settings</td>
|
||
<td>Chord documents</td>
|
||
</tr>
|
||
<tr>
|
||
<td>data core enforces on conversations (read-only one-shots, cwd followers)</td>
|
||
<td>a core per-conversation policy document, written when the conversation is created (<code>createConversation({ init })</code>)</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Telemetry:</strong> pass the host's adapter as <code>HarnessOptions.telemetry</code>; Durable stores trace carriers on submissions and
|
||
tasks (chapter XVII).</p>
|
||
</section>
|
||
|
||
<section class="chapter" id="reload"><header class="opener"><div class="numeral">XV</div><h2>Reload and Rollout</h2><p class="lede">What happens when you press Reload: per host, per object, and across the server, the workers and every tab.</p></header>
|
||
<p class="dropcap">Back to the preface. Mario changed the notes plugin's sort order and pressed Reload. Everything the earlier chapters
|
||
asked of plugin authors (synchronous factories, natives only in objects, routines instead of timers, data in
|
||
documents) exists so that this moment is safe. This chapter shows what Chord does with it.</p>
|
||
<h3 id="on-one-host">On one host</h3>
|
||
<pre class="plain"><code>1. load new bundles; an import or syntax error rejects
|
||
2. staging facet setups and every live object's new factory run with a recording self
|
||
(declarations only, nothing activated); any throw rejects
|
||
3. commit the host switches to the new code atomically
|
||
4. after per object: pure objects swap, native objects restart</code></pre>
|
||
<p><strong>Rollback is all-or-nothing per host.</strong> Any load or staging error leaves the whole host on its previous code, so a
|
||
host is always completely at one revision and its status is one value. Runtime states (a registry conflict, a failed
|
||
open, a migration) are not reasons to roll back; after the commit, errors stay with one object. There is no factory
|
||
trial, no migrate dry run, and no consumer contract check in staging: an incompatible consumer fails at bind with
|
||
<code>contract_mismatch</code> and a notice naming plugin and type.</p>
|
||
<div class="steps"><div class="steps-head"><span class="title">Reloading @studio/notes on worker S1, object by object</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Load and stage</h4><p>The new bundles load. The facet setup and the factories of <code>Notes</code> (three views), <code>NoteStore { S1 }</code> and <code>NoteInbox</code>
|
||
run with a recording <code>self</code>: they declare, nothing activates. Nothing throws, so the host commits to the new code.</p>
|
||
</div><div class="step-panel"><h4>NoteStore: pure, swapped in place</h4><p><code>NoteStore</code> has no <code>self.open</code>. Its new methods and derives take over; <code>NoteList</code>, the drafts, its references and the
|
||
running import job stay. Routines restart, serialized: the new <code>cleanup</code> waits for the old handler in flight. The gate
|
||
opens.</p>
|
||
</div><div class="step-panel"><h4>Running calls finish on old code</h4><p>An <code>add</code> that was running when the swap happened finishes on the old code with its old handles; its transaction still
|
||
commits. Only new calls get the new handles. Exception (R15): if the reload raised a document's version, old code that
|
||
touches the migrated document throws, the document stays intact, and that call fails once.</p>
|
||
</div><div class="step-panel"><h4>The import job keeps its old run</h4><p>The job's <code>run</code> keeps the code it started with. When it settles, the <strong>new</strong> <code>finish</code> records the outcome.</p>
|
||
</div><div class="step-panel"><h4>The views: pure, swapped</h4><p>The three <code>Notes</code> views swap in place. Owner, Ann and Bob keep their references; the reader derive is recomputed with
|
||
the new sort and the difference is sent. Nobody sees a reconnect.</p>
|
||
</div><div class="step-panel"><h4>NoteInbox: native, restarts</h4><p><code>NoteInbox</code> has a watcher. Its running work ends <code>aborted</code>, the watcher closes with reason <code>reload</code>, the new factory
|
||
runs, the watcher reopens. Its documents would stay. Whether a file dropped during the restart is seen is the
|
||
watcher's business: Studio's <code>watchDir</code> scans the directory once when it opens.</p>
|
||
</div></div>
|
||
<p><strong>Why natives restart instead of being handed over:</strong> a native has no line, no transactions and no schema. If old and
|
||
new code shared it, they would interleave on it unchecked. In practice restarts are cheap: the Durable harness is base
|
||
code, terminal PTYs live in a separate terminal host process, and model runtimes, sandboxes and a search index rebuild
|
||
in milliseconds. Only reloading the plugin that owns SSH connections (running remote commands die) or the relay socket
|
||
(relayed connections drop) hurts.</p>
|
||
<p><strong>Removing a type:</strong> its objects end (routines stop, natives close, references end with an error); in-memory documents
|
||
go with them; persisted ones stay until retired. <strong>Consumers elsewhere</strong> see a restarting object like any object that
|
||
briefly goes away: <code>loading</code>, parked calls, then <code>live</code>.</p>
|
||
<p><strong>UI facets reload too.</strong> A reload unmounts and remounts components. That is why "leaving" (abort a login, close an
|
||
overlay) is never derived from an unmount (chapter VIII).</p>
|
||
<h3 id="across-hosts">Across hosts</h3>
|
||
<p>A <strong>revision</strong> is (server incarnation, sequence) and names the complete desired code set: every facet with its
|
||
digest, not a batch of changes. Every install reconciles everything missing, and a host reports a revision only when it
|
||
runs that complete set. A revision also carries the code-set digest (a hash over the facet bundles' content hashes),
|
||
so a server restart with unchanged code triggers no reinstall.</p>
|
||
<p>The order is <strong>server, workers, clients</strong>. Each host reports <code>installed</code>, <code>installed_failed</code> (with the objects that
|
||
failed) or <code>rejected</code> (with the reason; it stays on its previous code). A server that rejects stops the rollout. A
|
||
host that starts or reconnects installs the current revision before anything is routed to it. Installs are serialized
|
||
per host and bounded (60 s).</p>
|
||
<p>Calls keep flowing during a rollout. When a call reaches a provider on another revision, the mismatch response
|
||
carries the provider's revision and status:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Provider</th>
|
||
<th>The call</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>ahead, or behind and installing</td>
|
||
<td><code>updating</code>: values readable, calls park (with the park deadline)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>rejected</td>
|
||
<td>fails at once; retried on the next revision</td>
|
||
</tr>
|
||
<tr>
|
||
<td>same revision</td>
|
||
<td><code>contract_mismatch</code>: a real mismatch, final</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>A host checks its own install status first: if its own install was rejected, a call fails at once with the reason
|
||
instead of waiting on a provider. Calls parked on a binding that is released end with an error and are not resent.
|
||
There are no cross-host transactions and no votes.</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 438" width="100%" role="img" aria-label="A tab calls into a worker that is still installing. The call parks and runs once the worker reports the revision." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f9m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f9a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f9b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="130.7" y1="52" x2="130.7" y2="430" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="45.7" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="130.7" y="27" font-size="11" fill="#2a2723" text-anchor="middle">tab</text><text x="130.7" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">revision 8</text><line x1="360" y1="52" x2="360" y2="430" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="275" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="360" y="27" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="360" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">revision 8</text><line x1="589.3" y1="52" x2="589.3" y2="430" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="504.3" y="10" width="170" height="42" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="589.3" y="27" font-size="11" fill="#2a2723" text-anchor="middle">worker S1</text><text x="589.3" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">installing 8</text><g class="step edge" style="--i:1"><line x1="360" y1="78" x2="586.3" y2="78" stroke="#b4472f" marker-end="url(#f9a)"/><text x="474.7" y="72" font-size="10" fill="#b4472f" text-anchor="middle">install revision 8</text></g><g class="step edge" style="--i:2"><line x1="130.7" y1="110" x2="357" y2="110" stroke="#7b7468" marker-end="url(#f9m)"/><text x="245.3" y="104" font-size="10" fill="#2a2723" text-anchor="middle">call Notes.add (new contract)</text></g><g class="step edge" style="--i:3"><line x1="360" y1="142" x2="586.3" y2="142" stroke="#7b7468" marker-end="url(#f9m)"/><text x="474.7" y="136" font-size="10" fill="#2a2723" text-anchor="middle">relay</text></g><g class="step edge" style="--i:4"><line x1="589.3" y1="174" x2="363" y2="174" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f9m)"/><text x="474.7" y="168" font-size="10" fill="#2a2723" text-anchor="middle">updating (rev 7, installing)</text></g><g class="step edge" style="--i:5"><line x1="360" y1="206" x2="133.7" y2="206" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f9m)"/><text x="245.3" y="200" font-size="10" fill="#2a2723" text-anchor="middle">updating</text></g><g class="step edge" style="--i:6"><path d="M130.7 232 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f9m)"/><text x="172.7" y="242" font-size="10" fill="#2a2723" text-anchor="start">park (deadline 60 s)</text></g><g class="step edge" style="--i:7"><line x1="589.3" y1="278" x2="363" y2="278" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f9m)"/><text x="474.7" y="272" font-size="10" fill="#2a2723" text-anchor="middle">installed 8</text></g><g class="step edge" style="--i:8"><line x1="360" y1="310" x2="133.7" y2="310" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f9m)"/><text x="245.3" y="304" font-size="10" fill="#2a2723" text-anchor="middle">provider at 8</text></g><g class="step edge" style="--i:9"><line x1="130.7" y1="342" x2="357" y2="342" stroke="#7b7468" marker-end="url(#f9m)"/><text x="245.3" y="336" font-size="10" fill="#2a2723" text-anchor="middle">call Notes.add (resent from park)</text></g><g class="step edge" style="--i:10"><line x1="360" y1="374" x2="586.3" y2="374" stroke="#7b7468" marker-end="url(#f9m)"/><text x="474.7" y="368" font-size="10" fill="#2a2723" text-anchor="middle">relay</text></g><g class="step edge" style="--i:11"><line x1="589.3" y1="406" x2="133.7" y2="406" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f9m)"/><text x="360" y="400" font-size="10" fill="#2a2723" text-anchor="middle">result</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 9.</span> A tab calls into a worker that is still installing. The call parks and runs once the worker reports the revision.</figcaption></figure>
|
||
<h3 id="start-barrier">Start barrier</h3>
|
||
<p>At host start and first load there is one more rule (chapter XIV): load every facet (the core facet with its start
|
||
data among them), wait until every facet and <code>lifetime: "host"</code> object other than the harness is live or failed, then
|
||
resume dependent work. Plugin reloads need no barrier.</p>
|
||
<h3 id="loading-and-the-dev-loop">Loading and the dev loop</h3>
|
||
<ul>
|
||
<li><strong>Roots:</strong> plugins come from built-in, global (<code><data>/plugins</code>), package (npm or git, installed with
|
||
<code>--ignore-scripts</code>) and project roots, found by directory listing; no source parsing. The server selects which load.
|
||
Dependencies between plugins resolve by contract name at runtime. Names are prefixed: objects <code><plugin>.<name></code>,
|
||
tools <code><plugin>_<name></code>.</li>
|
||
<li><strong>Loader D:</strong> one base bundle per surface (host packages and the app runtime) plus one script per facet, with host
|
||
packages passed in (no module map). Old generations are reclaimed by the garbage collector; a stable filename per
|
||
(facet, digest), and V8's compilation cache off on hosts that reload in place. The loader is an adapter: static
|
||
imports (tests, Durable Objects) work the same, with the app passing the plugin name to <code>load</code>.</li>
|
||
<li><strong>Dev loop:</strong> a source watcher marks plugins dirty and never applies anything. A reload control (the playground's web
|
||
shell, a CLI, an agent tool such as p2's <code>reload_plugins</code>) reloads the changed plugins, or restarts all hosts for
|
||
base code. Reloads are always explicit.</li>
|
||
</ul>
|
||
<div class="note review"><b class="tag">Review: project plugins (#14, deferred)</b><p>Per-directory plugins with trust prompts and snapshots (<code>apps/p2/docs/plugin-loading.md</code>, still the reference for
|
||
roots, trust and scopes) need their own pass over this design. Until then, treat the project root as designed but not
|
||
settled.</p>
|
||
</div>
|
||
<div class="note"><b class="tag">Built so far</b><p>Reload and rollout are layer L10, the last before telemetry. The frozen kernel has a reload path with host values and
|
||
a development freeze, both removed from the design.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="contracts"><header class="opener"><div class="numeral">XVI</div><h2>Changing Contracts</h2><p class="lede">How a contract gets a new version while old tabs, old workers and old plugins are still talking to it.</p></header>
|
||
<p class="dropcap">Chapter XV ended with a tab that calls into a worker on another revision. That case is short-lived: the rollout
|
||
finishes and both sides run the same code. Other cases last longer. A phone kept a tab open overnight with yesterday's
|
||
web bundle. A plugin from another author was built against <code>Notes</code> version 1 and is not updated yet. In both, a
|
||
consumer holds a contract that the provider no longer has. This chapter is about what the provider does then. It is
|
||
also the one part of Chord v2 that is built: everything in the sections on <code>evolve</code>, <code>checkAccepts</code> and fingerprints
|
||
runs today in <code>packages/chord/src/next/contracts/</code>.</p>
|
||
<h3 id="what-has-a-version">What has a version</h3>
|
||
<p>Everything that is stored or crosses a boundary has a version. Each surface handles an old version its own way:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Surface</th>
|
||
<th>Version</th>
|
||
<th>When an old one shows up</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>object type (contract)</td>
|
||
<td><code>version</code> plus fingerprint</td>
|
||
<td><code>evolve</code> and <code>checkAccepts</code> on the provider; otherwise <code>contract_mismatch</code></td>
|
||
<td>this one</td>
|
||
</tr>
|
||
<tr>
|
||
<td>document</td>
|
||
<td><code>version</code> plus schema fingerprint</td>
|
||
<td>pure <code>migrate</code> on access; a schema change without a bump fails the object <code>broken</code></td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td>job</td>
|
||
<td><code>version</code></td>
|
||
<td><code>accepts</code> on outcomes and <code>running</code>; <code>blocked</code> without an adapter</td>
|
||
<td>XI</td>
|
||
</tr>
|
||
<tr>
|
||
<td>registry</td>
|
||
<td><code>version</code></td>
|
||
<td><code>accepts</code> adapts contributions; one without a match is left out and reported</td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>native</td>
|
||
<td>none</td>
|
||
<td>the object restarts on reload</td>
|
||
<td>IX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Durable tasks and documents</td>
|
||
<td>Durable's <code>version</code></td>
|
||
<td>Durable's <code>migrate</code></td>
|
||
<td>XIV</td>
|
||
</tr>
|
||
<tr>
|
||
<td>link protocol</td>
|
||
<td>protocol version</td>
|
||
<td>refused; hosts update together, a web page reloads</td>
|
||
<td>XVII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>UI components, Chord and app APIs</td>
|
||
<td>none yet</td>
|
||
<td>exposed as they are; an SDK will version them later</td>
|
||
<td></td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The rule behind every row is the same as for documents: <strong>every change bumps the version</strong>, additive ones included.
|
||
Chord never guesses compatibility from the shape of two schemas.</p>
|
||
<h3 id="a-history-not-a-file-of-versions">A history, not a file of versions</h3>
|
||
<p>A contract history starts with a <strong>base</strong>: a full <code>remote()</code> or <code>local()</code> declaration with an explicit <code>version</code>. The
|
||
<code>Notes</code> contract from chapters II and III is a base at version 1. Studio now wants pinned notes: notes get a <code>pinned</code>
|
||
flag and editors get a <code>pin</code> method. Instead of editing the declaration, the plugin continues the history:</p>
|
||
<pre><code><span class="tk-c">// notes/contract.ts</span>
|
||
<span class="tk-k">import</span> { bool, evolve, nul } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NoteV2</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), text: <span class="tk-f">str</span>({ max: <span class="tk-n">2000</span> }), by: <span class="tk-f">str</span>(), at: <span class="tk-f">int</span>(), pinned: <span class="tk-f">bool</span>() });
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">PublicNoteV2</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), text: <span class="tk-f">str</span>(), at: <span class="tk-f">int</span>(), pinned: <span class="tk-f">bool</span>() });
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NotesV2</span> = <span class="tk-f">evolve</span>(<span class="tk-t">Notes</span>, (e) =>
|
||
e.<span class="tk-f">state</span>(<span class="tk-s">"editor"</span>, <span class="tk-s">"notes"</span>, <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">NoteV2</span>) }))
|
||
.<span class="tk-f">state</span>(<span class="tk-s">"reader"</span>, <span class="tk-s">"notes"</span>, <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">PublicNoteV2</span>) }))
|
||
.<span class="tk-f">addMethod</span>(<span class="tk-s">"editor"</span>, <span class="tk-s">"pin"</span>, <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>(), pinned: <span class="tk-f">bool</span>() }), <span class="tk-f">nul</span>()),
|
||
);</code></pre>
|
||
<p><code>evolve(prev, (e) => …)</code> appends one version to <code>prev</code>'s history: <code>NotesV2.version</code> is 2, and <code>NotesV2</code> still knows
|
||
version 1 exactly as it was declared. Every version is <strong>materialized and fingerprinted at declaration</strong>. That is what
|
||
makes old code safe: an old plugin bundle that contains its own copy of <code>Notes</code> version 1 computes the same
|
||
fingerprint as the history inside <code>NotesV2</code>. There is no build step, no generated file and no fingerprint copied by
|
||
hand.</p>
|
||
<p>The edits:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Edit</th>
|
||
<th>Changes</th>
|
||
<th>Kind</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>e.state(variant, member, schema)</code></td>
|
||
<td>one state member of one variant</td>
|
||
<td>remote</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.addState(variant, member, schema)</code> / <code>e.removeState(variant, member)</code></td>
|
||
<td>adds or removes a state member</td>
|
||
<td>remote</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.state(member, schema)</code>, <code>e.addState(member, schema)</code>, <code>e.removeState(member)</code></td>
|
||
<td>the same, flat</td>
|
||
<td>local</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.args(name, schema)</code> / <code>e.result(name, schema)</code></td>
|
||
<td>one side of a method</td>
|
||
<td>both</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.method(name, args, result)</code></td>
|
||
<td>both sides of a method</td>
|
||
<td>both</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.addMethod(variant, name, args, result)</code></td>
|
||
<td>adds a method (or an existing one to another variant, same signature)</td>
|
||
<td>remote</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.addMethod(name, args, result)</code></td>
|
||
<td>adds a method</td>
|
||
<td>local</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>e.removeMethod(name)</code></td>
|
||
<td>removes a method from every variant</td>
|
||
<td>both</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Edits chain. The <code>method(...)</code> wrapper appears only in base declarations; edits take the schemas directly, so a
|
||
method without a result is added with <code>nul()</code>. The rules of chapter III still hold across versions: a method has one
|
||
signature in every variant, and a member's kind never changes, not even after it was removed and added again.</p>
|
||
<p><strong>What <code>evolve</code> cannot do:</strong> change the input or the set of variants. Both change what an object <em>is</em> (its identity,
|
||
who gets which members), and no adapter can turn one into the other. That needs a new history base, below.</p>
|
||
<h3 id="the-provider-says-what-it-accepts">The provider says what it accepts</h3>
|
||
<p><code>NotesV2</code> says what changed. It does not say how version 1 consumers are served; the <strong>provider</strong> decides that, with
|
||
mappings that live in its own code, never in the contract. The worker facet declares them with <code>checkAccepts</code>:</p>
|
||
<pre><code><span class="tk-c">// notes/worker.ts</span>
|
||
<span class="tk-k">import</span> { checkAccepts } <span class="tk-k">from</span> <span class="tk-s">"@earendil-works/chord/next"</span>;
|
||
|
||
<span class="tk-k">const</span> toV1 = ({ pinned, ...note }: <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">NoteV2</span>>) => note;
|
||
<span class="tk-k">const</span> toPublicV1 = ({ pinned, ...note }: <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">PublicNoteV2</span>>) => note;
|
||
|
||
<span class="tk-k">const</span> notesAccepts = <span class="tk-f">checkAccepts</span>(<span class="tk-t">NotesV2</span>, {
|
||
<span class="tk-n">1</span>: {
|
||
state: {
|
||
editor: { notes: (current) => ({ items: current.items.<span class="tk-f">map</span>(toV1) }) },
|
||
reader: { notes: (current) => ({ items: current.items.<span class="tk-f">map</span>(toPublicV1) }) },
|
||
},
|
||
methods: {}, <span class="tk-c">// add and remove did not change; pin is new</span>
|
||
},
|
||
});
|
||
<span class="tk-c">// notesAccepts.versions: [{ version: 1, fingerprint: Notes.fingerprint }]</span>
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">NotesV2</span>, notesFactory, { accepts: notesAccepts }); <span class="tk-c">// working name for the objects-layer option</span></code></pre>
|
||
<p><code>checkAccepts</code> is <strong>pure</strong>: it checks the mappings without calling them and returns only the version metadata of the
|
||
old versions it lists. A provider that lists nothing accepts only its current version. The objects layer later
|
||
compares a consumer's version and fingerprint against this list.</p>
|
||
<p>The mappings point in the direction the data flows:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Member in the old version</th>
|
||
<th>Mapping</th>
|
||
<th>Direction</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>state, changed</td>
|
||
<td><code>(currentMember) => oldMember</code></td>
|
||
<td>provider's current value to what the old consumer expects</td>
|
||
</tr>
|
||
<tr>
|
||
<td>state, removed</td>
|
||
<td><code>(currentVariantState) => oldMember</code>, or <code>"refuse"</code></td>
|
||
<td>receives the whole current state of that variant</td>
|
||
</tr>
|
||
<tr>
|
||
<td>method arguments, changed</td>
|
||
<td><code>args: (old) => current</code></td>
|
||
<td>old caller's arguments to current ones</td>
|
||
</tr>
|
||
<tr>
|
||
<td>method result, changed</td>
|
||
<td><code>result: (current) => old</code></td>
|
||
<td>current result back to the old shape</td>
|
||
</tr>
|
||
<tr>
|
||
<td>method, changed or removed</td>
|
||
<td><code>{ call: async (oldArgs, ctx) => oldResult }</code>, or <code>"refuse"</code></td>
|
||
<td>replaces the method for old callers</td>
|
||
</tr>
|
||
<tr>
|
||
<td>member added since</td>
|
||
<td>nothing</td>
|
||
<td>an old consumer never asks for it</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>A changed state member cannot be refused: the old consumer still has that member and must get a value. A removed one
|
||
can be computed from what the variant has now, or refused. <code>e.method(name, args, result)</code> replaces both sides and
|
||
therefore needs both mappings, even if one side stayed the same (then an identity mapping).</p>
|
||
<p><strong>Required, at type level and again at runtime.</strong> For every member that exists in the old version and changed or was
|
||
removed since, the mapping is required by the type of <code>checkAccepts</code>. Because plugins are bundled without type
|
||
checks, the same check runs at declaration: a missing mapping, a key for a member that needs none, an unknown version
|
||
or a wrong shape throws, naming the member's path (for example <code>accepts[1].state.reader.notes</code>). An absent <code>state</code> or
|
||
<code>methods</code> section counts as empty and may be left out only when nothing there needs a mapping.</p>
|
||
<p><strong>Conservative with no-op edits.</strong> An edit that sets a schema to what it already was, or changes a member and then
|
||
changes it back, still requires an identity mapping. TypeScript erases constraints such as <code>{ max: 2000 }</code> from the
|
||
static type, so two schemas that look the same to the compiler may differ; Chord does not try to tell.</p>
|
||
<h4>Old versions map straight to the current one</h4>
|
||
<p>Studio later replaces <code>remove</code> with <code>archive</code>, which keeps the note in a trash list:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NotesV3</span> = <span class="tk-f">evolve</span>(<span class="tk-t">NotesV2</span>, (e) =>
|
||
e.<span class="tk-f">removeMethod</span>(<span class="tk-s">"remove"</span>).<span class="tk-f">addMethod</span>(<span class="tk-s">"editor"</span>, <span class="tk-s">"archive"</span>, <span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() }), <span class="tk-f">nul</span>()),
|
||
);
|
||
|
||
<span class="tk-k">const</span> notesAccepts = <span class="tk-f">checkAccepts</span>(<span class="tk-t">NotesV3</span>, {
|
||
<span class="tk-n">1</span>: {
|
||
state: { editor: { notes: (c) => ({ items: c.items.<span class="tk-f">map</span>(toV1) }) }, reader: { notes: (c) => ({ items: c.items.<span class="tk-f">map</span>(toPublicV1) }) } },
|
||
methods: { remove: { call: <span class="tk-k">async</span> ({ id }, ctx) => { <span class="tk-k">await</span> <span class="tk-f">archiveNote</span>(id, ctx); <span class="tk-k">return</span> <span class="tk-k">null</span>; } } },
|
||
},
|
||
<span class="tk-n">2</span>: { methods: { remove: { call: <span class="tk-k">async</span> ({ id }, ctx) => { <span class="tk-k">await</span> <span class="tk-f">archiveNote</span>(id, ctx); <span class="tk-k">return</span> <span class="tk-k">null</span>; } } } },
|
||
});</code></pre>
|
||
<p>Each entry maps <strong>one old version directly to the current one</strong>, not through the versions in between. Version 1
|
||
needs the state mappings (its notes have no <code>pinned</code>) and the <code>remove</code> replacement; version 2 needs only the
|
||
<code>remove</code> replacement, and leaves <code>state</code> out because its state is unchanged. A member that an old version never had
|
||
needs nothing for that version, even if it was added, changed and removed later. Dropping support for version 1 is
|
||
deleting its entry: version 1 consumers then get <code>contract_mismatch</code>.</p>
|
||
<h3 id="binding-an-old-consumer">Binding an old consumer</h3>
|
||
<p>A hold carries only the consumer's contract <strong>version and fingerprint</strong>, never a description of the contract (R6).
|
||
The provider decides with its own schemas:</p>
|
||
<figure class="fig wide anim"><svg viewBox="0 0 720 241" width="100%" role="img" aria-label="What a provider does with a hold. The decision uses only the provider's own history; a peer's schema is never an input." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f10m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f10a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f10b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#7b7468" text-anchor="start" font-weight="600" letter-spacing="2">THE HOLD ARRIVES</text><text x="16" y="144" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">OUTCOME</text><g class="step" style="--i:1"><rect x="19" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="124" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">hold</text><text x="124" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">version 1, fingerprint F1</text></g><g class="step" style="--i:3"><rect x="255" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">equal to current?</text><text x="360" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">version and fingerprint</text></g><g class="step" style="--i:7"><rect x="491" y="36" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="596" y="55.3" font-size="11" fill="#2a2723" text-anchor="middle">accepted old version?</text><text x="596" y="69.3" font-size="9.5" fill="#7b7468" text-anchor="middle">F1 from the provider's own history</text></g><g class="step" style="--i:5"><rect x="19" y="158" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="124" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">bind</text><text x="124" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">current members</text></g><g class="step" style="--i:9"><rect x="255" y="158" width="210" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="360" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle">bind adapted</text><text x="360" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">through the mappings</text></g><g class="step" style="--i:11"><rect x="491" y="158" width="210" height="44" rx="4" fill="#efe9dd" stroke="#c9bfae"/><text x="596" y="177.3" font-size="11" fill="#2a2723" text-anchor="middle" font-family="Menlo,monospace">contract_mismatch</text><text x="596" y="191.3" font-size="9.5" fill="#7b7468" text-anchor="middle">or updating, during a rollout</text></g><g class="step edge" style="--i:2"><path d="M229 58 L253 58" fill="none" stroke="#7b7468" marker-end="url(#f10m)"/></g><g class="step edge" style="--i:4"><path d="M360 80 L124 156" fill="none" stroke="#7b7468" marker-end="url(#f10m)"/><rect x="246" y="112" width="23.8" height="13" fill="#fbf8f2" opacity=".92"/><text x="250" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">yes</text></g><g class="step edge" style="--i:6"><path d="M465 58 L489 58" fill="none" stroke="#7b7468" marker-end="url(#f10m)"/><rect x="468.7" y="41" width="18.5" height="13" fill="#fbf8f2" opacity=".92"/><text x="478" y="51" font-size="9.5" fill="#7b7468" text-anchor="middle">no</text></g><g class="step edge" style="--i:8"><path d="M596 80 L360 156" fill="none" stroke="#7b7468" marker-end="url(#f10m)"/><rect x="482" y="112" width="23.8" height="13" fill="#fbf8f2" opacity=".92"/><text x="486" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">yes</text></g><g class="step edge" style="--i:10"><path d="M596 80 L596 156" fill="none" stroke="#7b7468" marker-end="url(#f10m)"/><rect x="600" y="112" width="18.5" height="13" fill="#fbf8f2" opacity=".92"/><text x="604" y="122" font-size="9.5" fill="#7b7468" text-anchor="start">no</text></g><text x="16" y="226" font-size="10.5" fill="#7b7468" text-anchor="start">A mismatch fails only that consumer's binding. The provider object and its other bindings are not affected.</text></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 10.</span> What a provider does with a hold. The decision uses only the provider's own history; a peer's schema is never an input.</figcaption></figure>
|
||
<p>Once a version 1 tab is bound, it is served as if version 1 were current:</p>
|
||
<div class="steps"><div class="steps-head"><span class="title">A version 1 tab on a version 3 worker</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Bind</h4><p>The tab holds <code>Notes { S1 }</code> with version 1 and fingerprint F1. The worker's history computes F1 for version 1, and
|
||
<code>notesAccepts</code> lists version 1, so the hold binds adapted. The tab gets the <code>editor</code> variant as version 1 defined it:
|
||
<code>notes</code>, <code>add</code>, <code>remove</code>. It never sees <code>pin</code> or <code>archive</code>.</p>
|
||
</div><div class="step-panel"><h4>State through the mapping</h4><p>Chord serves the adapted state like a derive of the provider's state: it applies the mapping to the current value
|
||
and keeps the result per binding. The snapshot the tab receives has no <code>pinned</code> fields.</p>
|
||
</div><div class="step-panel"><h4>A commit</h4><p>The owner pins a note from a current tab. The worker sends the current tab the draft's ops. For the version 1 tab,
|
||
Chord applies the mapping to the new value and sends the difference to its last adapted value (<code>diffRevisions</code>). The
|
||
mapping produced nothing new for a pin, so the version 1 tab receives no change at all. A large list is never resent
|
||
whole on every commit.</p>
|
||
</div><div class="step-panel"><h4>A call</h4><p>The tab calls <code>remove({ id })</code>. Its arguments are checked against the provider's own version 1 schema, then the
|
||
<code>call</code> replacement runs <code>archiveNote</code> on version 3 code. Its <code>null</code> result is what version 1 expects. Had the method
|
||
only changed, <code>args</code> would have mapped the arguments forward and <code>result</code> the result back.</p>
|
||
</div><div class="step-panel"><h4>The worker moves on</h4><p>A later revision drops the version 1 entry. When the tab re-holds (or the worker restarts and the tab binds again),
|
||
the hold gets <code>contract_mismatch</code>, and Studio's panel shows "reload to update" instead of failing silently.</p>
|
||
</div></div>
|
||
<h3 id="a-new-history-base">A new history base</h3>
|
||
<p>Some changes cannot be adapted. Studio decides that notes belong to a project, not a session: the input changes from
|
||
<code>{ session }</code> to <code>{ project }</code>. That is a new base, written in full, with an explicit version that continues the
|
||
published numbering:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">NotesV4</span> = <span class="tk-f">remote</span>(<span class="tk-s">"notes"</span>, {
|
||
version: <span class="tk-n">4</span>, <span class="tk-c">// explicit: the published history used 1 to 3</span>
|
||
input: <span class="tk-f">obj</span>({ project: <span class="tk-f">str</span>() }),
|
||
placement: () => <span class="tk-t">Server</span>,
|
||
variants: {
|
||
editor: { state: { notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">NoteV2</span>) }) }, methods: { add: <span class="tk-f">method</span>(…), pin: <span class="tk-f">method</span>(…), archive: <span class="tk-f">method</span>(…) } },
|
||
reader: { state: { notes: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">PublicNoteV2</span>) }) } },
|
||
},
|
||
});
|
||
<span class="tk-c">// checkAccepts(NotesV4).versions is empty</span></code></pre>
|
||
<p>A new base has no history before its version and <strong>cannot accept older versions</strong>, not even for an additive change.
|
||
Every consumer built against versions 1 to 3 gets <code>contract_mismatch</code> and must update. Later <code>evolve</code> calls continue
|
||
from version 4. The same applies to a change of the variant set. Do not spread an older declaration into a new one or
|
||
copy its fingerprint; the contract objects are branded and <code>evolve</code> and <code>checkAccepts</code> reject copies.</p>
|
||
<h3 id="local-contracts">Local contracts</h3>
|
||
<p><code>local</code> contracts (chapter IV) evolve the same way, with a flat members map and no variants:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">SessionInfoV2</span> = <span class="tk-f">evolve</span>(<span class="tk-t">SessionInfo</span>, (e) => e.<span class="tk-f">state</span>(<span class="tk-s">"init"</span>, <span class="tk-t">InitV2</span>));
|
||
<span class="tk-f">checkAccepts</span>(<span class="tk-t">SessionInfoV2</span>, { <span class="tk-n">1</span>: { state: { init: (current) => <span class="tk-f">toInitV1</span>(current) }, methods: {} } });</code></pre>
|
||
<p>Same-host consumers of a <code>local</code> type are usually reloaded together with their provider, but not always: another
|
||
plugin on the same host may be built against an older version. The rules are identical; only the variant argument is
|
||
missing.</p>
|
||
<h3 id="implementing-a-contract">Implementing a contract</h3>
|
||
<p><code>Implementation<typeof NotesV2></code> is the type of everything a provider must supply for the current version: one state
|
||
getter per member and variant, and one asynchronous method per method, each receiving <code>(args, ctx)</code>:</p>
|
||
<pre><code><span class="tk-k">const</span> impl: <span class="tk-t">Implementation</span><<span class="tk-k">typeof</span> <span class="tk-t">NotesV2</span>> = {
|
||
state: { editor: { notes: () => list.value }, reader: { notes: () => publicList.value } },
|
||
methods: { add: <span class="tk-k">async</span> ({ text }, ctx) => <span class="tk-f">addNote</span>(text, ctx), remove: <span class="tk-k">async</span> ({ id }) => <span class="tk-f">removeNote</span>(id), pin: <span class="tk-k">async</span> (a) => <span class="tk-f">pin</span>(a) },
|
||
};</code></pre>
|
||
<p>Keep declaration types <strong>inferred</strong> (no annotation on <code>NotesV2</code>): the inferred type carries the complete history, and
|
||
<code>checkAccepts</code> needs it to know what changed.</p>
|
||
<div class="note review"><b class="tag">Review: L1's implementation shape and the factory's return value</b><p><code>Implementation<C></code> in <code>src/next/contracts/types.ts</code> is <code>{ state: { variant: { member: getter } }, methods }</code>. The
|
||
design's factories (<code>design.md</code> §6, used throughout this guide) return <code>{ variant, member: value, method: fn }</code>.
|
||
The objects layer (L4) has to connect the two, and also define how <code>serve</code> receives the checked accepts (this guide
|
||
uses the working name <code>{ accepts }</code> as a third argument) and what <code>"refuse"</code> does at runtime. The natural reading,
|
||
used in the stepper above, is that a refused method call and a hold that needs a refused state member end with
|
||
<code>contract_mismatch</code>; no source says so.</p>
|
||
</div>
|
||
<h3 id="the-fingerprint">The fingerprint</h3>
|
||
<p>The fingerprint is a 64-bit hash (<code>hash64</code>, portable across runtimes) of a <strong>normal form</strong> of the contract's
|
||
schemas. The normal form decides what counts as a change:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Ignored</th>
|
||
<th>Significant</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>order of object keys, <code>required</code> and <code>enum</code> (sorted)</td>
|
||
<td>order of union and intersection branches (<code>anyOf</code>, <code>oneOf</code>, <code>allOf</code>) and of tuples</td>
|
||
</tr>
|
||
<tr>
|
||
<td>annotations on schema nodes: <code>title</code>, <code>description</code>, <code>examples</code>, <code>$comment</code>, <code>deprecated</code></td>
|
||
<td>defaults, formats, patterns, bounds, <code>$ref</code> names</td>
|
||
</tr>
|
||
<tr>
|
||
<td>placement</td>
|
||
<td>input, variants, member kinds and every member schema</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Union order is significant because it is meaning: TypeBox picks defaults and decodes by the first matching branch.
|
||
Annotations are dropped only on schema nodes; an object property that happens to be named <code>title</code> is data and counts.</p>
|
||
<p>Only data schemas are allowed. One walker copies, validates and normalizes each schema against a <strong>whitelist of
|
||
TypeBox keywords</strong>: primitives, arrays and tuples, objects and records, unions and intersections, <code>$defs</code> and <code>$ref</code>
|
||
(use <code>$ref</code> for recursive types), constraints and annotations. Conditional and dependent schemas, <code>not</code>,
|
||
<code>definitions</code>, <code>contentSchema</code>, <code>unevaluated*</code>, <code>contains</code>, <code>propertyNames</code>, unknown keywords, executable or custom
|
||
TypeBox features, arbitrary <code>Unsafe</code> and refinements, accessors, symbols and cyclic objects are rejected at
|
||
declaration. Bytes are the one special leaf: <code>bytes({ min, max })</code> checks a <code>Uint8Array</code> and has the data marker
|
||
<code>{ type: "bytes", minLength?, maxLength? }</code>, so it hashes like any schema; TypeBox's checking still works on it.</p>
|
||
<p>Schemas are deep-copied into a private, frozen history; your own schema objects are copied, not frozen.
|
||
<code>NORMAL_FORM_VERSION</code> is part of every digest, so a change of the normal form is a link protocol change.</p>
|
||
<p><strong>Not a security boundary.</strong> A fingerprint says "we were compiled against the same contract"; it proves nothing. A
|
||
hostile client can send any fingerprint it likes. Providers therefore always validate arguments with their own
|
||
schemas (chapter IV), whatever the fingerprint says.</p>
|
||
<h3 id="rules-of-thumb">Rules of thumb</h3>
|
||
<ul>
|
||
<li>Treat a published contract like a published API: every change is <code>evolve</code>, every <code>evolve</code> needs a decision about
|
||
old consumers.</li>
|
||
<li>Keep mappings small and pure. A state mapping runs for every commit and every adapted binding; make it a map over
|
||
the value, not a query.</li>
|
||
<li>Accept the versions your users still run (one or two back during a normal rollout), and delete entries when the
|
||
metrics show no old bindings.</li>
|
||
<li>Change the input or the variants only with a new base, and only when updating every consumer is acceptable.</li>
|
||
<li>Contract, document and job versions are separate and each is decided on its own. Pinned notes needed <code>NotesV2</code>
|
||
for the wire and <code>NoteList</code> version 2 with a <code>migrate</code> that adds <code>pinned: false</code> (chapter V), but no new
|
||
<code>ImportJob</code> version, because its outcome did not change.</li>
|
||
</ul>
|
||
<div class="note"><b class="tag">Built so far</b><p>This chapter's contract layer is built: <code>packages/chord/src/next/contracts/</code> (commit <code>7d2bb8ea8</code>, about 1,300 lines
|
||
with <code>remote</code>, <code>local</code>, <code>method</code>, <code>evolve</code>, <code>checkAccepts</code>, <code>Implementation</code>, the schema helpers, <code>fingerprintOf</code>,
|
||
<code>schemaNormalForm</code> and <code>NORMAL_FORM_VERSION</code>), with nine test files covering evolution, fingerprints, the hash,
|
||
local contracts, long chains, schemas, types and review findings. It stands alone: it hosts nothing, calls no
|
||
mapping and does not import the frozen runtime. Binding by version and fingerprint, adapted state and calls belong to
|
||
the objects and protocol layers, not built yet.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="wire"><header class="opener"><div class="numeral">XVII</div><h2>On the Wire</h2><p class="lede">What travels over a link, what a relay does with it, how big things are kept in check, what each hop costs, and how to see it in traces.</p></header>
|
||
<p class="dropcap">Most plugin code never thinks about the wire: it declares state, commits transactions and calls methods, and Chord
|
||
moves the bytes. You need this chapter when something is slow, when a value is large, when a link misbehaves, or when
|
||
you want to read a trace. It also explains why the earlier chapters insisted on small operations, shared structure and
|
||
no callbacks: each of those rules removes a cost on some hop.</p>
|
||
<h3 id="what-a-state-member-sends">What a state member sends</h3>
|
||
<p>Every exposed document, derive or readable is a <strong>state member</strong>. Over a hold it produces one of these:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Update</th>
|
||
<th>When</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>snap</code></td>
|
||
<td>on hold and on re-hold: the whole value, once</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ops</code></td>
|
||
<td>per commit: the draft's recorded ops, no diff</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ops</code> from a diff</td>
|
||
<td>per change of a derive (and of an adapted binding, chapter XVI)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>throttled <code>ops</code></td>
|
||
<td>job progress</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>reset</code></td>
|
||
<td>a migration commit replaced the value (adapted for older consumers)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>error</code></td>
|
||
<td>the member failed</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Commits are atomic for consumers.</strong> On the same host, subscribers are notified once, after every document of the
|
||
commit is adopted. Remotely, one <code>state</code> message per hold carries a batch of member updates that is applied before
|
||
listeners run, and a commit that touches several holds on one link travels as <strong>one</strong> <code>state</code> message on that link.
|
||
A consumer never sees half a commit.</p>
|
||
<h3 id="messages">Messages</h3>
|
||
<p>A message is a <strong>header plus opaque parts</strong> (R1). There is one part per hold the message concerns; the header says
|
||
which hold each part belongs to and how large it is.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Direction</th>
|
||
<th>Kind</th>
|
||
<th>Parts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>consumer to provider</td>
|
||
<td><code>hold</code>, <code>release</code>, <code>call</code>, <code>cancel</code></td>
|
||
<td><code>call</code> has one (the arguments)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>provider to consumer</td>
|
||
<td><code>bound</code></td>
|
||
<td>one: the snapshot</td>
|
||
</tr>
|
||
<tr>
|
||
<td></td>
|
||
<td><code>state</code></td>
|
||
<td>one per hold the commit touched on this link</td>
|
||
</tr>
|
||
<tr>
|
||
<td></td>
|
||
<td><code>result</code></td>
|
||
<td>one</td>
|
||
</tr>
|
||
<tr>
|
||
<td></td>
|
||
<td><code>gone</code></td>
|
||
<td>none</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>There are no request ids in application code. Hold ids and call ids exist only inside the protocol, per link.</p>
|
||
<p><strong>One binary format on every link (Q4):</strong> <code>u32 header length | header JSON | part bytes</code>, as one <code>Uint8Array</code>. A
|
||
WebSocket sends it as a binary frame, IPC passes it with <code>serialization: "advanced"</code>, a memory link delivers it
|
||
asynchronously. Encoding, framing, header checks, id resolution, flow control and size limits are one shared protocol
|
||
layer; link adapters only move bytes. Tests on memory links therefore run the production path.</p>
|
||
<p>A part is encoded <strong>once, at its origin</strong>, with that hold's own Delta encoder (paths are interned per hold, end to
|
||
end between provider and consumer). A JSON part above a threshold (about 16 KiB) is compressed once at its origin,
|
||
whatever the link; byte segments stay raw; relays forward compressed parts as they are; the header names the codec.</p>
|
||
<p><strong>The header is a TypeBox schema (R4)</strong>, a union tagged by <code>kind</code>; the protocol types derive from it. Headers from
|
||
untrusted links (client WebSockets) are checked with the schema compiled once; trusted links (IPC between server and
|
||
workers) are not checked. <strong>Unknown ids are ignored on both sides (Q1).</strong> They are normal races: a <code>result</code> crossing a
|
||
<code>cancel</code>, a <code>call</code> crossing a <code>gone</code>, a state part crossing a <code>release</code>. The side that ended a hold or call settles
|
||
its own pending work: a consumer that receives <code>gone(h)</code> rejects its pending calls on <code>h</code>, and the order per
|
||
direction guarantees nothing else for <code>h</code> follows. Only a header that fails the schema is a protocol error, and it
|
||
closes that link.</p>
|
||
<p>The link protocol has a version. Hosts with different protocol versions refuse each other; a web page reloads to get
|
||
the server's protocol.</p>
|
||
<h3 id="through-a-relay">Through a relay</h3>
|
||
<p>A tab never talks to a worker directly (chapter I). The server <strong>relays</strong>, and a relay reads and rewrites only
|
||
headers: it maps hold and call ids between its two links, stamps the principal on <code>hold</code> and <code>call</code> (a client's
|
||
principal field is always overwritten; only a trusted parent link may carry one), and groups parts per destination
|
||
link, so each link gets only its own holds' parts. It never parses a payload.</p>
|
||
<figure class="fig anim"><svg viewBox="0 0 720 422" width="100%" role="img" aria-label="The owner adds a note while Ann watches. The commit leaves the worker as one state message with two parts; the server splits it by destination without decoding either part." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f11m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f11a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f11b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><line x1="102" y1="52" x2="102" y2="414" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="23" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="102" y="27" font-size="11" fill="#2a2723" text-anchor="middle">owner's tab</text><text x="102" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">hold h1</text><line x1="274" y1="52" x2="274" y2="414" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="195" y="10" width="158" height="42" rx="4" fill="#efe9dd" stroke="#c9bfae"/><text x="274" y="27" font-size="11" fill="#2a2723" text-anchor="middle">Ann's tab</text><text x="274" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">hold h2</text><line x1="446" y1="52" x2="446" y2="414" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="367" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="446" y="27" font-size="11" fill="#2a2723" text-anchor="middle">server</text><text x="446" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">relay</text><line x1="618" y1="52" x2="618" y2="414" stroke="#c9bfae" stroke-dasharray="3 3"/><rect x="539" y="10" width="158" height="42" rx="4" fill="#fff" stroke="#c9bfae"/><text x="618" y="27" font-size="11" fill="#2a2723" text-anchor="middle">worker S1</text><text x="618" y="41" font-size="9.5" fill="#7b7468" text-anchor="middle">NoteStore, Notes</text><g class="step edge" style="--i:1"><line x1="102" y1="78" x2="443" y2="78" stroke="#7b7468" marker-end="url(#f11m)"/><text x="274" y="72" font-size="10" fill="#2a2723" text-anchor="middle">call c7 Notes.add (1 part)</text></g><g class="step edge" style="--i:2"><path d="M446 104 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f11m)"/><text x="488" y="114" font-size="10" fill="#2a2723" text-anchor="start">map ids, stamp owner</text></g><g class="step edge" style="--i:3"><line x1="446" y1="150" x2="615" y2="150" stroke="#b4472f" marker-end="url(#f11a)"/><text x="532" y="144" font-size="10" fill="#b4472f" text-anchor="middle">call c3 (part forwarded as is)</text></g><g class="step edge" style="--i:4"><path d="M618 176 h-34 v14 h32" fill="none" stroke="#7b7468" marker-end="url(#f11m)"/><text x="576" y="186" font-size="10" fill="#2a2723" text-anchor="end">method runs, self.tx commits</text></g><g class="step edge" style="--i:5"><line x1="618" y1="222" x2="449" y2="222" stroke="#b4472f" marker-end="url(#f11a)"/><text x="532" y="216" font-size="10" fill="#b4472f" text-anchor="middle">state: parts for h1', h2'</text></g><g class="step edge" style="--i:6"><path d="M446 248 h34 v14 h-32" fill="none" stroke="#7b7468" marker-end="url(#f11m)"/><text x="488" y="258" font-size="10" fill="#2a2723" text-anchor="start">group parts per link</text></g><g class="step edge" style="--i:7"><line x1="446" y1="294" x2="105" y2="294" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f11m)"/><text x="274" y="288" font-size="10" fill="#2a2723" text-anchor="middle">state (h1 part)</text></g><g class="step edge" style="--i:8"><line x1="446" y1="326" x2="277" y2="326" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f11m)"/><text x="360" y="320" font-size="10" fill="#2a2723" text-anchor="middle">state (h2 part)</text></g><g class="step edge" style="--i:9"><line x1="618" y1="358" x2="449" y2="358" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f11m)"/><text x="532" y="352" font-size="10" fill="#2a2723" text-anchor="middle">result c3</text></g><g class="step edge" style="--i:10"><line x1="446" y1="390" x2="105" y2="390" stroke="#7b7468" stroke-dasharray="4 3" marker-end="url(#f11m)"/><text x="274" y="384" font-size="10" fill="#2a2723" text-anchor="middle">result c7</text></g></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 11.</span> The owner adds a note while Ann watches. The commit leaves the worker as one <code>state</code> message with two parts; the server splits it by destination without decoding either part.</figcaption></figure>
|
||
<p>The <code>state</code> message reaches the owner's tab before the <code>result</code>, so when <code>await notes.add(…)</code> resolves, the new note
|
||
is already in <code>notes.value</code>. Ann's view is a different variant (reader) and gets its own part from its own derive; the
|
||
relay neither knows nor cares.</p>
|
||
<h3 id="flow-control">Flow control</h3>
|
||
<p>Each link has <strong>one byte window</strong>, counted from each message's <code>byteLength</code> (parts are already encoded bytes; nothing
|
||
is measured or stringified to count it). The window covers every message, first snapshots included.</p>
|
||
<ul>
|
||
<li><strong>Snapshots are always admitted (R19)</strong>, even when larger than the window; they use it up. Whether a hold
|
||
<strong>overflows</strong> is decided on the changes queued after the snapshot.</li>
|
||
<li><strong>Under backpressure</strong> a commit's ops are collected per hold and encoded into one part when credits return. The
|
||
consumer still gets the commit atomically.</li>
|
||
<li><strong>After an overflow</strong> the hold is dropped and established again once the previous snapshot's debt has drained
|
||
(credits back at the full window, Q3), and not more often than a minimum interval. So there is at most one snapshot
|
||
in flight per hold: no re-hold storm, and no stall for a snapshot larger than the window.</li>
|
||
<li><strong>Calls</strong> are throttled by call credits the provider grants (a generous window). Beyond them calls wait locally.
|
||
Nothing is refused for being "too many", and holds are not limited by count; authorization and input validation
|
||
protect them.</li>
|
||
<li><strong>Byte-heavy traffic</strong> such as terminals uses separate channels (chapter XIII), so a large state message never
|
||
holds up keystrokes.</li>
|
||
</ul>
|
||
<p><strong>Recovery:</strong> after a link drops, the consumer re-holds every hold over the new link and receives snapshots; calls in
|
||
flight fail as on any disconnect, calls made meanwhile park. A relay whose window overflows releases the hold and
|
||
re-holds; the provider sends a fresh snapshot. After a routed link drops, Chord re-resolves the address (with the
|
||
per-address backoff) while holds on it exist, so a passive viewer recovers without anyone pressing anything.</p>
|
||
<h3 id="size-limits">Size limits</h3>
|
||
<p>Limits apply <strong>per part</strong> (R2, Q2), both to the compressed and the decoded size, and are checked from the sizes in the
|
||
header before anything is decoded; zlib's <code>maxOutputLength</code> enforces the decoded limit in the one decode function.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Situation</th>
|
||
<th>What fails</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>a part over the limit</td>
|
||
<td>only its own hold or call, with <code>too_large</code>; the other parts of the message apply</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a snapshot over the limit</td>
|
||
<td>its hold ends <code>failed</code>, with no automatic re-hold</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a WebSocket frame over <code>maxPayload</code> (e.g. 64 MiB)</td>
|
||
<td>the connection closes (code 1009); this is the transport's hard cap</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Defaults for client WebSockets are 16 MiB compressed and 64 MiB decoded per part, configurable per link kind. The
|
||
decoded limit travels with relayed work: the server stamps the admitting link's limit on the header at admission
|
||
(never taken from the client), and the host that decodes enforces it. IPC therefore needs no limit of its own.</p>
|
||
<p>Large data is never a large state member or a large argument. Files go through chunked transfers or the HTTP
|
||
component (chapter XIII); long lists are keyed documents or bounded windows (chapter VIII).</p>
|
||
<h3 id="what-each-hop-costs">What each hop costs</h3>
|
||
<p>Chord v2's goal is as little compute and memory as possible while staying correct: <strong>O(size of the change)</strong> per
|
||
commit and per viewer, independent of the size of the value. A 500 KB value is touched as a whole only when a new
|
||
viewer takes its first snapshot. Delta, the value library under documents, was built for this, and its own primitives
|
||
are almost free: <code>diffRevisions</code> on shared subtrees takes 0.018 ms, <code>applyImmutable</code> of a small change 0.004 ms.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Hop</th>
|
||
<th>Work</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>commit</td>
|
||
<td>Delta's <code>prepare</code> (copies and checks only the values placed in this commit), adopt by pointer swap; storage writes the delta</td>
|
||
</tr>
|
||
<tr>
|
||
<td>reads in a transaction</td>
|
||
<td>the draft overlay, no copy; an unchanged draft writes nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>subscribers on the same host</td>
|
||
<td>the same value and ops, by reference</td>
|
||
</tr>
|
||
<tr>
|
||
<td>derives</td>
|
||
<td>compute plus <code>diffRevisions</code>; unchanged parts stay the same objects (<code>mapShared</code>, chapter VIII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>send</td>
|
||
<td>one encoding per hold part at its origin, size taken from the encoded bytes</td>
|
||
</tr>
|
||
<tr>
|
||
<td>relay</td>
|
||
<td>header only: ids mapped, principal stamped, parts grouped; parts never parsed</td>
|
||
</tr>
|
||
<tr>
|
||
<td>receive</td>
|
||
<td>compiled header schema on untrusted links; arguments stripped and validated once at the provider (compiled schema); inputs normalized</td>
|
||
</tr>
|
||
<tr>
|
||
<td>apply</td>
|
||
<td><code>applyImmutable</code>, structure shared</td>
|
||
</tr>
|
||
<tr>
|
||
<td>new hold</td>
|
||
<td>the value encoded once (unavoidable)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>memory</td>
|
||
<td>one authoritative value per document per host; replicas and derived values share structure; one cached value per adapted binding</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>These are targets. The frozen kernel measured far worse, which is why the design is so strict here:</p>
|
||
<div class="stats"><div class="stat"><b>35 ms</b><span>one small change to a 500 KB value in the frozen kernel, no viewer</span></div><div class="stat"><b>+13 ms</b><span>per viewer on top (10 viewers: 130 ms)</span></div><div class="stat"><b>17 / 33 / 101 ms</b><span>a tiny SQLite commit after 0 / 10 / 50 earlier deltas</span></div></div>
|
||
<p>Nearly all of that came from layers around Delta: whole-value validation on every access, whole-value freezing,
|
||
JSON-ness walks on every received frame, double stringification to measure a frame, snapshots cloned per viewer, and
|
||
a SQLite storage that replayed history, and schemas rebuilt for every check (a compiled TypeBox check takes
|
||
0.012 ms, an interpreted one 5.6 ms). The redesign removes each of them (chapters V, VIII and this one); the
|
||
before/after per hop is in <code>apps/p2/docs/p3-reviews/v2-cost-model.md</code>.</p>
|
||
<div class="note review"><b class="tag">Review: v2-cost-model.md is partly outdated</b><p>The measurements and the per-hop "after" targets in <code>apps/p2/docs/p3-reviews/v2-cost-model.md</code> are current. Its
|
||
closing section "Decisions this needs" is not: items 6 and 7 (validation by the caller's trust; contract descriptions
|
||
once per link and fingerprint) were replaced by R7 (validate for every caller) and R6 (no descriptions on the wire),
|
||
and §10 still describes the description fetch.</p>
|
||
</div>
|
||
<h4>Cost guards</h4>
|
||
<p>Chord reports <strong>full-value walks, deep copies, encodes, schema validations and materializations</strong> as ordinary
|
||
telemetry metrics, always on; with the NOOP adapter they cost an empty call where such work happens. Tests use the
|
||
in-memory adapter and assert them per hop. The reference case: one appended note to a 500 KB document with 10 viewers
|
||
does no walks, no copies, one encode per hold part at its origin and none at relays, and no validations. A
|
||
<code>vitest bench</code> suite measures the hops.</p>
|
||
<h4>Ways to make it slow anyway</h4>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Mistake</th>
|
||
<th>Why it costs</th>
|
||
<th>Instead</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>a derive that builds new objects for unchanged data</td>
|
||
<td>every commit diffs and sends O(value)</td>
|
||
<td>keep unchanged items identical: <code>mapShared</code>, <code>previous</code> (chapter VIII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>one long array that changes often (R24)</td>
|
||
<td>sharing is per path; each change copies one slot per element and visits each</td>
|
||
<td>keyed documents or a bounded window</td>
|
||
</tr>
|
||
<tr>
|
||
<td>bytes as base64 strings</td>
|
||
<td>inflated, parsed, compressed as text</td>
|
||
<td><code>bytes()</code> leaves; chunked transfers for files</td>
|
||
</tr>
|
||
<tr>
|
||
<td>awaiting I/O inside <code>self.tx</code></td>
|
||
<td>the object's line is blocked for every caller</td>
|
||
<td>do the I/O first, then a short transaction</td>
|
||
</tr>
|
||
<tr>
|
||
<td>storing registry entries or references in documents</td>
|
||
<td>stale after a reload, not data</td>
|
||
<td>look up the current value at use</td>
|
||
</tr>
|
||
<tr>
|
||
<td>opening natives at module level or in a facet</td>
|
||
<td>no owner: never closed, never restarted</td>
|
||
<td><code>self.open</code> in an object (chapter IX)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>callbacks as arguments</td>
|
||
<td>cannot cross a link, keep old code alive</td>
|
||
<td>registries, jobs, flows</td>
|
||
</tr>
|
||
<tr>
|
||
<td>one big object that everybody views</td>
|
||
<td>one large snapshot per viewer and per reconnect</td>
|
||
<td>split by viewer need; views of the same variant share one value</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="telemetry">Telemetry</h3>
|
||
<p>Chord reports traces, events and metrics through <code>@earendil-works/pi-telemetry</code>. Each host gets <strong>one adapter</strong>,
|
||
<code>createHost({ telemetry })</code>, NOOP by default; the adapter decides sampling, buffering, export and filtering. Only hosts
|
||
hold the adapter, to start root spans. Code sees spans:</p>
|
||
<ul>
|
||
<li><code>ctx.telemetry</code> is the current span (<code>Ctx</code> extends <code>Context</code>, so it is the same field Durable and pi-ai read). Every
|
||
span carries its adapter's metrics and propagation, so code with a <code>ctx</code> can record a metric or inject a carrier.</li>
|
||
<li><code>ctx.trace(name, attributes, fn)</code> runs <code>fn</code> in a child span with a child <code>ctx</code>.</li>
|
||
<li>App values such as the principal stay out of Durable's context.</li>
|
||
</ul>
|
||
<p><strong>Across links</strong>, <code>call</code> and <code>hold</code> carry the caller's trace carrier as a bounded header field (R5: limited key count
|
||
and string lengths); the provider runs the method under it, and each relay adds a <code>chord.relay</code> span. The call from the
|
||
sequence above, as a trace:</p>
|
||
<pre class="plain"><code>chord.call Notes.add [owner's tab]
|
||
└─ chord.relay [server]
|
||
└─ chord.method Notes.add [session:S1] principal=owner
|
||
· event chord.commit docs=2 ops=5</code></pre>
|
||
<p>Adapters trust typed input: no validation of malformed values and no try/catch around recording. Only carriers from
|
||
another process are checked (keys present, strings), and a bad one falls back to a new root.</p>
|
||
<p><strong>Shared work</strong> (one activation serving several <code>get</code>s, a worker start) is its own root span, linked to the trigger
|
||
that started it; each waiting span links to it. Routine runs, job runs, eviction, backoff retries and background
|
||
closes are root spans too, linked to their trigger where there is one.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Tier</th>
|
||
<th>What</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>spans</td>
|
||
<td>calls, methods, relays, holds, activations, opens, closes, loads, revocations, resolves, routine runs, job runs</td>
|
||
</tr>
|
||
<tr>
|
||
<td>events</td>
|
||
<td>commits inside methods, retries, parking</td>
|
||
</tr>
|
||
<tr>
|
||
<td>metrics</td>
|
||
<td>state messages, WebSocket bytes, commit durations, in-flight calls, active holds, open links, park durations, backpressure waits, the cost guards</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Ids are attributes of spans, never of metrics. <strong>Nothing is measured or walked for telemetry</strong>; only sizes the code
|
||
already has are reported. Errors carry a fixed category and a message cut to 500 characters; cancelling is not an
|
||
error. Chord ships a typed telemetry schema, as pi-ai (<code>AI_TELEMETRY_SCHEMA</code>) and Durable
|
||
(<code>DURABLE_TELEMETRY_SCHEMA</code>) do.</p>
|
||
<p><strong>Storing and reading:</strong> <code>@earendil-works/chord/telemetry-sqlite</code> over <code>chord/sqlite</code> (Node and Cloudflare) stores one
|
||
row per settled span (events and links inline) and metrics aggregated per series per 10 s, flushes in batches every
|
||
second, keeps 7 days, and drops (and counts) beyond a bounded queue. The read interface <code>TelemetryStore</code> is shared:
|
||
<code>findTraces</code> (by name, attributes, errors, time window, with limit and cursor) returns complete traces, <code>getTrace</code>
|
||
one, <code>findMetrics</code> metrics. One renderer, <code>pi-telemetry/render</code>, prints traces as above; it prints links and never
|
||
follows them. The playground's trace viewer reads the same store.</p>
|
||
<p><strong>Durable</strong> already emits submit and phase spans, stores carriers on submissions and tasks (<code>TaskOptions.trace</code>),
|
||
and pi-ai emits <code>pi.ai.request</code> and <code>pi.ai.poll</code>. With the harness given the host's adapter (chapter XIV), an agent
|
||
tool that calls <code>Notes.add</code> through <code>chord(fn, context)</code> shows up as one tree from the model request down to the
|
||
commit.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>Nothing of the protocol is built in v2: messages, relays, flow control and limits are layers L6 and L7, telemetry in
|
||
Chord is L11. Built in pi: <code>pi-telemetry</code> (propagation, links, metrics, the store interface, the renderer),
|
||
<code>chord/context</code> with <code>Context.telemetry</code>, pi-ai and Durable telemetry, and <code>chord/telemetry-sqlite</code>. Delta is built;
|
||
<code>mapShared</code> and bytes as atomic leaves in <code>diffRevisions</code> are still to do. The frozen kernel sends descriptions on the
|
||
wire and closes a link on an unknown id; both are replaced.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="p2-core"><header class="opener"><div class="numeral">XVIII</div><h2>Building p2: the Core</h2><p class="lede">From a list of what the app must do to a set of owners: the base code every p3 plugin builds on.</p></header>
|
||
<p class="dropcap">The notes plugin used every Chord concept. A real app is the same concepts many times over. This chapter and the
|
||
next two assemble <strong>p3</strong>, the Chord v2 version of p2, from the parts the guide introduced. p3 is built next to p2 in
|
||
<code>apps/p3</code>, fully isolated with its own data directory, and <strong>without migrating p2's data</strong> (no import of
|
||
<code>catalog.sqlite</code>, <code>shares.sqlite</code> or the subagents table). Its plugins are derived from what the app must do, not
|
||
ported one to one from p2's plugin layout.</p>
|
||
<div class="note review"><b class="tag">Review: p3 is not designed yet</b><p>The design fixes the rules (<code>design.md</code> §25) and lists the p3 decisions still open (#17, #18, #20 to #23); the
|
||
per-domain design of p3 has not started. What these three chapters show is one construction that follows the rules,
|
||
derived from the functionality map in <code>apps/p2/docs/p3-functions/</code>. Object names are working names. Where a domain
|
||
has an open decision, the text says which.</p>
|
||
</div>
|
||
<h3 id="start-from-functions-not-from-plugins">Start from functions, not from plugins</h3>
|
||
<p>The functionality map in <code>apps/p2/docs/p3-functions/</code> lists what p2 does, in plain words, with four facts per
|
||
function: the data and its size, how durable it is, who may see or do it, and where it must be available. It groups
|
||
the functions into 22 domains:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Domain</th>
|
||
<th>#</th>
|
||
<th>Domain</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>1</td>
|
||
<td>sessions</td>
|
||
<td>12</td>
|
||
<td>access</td>
|
||
</tr>
|
||
<tr>
|
||
<td>2</td>
|
||
<td>session workers</td>
|
||
<td>13</td>
|
||
<td>models, credentials, settings</td>
|
||
</tr>
|
||
<tr>
|
||
<td>3</td>
|
||
<td>conversations and agent</td>
|
||
<td>14</td>
|
||
<td>prompt resources</td>
|
||
</tr>
|
||
<tr>
|
||
<td>4</td>
|
||
<td>shell commands</td>
|
||
<td>15</td>
|
||
<td>status and presence</td>
|
||
</tr>
|
||
<tr>
|
||
<td>5</td>
|
||
<td>chat mode</td>
|
||
<td>16</td>
|
||
<td>search</td>
|
||
</tr>
|
||
<tr>
|
||
<td>6</td>
|
||
<td>agents (subagents, one-shots, peer messages)</td>
|
||
<td>17</td>
|
||
<td>remote access and forwards</td>
|
||
</tr>
|
||
<tr>
|
||
<td>7</td>
|
||
<td>code mode</td>
|
||
<td>18</td>
|
||
<td>logs and notices</td>
|
||
</tr>
|
||
<tr>
|
||
<td>8</td>
|
||
<td>session files</td>
|
||
<td>19</td>
|
||
<td>dev and reload</td>
|
||
</tr>
|
||
<tr>
|
||
<td>9</td>
|
||
<td>environments and execution</td>
|
||
<td>20</td>
|
||
<td>web shell</td>
|
||
</tr>
|
||
<tr>
|
||
<td>10</td>
|
||
<td>workspace</td>
|
||
<td>21</td>
|
||
<td>TUI shell</td>
|
||
</tr>
|
||
<tr>
|
||
<td>11</td>
|
||
<td>terminals</td>
|
||
<td>22</td>
|
||
<td>transport</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The four facts answer the four questions every Chord object needs, in order:</p>
|
||
<figure class="fig wide anim"><svg viewBox="0 0 720 290.5" width="100%" role="img" aria-label="From a function in the map to code. Each fact in the map answers one question Chord needs answered." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f12m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f12a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f12b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#7b7468" text-anchor="start" font-weight="600" letter-spacing="2">THE MAP SAYS</text><text x="16" y="167.5" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">CHORD NEEDS</text><g class="step" style="--i:1"><rect x="16" y="36" width="152.5" height="67.5" rx="4" fill="#fff" stroke="#c9bfae"/><text x="92.3" y="67" font-size="11" fill="#2a2723" text-anchor="middle">data</text><text x="92.3" y="81" font-size="9.5" fill="#7b7468" text-anchor="middle">what, how big</text></g><g class="step" style="--i:4"><rect x="194.5" y="36" width="152.5" height="67.5" rx="4" fill="#fff" stroke="#c9bfae"/><text x="270.8" y="54.5" font-size="11" fill="#2a2723" text-anchor="middle">durability</text><text x="270.8" y="68.5" font-size="9.5" fill="#7b7468" text-anchor="middle">ephemeral · until restart</text><text x="270.8" y="81" font-size="9.5" fill="#7b7468" text-anchor="middle">· persisted ·</text><text x="270.8" y="93.5" font-size="9.5" fill="#7b7468" text-anchor="middle">transcript-bound</text></g><g class="step" style="--i:7"><rect x="373" y="36" width="152.5" height="67.5" rx="4" fill="#fff" stroke="#c9bfae"/><text x="449.3" y="60.8" font-size="11" fill="#2a2723" text-anchor="middle">who</text><text x="449.3" y="74.8" font-size="9.5" fill="#7b7468" text-anchor="middle">owner · guest read · guest</text><text x="449.3" y="87.3" font-size="9.5" fill="#7b7468" text-anchor="middle">write · worker</text></g><g class="step" style="--i:10"><rect x="551.5" y="36" width="152.5" height="67.5" rx="4" fill="#fff" stroke="#c9bfae"/><text x="627.8" y="60.8" font-size="11" fill="#2a2723" text-anchor="middle">where</text><text x="627.8" y="74.8" font-size="9.5" fill="#7b7468" text-anchor="middle">server · worker · web ·</text><text x="627.8" y="87.3" font-size="9.5" fill="#7b7468" text-anchor="middle">TUI · environment</text></g><g class="step" style="--i:3"><rect x="16" y="181.5" width="152.5" height="55" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="92.3" y="200" font-size="11" fill="#2a2723" text-anchor="middle">the owner</text><text x="92.3" y="214" font-size="9.5" fill="#7b7468" text-anchor="middle">smallest object it belongs</text><text x="92.3" y="226.5" font-size="9.5" fill="#7b7468" text-anchor="middle">to</text></g><g class="step" style="--i:6"><rect x="194.5" y="181.5" width="152.5" height="55" rx="4" fill="#fff" stroke="#c9bfae"/><text x="270.8" y="200" font-size="11" fill="#2a2723" text-anchor="middle">where it is kept</text><text x="270.8" y="214" font-size="9.5" fill="#7b7468" text-anchor="middle">Durable, persisted or</text><text x="270.8" y="226.5" font-size="9.5" fill="#7b7468" text-anchor="middle">in-memory document, tab</text></g><g class="step" style="--i:9"><rect x="373" y="181.5" width="152.5" height="55" rx="4" fill="#fff" stroke="#2e6a8a" stroke-width="1.5"/><text x="449.3" y="206.3" font-size="11" fill="#2a2723" text-anchor="middle">variants</text><text x="449.3" y="220.3" font-size="9.5" fill="#7b7468" text-anchor="middle">and revocation</text></g><g class="step" style="--i:12"><rect x="551.5" y="181.5" width="152.5" height="55" rx="4" fill="#fff" stroke="#c9bfae"/><text x="627.8" y="206.3" font-size="11" fill="#2a2723" text-anchor="middle">host and code</text><text x="627.8" y="220.3" font-size="9.5" fill="#7b7468" text-anchor="middle">placement; core or plugin</text></g><g class="step edge" style="--i:2"><path d="M92.3 103.5 L92.3 179.5" fill="none" stroke="#7b7468" marker-end="url(#f12m)"/></g><g class="step edge" style="--i:5"><path d="M270.8 103.5 L270.8 179.5" fill="none" stroke="#7b7468" marker-end="url(#f12m)"/></g><g class="step edge" style="--i:8"><path d="M449.3 103.5 L449.3 179.5" fill="none" stroke="#7b7468" marker-end="url(#f12m)"/></g><g class="step edge" style="--i:11"><path d="M627.8 103.5 L627.8 179.5" fill="none" stroke="#7b7468" marker-end="url(#f12m)"/></g><text x="16" y="260.5" font-size="10.5" fill="#7b7468" text-anchor="start">Durability decides storage: transcript-bound data is Durable's; persisted app data is a persisted document with a</text><text x="16" y="275.5" font-size="10.5" fill="#7b7468" text-anchor="start">stable identity; until-restart data is an in-memory document or a native; tab-scoped UI state stays in the client.</text></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 12.</span> From a function in the map to code. Each fact in the map answers one question Chord needs answered.</figcaption></figure>
|
||
<p><strong>One owner per thing, the smallest object it belongs to.</strong> A guest's grant belongs to the access owner, not to the
|
||
session; a draft belongs to one (conversation, author) pair, not to the chat plugin as a whole. Ownership nests, and
|
||
rules set at creation are written in the creating commit (Durable's <code>createConversation({ init })</code>).</p>
|
||
<p><strong>Core or plugin.</strong> Core owners are what the app always needs and everything builds on; they are always present, so
|
||
other code may depend on them directly. Plugin owners may be missing; anything that depends on an optional feature
|
||
does so through a registry it owns itself (chapter XIII). The risk to avoid is a <strong>core god object</strong>: each core owner
|
||
owns only what belongs to it.</p>
|
||
<h3 id="the-ownership-map">The ownership map</h3>
|
||
<p>The result of that procedure for p3, as a starting point:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Domain</th>
|
||
<th>Owner object(s)</th>
|
||
<th>Host</th>
|
||
<th>Code</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>access</td>
|
||
<td><code>Access</code> (grants, devices, share links), <code>authenticate</code></td>
|
||
<td>server</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>sessions</td>
|
||
<td><code>Catalog</code> (session records), lifecycle jobs</td>
|
||
<td>server</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>session workers</td>
|
||
<td>the supervisor (<code>route(Session)</code> + idle stop)</td>
|
||
<td>server</td>
|
||
<td>core (app code)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>session location</td>
|
||
<td><code>SessionLocation { session }</code></td>
|
||
<td>worker</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>conversations and agent</td>
|
||
<td><code>Harness</code>, <code>ConversationStore</code>, <code>Conversation</code> views</td>
|
||
<td>worker</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>status and presence</td>
|
||
<td><code>SessionStatus</code> (server copy), reporters on workers</td>
|
||
<td>both</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>shell commands</td>
|
||
<td><code>ShellJob</code> in the chat plugin</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>chat mode</td>
|
||
<td>chat plugin</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>agents</td>
|
||
<td>subagents plugin (Durable child conversations)</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>code mode</td>
|
||
<td>code mode plugin (sandbox native)</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>session files</td>
|
||
<td><code>SessionFiles { session }</code> (proposed, #17)</td>
|
||
<td>server</td>
|
||
<td>core or plugin, open</td>
|
||
</tr>
|
||
<tr>
|
||
<td>environments</td>
|
||
<td>envs plugin (config, SSH pools #20)</td>
|
||
<td>server and worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>workspace</td>
|
||
<td>workspace plugin</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>terminals</td>
|
||
<td>terminal host process (#23), terminals plugin</td>
|
||
<td>separate process</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>models, credentials</td>
|
||
<td>models plugin, login job + flow</td>
|
||
<td>server</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>settings</td>
|
||
<td><code>Settings</code> (single writer, #21)</td>
|
||
<td>server</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>prompt resources</td>
|
||
<td>prompt plugin (watchers in the session's environment)</td>
|
||
<td>worker</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>search</td>
|
||
<td>search plugin (index on the server, feed on workers)</td>
|
||
<td>both</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>remote access, forwards</td>
|
||
<td>radius plugin, ports plugin</td>
|
||
<td>server</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>logs and notices</td>
|
||
<td><code>Notices</code> (one channel), log capture</td>
|
||
<td>server</td>
|
||
<td>core</td>
|
||
</tr>
|
||
<tr>
|
||
<td>dev and reload</td>
|
||
<td>the reload control, <code>reload_plugins</code> tool</td>
|
||
<td>server</td>
|
||
<td>plugin</td>
|
||
</tr>
|
||
<tr>
|
||
<td>web and TUI shells</td>
|
||
<td>UI facets over the objects above</td>
|
||
<td>clients</td>
|
||
<td>shells</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The rest of this chapter builds the core rows. Chapters XIX and XX build the plugins.</p>
|
||
<h3 id="the-base-code">The base code</h3>
|
||
<pre class="plain"><code>apps/p3/
|
||
src/
|
||
hosts.ts Server, Session host kinds
|
||
principal.ts P, Revocation, principalKey, revokedBy, can()
|
||
server/main.ts createHost, load, route(Session), WebSocket and Radius entry points, supervisor
|
||
worker/main.ts createHost with the start data, connect to the parent
|
||
core/ the core facets: Access, Catalog, SessionStatus, Settings, Notices (server);
|
||
SessionInfo, SessionLocation, Harness, ConversationStore (worker)
|
||
plugins/ chat, subagents, codemode, envs, workspace, terminals, models, login,
|
||
prompt, search, ports, radius, files, dev</code></pre>
|
||
<p><code>hosts.ts</code>, <code>principal.ts</code>, the <code>SessionInfo</code> start data and the <code>Harness</code> object are the ones the guide already
|
||
built for Studio (chapters I, III, IX and XIV). Base code restarts the hosts when it changes; everything in
|
||
<code>plugins/</code> reloads.</p>
|
||
<h3 id="who-is-calling-authentication">Who is calling: authentication</h3>
|
||
<p>Chord stamps principals; the app produces them. p3's server has four ways in, each turned into a principal by
|
||
<code>authenticate(req)</code> when a WebSocket connects (chapter III):</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Way in</th>
|
||
<th>Principal</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>loopback without a cookie (TUI, CLI, first page load)</td>
|
||
<td><code>{ kind: "owner", access: "local" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>loopback owner browser cookie (a per-port secret file)</td>
|
||
<td><code>{ kind: "owner", access: "local" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>a device cookie (HMAC-signed, per port, 30 days), obtained with a one-time sign-in token</td>
|
||
<td><code>{ kind: "owner", access: "device", device }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>a guest cookie from a share link</td>
|
||
<td><code>{ kind: "guest", session, mode, grant, revision }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>the Radius relay channel</td>
|
||
<td><code>{ kind: "owner", access: "relay" }</code></td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>The checks that are not Chord's stay in this function: origin checks and Fetch Metadata (a browser request from
|
||
another site gets no principal, except a top-level navigation), proxy trust, the sign-in token rules (10 minutes,
|
||
single use, at most 32 outstanding, memory only), cookie expiry and revocation lists. A failed check closes the socket
|
||
before Chord sees it.</p>
|
||
<p><strong>One permission matrix.</strong> Every function in the map names who may use it, and p2 checks those rules in several
|
||
places. p3 writes them once, in <code>principal.ts</code>, and every factory and <code>authorize</code> uses it:</p>
|
||
<pre><code><span class="tk-c">// src/principal.ts</span>
|
||
<span class="tk-k">type</span> <span class="tk-t">Action</span> = <span class="tk-s">"read"</span> | <span class="tk-s">"send"</span> | <span class="tk-s">"upload"</span> | <span class="tk-s">"stop"</span> | <span class="tk-s">"workspace"</span> | <span class="tk-s">"terminal"</span> | <span class="tk-s">"manage"</span>;
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">can</span>(p: P, action: <span class="tk-t">Action</span>, session?: string): boolean {
|
||
<span class="tk-k">if</span> (p.kind === <span class="tk-s">"owner"</span> || p.kind === <span class="tk-s">"server"</span>) <span class="tk-k">return</span> <span class="tk-k">true</span>;
|
||
<span class="tk-k">if</span> (p.kind === <span class="tk-s">"worker"</span>) <span class="tk-k">return</span> action !== <span class="tk-s">"manage"</span> && p.session === session;
|
||
<span class="tk-k">if</span> (p.kind !== <span class="tk-s">"guest"</span> || p.session !== session) <span class="tk-k">return</span> <span class="tk-k">false</span>;
|
||
<span class="tk-k">if</span> (action === <span class="tk-s">"terminal"</span> || action === <span class="tk-s">"manage"</span>) <span class="tk-k">return</span> <span class="tk-k">false</span>; <span class="tk-c">// terminals: owner only</span>
|
||
<span class="tk-k">if</span> (p.mode === <span class="tk-s">"read"</span>) <span class="tk-k">return</span> action === <span class="tk-s">"read"</span>; <span class="tk-c">// no workspace for guest read</span>
|
||
<span class="tk-k">return</span> <span class="tk-k">true</span>; <span class="tk-c">// guest write: send, upload, stop, workspace</span>
|
||
}</code></pre>
|
||
<p>Factories pick variants from it (<code>can(p, "send", session) ? "writer" : "reader"</code>), so the matrix is visible in one
|
||
file and the members a caller lacks are simply not there.</p>
|
||
<h3 id="access-grants-devices-share-links">Access: grants, devices, share links</h3>
|
||
<p><code>Access</code> is the core owner of everything that lets someone in: share links per session and mode, guest records, the
|
||
device list and the signing key. It lives on the server as the chapter IV pattern: a <code>local</code> store that persists the
|
||
documents (only stable identities may), and a <code>remote</code> view for the owner's settings screen:</p>
|
||
<pre><code><span class="tk-c">// src/core/access.ts (server, in the core server facet)</span>
|
||
<span class="tk-k">const</span> <span class="tk-t">Links</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"access.links"</span>, { schema: <span class="tk-f">map</span>(<span class="tk-t">ShareLink</span>), version: <span class="tk-n">1</span>, persist: <span class="tk-k">true</span>,
|
||
initial: () => ({}), checkpointWhen: (_v, _o, info) => info.deltasSinceBase > <span class="tk-n">64</span> });
|
||
<span class="tk-k">const</span> <span class="tk-t">Guests</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"access.guests"</span>, { … }); <span class="tk-c">// same shape: map(Guest), persisted</span>
|
||
<span class="tk-k">const</span> <span class="tk-t">Devices</span> = <span class="tk-f">defineState</span>(<span class="tk-s">"access.devices"</span>, { … }); <span class="tk-c">// map(Device), persisted</span>
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">AccessStore</span>, (_input, self) => { <span class="tk-c">// local: may persist, acts for the host</span>
|
||
<span class="tk-k">const</span> links = self.<span class="tk-f">state</span>(<span class="tk-t">Links</span>), guests = self.<span class="tk-f">state</span>(<span class="tk-t">Guests</span>), devices = self.<span class="tk-f">state</span>(<span class="tk-t">Devices</span>);
|
||
<span class="tk-k">return</span> {
|
||
links, guests, devices,
|
||
removeGuest: <span class="tk-k">async</span> ({ grant }, ctx) => {
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(guests))[grant].blocked = <span class="tk-k">true</span>; }, ctx);
|
||
host.<span class="tk-f">revoke</span>({ grant }); <span class="tk-c">// live access ends now</span>
|
||
},
|
||
resetLink: <span class="tk-k">async</span> ({ session, mode }, ctx) => {
|
||
<span class="tk-k">const</span> blocked = <span class="tk-k">await</span> self.<span class="tk-f">tx</span>((tx) => <span class="tk-f">rotateLink</span>(tx, links, guests, session, mode), ctx);
|
||
<span class="tk-k">for</span> (<span class="tk-k">const</span> grant <span class="tk-k">of</span> blocked) host.<span class="tk-f">revoke</span>({ grant });
|
||
},
|
||
signOut: <span class="tk-k">async</span> ({ device }, ctx) => {
|
||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => { (<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(devices))[device].revoked = <span class="tk-k">true</span>; }, ctx);
|
||
host.<span class="tk-f">revoke</span>({ device });
|
||
},
|
||
… <span class="tk-c">// shareLink, join, signOutAll</span>
|
||
};
|
||
});
|
||
|
||
host.<span class="tk-f">serve</span>(<span class="tk-t">Access</span>, (_input, principal, self) => { <span class="tk-c">// remote view: owner only</span>
|
||
<span class="tk-k">if</span> (!<span class="tk-f">can</span>(principal, <span class="tk-s">"manage"</span>)) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>();
|
||
<span class="tk-k">const</span> store = self.<span class="tk-f">get</span>(<span class="tk-t">AccessStore</span>, {});
|
||
<span class="tk-k">return</span> {
|
||
variant: <span class="tk-s">"owner"</span>,
|
||
guests: self.<span class="tk-f">derive</span>(<span class="tk-s">"guests"</span>, [store.guests], ([g]) => <span class="tk-f">publicGuests</span>(g)), <span class="tk-c">// names, modes; no tokens</span>
|
||
removeGuest: (args, ctx) => store.<span class="tk-f">removeGuest</span>(args, ctx),
|
||
resetLink: (args, ctx) => store.<span class="tk-f">resetLink</span>(args, ctx),
|
||
signOut: (args, ctx) => store.<span class="tk-f">signOut</span>(args, ctx),
|
||
};
|
||
});</code></pre>
|
||
<p><code>authenticate</code> is base code on the same host and checks <code>AccessStore</code>'s current documents on every new connection
|
||
(it holds a reference to the store, taken with <code>server.root</code> at start). Live access and lasting denial stay separate, as chapter VII explained:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Event</th>
|
||
<th>Lasting effect (data)</th>
|
||
<th>Live effect</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>remove a guest</td>
|
||
<td>guest record blocked</td>
|
||
<td><code>revoke({ grant })</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>reset a share link</td>
|
||
<td>new token; its guests' grants blocked</td>
|
||
<td><code>revoke({ grant })</code> for each</td>
|
||
</tr>
|
||
<tr>
|
||
<td>archive a session</td>
|
||
<td>grants of that session void while archived</td>
|
||
<td><code>revoke({ session })</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>restore a session</td>
|
||
<td>grants valid again; revision bumped</td>
|
||
<td>none: guests reconnect</td>
|
||
</tr>
|
||
<tr>
|
||
<td>sign out a device</td>
|
||
<td>device revoked until its expiry</td>
|
||
<td><code>revoke({ device })</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>sign out all devices</td>
|
||
<td>signing key rotated, tokens cleared</td>
|
||
<td><code>revoke({ device })</code> for each</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>A guest who rejoins with the same browser keeps its identity; a removed guest cannot mint a new one, because the
|
||
block is in <code>Guests</code> and <code>authenticate</code> refuses it. Names are unverified, cleaned, at most 40 characters, unique per
|
||
session ("Ann (2)").</p>
|
||
<h3 id="the-catalog">The catalog</h3>
|
||
<p><code>Catalog</code> owns the session records: id, title (and whether it was edited), location, model default, pinned,
|
||
archived, timestamps. It lives on the server, persisted, and it <strong>never starts a worker</strong>: listing sessions, showing
|
||
their status or their unread marks reads server-side copies only.</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Catalog</span> = <span class="tk-f">remote</span>(<span class="tk-s">"p3.catalog"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
input: <span class="tk-f">obj</span>({}),
|
||
placement: () => <span class="tk-t">Server</span>(),
|
||
variants: {
|
||
owner: {
|
||
state: { sessions: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">SessionRow</span>) }) },
|
||
methods: {
|
||
create: <span class="tk-f">method</span>(<span class="tk-t">CreateArgs</span>, <span class="tk-f">str</span>()), rename: <span class="tk-f">method</span>(<span class="tk-t">RenameArgs</span>), pin: <span class="tk-f">method</span>(<span class="tk-t">PinArgs</span>),
|
||
archive: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })), restore: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">str</span>() })),
|
||
},
|
||
},
|
||
guest: { state: { sessions: <span class="tk-f">obj</span>({ items: <span class="tk-f">arr</span>(<span class="tk-t">SessionRow</span>) }) } }, <span class="tk-c">// only the guest's own session, if not archived</span>
|
||
},
|
||
});</code></pre>
|
||
<p>Behind it sits a persisted <code>local</code> <code>CatalogStore</code>; the guest variant is a derive over it, as in chapter IV. The lifecycle operations (archive, clone, fork,
|
||
relocate, delete) are chapter XIX; here only the rule that keeps the catalog small: <strong>the catalog does not know which
|
||
plugins exist.</strong> Archiving a session writes <code>archived: true</code> and revokes the session; every plugin that keeps
|
||
per-session things reconciles from <code>catalog.sessions</code> with a routine (chapter X), as the notes plugin did. The ports
|
||
plugin closes forwards of archived sessions that way, also after a crash.</p>
|
||
<h3 id="on-the-worker-start-data-location-harness">On the worker: start data, location, harness</h3>
|
||
<p>Three core objects exist on every worker from its start:</p>
|
||
<ul>
|
||
<li><strong><code>SessionInfo</code></strong> (chapter IX): the start message as <code>constant(init)</code>, with the session id, data directory and the
|
||
location the server chose at start.</li>
|
||
<li><strong><code>SessionLocation { session }</code></strong> (working name), lifetime host: the current cwd and environment, with a location
|
||
revision. Relocation changes it while the worker runs (chapter XIX); natives that depend on the location follow it
|
||
with a routine and a generation (chapter IX), and conversations that follow the session's cwd are listed in the core
|
||
per-conversation policy document.</li>
|
||
<li><strong><code>Harness</code></strong> (chapter XIV): the Durable harness for the session's store, one SQLite database per session, opened
|
||
under an exclusive lock keyed by the real data directory and session id. It refuses to start when the saved root
|
||
model is not configured, and resumes interrupted Durable work on start.</li>
|
||
</ul>
|
||
<p><code>ConversationStore</code> and the <code>Conversation</code> views in front of it are chapter XIX.</p>
|
||
<h3 id="workers-start-keep-alive-stop">Workers: start, keep alive, stop</h3>
|
||
<p>Starting, supervising and stopping workers is app code (chapter I); Chord only routes and counts. p3's supervisor is
|
||
<code>server.route(Session, { authorize, resolve })</code> plus a small server object that keeps per-session worker state:</p>
|
||
<div class="steps"><div class="steps-head"><span class="title">A worker's life, as the supervisor sees it</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>Authorize, then start</h4><p>A tab gets <code>Notes { S1 }</code>. <code>authorize</code> checks <code>can(p, "read", "S1")</code> and that the session exists and is not archived
|
||
(an archived session refuses, except an index rebuild that starts the worker without resuming work). <code>resolve</code> forks
|
||
<code>worker/main.ts</code> with the start data from the catalog, bounded by a 60 s readiness timeout.</p>
|
||
</div><div class="step-panel"><h4>A start failure sticks</h4><p>If the worker fails to start (an unconfigured environment, a missing model), the error is kept per session and
|
||
every <code>get</code> fails at once with it, until the owner presses Retry. That clears the error and the related notices.
|
||
Guests see that the session is unavailable, not the error text.</p>
|
||
</div><div class="step-panel"><h4>Counting</h4><p>The core facet on the worker reports <code>host.activity</code> (<code>{ references, calls, jobs, lingering }</code>) and the harness's
|
||
running Durable task count to the supervisor object, from a routine whenever either changes. The call carries
|
||
<code>ctx.incarnation</code>, so a report from an older worker incarnation is ignored (chapter X).</p>
|
||
</div><div class="step-panel"><h4>Idle stop</h4><p>When everything has been zero for five minutes (checked every second, as in p2), the supervisor stops the worker:
|
||
the worker closes its host (routines stop, natives close, storage flushes) and exits. Durable work stays persisted
|
||
and resumes on the next start. Detached work that must keep the worker alive (a shell command whose caller left) is
|
||
a job, so it counts.</p>
|
||
</div><div class="step-panel"><h4>Crash</h4><p>If the worker exits on its own, the link drops. The supervisor records the exit code and a redacted stderr tail
|
||
(at most 4,000 characters) for that incarnation and posts one notice. References that still exist make Chord call
|
||
<code>resolve</code> again with backoff; a passive viewer recovers without a click.</p>
|
||
</div><div class="step-panel"><h4>Shutdown</h4><p>On server shutdown the supervisor stops every worker, with a forced exit after three seconds. Durable work remains
|
||
persisted.</p>
|
||
</div></div>
|
||
<h3 id="status-without-a-worker">Status without a worker</h3>
|
||
<p>The session list shows per session whether it is running, busy (which conversations), its task count, usage and cost,
|
||
and an error. None of that may start a worker. The pattern is chapter X's pub/sub: a reporter on each worker (a
|
||
routine over the harness's state) calls <code>SessionStatus.report(summary)</code> on the server; <code>SessionStatus</code> keeps the latest
|
||
value per session and ignores calls whose <code>ctx.incarnation</code> is older than the newest it has seen; clients read
|
||
<code>SessionStatus</code>, which lives on the server. Children lists, unread marks, search index rows and artifact lists use the
|
||
same pattern (chapter XIX).</p>
|
||
<h3 id="settings-and-notices">Settings and notices</h3>
|
||
<p><strong>Settings</strong> (default model and thinking, run settings, compaction, queue modes, code mode switch, owner name) have
|
||
<strong>one writer</strong>: a core <code>Settings</code> object on the server owns the file, validates writes and serializes them; every
|
||
other process follows its state through a reference instead of watching and rewriting the file itself. p2 watches
|
||
settings, models and auth in every process and writes them from two, which is decision #21.</p>
|
||
<p><strong>Notices</strong> are one channel: one core <code>Notices</code> object on the server, written by everything that fails or finishes
|
||
(worker crashes, reload results, model errors, login outcomes, grants), keeping one latest notice per source, at most
|
||
50. The owner's shell reads one list; dismissing is a method. p2 has five notice channels for the same thing.</p>
|
||
<div class="note review"><b class="tag">Review: settings and file formats (#21, #22)</b><p>Single writer for settings, models and auth (#21) and versioning on-disk formats such as <code>envs.json</code> like documents
|
||
(#22) are open p3 decisions in <code>design.md</code> §26. The construction above assumes "one owner on the server, others
|
||
follow its state" for #21.</p>
|
||
</div>
|
||
<div class="note"><b class="tag">Built so far</b><p>Nothing of p3 exists. The functionality map is complete (<code>apps/p2/docs/p3-functions/</code>, five area files plus the
|
||
condensed <code>README.md</code>), and the design's app rules (<code>design.md</code> §25) are decided. p3 starts after the layered rebuild
|
||
and the playground's first real proof.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="p2-sessions"><header class="opener"><div class="numeral">XIX</div><h2>Sessions and Conversations</h2><p class="lede">The session lifecycle, status without a worker, chat, shell commands, subagents and search, each mapped to an owner and the Chord parts it uses.</p></header>
|
||
<p class="dropcap">This chapter builds the domains a person touches most: the session list and its operations, the conversation view
|
||
and composer, shell commands, chat mode, subagents, messages between sessions, search and unread marks. Each section
|
||
names the owner, the host, and the Chord parts it uses; code appears only where it shows something new.</p>
|
||
<h3 id="the-session-lifecycle">The session lifecycle</h3>
|
||
<p>The catalog (chapter XVIII) owns the session records. Its operations, by the Chord part each needs:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Operation</th>
|
||
<th>Owner and form</th>
|
||
<th>Notes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>create</td>
|
||
<td><code>Catalog.create</code>, a method</td>
|
||
<td>absolute directory, <code>~</code> expanded, must be a directory; on an environment, resolved there through the envs plugin; refuses an unknown model; model and thinking default to the last used</td>
|
||
</tr>
|
||
<tr>
|
||
<td>rename, pin</td>
|
||
<td>methods on <code>Catalog</code></td>
|
||
<td>title 1 to 120 characters, whitespace collapsed; an explicit title is never replaced automatically</td>
|
||
</tr>
|
||
<tr>
|
||
<td>automatic title</td>
|
||
<td>a routine on the search feed</td>
|
||
<td>the first user message, line breaks to spaces, 100 characters</td>
|
||
</tr>
|
||
<tr>
|
||
<td>agent rename</td>
|
||
<td>an agent tool on the worker calling <code>Catalog</code></td>
|
||
<td>root conversation only</td>
|
||
</tr>
|
||
<tr>
|
||
<td>archive, restore</td>
|
||
<td>methods; revocation; reconciling routines</td>
|
||
<td>below</td>
|
||
</tr>
|
||
<tr>
|
||
<td>clone, fork, relocate</td>
|
||
<td>jobs on the catalog store</td>
|
||
<td>outlive the call, cancellable, one outcome</td>
|
||
</tr>
|
||
<tr>
|
||
<td>delete</td>
|
||
<td>a job; decision #18</td>
|
||
<td>below</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h4>Archive and restore</h4>
|
||
<p>Archiving must stop the worker whatever it is doing, end guest access, and close the session's port forwards, while
|
||
keeping its storage. The catalog does none of the plugin-specific parts:</p>
|
||
<div class="steps"><div class="steps-head"><span class="title">Archiving S1</span><span class="count"></span><button class="prev" type="button">Back</button><button class="next" type="button">Next</button></div><div class="step-panel"><h4>One write</h4><p><code>Catalog.archive({ id: "S1" })</code> sets <code>archived: true</code> in the session record, in one transaction.</p>
|
||
</div><div class="step-panel"><h4>Revoke the session</h4><p>The catalog calls <code>host.revoke({ session: "S1" })</code>. Every guest principal of S1 has <code>{ session: "S1" }</code> in its
|
||
<code>revokedBy</code>, so their objects end and their links close on the server and on the worker (chapter VII). New
|
||
connections are refused by <code>authenticate</code>, because grants of an archived session are void.</p>
|
||
</div><div class="step-panel"><h4>Stop the worker</h4><p>The supervisor reconciles from <code>catalog.sessions</code> with a routine: an archived session's worker is stopped, and
|
||
<code>authorize</code> refuses to start it again.</p>
|
||
</div><div class="step-panel"><h4>Plugins reconcile</h4><p>The ports plugin's routine on <code>catalog.sessions</code> closes forwards of archived sessions. The search plugin marks the
|
||
session's rows archived. If the server crashed between the write and these steps, the routines run again with the
|
||
current value at the next start: nothing is a notification that can be lost.</p>
|
||
</div><div class="step-panel"><h4>Restore</h4><p><code>Catalog.restore</code> clears the flag. Grants are valid again, so guests reconnect and authenticate as before; forwards
|
||
stay closed.</p>
|
||
</div></div>
|
||
<h4>Clone, fork, relocate</h4>
|
||
<p>Each is work that outlives its call, can be cancelled and has one outcome, so each is a <strong>job</strong> (chapter XI) on the
|
||
catalog's local store, keyed by the owner's request id. They differ in what they pause:</p>
|
||
<ul>
|
||
<li><strong>Clone</strong> copies the session's store, uploads, downloads and artifacts under a new id, with the source worker
|
||
paused (refused while work runs) and title "(copy)"; directories <code>0700</code>, files <code>0600</code>, no links, no overwrite; a
|
||
partial copy is removed on failure.</li>
|
||
<li><strong>Fork</strong> before a user message copies the active context as of the entry before it into a new session (title
|
||
"(fork)"), with the message waiting in the new session's editor. It needs two workers: the source reads history,
|
||
the new one is seeded empty. A failure archives the copy.</li>
|
||
<li><strong>Relocate</strong> (cwd, environment or both): refused while the session is busy (naming the running subagents) or when
|
||
a subagent has its own cwd and the move crosses machines. The job fences the worker (new admissions wait), updates
|
||
the record, and moves the running worker in place by updating <code>SessionLocation</code>: natives that follow the location
|
||
switch with a generation (chapter IX), and conversations that follow the session's cwd are rewritten in one Durable
|
||
commit before work resumes. After 60 s it restarts the worker instead; on failure it releases the fence.</li>
|
||
</ul>
|
||
<div class="note review"><b class="tag">Review: jobs as objects (#9)</b><p>The design defers whether a long relocation or a login is a record inside its owner (watched through <code>running</code>) or an
|
||
object of its own (<code>Relocation { id }</code>). The construction above uses records. Decide when login is ported
|
||
(<code>design.md</code> §15, §26).</p>
|
||
</div>
|
||
<h4>Delete</h4>
|
||
<p>p2 has no session delete. p3 needs one that removes the session everywhere: every document tagged with the session
|
||
(<code>store.retireByTag(SessionTag, id)</code>, chapter V), the worker's databases (the Durable store), the session's files, the
|
||
search rows, read marks, children copies, forwards, guest records, and finally the catalog entry. The design commits to
|
||
this outcome (<code>design.md</code> §25); how plugins take part is open.</p>
|
||
<div class="note review"><b class="tag">Review: session delete (#18)</b><p>Options in <code>design.md</code> §26: per-session objects plus <code>SessionTag</code> plus one session directory (delete is retire by tag
|
||
and remove a directory, plugins need do nothing), or owner hooks contributed through a catalog registry. Also open:
|
||
whether the search index is per session (deleted with it) or global with a hook. Who holds the store for
|
||
<code>retireByTag</code> is not specified either.</p>
|
||
</div>
|
||
<h3 id="status-children-unread-reports-to-the-server">Status, children, unread: reports to the server</h3>
|
||
<p>The session list must show status, subagents and unread marks for sessions whose workers are not running, and must
|
||
not start them. All three follow chapter X's pub/sub pattern: a routine on the worker calls a server object with the
|
||
current summary, the server object keeps the latest value per session (persisted where it must survive a restart),
|
||
ignores calls from an older <code>ctx.incarnation</code>, and clients read the server object.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Summary</th>
|
||
<th>Source on the worker</th>
|
||
<th>Kept on the server</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>status: running, error, task count, busy conversations, usage and cost</td>
|
||
<td>the harness's task graph and usage</td>
|
||
<td><code>SessionStatus</code>, in memory: a stopped worker is "not running"</td>
|
||
</tr>
|
||
<tr>
|
||
<td>children: name, model, thinking, tools, cwd, last entry, archived</td>
|
||
<td>the subagents plugin's child list</td>
|
||
<td><code>Children</code>, persisted</td>
|
||
</tr>
|
||
<tr>
|
||
<td>latest root answer</td>
|
||
<td>the search feed</td>
|
||
<td><code>Unread</code>: compared with the owner's read mark, persisted, shared by all owner devices</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Guests see their own session's status without the error text. The owner's read mark is written when the root
|
||
conversation is visible, and a new session starts as read.</p>
|
||
<h3 id="conversations">Conversations</h3>
|
||
<p>The harness (chapter XIV) owns the Durable store. The UI needs, per conversation: a live window of entries, older
|
||
pages, usage numbers, the working indicator, the inbox (queued inputs), and the actions. Two core objects provide them:</p>
|
||
<ul>
|
||
<li><strong><code>ConversationStore { conversation }</code></strong> (local, on the worker): opens the Durable watch as its native, reaching
|
||
the harness through the core-private <code>HarnessRuntime</code> registry entry, and exposes a <strong>window</strong> as a derive: the
|
||
newest entries (at most 100 or 512 KiB, at least 20) plus the conversation's documents. The window's start stays
|
||
put while it is within 1.5 times the limits, so new entries arrive as small appends instead of a shifted list
|
||
(chapter VIII's window pattern).</li>
|
||
<li><strong><code>Conversation { session, conversation }</code></strong> (remote, placement <code>Session</code>): the per-principal view. Variants by
|
||
<code>can()</code>: <code>owner</code> (everything, including model, thinking and handoff), <code>writer</code> (guest write: send, steer, stop,
|
||
compact, shell), <code>reader</code> (the window only).</li>
|
||
</ul>
|
||
<pre><code>host.<span class="tk-f">serve</span>(<span class="tk-t">Conversation</span>, ({ session, conversation }, principal, self) => {
|
||
<span class="tk-k">const</span> store = self.<span class="tk-f">get</span>(<span class="tk-t">ConversationStore</span>, { conversation });
|
||
<span class="tk-k">const</span> policy = self.<span class="tk-f">get</span>(<span class="tk-t">ConversationPolicy</span>, { conversation }); <span class="tk-c">// core: readOnly, followsSessionCwd</span>
|
||
<span class="tk-k">const</span> author = <span class="tk-f">authorOf</span>(principal); <span class="tk-c">// structured: kind, id, display name</span>
|
||
<span class="tk-k">if</span> (!<span class="tk-f">can</span>(principal, <span class="tk-s">"send"</span>, session)) <span class="tk-k">return</span> { variant: <span class="tk-s">"reader"</span>, view: store.window, page: store.page };
|
||
<span class="tk-k">return</span> {
|
||
variant: <span class="tk-f">can</span>(principal, <span class="tk-s">"manage"</span>) ? <span class="tk-s">"owner"</span> : <span class="tk-s">"writer"</span>,
|
||
view: store.window,
|
||
page: store.page, <span class="tk-c">// older history: returns data</span>
|
||
send: ({ text, attachments, mode, requestId }, ctx) => {
|
||
<span class="tk-k">if</span> (policy.value.readOnly) <span class="tk-k">throw</span> <span class="tk-f">forbidden</span>(<span class="tk-s">"read-only conversation"</span>);
|
||
<span class="tk-k">return</span> store.<span class="tk-f">submit</span>({ <span class="tk-k">type</span>: <span class="tk-s">"input"</span>, content: text, attachments, author, whenBusy: mode, requestId }, ctx);
|
||
},
|
||
stop: (_args, ctx) => store.<span class="tk-f">stop</span>(author, ctx), <span class="tk-c">// allowed even when read-only</span>
|
||
…
|
||
};
|
||
});</code></pre>
|
||
<p>What each function becomes:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Function</th>
|
||
<th>How</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>send, retried send</td>
|
||
<td>Durable <code>submit</code> with the author's <code>requestId</code>: a retry with the same id is admitted once (Durable dedupes, chapter XIV)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>steer or queue while busy</td>
|
||
<td><code>whenBusy: "steer"</code> or a follow-up; the inbox is <code>docs["pi.inbox"]</code> in the window</td>
|
||
</tr>
|
||
<tr>
|
||
<td>attribution</td>
|
||
<td>the view derives the author from the principal; the entry stores it structured, the model sees a speaker block; nothing parses text</td>
|
||
</tr>
|
||
<tr>
|
||
<td>wait for prompt resources</td>
|
||
<td>the first input of a fresh worker waits until the prompt plugin's resources are loaded (a registry readable the store follows)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>stop, dequeue</td>
|
||
<td>withdraw queued inputs, abort the run; the caller's texts return to its editor, other authors' to their <strong>restored drafts</strong></td>
|
||
</tr>
|
||
<tr>
|
||
<td>restored drafts</td>
|
||
<td>a keyed document per (conversation, author), tagged <code>SessionTag</code>, persisted in the store; claimed once, first tab wins</td>
|
||
</tr>
|
||
<tr>
|
||
<td>read-only conversations</td>
|
||
<td>the core per-conversation policy document, written at <code>createConversation({ init })</code> by whoever creates the conversation; send, dequeue, configure, compact and shell refuse; stop is allowed</td>
|
||
</tr>
|
||
<tr>
|
||
<td>history pages</td>
|
||
<td>a method returning a page (100 entries, 1 MB, 2,000 scanned); compaction and reset markers always included</td>
|
||
</tr>
|
||
<tr>
|
||
<td>model and thinking</td>
|
||
<td>owner variant only; validated against the models plugin's catalog; the root conversation's model is also reported as the session default</td>
|
||
</tr>
|
||
<tr>
|
||
<td>compaction, handoff</td>
|
||
<td>Durable operations through the store</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Drafts in the composer</strong> are UI state, not Chord state: the web shell keeps one draft in <code>sessionStorage</code>, the TUI
|
||
keeps one per conversation across UI replacements, and pending sends keep their request id until acknowledged
|
||
("Sending when connected"). Only restored drafts, which the worker produces, are documents.</p>
|
||
<h3 id="shell-commands">Shell commands</h3>
|
||
<p><code>!cmd</code> runs a command in the conversation's cwd on its environment and records the result as an entry; <code>!!cmd</code> does
|
||
the same but keeps the output out of the model's context. One command at a time per conversation; the live tail is
|
||
at most 50 KB or 2,000 lines, sampled every 200 ms; the full output spills to a file; the result entry is placed at the
|
||
run's next boundary while the agent is busy and never starts a run. This is the design's job example almost as is:</p>
|
||
<pre><code><span class="tk-k">const</span> <span class="tk-t">ShellJob</span> = <span class="tk-f">defineJob</span>(<span class="tk-s">"chat.shell"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
args: <span class="tk-f">obj</span>({ command: <span class="tk-f">str</span>({ max: <span class="tk-n">100_000</span> }), include: <span class="tk-f">bool</span>(), author: <span class="tk-t">Author</span> }),
|
||
progress: <span class="tk-f">obj</span>({ output: <span class="tk-f">str</span>() }),
|
||
result: <span class="tk-t">ShellResult</span>,
|
||
concurrency: <span class="tk-n">1</span>, <span class="tk-c">// one per conversation</span>
|
||
});
|
||
|
||
<span class="tk-k">const</span> results = self.<span class="tk-f">state</span>(<span class="tk-t">ShellResults</span>); <span class="tk-c">// keyed by run id: dedupe after completion</span>
|
||
<span class="tk-k">const</span> shell = self.<span class="tk-f">job</span>(<span class="tk-t">ShellJob</span>, {
|
||
run: <span class="tk-k">async</span> ({ command, include, author }, ctx) => {
|
||
<span class="tk-k">const</span> out = <span class="tk-k">new</span> <span class="tk-t">OutputBuffer</span>({ maxBytes: <span class="tk-n">50_000</span>, maxLines: <span class="tk-n">2000</span>, retain: <span class="tk-s">"tail"</span> });
|
||
<span class="tk-k">const</span> env = <span class="tk-k">await</span> <span class="tk-f">environmentFor</span>(ctx); <span class="tk-c">// the conversation's environment, chapter XX</span>
|
||
<span class="tk-k">const</span> r = <span class="tk-k">await</span> env.<span class="tk-f">exec</span>(command, { cwd: env.cwd, signal: ctx.signal, spill: <span class="tk-f">spillPath</span>(ctx.jobId) },
|
||
(chunk) => { out.<span class="tk-f">push</span>(chunk); ctx.<span class="tk-f">progress</span>({ output: out.<span class="tk-f">snapshot</span>().text }); });
|
||
<span class="tk-k">const</span> data = { command, output: out.<span class="tk-f">snapshot</span>().text, exit: r.code, cancelled: ctx.signal.aborted };
|
||
<span class="tk-k">await</span> conversation.<span class="tk-f">submit</span>(
|
||
{ <span class="tk-k">type</span>: <span class="tk-s">"write"</span>, entry: <span class="tk-f">shellEntry</span>(data, author, include), requestId: <span class="tk-s">`shell:${ctx.jobId}`</span> }, ctx);
|
||
<span class="tk-k">return</span> data;
|
||
},
|
||
finish: <span class="tk-k">async</span> (done, tx) => {
|
||
(<span class="tk-k">await</span> tx.<span class="tk-f">draft</span>(results, done.id)).outcome = done.kind === <span class="tk-s">"done"</span> ? done.result : { error: done.error };
|
||
},
|
||
});</code></pre>
|
||
<p>The request id <code>shell:${ctx.jobId}</code> makes the entry idempotent: an attempt interrupted after the write cannot write
|
||
it twice. Cancel is <code>shell.abort(id)</code>, allowed for the command's author and the owner. A retried <code>runShell</code> with the
|
||
same request id returns the running job (dedupe of unsettled runs), or the stored result (the owner's own dedupe after
|
||
completion, chapter XI). p2's "cancel when nobody watched for 30 s" is dropped: a job keeps its owner alive and does
|
||
not depend on viewers.</p>
|
||
<h3 id="chat-mode">Chat mode</h3>
|
||
<p>Chat mode is a per-conversation switch that everyone in the session sees and only the owner changes; forks and new
|
||
subagents start with it off. Because it must fork and rewind with the conversation, it is <strong>transcript-bound</strong>: a
|
||
Durable conversation document, not a Chord document. Messages without <code>@pi</code> become display-only Durable <code>write</code>
|
||
entries (author, text, attachments, reply quote); a message with <code>@pi</code> becomes an input with a digest of the chat
|
||
since the last mention (at most 100 messages, 20 files, 4 images), recording which entries it passed on. The reply
|
||
quote is derived on the worker from the entry id the client names: the client never supplies author or quote text.</p>
|
||
<h3 id="subagents-and-one-shot-agents">Subagents and one-shot agents</h3>
|
||
<p>Subagents are Durable child conversations owned by tasks, as in Durable's own background-subagent example: one
|
||
<code>subagent</code> tool spawns, steers, waits for, stops and lists them; each child is owned by a background anchor task, and
|
||
each message is delivered by a reporter task that posts the answer back to the parent once, with request ids so a
|
||
restart sends nothing twice. Chord adds only three things:</p>
|
||
<ul>
|
||
<li>the <strong>child list</strong> for the sidebar is reported to the server (<code>Children</code>, above), so it shows without a worker;</li>
|
||
<li>a child that follows the session's cwd, or a read-only one-shot agent created by a code-mode script, is marked in
|
||
the core <strong>per-conversation policy document</strong> when it is created, so chat and relocation enforce it without knowing
|
||
the subagents plugin (<code>design.md</code> §25 porting guideline);</li>
|
||
<li>archive and restore of a child from the UI are methods on the subagents plugin's view, refused for one-shots.</li>
|
||
</ul>
|
||
<p>Subagents cannot spawn subagents; thinking levels are clamped to what the model supports, in one helper shared with
|
||
the session's model choice.</p>
|
||
<h3 id="messages-between-sessions">Messages between sessions</h3>
|
||
<p>The root agent can list other sessions and send one an attributed message. The tool runs on the sender's worker and
|
||
calls a server object through <code>chord(fn, context)</code> (chapter XIV):</p>
|
||
<pre><code><span class="tk-k">const</span> peerSend = <span class="tk-f">defineTool</span>({
|
||
name: <span class="tk-s">"session_send"</span>,
|
||
execute: (args, api, context) => <span class="tk-f">chord</span>(<span class="tk-k">async</span> (ctx) => {
|
||
<span class="tk-k">const</span> peers = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Peers</span>, {}); <span class="tk-c">// server, worker variant</span>
|
||
<span class="tk-k">await</span> peers.<span class="tk-f">send</span>({ to: args.session, text: args.text, mode: args.mode, requestId: <span class="tk-s">`peer:${api.taskId}`</span> }, ctx);
|
||
<span class="tk-k">return</span> { output: [{ <span class="tk-k">type</span>: <span class="tk-s">"text"</span>, text: <span class="tk-s">"sent"</span> }] };
|
||
}, context),
|
||
});</code></pre>
|
||
<p><code>Peers</code> checks that the sender is a root conversation of a non-archived session, refuses sending to itself and
|
||
archived recipients, then gets the recipient's <code>Conversation</code> (which starts its worker) and submits with the
|
||
sender as a structured author and the same request id (the tool task's id, stable across restarts), so a retried
|
||
tool call delivers once. The sender's
|
||
incarnation is <code>ctx.incarnation</code>; a call from a replaced worker is refused. Only owner-initiated turns may use the
|
||
tool; guests never.</p>
|
||
<h3 id="search">Search</h3>
|
||
<p>The search plugin indexes user and assistant messages of root and subagent conversations (no shell output, no
|
||
Durable internal inputs) for title, cwd and message search across sessions, active or archived.</p>
|
||
<figure class="fig wide anim"><svg viewBox="0 0 720 252" width="100%" role="img" aria-label="The search feed. The worker pushes pages from a cursor; the server owns the index and never starts a worker to answer a query." xmlns="http://www.w3.org/2000/svg" style="font-family:'Avenir Next','Helvetica Neue',Helvetica,Arial,sans-serif;max-width:760px"><defs><marker id="f13m" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#7b7468"/></marker><marker id="f13a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#b4472f"/></marker><marker id="f13b" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#2e6a8a"/></marker></defs><text x="16" y="22" font-size="10" fill="#2e6a8a" text-anchor="start" font-weight="600" letter-spacing="2">WORKER S1</text><text x="16" y="155" font-size="10" fill="#b4472f" text-anchor="start" font-weight="600" letter-spacing="2">SERVER</text><g class="step" style="--i:1"><rect x="19" y="36" width="210" height="55" rx="4" fill="#fff" stroke="#c9bfae"/><text x="124" y="60.8" font-size="11" fill="#2a2723" text-anchor="middle">Durable commit</text><text x="124" y="74.8" font-size="9.5" fill="#7b7468" text-anchor="middle">in any conversation</text></g><g class="step" style="--i:3"><rect x="255" y="36" width="210" height="55" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="54.5" font-size="11" fill="#2a2723" text-anchor="middle">wake</text><text x="360" y="68.5" font-size="9.5" fill="#7b7468" text-anchor="middle">a routine on the harness's commit</text><text x="360" y="81" font-size="9.5" fill="#7b7468" text-anchor="middle">signal</text></g><g class="step" style="--i:5"><rect x="491" y="36" width="210" height="55" rx="4" fill="#fff" stroke="#c9bfae"/><text x="596" y="60.8" font-size="11" fill="#2a2723" text-anchor="middle">read from cursor</text><text x="596" y="74.8" font-size="9.5" fill="#7b7468" text-anchor="middle">200 entries per page</text></g><g class="step" style="--i:7"><rect x="19" y="169" width="210" height="44" rx="4" fill="#fff" stroke="#b4472f" stroke-width="1.5"/><text x="124" y="188.3" font-size="11" fill="#2a2723" text-anchor="middle">SearchIndex</text><text x="124" y="202.3" font-size="9.5" fill="#7b7468" text-anchor="middle">native: SQLite FTS</text></g><g class="step" style="--i:9"><rect x="255" y="169" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="360" y="188.3" font-size="11" fill="#2a2723" text-anchor="middle">cursor per conversation</text><text x="360" y="202.3" font-size="9.5" fill="#7b7468" text-anchor="middle">moves after the whole range</text></g><g class="step" style="--i:0"><rect x="491" y="169" width="210" height="44" rx="4" fill="#fff" stroke="#c9bfae"/><text x="596" y="188.3" font-size="11" fill="#2a2723" text-anchor="middle">query</text><text x="596" y="202.3" font-size="9.5" fill="#7b7468" text-anchor="middle">terms, scope, at most 41 hits</text></g><g class="step edge" style="--i:2"><path d="M229 63.5 L253 63.5" fill="none" stroke="#7b7468" marker-end="url(#f13m)"/></g><g class="step edge" style="--i:4"><path d="M465 63.5 L489 63.5" fill="none" stroke="#7b7468" marker-end="url(#f13m)"/></g><g class="step edge" style="--i:6"><path d="M596 91 L124 167" fill="none" stroke="#7b7468" marker-end="url(#f13m)"/><rect x="364" y="123" width="71.2" height="13" fill="#fbf8f2" opacity=".92"/><text x="368" y="133" font-size="9.5" fill="#7b7468" text-anchor="start">report(page)</text></g><g class="step edge" style="--i:8"><path d="M229 191 L253 191" fill="none" stroke="#7b7468" marker-end="url(#f13m)"/></g><text x="16" y="237" font-size="10.5" fill="#7b7468" text-anchor="start">Reports carry ctx.incarnation. Indexing is idempotent: a page sent twice changes nothing.</text></svg><figcaption><button class="replay" type="button">Replay</button><span class="fignum">Figure 13.</span> The search feed. The worker pushes pages from a cursor; the server owns the index and never starts a worker to answer a query.</figcaption></figure>
|
||
<p>The index is a native owned by a host-lifetime object on the server; cursors live with the index, so after a crash
|
||
the worker asks for the cursor and continues. Rebuilding one session's index drops its rows and rescans, starting
|
||
an archived session's worker without resuming its work. Queries are methods returning data: at most 41 hits,
|
||
snippets of 300 characters, queries up to 500 characters. The same feed sets automatic titles, moves "updated at"
|
||
forward and supplies the latest root answer for unread marks.</p>
|
||
<h3 id="presence">Presence</h3>
|
||
<p>Viewers per session are <code>host.references(Conversation)</code> on the worker, counted per client (chapter VIII), and
|
||
reported with the status. Connected guests (name and mode) are the references whose principal is a guest. Presence
|
||
is not keep-alive: the supervisor uses <code>host.activity</code>, which also counts calls and jobs.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>None of this exists in p3. The parts it relies on are Durable (built: submit with request ids, steer and queue,
|
||
child conversations, background tasks) and the Chord layers L3 to L9 (not built).</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="p2-machines"><header class="opener"><div class="numeral">XX</div><h2>Machines, Files, Models</h2><p class="lede">Environments, workspace, terminals, session files, ports and Radius, models and logins, prompt resources, logs, reload and the shells: the rest of p3.</p></header>
|
||
<p class="dropcap">The last domains are the ones that touch the outside world: other machines over SSH, the file system and git,
|
||
terminals, the relay, model providers and their credentials. Most of them are native objects, and most of them are
|
||
places where p2 has the same thing several times (four SSH connections to one environment, five notice channels,
|
||
three copies of the environment config parser). The rule that fixes that is the one from chapter XVIII: one owner per
|
||
thing.</p>
|
||
<h3 id="environments">Environments</h3>
|
||
<p>The <strong>envs plugin</strong> owns the environment configuration: name, host, user, port, identity file, shell and its
|
||
variables, plus a managed ed25519 key per environment and its own <code>known_hosts</code> (kept apart from the user's, aliased
|
||
per environment name).</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Function</th>
|
||
<th>Owner and form</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>list, add, edit, delete (refused while a session, archived too, uses it)</td>
|
||
<td><code>Envs</code> on the server: a persisted document behind an owner-only view</td>
|
||
</tr>
|
||
<tr>
|
||
<td>validation (no leading <code>-</code>, no whitespace or control characters in host and user)</td>
|
||
<td>the view's methods, before the write</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a hand-edited <code>envs.json</code> is picked up; an invalid file keeps the last valid set and shows an error</td>
|
||
<td>a native file watcher (poll fallback) in the envs store; one writer (#21)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>environment revision: a hash of the config, part of its identity</td>
|
||
<td>a derive</td>
|
||
</tr>
|
||
<tr>
|
||
<td>connection test (connect, deploy the daemon, platform, latency, warnings)</td>
|
||
<td>a job; the result per revision in a document</td>
|
||
</tr>
|
||
<tr>
|
||
<td>unknown or changed host key: fingerprints, trust after confirmation, forget</td>
|
||
<td>a flow (chapter XII) on the server</td>
|
||
</tr>
|
||
<tr>
|
||
<td>browse directories (<code>~</code>, relative paths, at most 10k scanned, 1,000 shown)</td>
|
||
<td>a method with a <strong>call-scoped</strong> SSH session (chapter IX) or the pooled connection</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a changed environment restarts its sessions' workers once idle</td>
|
||
<td>a routine on the envs store and the status reports</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Execution in the agent.</strong> The worker's harness asks <code>HarnessEnvironments</code> for an environment per target (chapter
|
||
XIV). The envs plugin contributes it from its worker facet: local execution at the session's cwd, or a remote one over
|
||
SSH through the <code>pi-env</code> daemon, deployed per platform and connecting at the first operation. A remote session never
|
||
falls back to local tools. The environment follows <code>SessionLocation</code>: after a move, the old connection closes once
|
||
the move has committed and watchers reopen at the new location.</p>
|
||
<pre><code><span class="tk-c">// envs/worker.ts</span>
|
||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((host, self) => {
|
||
<span class="tk-k">const</span> location = self.<span class="tk-f">get</span>(<span class="tk-t">SessionLocation</span>, { session });
|
||
<span class="tk-k">const</span> pool = self.<span class="tk-f">get</span>(<span class="tk-t">EnvPool</span>, {}); <span class="tk-c">// one owner of SSH connections (#20)</span>
|
||
self.<span class="tk-f">contribute</span>(<span class="tk-t">HarnessEnvironments</span>, (target) =>
|
||
target.env === <span class="tk-s">"local"</span> ? <span class="tk-f">localEnv</span>(target.cwd) : pool.<span class="tk-f">environment</span>(target.env, target.cwd));
|
||
});</code></pre>
|
||
<div class="note review"><b class="tag">Review: connection pools (#20)</b><p>SSH connections to one environment exist four times in p2 (worker environment, browse connection, test connection,
|
||
terminal host). <code>design.md</code> §26 asks for one owner with a pool or deliberate per-use connections. <code>EnvPool</code> above is
|
||
the first option: a native object that closes idle connections after 10 minutes and invalidates them on a config or
|
||
key change. Reloading the plugin that owns the pool kills running remote commands (chapter XV), so it should change
|
||
rarely.</p>
|
||
</div>
|
||
<h3 id="workspace-and-file-index">Workspace and file index</h3>
|
||
<p>The <strong>workspace plugin</strong> serves the viewed conversation's working tree (a subagent's own cwd included) on the worker:
|
||
list a folder (at most 2,000 entries, <code>.git</code> hidden), read a file (1 MiB, binary detected), git status, diff, log and
|
||
single commits, literal text search, and all paths for name filtering. Every query is a <strong>method returning data</strong>, not
|
||
state: results are bounded and requested by the UI. Only change notifications are state: a counter in a document,
|
||
bumped by a native watcher of the tree, <code>HEAD</code>, the index and config (debounced 100 ms); viewers reload what they show
|
||
when it changes.</p>
|
||
<p>Two rules live in one place each instead of three:</p>
|
||
<ul>
|
||
<li><strong>Confinement:</strong> path rules, a canonical root, no <code>.git</code>, the app's private data directory denied (also excluded
|
||
from search). One helper used by listing, reading, uploads and the file index.</li>
|
||
<li><strong>Git hardening:</strong> no optional locks, no fsmonitor, filters disabled, no prompts or pager, no system config, 10 s
|
||
timeout, output caps.</li>
|
||
</ul>
|
||
<p>Downloads (25 MiB, byte ranges for media) go through the HTTP component (chapter XIII) with a route on the server that
|
||
reads chunks from the worker; uploads into the project use the chunked protocol of chapter XIII (1 MiB chunks, temp
|
||
file then rename, never overwrite, writes serialized). Guest read gets no workspace: the view has no variant for it.</p>
|
||
<p>The <strong>file index</strong> for <code>@</code> completion belongs to the same owner, so completion consumes the index instead of scanning
|
||
on its own: built from the environment's listing (else git or a walk, at most 50k paths), rebuilt at most once per
|
||
second while files change, discarded after a move. Review comments drafted on files and diffs are browser state until
|
||
sent; sending them is an ordinary <code>Conversation.send</code>.</p>
|
||
<h3 id="terminals">Terminals</h3>
|
||
<p>Terminals must survive server and worker restarts, so their PTYs live in a <strong>detached terminal host process</strong>, one per
|
||
data directory, with its own process lock; it exits after 10 minutes with no terminals and no clients. The terminals
|
||
plugin on the server is the owner the UI talks to: list (title, cwd, environment, size, exit status), open at the
|
||
session's cwd and environment (refused for archived sessions), attach, close (asking first if a foreground job runs).
|
||
Terminals are <strong>owner only</strong>.</p>
|
||
<p>Bytes do not go over object links. Attaching opens a <strong>channel</strong> (chapter XIII) with a single-use token (30 s): JSON
|
||
control on the object, bytes on the channel, with its own acknowledged window (256 KiB, acknowledgements per 64 KiB; a
|
||
viewer that falls behind gets a fresh screen snapshot; an 8 MiB backlog disconnects). Attaching gets the current
|
||
screen from a headless emulator with 10k lines of scrollback.</p>
|
||
<div class="note review"><b class="tag">Review: the terminal host process (#23)</b><p>The terminal host runs its own code. Is it part of the revision, reloaded or restarted with it (and running terminals
|
||
end), or deliberately outside it with a "host runs older code" notice as in p2? Open in <code>design.md</code> §26.</p>
|
||
</div>
|
||
<h3 id="session-files">Session files</h3>
|
||
<p>Attachments (25 MiB, at most 20 per message), sent-file snapshots made by the agent's <code>send_file</code> tool, versioned
|
||
artifacts (HTML, SVG, Markdown, text; 2 MiB; each update a new immutable version) and shell spill files are all
|
||
per-session bytes with caps, immutable ids and range reads; p2 has two upload routes, two caps and two directory
|
||
layouts for them. The proposal is one owner:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">SessionFiles</span> = <span class="tk-f">remote</span>(<span class="tk-s">"p3.session-files"</span>, {
|
||
version: <span class="tk-n">1</span>,
|
||
input: <span class="tk-f">obj</span>({ session: <span class="tk-f">str</span>() }),
|
||
placement: () => <span class="tk-t">Server</span>(), <span class="tk-c">// readable without a worker</span>
|
||
variants: { writer: { … }, reader: { … }, worker: { … } },
|
||
});</code></pre>
|
||
<p><code>SessionFiles { session }</code> on the server holds the index (per kind, readable without a worker), the chunked upload
|
||
and read protocol, the caps per kind, copying on clone and fork, and deletion with the session. Attachments are copied
|
||
into a remote environment before an input is admitted, with no fallback on failure. Artifact previews run in a
|
||
sandboxed frame with its own CSP and no app identity; ordinary file previews allow only safe types.</p>
|
||
<div class="note review"><b class="tag">Review: session files (#17)</b><p>Proposed in <code>design.md</code> §26, not decided. Open sub-points: one preview limit (p2 has 256 KiB for sent files and
|
||
512 KiB for artifacts), what a fork copies of the artifact list, and how attachments are copied into remote
|
||
environments. Related: #22, whether on-disk layouts such as downloads and uploads get versions like documents.</p>
|
||
</div>
|
||
<h3 id="ports-and-radius">Ports and Radius</h3>
|
||
<p>The <strong>radius plugin</strong> keeps the outbound relay connection, a native in a host-lifetime object on the server: sign-in
|
||
by device code from the CLI (credential in a private file), token refresh, a heartbeat in the link adapter (20 s ping,
|
||
60 s timeout; TCP does not notice a sleeping laptop), backoff from 1 to 30 s, a disable switch. The relay delivers two
|
||
kinds of channels:</p>
|
||
<pre><code><span class="tk-c">// radius/server.ts (the entry point: one line, as in chapter I)</span>
|
||
radius.<span class="tk-f">onChannel</span>((ws) => host.<span class="tk-f">accept</span>(<span class="tk-f">webSocketLink</span>(ws), { principal: { kind: <span class="tk-s">"owner"</span>, access: <span class="tk-s">"relay"</span> } }));
|
||
radius.<span class="tk-f">onForward</span>((channel) => <span class="tk-f">proxyToForward</span>(channel, host.<span class="tk-f">registry</span>(<span class="tk-t">RadiusForwards</span>).value));</code></pre>
|
||
<p>Object links become owner principals with <code>access: "relay"</code>: owner, but treated as remote (no localhost-only
|
||
powers). p2 had to fake a loopback request with a secret header for this; with <code>accept</code> that is gone.</p>
|
||
<p>Forward channels go to <code>localhost:<port></code> (Host, Origin and Location rewritten; 502 when nothing listens; anonymous
|
||
visitors only to public forwards). Which forwards exist is not radius's business: it owns a registry,
|
||
<code>radius.forwards</code>, and the <strong>ports plugin</strong> contributes its forwards (at most 50, random ids, a public toggle that
|
||
needs confirmation, an agent tool <code>ports</code> for top-level owner-initiated conversations). Without the ports plugin the
|
||
table is empty and radius still works: an optional dependency is a registry owned by the dependent (chapter XIII). The
|
||
ports plugin closes forwards of archived sessions with a routine on <code>catalog.sessions</code> (chapter XIX).</p>
|
||
<div class="note review"><b class="tag">Review: may a plugin call <code>accept</code>?</b><p><code>design.md</code> §3 shows the Radius line in the server's start code and calls entry points "app or adapter code". If
|
||
radius is a plugin, its facet needs the host's <code>accept</code>, which no other part of this guide gives a plugin. The
|
||
alternative is that the start code keeps the entry point and gets channels from the radius object. Not specified.</p>
|
||
</div>
|
||
<h3 id="models-and-credentials">Models and credentials</h3>
|
||
<p>The <strong>models plugin</strong> owns the model runtime (pi-ai's models with custom providers and the model cache) in a
|
||
host-lifetime object, and the credentials. It contributes the runtime to <code>HarnessModels</code> (chapter XIV), so after a
|
||
models reload the next request uses the new runtime while a running stream finishes on the old one. <strong>Credentials
|
||
never go to clients</strong>: the views expose providers, login methods and which type is signed in, never tokens. Every
|
||
secret is registered for redaction in one place, the log capture.</p>
|
||
<p>Views: a model catalog for pickers (names, ids, context windows, thinking levels; at most 300 matches in the web),
|
||
logout (refused during a login), refresh a provider's model list. The default model and the cycling scope are
|
||
settings (chapter XVIII).</p>
|
||
<h4>Login</h4>
|
||
<p>A login is the guide's flow example (chapter XII): a <strong>job</strong> on the server that runs pi-ai's login, and a flow that
|
||
shows its steps to the starting owner.</p>
|
||
<pre><code><span class="tk-k">const</span> <span class="tk-t">LoginJob</span> = <span class="tk-f">defineJob</span>(<span class="tk-s">"models.login"</span>, {
|
||
version: <span class="tk-n">1</span>, args: <span class="tk-f">obj</span>({ provider: <span class="tk-f">str</span>(), <span class="tk-k">type</span>: <span class="tk-t">AuthType</span> }), progress: <span class="tk-t">LoginState</span>, result: <span class="tk-f">obj</span>({ ok: <span class="tk-f">bool</span>() }),
|
||
concurrency: <span class="tk-n">1</span>, <span class="tk-c">// one login at a time on the server</span>
|
||
});
|
||
|
||
<span class="tk-k">const</span> login = self.<span class="tk-f">job</span>(<span class="tk-t">LoginJob</span>, {
|
||
run: <span class="tk-k">async</span> ({ provider, <span class="tk-k">type</span> }, ctx) => {
|
||
<span class="tk-k">await</span> runtime.value.<span class="tk-f">login</span>(provider, <span class="tk-k">type</span>, {
|
||
signal: ctx.signal, <span class="tk-c">// 10 minute deadline, cancel, leaving</span>
|
||
prompt: (prompt) => steps.<span class="tk-f">ask</span>(prompt, ctx), <span class="tk-c">// text, secret, select, manual_code</span>
|
||
notify: (event) => steps.<span class="tk-f">notice</span>(event, ctx), <span class="tk-c">// info, auth_url, device_code, progress</span>
|
||
});
|
||
<span class="tk-k">return</span> { ok: <span class="tk-k">true</span> };
|
||
},
|
||
finish: <span class="tk-k">async</span> (done, tx) => { <span class="tk-c">/* record the outcome; refresh the model list */</span> },
|
||
});</code></pre>
|
||
<p><code>prompt</code> is a promise-based callback inside the work; the resolver stays in the job (<code>steps.ask</code> records the step in
|
||
the flow state and waits for <code>answer</code>), and nothing crosses an object boundary. On a relayed connection the device
|
||
code method is selected automatically. The flow is visible only to the owner principal that started it; others are
|
||
refused. Leaving ends it: when the starting owner's references are all gone (presence, not an unmount), a routine
|
||
aborts the job. Codes, URLs and typed secrets are redacted from logs.</p>
|
||
<h3 id="prompt-resources">Prompt resources</h3>
|
||
<p><code>AGENTS.md</code>, skills and templates are read in the session's environment, watched (poll fallback, last valid state),
|
||
and expanded on the worker before sending. The <strong>prompt plugin</strong> owns them per session on the worker, contributes the
|
||
system prompt section and skills to <code>HarnessExtensions</code> as readable contributions (a change recomposes), and exposes a
|
||
readiness readable: the first input of a fresh worker waits until it is ready (chapter XIX). After a move the watchers
|
||
reopen at the new location.</p>
|
||
<h3 id="code-mode">Code mode</h3>
|
||
<p>Code mode is a plugin (chapter XIV): its <code>pi-codemode</code> sandbox is a native; scripts call tools as nested Durable calls
|
||
and replay after a restart; the tool entry looks up the current sandbox when it starts. A reload seals and drains the
|
||
old sandbox for at most 30 s. The on/off switch is a setting.</p>
|
||
<h3 id="logs-and-notices">Logs and notices</h3>
|
||
<p><strong>Log capture</strong> tees stdout and stderr of the server, workers and detached hosts without changing their behaviour,
|
||
redacts known secrets and credential patterns, and keeps bounded rings (at most 128 sources, 1,000 lines or 256 KiB
|
||
each). Detached hosts publish redacted snapshots to a private file. Logs are read without waking a stopped worker.
|
||
<strong>Notices</strong> are the one channel from chapter XVIII; dismissal stores only notice ids.</p>
|
||
<h3 id="dev-and-reload">Dev and reload</h3>
|
||
<p>The dev plugin is chapter XV's reload control for p3: a source watcher marks plugins dirty and never applies anything;
|
||
the <code>reload_plugins</code> agent tool and a control in the owner's shell reload the changed plugins, or restart all hosts for
|
||
base code (through the supervisor, reopening the sessions that were running). UI bundles are published only after a
|
||
successful backend change; a web page detects a new manifest and reloads with its drafts and navigation state saved;
|
||
the TUI swaps its sandboxed UI code with a snapshot of its state. A local Unix socket (<code>0700</code>) accepts development
|
||
commands without a network admin port.</p>
|
||
<h3 id="the-shells">The shells</h3>
|
||
<p>The web and TUI shells are UI facets over the objects above. Two rules matter for Chord:</p>
|
||
<ul>
|
||
<li><strong>UI state survives navigation, reload and reconnect on purpose.</strong> Composer drafts, pending sends with their request
|
||
ids, reading positions and review drafts are kept in tab storage or the TUI's replacement snapshot. A reconnect
|
||
re-holds and gets snapshots; nothing is lost that was not in flight.</li>
|
||
<li><strong>Leaving is never derived from an unmount.</strong> A plugin reload remounts components (chapter XV). Cancelling a login,
|
||
closing an overlay or giving up a lease is an explicit action or a dropped reference.</li>
|
||
</ul>
|
||
<p>The TUI launcher keeps downloaded UI code in a permission-dropped worker with no access to files, network or
|
||
credentials; local side effects (copy, upload, download, open a URL, an external editor) need a real user gesture or a
|
||
confirmation. That is app code around Chord, not part of it.</p>
|
||
<h3 id="transport">Transport</h3>
|
||
<p>The last domain, transport, is chapter XVII: one message format, per-part limits (16 MiB compressed and 64 MiB decoded
|
||
on client WebSockets, instead of p2's 1 MiB input cap and 32 pending calls), one byte window per link, re-holds with
|
||
snapshots after a reconnect, and a protocol version that makes an outdated web page reload.</p>
|
||
<div class="note"><b class="tag">Built so far</b><p>None of p3 exists. pi-ai's login API, pi-codemode and the <code>pi-env</code> daemon exist and are used by p2 today; the HTTP
|
||
component and channels are deferred until after the kernel's first real proof (chapter XIII).</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter" id="compared"><header class="opener"><div class="numeral">XXI</div><h2>Other Ways to Do It</h2><p class="lede">Cordis and DeepSeek Harness attack the same problem, plugins that come and go while an agent runs, inside one process. What that buys, and what it costs.</p></header>
|
||
<p class="dropcap">Chord v2 is not the only answer to "replace plugin code while the app keeps running". The closest relative is
|
||
<strong>Cordis</strong>, a TypeScript plugin framework by Shigma that has been the plugin kernel of the Koishi chatbot framework
|
||
since 2019, and its most visible user, <strong>DeepSeek Harness</strong> (<code>dsh</code>), DeepSeek's open-source agent harness, whose
|
||
motto is "everything is a plugin". This chapter compares them with Chord v2 on the questions the guide cared about:
|
||
who owns side effects, how plugins depend on each other, what a reload keeps, what a crash keeps, and how far the model
|
||
reaches beyond one process.</p>
|
||
<div class="note"><b class="tag">Sources and status</b><p>DeepSeek Harness at commit <code>687f282</code> (release 0.2.1-rc.2), which vendors Cordis as <code>@deepseek-ai/cordis</code> 4.0.5 (Cordis
|
||
RC7 with local changes, <code>vendor/README.md</code>): its Cordis tutorial (<code>docs/cordis-tutorial/</code>), <code>docs/architecture.md</code>,
|
||
<code>docs/subsystems/persistence.md</code>, <code>docs/subsystems/core.md</code> and <code>packages/boot/hmr/README.md</code>; the Cordis paper "A
|
||
Programming Paradigm for Spatiotemporal Composability" (arXiv 2608.25512). Cordis and dsh are built and in use; Chord v2
|
||
is the design this guide describes. The comparison is between a working system and a design.</p>
|
||
</div>
|
||
<h3 id="cordis-in-one-example">Cordis in one example</h3>
|
||
<p>A Cordis plugin is a function (or an object with <code>apply</code>, or a <code>Service</code> subclass) that receives a <strong>context</strong>. It may
|
||
declare the services it needs with <code>inject</code>, and everything it does to the world goes through the context:</p>
|
||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> name = <span class="tk-s">"greeter"</span>;
|
||
<span class="tk-k">export</span> <span class="tk-k">const</span> inject = [<span class="tk-s">"tools"</span>]; <span class="tk-c">// activate only while a `tools` service exists</span>
|
||
|
||
<span class="tk-k">export</span> <span class="tk-k">function</span> <span class="tk-f">apply</span>(ctx: <span class="tk-t">Context</span>) {
|
||
ctx.tools.<span class="tk-f">register</span>(<span class="tk-f">defineTool</span>({ name: <span class="tk-s">"greet"</span>, <span class="tk-c">/* … */</span> })); <span class="tk-c">// a registration is an effect: undone on unload</span>
|
||
ctx.<span class="tk-f">on</span>(<span class="tk-s">"session/start"</span>, (s) => <span class="tk-f">log</span>(s.id)); <span class="tk-c">// an effect-bound listener</span>
|
||
ctx.<span class="tk-f">effect</span>(() => { <span class="tk-c">// any other side effect, with its disposer</span>
|
||
<span class="tk-k">const</span> timer = <span class="tk-f">setInterval</span>(work, <span class="tk-n">200</span>);
|
||
<span class="tk-k">return</span> () => <span class="tk-f">clearInterval</span>(timer);
|
||
});
|
||
}</code></pre>
|
||
<p>Each plugin instance is a <strong>fiber</strong> with states <code>PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED</code> (or <code>FAILED</code>).
|
||
Every effect, registration, listener, child plugin and provided service is recorded on the fiber, and unloading runs
|
||
the disposers. The paper calls this <strong>temporal composability</strong>: a component can be removed at any time and reverses its
|
||
own effects. Its counterpart, <strong>spatial composability</strong>, is the dependency side: <code>inject</code> is live, so a plugin starts
|
||
when its services appear, and unloads (and later reloads) when a service it depends on goes away or is replaced. The
|
||
paper's claim is that independently written components can therefore be combined and replaced without disturbing each
|
||
other. The next sections check how far that holds.</p>
|
||
<p><strong>Services</strong> are named capabilities on the context (<code>ctx.provide("x", value)</code> or <code>class X extends Service</code>). Names are
|
||
runtime strings in one flat namespace per application; TypeScript declaration merging makes <code>ctx.x</code> typecheck, but
|
||
nothing checks the service's shape at runtime, and optional dependencies are probed with <code>ctx.get("x")</code>. Scopes come
|
||
from the context tree: <code>ctx.extend()</code> derives a child context, <code>ctx.isolate(name)</code> gives one subtree its own binding of
|
||
a service, so two parts of an app can use two different <code>shell</code> providers.</p>
|
||
<p><strong>Hot reload</strong> follows from the two halves: the reload plugin unloads the old fiber (unwinding all its effects) and
|
||
loads the new module with the same config. Nothing held in the old instance's closures or fields survives; a plugin
|
||
that needs state across reloads must keep it in a service or on disk. Config edits are diffed by entry id and
|
||
mount, unmount or reconfigure only what changed; config fields declared volatile update in place without a remount.</p>
|
||
<h3 id="deepseek-harness-on-cordis">DeepSeek Harness on Cordis</h3>
|
||
<p>dsh applies "everything is a plugin" to an agent: the model adapters (<code>ctx.llm</code>), the tool registry (<code>ctx.tools</code>), the
|
||
session log (<code>ctx.sessions</code>), the agents (<code>ctx.agents</code>) and the agent loop itself (<code>ctx.agentLoop</code>) are plugins and
|
||
services. A running dsh is a plugin tree composed at boot from a <strong>profile</strong> (<code>web</code>, <code>headless</code>, <code>sdk</code>, <code>acp</code>) that
|
||
stacks <strong>bundles</strong> of Cordis config rows, with user patches on top. There is no privileged core: replacing the agent
|
||
loop is a config patch.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Concern</th>
|
||
<th>How dsh does it</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>sessions</td>
|
||
<td>a durable, append-only event log per session (JSONL backend, single writer, <code>flush</code> as the durability barrier); model history is derived from the events</td>
|
||
</tr>
|
||
<tr>
|
||
<td>crash mid-turn</td>
|
||
<td>the log keeps everything appended; on resume the agent loop closes the open turn by appending missing tool errors and a synthetic <code>turn/end { reason: "interrupted" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>tools</td>
|
||
<td><code>ctx.tools.register(defineTool(…))</code>, an effect of the registering plugin; calls go through pre/execute/post policy waterfalls</td>
|
||
</tr>
|
||
<tr>
|
||
<td>approval</td>
|
||
<td>closed by default; <code>allowed-once</code> is the only grant; plugin installation needs full access or per-call approval</td>
|
||
</tr>
|
||
<tr>
|
||
<td>reload</td>
|
||
<td>a source watcher evicts changed modules; Loader entries running affected plugins stop and start again with the new code; a failed replacement is an activation failure, its previous code no longer runs</td>
|
||
</tr>
|
||
<tr>
|
||
<td>processes</td>
|
||
<td>one plugin tree in the Host process; the browser client and Electron's renderer talk to it over RPC streams</td>
|
||
</tr>
|
||
<tr>
|
||
<td>distribution</td>
|
||
<td>a Plugin Manager installs and enables bundles per profile; plugin markets are optional indexes</td>
|
||
</tr>
|
||
<tr>
|
||
<td>subagents</td>
|
||
<td>local subagents with their own durable sessions, and external ones</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="what-the-guarantees-are-in-practice">What the guarantees are in practice</h3>
|
||
<p>Reading the Cordis source vendored in dsh and dsh's own documentation, the guarantees are narrower than the paper's
|
||
framing. An earlier review for p2 came to the same result (<code>apps/p2/docs/p3-reviews/dsh-compare.md</code>).</p>
|
||
<ul>
|
||
<li><strong>Reversal is cooperative and unordered.</strong> <code>_unload</code> runs all of a fiber's top-level disposers concurrently
|
||
(<code>Promise.all</code> in <code>vendor/cordis/src/fiber.ts</code>); order holds only inside one effect, and the primer tells authors to
|
||
"keep the related work in one effect" when teardown order matters. A disposer that throws is logged and skipped. A
|
||
disposer that never settles stalls the teardown; dsh's jobs service works around this by force-failing a job whose
|
||
cancel throws and warning that "the work may be orphaned" (<code>packages/jobs/jobs-local/README.md</code>). Reversal is as
|
||
good as every disposer, and nothing checks that a plugin registered all its effects.</li>
|
||
<li><strong>Behaviour depends on registration order, and a reload changes it.</strong> <code>emit</code>, <code>waterfall</code>, <code>serial</code> and <code>bail</code>
|
||
listeners run in registration order (<code>docs/cordis-primer.md</code>). Registering appends the listener (<code>push</code> in
|
||
<code>vendor/cordis/src/events.ts</code>, unless <code>prepend</code>), so when a plugin reloads, its listeners move to the end of the
|
||
list. A tool-policy listener that ran first runs last after its own plugin reloads; a waterfall that short-circuits
|
||
now sees a different decision first.</li>
|
||
<li><strong>Dependencies are strings, and every provider reload restarts its dependents.</strong> A fiber's activation key is the
|
||
concatenation of its injected providers' fiber ids (<code>_refresh</code> in <code>fiber.ts</code>), so replacing a provider unloads and
|
||
reloads every plugin that injects it, with their in-memory state. Service APIs are "pre-stable; update every
|
||
consumer" (<code>AGENTS.md</code>): there is no versioned contract between a provider and its consumers.</li>
|
||
<li><strong>Reloading a core service ends live work.</strong> Unloading the agent provider "stops and drains every live handle it
|
||
made" (<code>packages/core/agent/README.md</code>), so running agents stop; reloading the session persistence backend under
|
||
live sessions invalidates their write handles, and "writes fail loudly until the sessions restart"
|
||
(<code>.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md</code>).</li>
|
||
<li><strong>Registrations leave a gap.</strong> A reloading plugin's tools and other registry entries are removed with its old fiber
|
||
and added again by the new one; anything that looks them up in between does not find them.</li>
|
||
</ul>
|
||
<p>None of this is a bug in the narrow sense: each point is documented, and dsh is built around it. But "components
|
||
interleave without disturbing each other" holds only for plugins that own nothing other plugins depend on, register
|
||
every effect, order nothing by registration, and hold no state that must outlive a reload.</p>
|
||
<h3 id="side-by-side">Side by side</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>Cordis and dsh</th>
|
||
<th>Chord v2</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>unit</td>
|
||
<td>a plugin instance (fiber) in one context tree</td>
|
||
<td>an object per (type, input, principal), placed on a host (chapters II, III)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>side effects</td>
|
||
<td>recorded on the fiber with <code>ctx.effect</code> and friends; on unload the disposers run concurrently, each as good as its author made it</td>
|
||
<td>declared through <code>self</code> (documents, natives, routines, jobs, contributions); Chord activates and undoes them (chapters V, IX to XI)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>dependencies</td>
|
||
<td>service names as strings in one flat namespace, via <code>inject</code>; a provider reload restarts every dependent</td>
|
||
<td>typed contracts via <code>get(Type, input)</code>; a reference parks and recovers through restarts (chapter VI); optional dependencies are registries (chapter XIII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>ordering</td>
|
||
<td>listeners run in registration order, which changes when a plugin reloads</td>
|
||
<td>registries compose with a declared <code>compose</code> function; nothing depends on load order (chapter XIII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>where code runs</td>
|
||
<td>one process; remote UIs over RPC to the Host</td>
|
||
<td>a tree of hosts (server, workers, tabs); objects reachable across links, relayed by the server (chapters I, XVII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>who is calling</td>
|
||
<td>not a framework concept</td>
|
||
<td>principals stamped by links, variants per caller, revocation (chapters III, VII)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>state</td>
|
||
<td>the plugin's own business; dsh's session log is a service</td>
|
||
<td>documents written in transactions, persisted, versioned and migrated (chapter V)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>reload</td>
|
||
<td>unload the old instance, load the new one: in-memory state and running work of that plugin end</td>
|
||
<td>pure objects swap their code in place with their documents and running work kept; only objects with natives restart (chapter XV)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>a running agent turn during reload</td>
|
||
<td>entries of changed plugins stop; unloading the agent provider stops its live agents</td>
|
||
<td>the harness is base code; Durable pins tools per task phase, so a running call finishes on old code and the next phase uses the new (chapter XIV)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>crash recovery of a turn</td>
|
||
<td>the turn is closed as <code>interrupted</code></td>
|
||
<td>pi-durable continues the turn's tasks from their checkpoints; replay-safe tools rerun (chapter XIV)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>contracts and versions</td>
|
||
<td>TypeScript types only, pre-stable APIs, every consumer updated together; config validated with a schema</td>
|
||
<td>contract versions with fingerprints and provider-side mappings for old consumers (chapter XVI)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>status</td>
|
||
<td>in production in Koishi since 2019; dsh released</td>
|
||
<td>a design with the contract layer built</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="what-the-comparison-says">What the comparison says</h3>
|
||
<p><strong>The core idea is the same.</strong> "Factories declare, Chord activates" (chapter II) and Cordis' reversible effects are
|
||
two forms of one rule: a plugin may only change the world through its runtime, so the runtime knows what to undo.
|
||
Both reject the module-level timer and the stored callback for the same reason.</p>
|
||
<p><strong>The difference is what survives a reload.</strong> Cordis reloads at plugin granularity: the old instance unwinds and the
|
||
new one starts from nothing, together with every plugin that depends on it. That is simple, but a plugin's in-memory
|
||
state and its dependents' state and work in flight end with it, and correctness rests on every disposer and on
|
||
registration order. Chord v2 keeps state out of closures on purpose (documents, references, jobs as
|
||
records) so that it can swap the code of a pure object while its data and running work stay, and restarts only what
|
||
holds OS handles. That is more machinery: transactions, a store, versions on every document and contract.</p>
|
||
<p><strong>The other difference is reach.</strong> Cordis composes plugins inside one process, and dsh adds RPC to its own clients.
|
||
Chord v2 makes the process boundary part of the model: objects live on the host their placement names, callers are
|
||
principals, and a reload rolls out across the server, the workers and every tab. An app like p2, with a process per
|
||
session and guests in other browsers, needs that; a single-process agent does not.</p>
|
||
<div class="note review"><b class="tag">Review: what a reload does to a running tool call in dsh</b><p>The sources say that Loader entries running affected plugins stop and that unloading the agent provider stops its live
|
||
agents (<code>packages/boot/hmr/README.md</code>, <code>docs/subsystems/core.md</code>). They do not say what happens to a tool call in
|
||
flight when only the tool's plugin reloads; the comparison above does not claim either way.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter epilogue" id="what-s-next"><header class="opener"><div class="numeral">∴</div><h2>What's Next</h2><p class="lede">Where Chord v2 stands, what is still undecided, and where the written sources disagree with each other.</p></header>
|
||
<p class="dropcap">The guide built Studio from one object to a p2-sized app. Every piece used the same few rules: one owner per thing,
|
||
factories that only declare, state that changes only in transactions, long-lived behaviour in declared forms, and
|
||
mappings and migrations owned by the provider. Those rules are what let Mario press Reload on his phone in the
|
||
preface while the agent keeps running. This last chapter says how much of that exists and what is still open.</p>
|
||
<h3 id="status">Status</h3>
|
||
<div class="stats"><div class="stat"><b>~10k</b><span>lines in the frozen first kernel (steps 1 to 6, 443 tests), kept as a reference only</span></div><div class="stat"><b>1 of 11</b><span>layers of the rebuild started: L1 contracts, built and reviewed</span></div><div class="stat"><b>6–7k</b><span>target size of Chord v2 without tests</span></div></div>
|
||
<ul>
|
||
<li><strong>The design is decided</strong> except the points marked below. <code>packages/chord/docs/next/design.md</code> is the target;
|
||
<code>packages/chord/docs/next/decisions.md</code> is the log and wins where they differ.</li>
|
||
<li><strong>The rebuild</strong> goes layer by layer, each with a one-page spec the owner approves, a minimal build with counting and
|
||
garbage-collection tests, the cost-guard metrics, and a cleanup loop: L1 data and contracts; L2 readable and
|
||
derive; L3 documents and store; L4 objects; L5 references and <code>ctx</code>; L6 protocol and links; L7 relay, routes,
|
||
revocation; L8 routines and jobs; L9 registries; L10 reload and rollout; L11 telemetry in Chord.</li>
|
||
<li><strong>Built:</strong> the contract layer (<code>src/next/contracts/</code>, commit <code>7d2bb8ea8</code>, chapter XVI). In pi: Delta,
|
||
<code>pi-telemetry</code>, <code>chord/context</code>, <code>chord/telemetry-sqlite</code>, pi-ai and Durable telemetry, Durable's required
|
||
<code>checkpointWhen</code>.</li>
|
||
<li><strong>Next, in order</strong> (<code>apps/p2/docs/p3-handoff.md</code>): decide Q12 and Q15, which the L1 spec needs; write the spec for
|
||
the rest of L1 (values, inputs and identity, argument checks, principals, errors; draft in
|
||
<code>packages/chord/docs/next/layer-1.md</code>); then Q11, Q13, Q14 and the cuts C3 to C17 before the layers they affect.</li>
|
||
<li><strong>In pi:</strong> <code>mapShared</code> and bytes as atomic leaves in Delta's <code>diffRevisions</code>.</li>
|
||
<li><strong>Then</strong> the playground web shell (React, esbuild) with the reload control and a preview at <code>p3.mariozechner.at</code>,
|
||
and the first real proof: the harness, one tool with an approval pause, a code mode reload. Then the HTTP and
|
||
channel components, then p3, domain by domain.</li>
|
||
</ul>
|
||
<h3 id="deferred-decisions">Deferred decisions</h3>
|
||
<p>Kept with stable numbers in <code>design.md</code> §26, each waiting for a concrete case:</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Topic</th>
|
||
<th>When</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>9</td>
|
||
<td>jobs as objects: a record inside the owner, or an object addressed by its id</td>
|
||
<td>when login is ported</td>
|
||
<td>XI, XIX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>11</td>
|
||
<td>where a flow lives: a keyed document of its owner, or an object <code>LoginFlow { id }</code></td>
|
||
<td>when login is ported</td>
|
||
<td>XII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>14</td>
|
||
<td>project plugins: per-directory plugins with trust prompts and snapshots</td>
|
||
<td>own pass over <code>plugin-loading.md</code></td>
|
||
<td>XV</td>
|
||
</tr>
|
||
<tr>
|
||
<td>16</td>
|
||
<td>a Durable Object profile</td>
|
||
<td>when a DO target becomes concrete</td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td>17</td>
|
||
<td>session files: one <code>SessionFiles { session }</code> owner on the server (proposed)</td>
|
||
<td>p3 design</td>
|
||
<td>XX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>18</td>
|
||
<td>session delete: retire by tag plus a session directory, or owner hooks</td>
|
||
<td>p3 design</td>
|
||
<td>XIX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>20</td>
|
||
<td>connection pools: one owner of SSH connections per environment, or per-use connections</td>
|
||
<td>p3 design</td>
|
||
<td>XX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>21</td>
|
||
<td>config files with a single writer: one owner on the server, others follow</td>
|
||
<td>p3 design</td>
|
||
<td>XVIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>22</td>
|
||
<td>file formats (<code>envs.json</code>, downloads, uploads) as versioned interfaces</td>
|
||
<td>p3 design</td>
|
||
<td>XX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>23</td>
|
||
<td>the terminal host process: inside the revision or deliberately outside</td>
|
||
<td>p3 design</td>
|
||
<td>XX</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="open-from-review-round-3">Open from review round 3</h3>
|
||
<p>Decisions (<code>apps/p2/docs/p3-reviews/v2-review3-summary.md</code>; Q1 to Q10 are decided):</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Problem</th>
|
||
<th>Proposal</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>Q11</td>
|
||
<td>a singleton that does not exist yet: <code>undefined</code> or <code>initial()</code>?</td>
|
||
<td>singletons read <code>initial()</code>; <code>undefined</code> only for absent keyed documents</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Q12</td>
|
||
<td>stripping arguments (R7) against "nothing is copied"</td>
|
||
<td>copy on change: a new object only when the argument had extra fields; never mutate the caller's value (proposed in the L1 draft)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Q13</td>
|
||
<td><code>ctx.incarnation</code> on relayed calls</td>
|
||
<td>relays stamp the origin host's incarnation, as they stamp the principal</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Q14</td>
|
||
<td>a writable holding functions exposed as a state member</td>
|
||
<td>only members with a JSON schema in the contract are exposable, checked at serve</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Q15</td>
|
||
<td>arguments of <code>local</code> methods: validated or not</td>
|
||
<td>apply R7 to <code>local</code> too, or keep <code>local</code> as the one exception</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p>Cuts the owner may choose (each removes a primitive this guide teaches):</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Cut</th>
|
||
<th>What it costs</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>C3</td>
|
||
<td>shrink jobs to a runner over documents (no versions, <code>accepts</code>, <code>blocked</code>, <code>persist</code>), or move jobs out of the kernel</td>
|
||
<td>job versioning, or <code>self.job</code> for core-only users</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C4</td>
|
||
<td>routines: <code>on</code> and <code>loop</code> only</td>
|
||
<td><code>every</code>'s due time across reloads</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C5</td>
|
||
<td>merge <code>lifetime: "host"</code> objects into the facet</td>
|
||
<td>a plugin's always-on natives share one failure domain</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C6</td>
|
||
<td>remove <code>broken()</code> for opens and <code>retryOn</code></td>
|
||
<td>nothing; reload and Retry still work</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C7</td>
|
||
<td>remove provider-side <code>linger</code></td>
|
||
<td>ten lines of app code</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C8</td>
|
||
<td>remove <code>state: false</code> (method-only references)</td>
|
||
<td>a method-only consumer pays one snapshot</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C9</td>
|
||
<td>remove <code>previous</code> from derives</td>
|
||
<td>derives that used it move to <code>mapShared</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>C10</td>
|
||
<td>one <code>cell(initial)</code> for <code>constant</code> and <code>writable</code></td>
|
||
<td>nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C11</td>
|
||
<td>remove <code>ctx.trace</code> (use <code>traceIn(ctx, …)</code>)</td>
|
||
<td>nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C12</td>
|
||
<td>remove call credits (the byte window covers calls)</td>
|
||
<td>nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C13</td>
|
||
<td>two timeouts (<code>closeTimeout</code>, <code>readyTimeout</code>) instead of eight</td>
|
||
<td>per-thing overrides</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C14</td>
|
||
<td>remove the <code>handle.change</code> and <code>handle.retire</code> shorthands</td>
|
||
<td>nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C15</td>
|
||
<td>remove <code>inVariant</code> and <code>expectVariant</code> (keep <code>RefIn</code>)</td>
|
||
<td>nothing</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C16</td>
|
||
<td>remove facet-owned mutable documents and their transaction exception</td>
|
||
<td>atomic writes across an object and its facet move to one owner</td>
|
||
</tr>
|
||
<tr>
|
||
<td>C17</td>
|
||
<td>no harness-specific start-barrier exception</td>
|
||
<td>factories before the barrier cannot depend on the harness</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="where-the-sources-disagree">Where the sources disagree</h3>
|
||
<p>This guide follows the decision log where it is newer than the design, and the design where it is newer than
|
||
everything else. The places that need a fix in the sources:</p>
|
||
<div class="note review"><b class="tag">Review: drift in the written sources</b><ul>
|
||
<li><code>design.md</code> §26 lists only the deferred decisions; it omits the open review-3 items Q11 to Q15 and the cuts C3 to
|
||
C17.</li>
|
||
<li><code>design.md</code> §3 has a garbled sentence, "Values are method arguments are stripped".</li>
|
||
<li><code>apps/p2/docs/p3-handoff.md</code> §3 is stale: it says all R items are decided and, two lines later, that R2 to R5 and
|
||
R7 to R26 are open, and "resume R6 next".</li>
|
||
<li><code>kernel.md</code> and <code>step-*.md</code> describe the frozen code: host values, a development freeze, consumer-side linger,
|
||
JSONL storage, descriptions on the wire, and a link closed on an unknown id. None of these is the target.</li>
|
||
<li><code>chord-next-walkthrough.md</code> lags the log: validation by trust (now R7, every caller), dev mode and freeze (removed
|
||
by R9), descriptions once per link (gone with R6), an IPC frame-count window (now one byte window, Q4), job outcome
|
||
<code>{ ok }</code> (now <code>{ kind }</code>), harness activation as the start barrier (R17), registries "newer refused at load" (Q8),
|
||
and hooks using <code>host.root</code> (now <code>chord(fn, context)</code>, Q6).</li>
|
||
<li><code>apps/p2/docs/questions.md</code> uses <code>self.resource</code> and <code>replace</code> (both rejected) and <code>host.root</code> in hooks.</li>
|
||
<li><code>apps/p2/docs/p3-reviews/v2-cost-model.md</code>: the measurements are current, its "Decisions this needs" section is
|
||
not (chapter XVII).</li>
|
||
<li>L1's <code>Implementation<C></code> shape differs from the factory return value used in the design (chapter XVI).</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="api"><header class="opener"><div class="numeral">A</div><h2>API Reference</h2><p class="lede">Every name the guide uses, grouped by where you meet it. Working names; only the contract layer is built.</p></header>
|
||
<h3 id="hosts">Hosts</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Call</th>
|
||
<th>Meaning</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>createHost<P, R>({ address?, principal?, storage?, principalKey?, revokedBy?, telemetry?, onError?, timeouts })</code></td>
|
||
<td>a host; with an address it is addressable and its own code acts as <code>principal</code>; without, a client</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.load([{ name, facet }])</code></td>
|
||
<td>load facets by plugin name; base code passes its core facet the same way</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.connect(link, { principal })</code></td>
|
||
<td>the link to the parent (at most one)</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.accept(link, { principal })</code></td>
|
||
<td>a link from a client; every request on it acts as <code>principal</code></td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.route(Kind, { authorize, resolve })</code></td>
|
||
<td>how to reach hosts of a kind; <code>resolve</code> returns <code>{ link, principal }</code></td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.revoke(r)</code></td>
|
||
<td>end matching objects and links here and below</td>
|
||
<td>VII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.root(fn, { principal?, signal?, telemetry? })</code></td>
|
||
<td>a call scope with a <code>ctx</code> for code outside objects</td>
|
||
<td>VI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.get(Type, input, { signal, state?, parkTimeout? })</code>, <code>host.hold(…)</code></td>
|
||
<td>a reference from outside objects; <code>release()</code> ends it</td>
|
||
<td>VI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.serve(Type, factory, { lifetime?, linger? })</code></td>
|
||
<td>serve an object type (in a facet)</td>
|
||
<td>II</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.registry(Registry)</code></td>
|
||
<td>the composed value of a registry, a readable</td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.references(Type)</code></td>
|
||
<td>who holds references to objects of a type on this host (presence)</td>
|
||
<td>VIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.activity</code></td>
|
||
<td><code>{ references, calls, jobs, lingering }</code>, for keep-alive</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>host.close()</code></td>
|
||
<td>end the host: objects, natives, links</td>
|
||
<td>I</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="inside-a-factory-self">Inside a factory: <code>self</code></h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Member</th>
|
||
<th>Meaning</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>self.state(Definition)</code></td>
|
||
<td>a document handle</td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.tx(async (tx) => …, ctx)</code></td>
|
||
<td>a transaction on the object's line</td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.derive(name, [sources], fn)</code></td>
|
||
<td>a derived readable</td>
|
||
<td>VIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.get(Type, input)</code></td>
|
||
<td>a reference held for the object's life</td>
|
||
<td>VI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.open(open, close, { closeTimeout? })</code></td>
|
||
<td>a native; <code>.value</code> once open</td>
|
||
<td>IX</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.routines.on(readable, fn)</code>, <code>.every(interval, fn)</code>, <code>.loop(fn)</code>, <code>.custom(…)</code></td>
|
||
<td>standing behaviour</td>
|
||
<td>X</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.job(Job, { run, finish? })</code></td>
|
||
<td>owned work that outlives its call</td>
|
||
<td>XI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.contribute(Registry, value)</code></td>
|
||
<td>a contribution (facets and <code>lifetime: "host"</code> objects only)</td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.writable(initial)</code></td>
|
||
<td>a host-local readable of any in-process value, with <code>set</code></td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>self.retryOn(readable)</code></td>
|
||
<td>a trigger that retries a broken activation</td>
|
||
<td>VI</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<p><strong>Document handles:</strong> <code>value</code>, <code>get(key)</code> (keyed), <code>keys</code>, <code>change(fn, ctx)</code> / <code>change(key, fn, ctx)</code> and <code>retire(ctx)</code> / <code>retire(key, ctx)</code> (shorthands for
|
||
one-document transactions). <strong>Transactions:</strong> <code>tx.draft(handle, key?, { tags? })</code>, <code>tx.retire(handle, key?)</code>,
|
||
<code>tx.start(job, args, { key? })</code>. <strong>Jobs:</strong> <code>job.start(args, ctx, { key? })</code>, <code>job.abort(id)</code>, <code>job.running</code>,
|
||
<code>run.outcome(ctx)</code>.</p>
|
||
<h3 id="references-and-ctx">References and <code>ctx</code></h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Member</th>
|
||
<th>Meaning</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>ref.<member></code></td>
|
||
<td>a state member (readable: <code>.value</code>, subscribe) or a method</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ref.state</code>, <code>ref.onState(fn)</code></td>
|
||
<td><code>loading</code>, <code>live</code>, <code>failed</code> (cause, <code>retryAt</code>), <code>released</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ref.retry()</code></td>
|
||
<td>retry a failed reference now</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ref.release()</code></td>
|
||
<td>end a reference taken outside objects</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ref.members</code></td>
|
||
<td>the variant's members</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ctx.signal</code>, <code>ctx.principal</code>, <code>ctx.incarnation</code>, <code>ctx.get</code></td>
|
||
<td>cancellation, caller, calling host's incarnation, call-scoped references</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ctx.telemetry</code>, <code>ctx.trace(name, attributes, fn)</code></td>
|
||
<td>the current span; a child span with a child <code>ctx</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ctx.jobId</code>, <code>ctx.progress(value)</code></td>
|
||
<td>in a job's <code>run</code></td>
|
||
</tr>
|
||
</tbody></table>
|
||
<h3 id="definitions">Definitions</h3>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>What it declares</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>hostKind(name, input?)</code></td>
|
||
<td>a kind of host and its address</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>remote(name, { version, input, placement, variants })</code></td>
|
||
<td>an object type reachable across links</td>
|
||
<td>II</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>local(name, { version, input, members })</code></td>
|
||
<td>an object type on its own host</td>
|
||
<td>IV</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>method(args?, result?)</code></td>
|
||
<td>a method in a base declaration</td>
|
||
<td>II</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>evolve(prev, (e) => …)</code>, <code>checkAccepts(contract, accepts)</code></td>
|
||
<td>the next contract version; the provider's mappings (<strong>built</strong>)</td>
|
||
<td>XVI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>Implementation<typeof C></code></td>
|
||
<td>the type of a provider for the current version (<strong>built</strong>)</td>
|
||
<td>XVI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>str num int bool nul lit oneOf union obj arr map opt bytes unknown</code>, <code>Static</code></td>
|
||
<td>schema helpers (<strong>built</strong>)</td>
|
||
<td>II</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>defineState(name, { schema, version, initial, checkpointWhen, persist?, keyed?, ordered?, migrate? })</code></td>
|
||
<td>a document</td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>defineTag<T>(name)</code></td>
|
||
<td>a tag; <code>store.retireByTag(tag, value)</code></td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>defineJob(name, { version, args, progress, result, concurrency?, persist?, accepts? })</code></td>
|
||
<td>a job type</td>
|
||
<td>XI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>defineRegistry(name, { version, compose, accepts? })</code></td>
|
||
<td>an extension point</td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>bindFacet<P>()</code></td>
|
||
<td>base code's <code>defineFacet</code>, with factories' principals typed as the app's <code>P</code> (working name)</td>
|
||
<td>III</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>defineFacet((host, self) => …)</code></td>
|
||
<td>a plugin's setup for one kind of host; imported from base code (<code>@studio/core</code>)</td>
|
||
<td>II, III</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>constant(value)</code></td>
|
||
<td>a readable that never changes (start data)</td>
|
||
<td>IX</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>broken(error)</code></td>
|
||
<td>a failure that is not retried by the backoff</td>
|
||
<td>VI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>inVariant(ref, …)</code>, <code>expectVariant(ref, …)</code>, <code>RefIn<…></code></td>
|
||
<td>narrowing a reference by variant</td>
|
||
<td>III</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>mapShared(fn)</code></td>
|
||
<td>an array map that keeps unchanged items identical (planned in Delta)</td>
|
||
<td>VIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>OutputBuffer</code>, <code>Progress</code></td>
|
||
<td>bounded output and throttled progress (<code>@earendil-works/chord/output</code>)</td>
|
||
<td>XI</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>memoryLinkPair()</code>, <code>ipcLink(child)</code>, <code>webSocketLink(ws)</code></td>
|
||
<td>link adapters</td>
|
||
<td>I</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>DocumentStorage</code>, <code>registerDocumentStorageConformance</code></td>
|
||
<td>the storage port and its shared test suite</td>
|
||
<td>V</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="defaults"><header class="opener"><div class="numeral">B</div><h2>Timeouts and Defaults</h2></header>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Timeout or default</th>
|
||
<th>Bounds</th>
|
||
<th>Default</th>
|
||
<th>Override</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>closeTimeout</code></td>
|
||
<td>a native's <code>close</code>, then reported as a possible leak</td>
|
||
<td>30 s</td>
|
||
<td>per native</td>
|
||
</tr>
|
||
<tr>
|
||
<td>call drain</td>
|
||
<td>an ending object's own call handlers</td>
|
||
<td>= <code>closeTimeout</code></td>
|
||
<td>per type</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>readyTimeout</code></td>
|
||
<td>activation; an awaited <code>get</code></td>
|
||
<td>60 s</td>
|
||
<td>per <code>get</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>park deadline</td>
|
||
<td>parked calls (link drop, loading, rollout)</td>
|
||
<td>60 s</td>
|
||
<td>per <code>get</code> (<code>parkTimeout</code>); per type</td>
|
||
</tr>
|
||
<tr>
|
||
<td>routine handover</td>
|
||
<td>a new routine waiting for the old handler on reload</td>
|
||
<td>10 s</td>
|
||
<td>per routine</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>finish</code> retry</td>
|
||
<td>backoff of a throwing job <code>finish</code></td>
|
||
<td>1 s to 60 s</td>
|
||
<td>per job</td>
|
||
</tr>
|
||
<tr>
|
||
<td>rollout install</td>
|
||
<td>one host installing a revision</td>
|
||
<td>60 s</td>
|
||
<td>none</td>
|
||
</tr>
|
||
<tr>
|
||
<td>linger</td>
|
||
<td>an object kept alive after its last reference</td>
|
||
<td>per type</td>
|
||
<td>per type</td>
|
||
</tr>
|
||
<tr>
|
||
<td>backoff</td>
|
||
<td>retries of transient failures, per address</td>
|
||
<td>1 s doubling to 60 s</td>
|
||
<td>none</td>
|
||
</tr>
|
||
<tr>
|
||
<td>eviction</td>
|
||
<td>keyed persisted documents idle in memory</td>
|
||
<td>10 minutes</td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td>client WebSocket limits</td>
|
||
<td>per part</td>
|
||
<td>16 MiB compressed, 64 MiB decoded</td>
|
||
<td>per link kind</td>
|
||
</tr>
|
||
<tr>
|
||
<td>compression</td>
|
||
<td>JSON parts compressed at their origin</td>
|
||
<td>above about 16 KiB</td>
|
||
<td></td>
|
||
</tr>
|
||
<tr>
|
||
<td>worker keep-alive (p2, app code)</td>
|
||
<td>idle time before the supervisor stops a worker</td>
|
||
<td>5 minutes</td>
|
||
<td>app</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="versions"><header class="opener"><div class="numeral">C</div><h2>Versions</h2></header>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Surface</th>
|
||
<th>Version</th>
|
||
<th>On change</th>
|
||
<th>Chapter</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>object type</td>
|
||
<td><code>version</code> plus fingerprint</td>
|
||
<td><code>evolve</code> and <code>checkAccepts</code>; otherwise <code>contract_mismatch</code></td>
|
||
<td>XVI</td>
|
||
</tr>
|
||
<tr>
|
||
<td>document</td>
|
||
<td><code>version</code> plus schema fingerprint</td>
|
||
<td>pure <code>migrate</code> on access; no bump fails <code>broken</code></td>
|
||
<td>V</td>
|
||
</tr>
|
||
<tr>
|
||
<td>job</td>
|
||
<td><code>version</code></td>
|
||
<td><code>accepts</code> on outcomes and <code>running</code>; <code>blocked</code> without</td>
|
||
<td>XI</td>
|
||
</tr>
|
||
<tr>
|
||
<td>registry</td>
|
||
<td><code>version</code></td>
|
||
<td><code>accepts</code> on contributions; unmatched ones left out and reported</td>
|
||
<td>XIII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>native</td>
|
||
<td>none</td>
|
||
<td>restarts on reload</td>
|
||
<td>IX</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Durable tasks and documents</td>
|
||
<td>Durable's <code>version</code></td>
|
||
<td>Durable's <code>migrate</code></td>
|
||
<td>XIV</td>
|
||
</tr>
|
||
<tr>
|
||
<td>link protocol</td>
|
||
<td>protocol version</td>
|
||
<td>refused; hosts update together</td>
|
||
<td>XVII</td>
|
||
</tr>
|
||
<tr>
|
||
<td>UI components, Chord and app APIs</td>
|
||
<td>none yet</td>
|
||
<td>exposed as they are</td>
|
||
<td></td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="errors"><header class="opener"><div class="numeral">D</div><h2>Errors and States</h2></header>
|
||
<p><code>ChordError</code> has a stable <code>code</code> and a finer <code>reason</code>; on the wire it is <code>{ code, reason?, message, details? }</code>
|
||
(specified in <code>layer-1.md</code>, not built).</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Code</th>
|
||
<th>Meaning</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>invalid</code></td>
|
||
<td>input, arguments or placement rejected (<code>invalid/input</code>, <code>invalid/args</code>, <code>invalid/placement</code>)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>contract_mismatch</code></td>
|
||
<td>the consumer's version and fingerprint are neither current nor accepted</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>forbidden</code></td>
|
||
<td>the caller may not do this</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>gone</code></td>
|
||
<td>the object or hold ended</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>not_ready</code></td>
|
||
<td>not activated in time</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>released</code></td>
|
||
<td>the reference was released</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>too_large</code></td>
|
||
<td>a part exceeded its size limit</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>unavailable</code></td>
|
||
<td>could not be reached before the deadline (<code>unavailable/locked</code> and others)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>aborted</code></td>
|
||
<td>cancelled by a signal</td>
|
||
</tr>
|
||
</tbody></table>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Type</th>
|
||
<th>Values</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>FailureCause</code></td>
|
||
<td><code>{ kind: "transient"; error }</code> (retried with backoff) or <code>{ kind: "broken"; error }</code> (retried only by a trigger)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>reference state</td>
|
||
<td><code>loading</code>, <code>live</code>, <code>failed</code> (cause, <code>retryAt</code>), <code>released</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>job outcome</td>
|
||
<td><code>{ kind: "done"; result }</code> or <code>{ kind: "failed"; error: "threw" | "aborted" | "lost" }</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>job phase</td>
|
||
<td><code>running</code>, <code>finishing</code>, <code>blocked</code></td>
|
||
</tr>
|
||
<tr>
|
||
<td>rollout status of a provider</td>
|
||
<td><code>updating</code> (calls park), rejected (fail at once), same revision (<code>contract_mismatch</code>)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>host install report</td>
|
||
<td><code>installed</code>, <code>installed_failed</code>, <code>rejected</code></td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="rejected"><header class="opener"><div class="numeral">E</div><h2>Rejected Designs</h2></header>
|
||
<p>From <code>design.md</code> §31, grouped. Each was considered and is not coming back without a new reason.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Area</th>
|
||
<th>Rejected</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td>reload</td>
|
||
<td>drain-based handover; object restart for state changes; a factory trial; a migrate dry run; a consumer contract check during staging; reload votes; per-plugin rollback within a host; keeping natives across reloads by name; <code>restart_required</code>; host values and a development freeze</td>
|
||
</tr>
|
||
<tr>
|
||
<td>state</td>
|
||
<td>version fences without transactions; volatile documents; per-group lines; optimistic transactions; <code>tx.get</code>; a JSONL document storage; validating state and documents on commits or reads; copying values on same-host calls; op-rewriting projections and a view algebra; Durable's Session as the object store</td>
|
||
</tr>
|
||
<tr>
|
||
<td>objects and references</td>
|
||
<td>resources replaced in place (<code>replace</code>, <code>deps</code>, stale handles); revoking proxies on <code>.value</code>; consumer-side linger; detached references counting toward presence; an <code>optional</code> flag on references; <code>opaque</code> members; callbacks as method arguments; generic call or request ids; a stream type in Chord</td>
|
||
</tr>
|
||
<tr>
|
||
<td>registries</td>
|
||
<td>registries created at runtime; entries resolved through mutable indirection; generic registry keys with a <code>conflict</code> flag; numeric middleware priorities; registry composition as a staging check</td>
|
||
</tr>
|
||
<tr>
|
||
<td>principals and placement</td>
|
||
<td>principal field patterns and re-stamping for revocation; <code>storeKey</code>; placement as data naming an input field; host-name strings in app code; "magic" input-field matching for delete</td>
|
||
</tr>
|
||
<tr>
|
||
<td>wire</td>
|
||
<td>an outbox for transient delivery errors; cross-relay reset coordination; count limits that refuse calls or holds; contracts taken from the running app</td>
|
||
</tr>
|
||
<tr>
|
||
<td>process and tooling</td>
|
||
<td>a topology layer above the host API; loader variants A and B; Chord ending the process on a storage failure; <code>defaultProjectTrust: "always"</code>; ceremony (<code>native/</code> directories, <code>defineNative</code>, lints); weakening type checking</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="glossary"><header class="opener"><div class="numeral">F</div><h2>Glossary</h2></header>
|
||
<p><strong>Adapted binding:</strong> a consumer bound at an older accepted contract version, served through the provider's mappings.
|
||
<strong>Base code:</strong> the app's own code, imported statically; a change restarts the hosts. <strong>Client:</strong> a tab or TUI host,
|
||
not addressable. <strong>Contract:</strong> an object type's declaration (name, input, placement, members, version).
|
||
<strong>Contribution:</strong> a value a plugin adds to a registry. <strong><code>ctx</code>:</strong> what every call receives. <strong>Derive:</strong> a value
|
||
computed from readables. <strong>Document:</strong> a named JSON value an object owns. <strong>Facet:</strong> a plugin's code for one kind of
|
||
host. <strong>Factory:</strong> the synchronous function that declares an object. <strong>Fingerprint:</strong> a hash of a contract's or
|
||
schema's normal form. <strong>Flow:</strong> input into running work, kept as state by its owner. <strong>Host:</strong> one participant of the
|
||
app, with an identity and an incarnation. <strong>Incarnation:</strong> the id a host gets on every start. <strong>Input:</strong> an object's
|
||
key among objects of its type; with the type and the caller's principal key it identifies the object. <strong>Job:</strong> owned work that outlives its call. <strong>Link:</strong> an ordered connection between
|
||
two hosts. <strong>Local type:</strong> an object type reachable only on its own host, without principals. <strong>Native:</strong> an OS-level
|
||
value opened with <code>self.open</code>. <strong>Park:</strong> a call waiting for a reference to become live again. <strong>Placement:</strong> the
|
||
function from input to host address. <strong>Plugin:</strong> a directory with a <code>package.json</code>, reloadable. <strong>Principal:</strong> who
|
||
calls, stamped by the link. <strong>Principal key:</strong> the string that identifies a principal in object identities. <strong>Pure
|
||
object:</strong> an object without natives; swapped in place on reload. <strong>Readable:</strong> a value plus its ordered changes.
|
||
<strong>Reference:</strong> what <code>get</code> returns. <strong>Registry:</strong> an owner's extension point. <strong>Relay:</strong> a host forwarding messages
|
||
between two links, rewriting only headers. <strong>Reload:</strong> swapping a plugin's code in running hosts. <strong>Restart:</strong> ending a
|
||
process. <strong>Revision:</strong> the complete code set the hosts should run. <strong>Revocation:</strong> ending live access for principals
|
||
matched by <code>revokedBy</code>. <strong>Routine:</strong> standing behaviour of an object. <strong>Server:</strong> the addressable root host. <strong>Shared
|
||
store:</strong> a local object that holds data once behind per-principal views. <strong>Tag:</strong> a typed label on documents, used to
|
||
retire them together. <strong>Transaction:</strong> an atomic set of document writes on the object's line. <strong>Variant:</strong> the set of
|
||
members a principal gets. <strong>View:</strong> a per-principal remote object in front of a shared store. <strong>Worker:</strong> a host the
|
||
server starts for one session.</p>
|
||
</section>
|
||
|
||
<section class="chapter appendix" id="sources"><header class="opener"><div class="numeral">G</div><h2>Sources</h2></header>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Source</th>
|
||
<th>Used for</th>
|
||
<th>Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody><tr>
|
||
<td><code>packages/chord/docs/next/decisions.md</code></td>
|
||
<td>every decision, review rounds 2 and 3</td>
|
||
<td>wins where newer</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>packages/chord/docs/next/design.md</code></td>
|
||
<td>the target design, all chapters</td>
|
||
<td>authoritative otherwise</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>packages/chord/src/next/contracts/</code> and its <code>README.md</code></td>
|
||
<td>chapter XVI</td>
|
||
<td>built code</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>packages/chord/docs/next/layer-1.md</code></td>
|
||
<td>errors, inputs, argument checks</td>
|
||
<td>draft spec</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>apps/p2/docs/p3-reviews/v2-review3-summary.md</code></td>
|
||
<td>open Q11 to Q15, cuts C3 to C17</td>
|
||
<td>current</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>apps/p2/docs/p3-reviews/v2-cost-model.md</code></td>
|
||
<td>measurements, per-hop costs</td>
|
||
<td>current except its last section</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>apps/p2/docs/p3-functions/</code></td>
|
||
<td>chapters XVIII to XX</td>
|
||
<td>current</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>apps/p2/docs/p3-handoff.md</code></td>
|
||
<td>status and order of work</td>
|
||
<td>§3 partly stale</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>packages/durable/README.md</code></td>
|
||
<td>the harness, submit, tasks, subagents</td>
|
||
<td>current</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>chord-next-walkthrough.md</code>, <code>kernel.md</code>, <code>step-*.md</code>, <code>apps/p2/docs/questions.md</code></td>
|
||
<td>background only</td>
|
||
<td>lag the decisions (see What's Next)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>DeepSeek Harness at <code>687f282</code> (its Cordis tutorial, architecture, persistence, core and HMR docs), the Cordis paper (arXiv 2608.25512)</td>
|
||
<td>"Other Ways to Do It"</td>
|
||
<td>external, built systems</td>
|
||
</tr>
|
||
</tbody></table>
|
||
</section>
|
||
|
||
<p class="colophon">Chord v2 · a guide · first edition · 2026-10-20 · based on design.md (target design v2), redesign-decisions.md (review rounds 2 and 3), chord-next-walkthrough.md, layer-1.md, contracts/README.md, the p3 functionality map, questions.md and the pi-durable README</p>
|
||
</div></div>
|
||
<script>(function activate(root) {
|
||
const replay = (f) => {
|
||
f.classList.remove("play");
|
||
void f.getBoundingClientRect();
|
||
f.classList.add("play");
|
||
};
|
||
const io = new IntersectionObserver(
|
||
(es) => {
|
||
for (const e of es)
|
||
if (e.isIntersecting) {
|
||
e.target.classList.add("play");
|
||
io.unobserve(e.target);
|
||
}
|
||
},
|
||
{ threshold: 0.3 },
|
||
);
|
||
root.querySelectorAll(".anim:not(.armed)").forEach((f) => {
|
||
f.classList.add("armed");
|
||
io.observe(f);
|
||
const b = f.querySelector(".replay");
|
||
if (b) b.onclick = () => replay(f);
|
||
});
|
||
root.querySelectorAll(".steps:not(.js)").forEach((s) => {
|
||
s.classList.add("js");
|
||
const ps = [...s.querySelectorAll(":scope>.step-panel")];
|
||
let i = 0;
|
||
const c = s.querySelector(".count"),
|
||
p = s.querySelector(".prev"),
|
||
n = s.querySelector(".next");
|
||
const show = () => {
|
||
ps.forEach((x, j) => x.classList.toggle("on", j === i));
|
||
c.textContent = `${i + 1} / ${ps.length}`;
|
||
p.disabled = i === 0;
|
||
n.disabled = i === ps.length - 1;
|
||
ps[i].querySelectorAll(".anim").forEach(replay);
|
||
};
|
||
p.onclick = () => {
|
||
i--;
|
||
show();
|
||
};
|
||
n.onclick = () => {
|
||
i++;
|
||
show();
|
||
};
|
||
show();
|
||
});
|
||
})(document)</script>
|
||
</body></html>
|