@quo-systems/quo 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SPEC.md CHANGED
@@ -539,7 +539,8 @@ the standing is.
539
539
  ### The public being
540
540
 
541
541
  A ward may have one public being, and no more. She is an ordinary being,
542
- booted by the owner and marked public. Arrivals for no heir reach her. Her
542
+ booted by the owner and then marked public by the owner's `public` ask.
543
+ Arrivals for no heir reach her. Her
543
544
  asker is `{}`: she is asked by anyone, and she can tell. She may hold
544
545
  standings. She may invite, and an occupant of hers arrives named while
545
546
  strangers still arrive as `{}`. A ward without a public being answers
@@ -560,6 +561,14 @@ So her answer must be safe to repeat. This is her obligation, not a gap at
560
561
  the door. Anything that must happen once lives behind an invitation, where
561
562
  there is a heir and a count.
562
563
 
564
+ Nothing else about her is different. Her cells, her standings and what she
565
+ keeps are hers, exactly as they are for every being, and Quo has no say in
566
+ them: she may hold a board every stranger plays on, a catalogue, a queue, or
567
+ nothing at all. She is an ordinary being who chose to describe herself to
568
+ strangers and to answer them, and that choice is the whole of what makes her
569
+ public. A stranger who keeps knocking is the harbor's, never hers and never
570
+ the door's.
571
+
563
572
  ### How a relation crosses wards
564
573
 
565
574
  1. She mints an id. Her ward mints a heir for it, keeps the pk, and hands
@@ -818,6 +827,14 @@ silent. It keeps every being it booted, builds every stance, mints every
818
827
  key, seals every ask that leaves, and unseals every one that arrives. Its
819
828
  beings trust it blindly. It trusts its harbor the same way.
820
829
 
830
+ Only a ward moves. A being has no address of her own and never leaves the
831
+ ward that booted her: her peers hold a standing at a ward pk, and what they
832
+ trust is that ward's word about an id, never the being under it. So which
833
+ beings share a ward is settled when they are booted, and a ward migrates
834
+ whole, with everyone in it, or not at all. To move one being would be to ask
835
+ every peer to trust a ward they never accepted, and asking that is what an
836
+ invitation is.
837
+
821
838
  Its ward is itself. It boots itself as the first being in its own map, under
822
839
  its own pk, and its own stance is built by the same code that builds every
823
840
  being's. Its cells are its partition, all the way down.
@@ -834,10 +851,14 @@ One ward is one runtime and one language, and every being in it shares both.
834
851
  A harbor that wants two languages starts two wards at least. Under
835
852
  `src/being/`, `src/ward/`, `src/harbor/` and `src/conformance/` no runtime is
836
853
  named. The whole
837
- platform surface is the language plus six globals -- `crypto`, `TextEncoder`,
838
- `TextDecoder`, `structuredClone`, `setTimeout`, `atob` -- and that list is a
839
- promise, not an accident: `test/terrain.test.ts` fails the build both when a
840
- file names a platform and when it reaches for a global outside the list.
854
+ platform surface is the language plus ten globals. Six are the ward's own:
855
+ `crypto`, `TextEncoder`, `TextDecoder`, `structuredClone`, `setTimeout` and
856
+ `atob`. Four more came with the harbor, and are the whole of what a reach
857
+ costs: `fetch` and `WebSocket`, the two kinds of reach; `DataView`, the frame
858
+ id on a socket; and `clearTimeout`, the dialer's reconnect called off. That
859
+ list is a promise, not an accident: `test/terrain.test.ts` fails the build
860
+ both when a file names a platform and when it reaches for a global outside
861
+ the list, and when the list names one the tree has stopped using.
841
862
 
842
863
  The package exports raw TypeScript, extension and all, on every specifier, so
843
864
  Node loads it directly and every other terrain reaches a ward through a
@@ -874,7 +895,8 @@ partition
874
895
  bind key -> her bind table
875
896
  standings id -> { ward, heir | null, current, next, seq } her keys for a standing
876
897
  occupants id -> heir pk
877
- knocks <ward>:<heir> | public:<ward> -> { current, next, spoke, seq } before take
898
+ knocks <ward>:<heir> | public:<ward> -> { current, next, spoke, sent, seq } before take
899
+ spoke: bytes came back once. sent: bytes went out once, answered or not
878
900
  answered <ward>:<heir> | public:<ward> -> true knocked and answered
879
901
  minted the last eight pks her side minted
880
902
  heirs heir pk -> { being, id, current, announced, fresh, mark, spent } the door's view of every occupant
@@ -945,19 +967,25 @@ become one.
945
967
  The root is not an occupant. It holds no heir, no key, no invitation. The
946
968
  ward names every arrival on the ask `{ id: 'OWNER' }`, the third asker of the
947
969
  ward-to-being edge, and answers it as such; an owner at the door arrives as
948
- her id. What an owner can do is exactly what a being cannot: boot, place a
949
- relation into a being of the ward, and take one out of her. The root's asks
950
- carry no allowance of their own.
970
+ her id. What an owner can do is exactly what a being cannot: boot, mark the
971
+ public being, place a relation into a being of the ward, take one out of
972
+ her, and take her out of the ward. The root's asks carry no allowance of
973
+ their own. The ward is a being to her owner: the empty ask is her describe,
974
+ and each ask in it carries a description and an input naming its fields, as
975
+ a being's asks do, so that a side renders the owner's asks the way it
976
+ renders anyone's and holds no list of its own.
951
977
 
952
978
  ```
953
- ask() -> { asks: [boot, invite, knock, remove],
979
+ ask() -> { asks: [boot, public, invite, knock, remove, unboot],
954
980
  notes: { pk, beings: { key: { class, public, digest } } } }
955
- ask('boot', { key, class, public? }) -> { booted: key } | { error }
981
+ ask('boot', { key, class }) -> { booted: key } | { error }
982
+ ask('public', { key }) -> { public: key } | { error }
956
983
  ask('invite', { being, id }) -> invitation | null | { error }
957
984
  ask('knock', { being | { boot: class, key },
958
985
  id, invitation, method?, args?, wanted? })
959
986
  -> { taken: id | null, answer } | { error: 'silence' | 'unreached' | ... }
960
987
  ask('remove', { being, id }) -> { removed: id } | { error }
988
+ ask('unboot', { being }) -> { unbooted: key, removed: [id, ...] } | { error }
961
989
  anything else -> { error: 'unknown ask' }
962
990
  ```
963
991
 
@@ -965,9 +993,15 @@ anything else -> { error: 'unknown ask' }
965
993
  spend, and saying nothing is the ward's default, exactly as for a being.
966
994
  - The describe runs every being's own describe as `OWNER` and hashes it; a
967
995
  being that throws or falls silent there shows a null digest.
968
- - Boot refuses a key already booted, a class the harbor does not know, and a
969
- second public being; a boot that made nobody leaves the partition as it
970
- found it. A throw at birth is that boot's error, and nothing half-lives.
996
+ - Boot refuses a key already booted and a class the harbor does not know; a
997
+ boot that made nobody leaves the partition as it found it. A throw at
998
+ birth is that boot's error, and nothing half-lives.
999
+ - Public marks a being already booted as the ward's one public being. It
1000
+ refuses a key not booted, the ward's own pk, and a second public being
1001
+ while one stands, since marking a second would leave the first holding
1002
+ every relation she had, reachable by nobody at the bare pk and told by
1003
+ nobody she was replaced. Marking the one already public again is
1004
+ answered, and changes nothing.
971
1005
  - Invite on the ward's own pk mints an owner, and only the root may ask it:
972
1006
  from the door it is answered as an invite on nobody. The ward has
973
1007
  occupants, its owners, and no standings: a knock for the ward itself is a
@@ -978,6 +1012,17 @@ anything else -> { error: 'unknown ask' }
978
1012
  chose her class and her cells at boot; her owner may choose a relation for
979
1013
  her too. The boot form names a being new or existing: a key already booted
980
1014
  is a being of theirs, not a class that failed.
1015
+ - **Unboot is the inverse of boot, and the only way a being leaves a ward.**
1016
+ Every relation she holds goes with her, by the same calls she would have
1017
+ used herself, so an occupant of hers hears `removed` from the keys her door
1018
+ kept rather than meeting a being who is not there; the owner is told which
1019
+ ids went. Her cells and her bind table go too, because a row naming a being
1020
+ no door holds would boot her again on the next restart. If she was the
1021
+ public being the mark goes with her, and the ward answers arrivals for no
1022
+ heir with silence as one that never had a public being does. The ward
1023
+ itself is refused, for the reason knock is: it would be a ward deleting
1024
+ itself from inside its own map, leaving its owners bound to a door that is
1025
+ gone.
981
1026
  - The owner's remove is the mirror of its knock: a relation out of a being,
982
1027
  by id, and the id may be an occupant or a standing, since the two share
983
1028
  one namespace. The being's own remove says nothing; the owner hears
@@ -1145,8 +1190,7 @@ way waits out the ceiling before asking again, because a suite does not
1145
1190
  become speakable by asking sooner. Its wait returns to the first second
1146
1191
  where the far side announced and was taken, and not where the line merely
1147
1192
  opened: a listener that opens and then refuses would otherwise be dialed at
1148
- a fixed two seconds for good, which is what the lab measured before the
1149
- code was read. Absent is this suite,
1193
+ a fixed two seconds for good. Absent is this suite,
1150
1194
  because a caller older than the header is this one. Present and different is
1151
1195
  refused as it is written: the number is compared as it was sent, so a suite
1152
1196
  is one spelling and not a family of them.
@@ -1188,7 +1232,10 @@ socket this harbor holds for that pk, and never onward by request. So a
1188
1232
  harbor that cannot be dialed is reached through the one it dialed, by
1189
1233
  anyone who holds a hint that its pk is there. The **dialer**,
1190
1234
  `src/harbor/dial.ts`, is that harbor's half: one held socket to a URL, its
1191
- own pks announced when the line opens, the listener's bound, dialed again
1235
+ own pks announced when the line opens and again whenever what this harbor
1236
+ holds changes, so a ward booted, adopted or dropped on a line already in hand
1237
+ is reachable through it without waiting for that line to fall; the listener's
1238
+ bound, dialed again
1192
1239
  with a wait that doubles from a second to thirty when the line drops, and
1193
1240
  while it is dialed it is one of the harbor's fallbacks, where a pk nobody
1194
1241
  here knows is sent. Fallbacks are a list and a pk is tried down it in
@@ -1393,8 +1440,8 @@ alone, one JavaScript file and one declaration file per source file with
1393
1440
  every relative import rewritten, and the exports map points there, types
1394
1441
  beside default. The
1395
1442
  build runs before every gate and every publish and is never tracked;
1396
- nothing under `test/` or `dock/` imports it except through the package
1397
- name, which is how a consumer does. `test/package.test.ts` is the one
1443
+ nothing in this repository imports it except through the package name,
1444
+ which is how a consumer does. `test/package.test.ts` is the one
1398
1445
  suite that meets the package as a stranger: it packs the tarball,
1399
1446
  installs it into an empty folder, and imports every entry point under
1400
1447
  plain Node. `npm pack --dry-run` shows what ships: `dist/`, the four
@@ -1413,11 +1460,6 @@ test/ the suites.
1413
1460
  vectors/ fixed inputs and outputs: the arithmetic, Quo's own framing, the frames on the wire.
1414
1461
  ```
1415
1462
 
1416
- `dock/` sits beside `src` and is not Quo: the beings, screens, model sides
1417
- and real harbors built on it, each folder with its own document. This
1418
- document does not read them, and they may not add a word to it. Its one
1419
- claim on them is that every harbor there passes `src/conformance/` untouched.
1420
-
1421
1463
  The hand to a kit in another language is two things, and they are of two
1422
1464
  kinds. `vectors/` is the byte-level hand: fixed inputs and outputs for
1423
1465
  everything a stranger can observe, the arithmetic, the ward pk, the digest,
@@ -1464,11 +1506,14 @@ passes the same suite. Those tests are the checklist, not the mock.
1464
1506
  on Quo actually has -- scattered wards on scattered harbors, partners and
1465
1507
  employees invited, knocking, taken, kicked, and moving to harbors of their
1466
1508
  own. It keeps a model of the graph, plays legal moves against it from three
1467
- fixed seeds, and after every move holds three things: that the ledger every
1468
- ward keeps closes on both ends, that every arc in it answers when asked
1469
- exactly what being that arc means, and that the same seed played under all
1470
- three topologies lands on the same graph. The last is the promise of Quo at
1471
- a scale a scene cannot reach. A relation is two arcs and never one edge:
1509
+ fixed seeds, and after every move holds two things: that the ledger every
1510
+ ward keeps closes on both ends, and that every arc in it answers when asked
1511
+ exactly what being that arc means. The second is held under all three
1512
+ topologies, and that is the promise of Quo at a scale a scene cannot
1513
+ reach: the model never learns the topology, so neither may any answer. The
1514
+ model is the script's own bookkeeping and is never compared with itself
1515
+ across topologies, which would hold by construction and prove nothing. A
1516
+ relation is two arcs and never one edge:
1472
1517
  the occupant is the host's, the standing is the guest's, and the model
1473
1518
  keeps them apart because the ward does. A red run prints its seed and the
1474
1519
  moves that got there. It is read beside one hand-written story that says in
@@ -1645,6 +1690,19 @@ Decided here, so that the answer is not rediscovered:
1645
1690
  the default says so with `wanted` on the ask, which is the whole of what a
1646
1691
  being needs. An owner who wants another ceiling is asking for a different
1647
1692
  ward.
1693
+ - **A leaked seed is a taken ward, and there is no succession.** The seed has
1694
+ one custodian, the harbor, which vouches that it stays secret; a being
1695
+ never touches it and no byte on the wire carries it. So it is lost only
1696
+ where that vouch broke, and a broken vouch is total and silent. It is not
1697
+ lost alone: a harbor keeps the seed and the partition under one name, and
1698
+ the partition holds every relation's keys, so there is no event where the
1699
+ address is taken and the relations are not. Nothing is left to succeed to.
1700
+ A succession signed by the key that speaks now is signed as well by
1701
+ whoever took it, and one that could not be forged would have to be
1702
+ committed to in advance, in the invitation, which carries nothing. The
1703
+ answer is a new ward, and every peer invited again as anyone is invited.
1704
+ Lose the seed, lose the ward; there is no recovery inside Quo, and that is
1705
+ the same sentence the root owner already hears.
1648
1706
 
1649
1707
  ## Glossary
1650
1708
 
@@ -12,20 +12,22 @@
12
12
  //
13
13
  // So this chapter does not write the sequence. It keeps a model of the graph,
14
14
  // plays legal moves against it from three fixed seeds, and after every move
15
- // asks three things:
15
+ // asks two things:
16
16
  //
17
17
  // the ledger is a graph every arc the wards hold closes on both ends
18
18
  // the ledger is what it can do every arc in the model is asked, and answers
19
19
  // exactly what being that arc means it answers
20
- // the graph is not the topology the same seed, played under one ward, under
21
- // a ward per being, and under two harbors,
22
- // ends in the same graph
23
20
  //
24
- // The third is the one that matters most here. A being never learns where the
25
- // other one is, so a script that is topology-invariant at forty moves is the
26
- // promise of Quo held at a scale a scene cannot reach -- and it is what makes
27
- // a migration honest: if nobody can tell the topology apart, moving a ward
28
- // between harbors cannot be felt.
21
+ // The second is asked under one ward, under a ward per being, and under two
22
+ // harbors, and that is where the promise of Quo is held: a being never learns
23
+ // where the other one is, so every answer, after every move, under every
24
+ // topology, is the answer the model wrote without knowing the topology
25
+ // either. Held forty times a run, it is the promise at a scale a scene
26
+ // cannot reach, and it is what makes a migration honest: if no answer can
27
+ // tell the topology apart, moving a ward between harbors cannot be felt.
28
+ // The model itself is the script's own bookkeeping and takes nothing from a
29
+ // ward, so it is never compared with itself across topologies: that would
30
+ // hold by construction and prove nothing.
29
31
  //
30
32
  // A relation is two arcs and never one edge. The occupant arc is the host's,
31
33
  // born at invite; the standing arc is the guest's, born at take, and only
@@ -45,17 +47,6 @@ const rolls = (seed) => () => {
45
47
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
46
48
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
47
49
  };
48
- // The graph, with nothing of the world in it: no key, no ward pk, no harbor.
49
- // Two runs under two topologies are the same estate when these agree.
50
- const abc = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
51
- const fingerprint = (g) => JSON.stringify({
52
- seats: g.seats.map((s) => JSON.stringify([s.host, s.id, s.guest, s.answered, s.live, s.taken])).sort(abc),
53
- holds: g.holds.map((h) => JSON.stringify([h.owner, h.id, h.seat.host, h.seat.id, h.live])).sort(abc),
54
- });
55
- // Every run of one seed, under every topology, must land on the same graph.
56
- // The topologies run in one process, so the first to finish records and the
57
- // rest are held to it.
58
- const landed = new Map();
59
50
  // ---- the ledger is a graph
60
51
  // Held after every move. Each line is a way the bookkeeping could stop being
61
52
  // a graph: an arc with one end, an arc with two owners, a relation filed in
@@ -303,14 +294,6 @@ export function estate(label, make, { canDown, test }) {
303
294
  throw new Error(`${at(step)}\n -> ${e.message}`, { cause: e });
304
295
  });
305
296
  }
306
- // and the estate that came out is the estate every topology comes out with
307
- const print = fingerprint(g);
308
- const key = `seed ${seed}`;
309
- const first = landed.get(key);
310
- if (!first)
311
- landed.set(key, { by: label, print });
312
- else
313
- assert.equal(print, first.print, `the estate under ${label} is not the estate under ${first.by}: a being learned the topology`);
314
297
  });
315
298
  }
316
299
  }
@@ -30,6 +30,7 @@ export declare class Harbor {
30
30
  readonly refused: Map<string, number>;
31
31
  readonly classes: Record<string, BeingClass>;
32
32
  readonly fallbacks: Reach[];
33
+ readonly announcers: Set<() => void>;
33
34
  constructor(store: Store, loader?: Loader);
34
35
  boot(): Promise<void>;
35
36
  carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
@@ -42,6 +42,18 @@ export class Harbor {
42
42
  // carries it: with a single slot, a harbor holding two lines answered
43
43
  // unreached for a pk the other line could have reached.
44
44
  fallbacks = [];
45
+ // How a dialer says what this harbor holds now. An announce is a claim and
46
+ // a claim is worth nothing until the door behind it answers a probe, so
47
+ // saying it again costs a proof and buys nothing to an impostor. What is
48
+ // not free is never saying it again: a ward booted, adopted or dropped
49
+ // after a line opened would be announced only by the next line, and a line
50
+ // that does not drop never opens again, so it would be unreachable through
51
+ // that listener for as long as the socket stayed healthy.
52
+ announcers = new Set();
53
+ #announce() {
54
+ for (const say of this.announcers)
55
+ say();
56
+ }
45
57
  constructor(store, loader = async () => ({})) {
46
58
  this.store = store;
47
59
  this.loader = loader;
@@ -164,6 +176,7 @@ export class Harbor {
164
176
  await h.save();
165
177
  this.wards.delete(name);
166
178
  this.doors.delete(h.pk);
179
+ this.#announce(); // this harbor no longer claims it: a listener unbinds a pk an announce stops naming
167
180
  }
168
181
  return this.store.take(name);
169
182
  }
@@ -213,6 +226,7 @@ export class Harbor {
213
226
  this.wards.set(name, hosted);
214
227
  this.doors.set(hosted.pk, door);
215
228
  await save();
229
+ this.#announce(); // reachable through every line this harbor already holds, not only the next one
216
230
  return hosted;
217
231
  }
218
232
  }
@@ -14,6 +14,7 @@ export function dial(harbor, url) {
14
14
  wait = 1000;
15
15
  void harbor.bind(far, s, false); // the listener's claims, each proven at its door: reached through this socket, not held for others
16
16
  }, (why) => {
17
+ harbor.announcers.delete(say);
17
18
  harbor.unbind(s);
18
19
  if (d.socket === s)
19
20
  d.socket = null;
@@ -30,9 +31,14 @@ export function dial(harbor, url) {
30
31
  wait = Math.min(wait * 2, 30000);
31
32
  });
32
33
  harbor.fallbacks.push(s); // from the moment it is dialed: an ask made before the handshake waits on the socket, and is not unreached
34
+ // What this harbor holds, said when the line opens and again whenever it
35
+ // changes. A ward booted or adopted after the handshake is announced on
36
+ // the line already in hand, and a dropped one stops being claimed at once.
37
+ const say = () => s.announce([...harbor.doors.keys()]);
33
38
  line.addEventListener('open', () => {
34
39
  d.socket = s;
35
- s.announce([...harbor.doors.keys()]);
40
+ harbor.announcers.add(say);
41
+ say();
36
42
  });
37
43
  };
38
44
  d.close = () => {
@@ -1,4 +1,4 @@
1
- import type { Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
1
+ import type { Ask, Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
2
2
  export type OwnerSide = {
3
3
  pk: string;
4
4
  doors: Map<string, {
@@ -24,9 +24,11 @@ export type OwnerSide = {
24
24
  };
25
25
  }>;
26
26
  publicKey(): string | null;
27
- instantiate(key: string, className: string, isPublic?: boolean): {
27
+ instantiate(key: string, className: string): {
28
28
  key: string;
29
29
  } | null;
30
+ unboot(key: string): string[] | null;
31
+ setPublic(key: string): void;
30
32
  };
31
- export declare const OWNER_ASKS: readonly ['boot', 'invite', 'knock', 'remove'];
33
+ export declare const OWNER_ASKS: Ask[];
32
34
  export declare function ownerAnswer(w: OwnerSide, asker: Asker, method: string | undefined, args: JsonObject): Promise<Json>;
@@ -1,13 +1,29 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // The owner's asks. The root owner holds the ask pointer; every other owner
3
- // is an occupant of the ward itself, invited by the root. Five asks:
4
- // describe, boot, invite, knock, remove. The knock is a being's knock made
5
- // for her, and the standing is written into her cells under the id the
6
- // owner gave; remove takes a relation out of her by id, the mirror of it.
3
+ // is an occupant of the ward itself, invited by the root. The ward is a
4
+ // being to her owner: the empty ask is her describe, and it carries her asks
5
+ // with a description and an input each, so a side renders them as it
6
+ // renders any being's. Six asks: boot, public, invite, knock, remove and
7
+ // unboot, which is boot's inverse and takes her relations with her. The
8
+ // knock is a being's knock made for her, and the standing is written into
9
+ // her cells under the id the owner gave; remove takes a relation out of her
10
+ // by id, the mirror of it.
7
11
  import { isSilence, isWord, wordOf } from '../being/silence.js';
8
12
  import { digest } from '../being/digest.js';
9
13
  import { OWNER } from '../being/types.js';
10
- export const OWNER_ASKS = ['boot', 'invite', 'knock', 'remove'];
14
+ const str = { type: 'string' };
15
+ export const OWNER_ASKS = [
16
+ { name: 'boot', description: 'boot a being by class name, under a key the owner chooses', input: { type: 'object', properties: { key: str, class: str }, required: ['key', 'class'] } },
17
+ { name: 'public', description: 'mark a booted being as the one public being of the ward, reached by anyone at the bare pk', input: { type: 'object', properties: { key: str }, required: ['key'] } },
18
+ { name: 'invite', description: 'mint an invitation on a being of the ward, under the id she will know the occupant by; on the ward pk it mints an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str }, required: ['being', 'id'] } },
19
+ {
20
+ name: 'knock',
21
+ description: 'knock for a being of the ward with an invitation, and take the standing under id if answered; being is a key booted, or { boot: class, key } to boot her first',
22
+ input: { type: 'object', properties: { being: { description: 'a key, or { boot, key }' }, id: str, invitation: { type: 'object' }, method: str, args: { type: 'object' }, wanted: { type: 'object', properties: { time: { type: 'number' } } } }, required: ['being', 'id', 'invitation'] },
23
+ },
24
+ { name: 'remove', description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk it unseats an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str }, required: ['being', 'id'] } },
25
+ { name: 'unboot', description: 'take a being out of the ward, with every relation she holds; her occupants hear removed, and the ward itself is refused', input: { type: 'object', properties: { being: str }, required: ['being'] } },
26
+ ];
11
27
  export async function ownerAnswer(w, asker, method, args) {
12
28
  // Every name the owner gives is a word. Coerced instead of checked, an
13
29
  // object passed where a name belongs becomes the string "[object Object]",
@@ -16,17 +32,24 @@ export async function ownerAnswer(w, asker, method, args) {
16
32
  if (method === undefined)
17
33
  return describe(w);
18
34
  if (method === 'boot') {
19
- // A ward may have one public being, and no more. Marking a second would
20
- // leave the first holding every relation she had, reachable by nobody at
21
- // the bare pk, and told by nobody that she had been replaced.
22
- if (args.public === true && w.publicKey() !== null)
23
- return { error: 'a ward has one public being' };
24
35
  const key = word(args.key), className = word(args.class);
25
36
  if (key === null || className === null)
26
37
  return { error: 'no such class, or key taken' };
27
- const door = w.instantiate(key, className, args.public === true);
38
+ const door = w.instantiate(key, className);
28
39
  return door ? { booted: door.key } : { error: 'no such class, or key taken' };
29
40
  }
41
+ if (method === 'public') {
42
+ // A ward may have one public being, and no more. Marking a second would
43
+ // leave the first holding every relation she had, reachable by nobody at
44
+ // the bare pk, and told by nobody that she had been replaced.
45
+ const key = word(args.key);
46
+ if (key === null || key === w.pk || !w.doors.has(key))
47
+ return { error: 'no such being' };
48
+ if (w.publicKey() !== null && w.publicKey() !== key)
49
+ return { error: 'a ward has one public being' };
50
+ w.setPublic(key);
51
+ return { public: key };
52
+ }
30
53
  if (method === 'invite') {
31
54
  // On the ward itself, only the root may invite: an occupant of the ward
32
55
  // is an owner, and ownership is minted by the ask pointer alone. An owner
@@ -90,6 +113,20 @@ export async function ownerAnswer(w, asker, method, args) {
90
113
  return { error: 'nothing to remove' };
91
114
  return { removed: id };
92
115
  }
116
+ if (method === 'unboot') {
117
+ // The inverse of boot, and the only way a being leaves a ward. Her
118
+ // relations go with her, so an occupant of hers hears `removed` from the
119
+ // keys the door kept, exactly as it would had the owner removed each id
120
+ // by hand; the owner hears which ids those were. The ward itself is
121
+ // refused, for the reason knock is: it is a being to nobody outside, and
122
+ // unbooting it would be a ward deleting itself from inside its own map,
123
+ // leaving its owners bound to a door that is gone.
124
+ const being = word(args.being);
125
+ if (being === null || being === w.pk)
126
+ return { error: 'no such being' };
127
+ const removed = w.unboot(being);
128
+ return removed === null ? { error: 'no such being' } : { unbooted: being, removed };
129
+ }
93
130
  return { error: 'unknown ask' };
94
131
  }
95
132
  // The ward's describe for its owner: its beings, their classes, a digest each,
@@ -110,7 +147,7 @@ async function describe(w) {
110
147
  beings[key] = { class: door.cells.class ?? null, public: w.publicKey() === key, digest: d };
111
148
  }
112
149
  return {
113
- asks: OWNER_ASKS.map((name) => ({ name, input: { type: 'object' } })),
150
+ asks: OWNER_ASKS,
114
151
  notes: { pk: w.pk, beings },
115
152
  };
116
153
  }
package/dist/ward/ward.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // leaves, opens every one that arrives.
7
7
  import { silence, isSilence, unreached, isWord, word } from '../being/silence.js';
8
8
  import { OWNER } from '../being/types.js';
9
- import { at, open, put, emptyBind, emptyCells } from './partition.js';
9
+ import { at, drop, open, put, emptyBind, emptyCells } from './partition.js';
10
10
  import { Heirs } from './heirs.js';
11
11
  import { makeDoor } from './door.js';
12
12
  import { buildStance } from './stance.js';
@@ -83,21 +83,46 @@ class Self {
83
83
  pk: this.pk,
84
84
  doors: this.doors,
85
85
  publicKey: () => this.p.public,
86
- instantiate: (key, className, isPublic) => this.#instantiate(key, className, isPublic),
86
+ instantiate: (key, className) => this.#instantiate(key, className),
87
+ unboot: (key) => this.#unboot(key),
88
+ setPublic: (key) => void (this.p.public = key),
87
89
  }, asker, method, args);
88
90
  }
89
91
  // ---- ward functions. on the object. no stance reaches them.
90
- #instantiate(key, className, isPublic = false) {
92
+ #instantiate(key, className) {
91
93
  if (!key || this.doors.has(key))
92
94
  return null;
93
95
  const door = this.#boot(key, className);
94
96
  if (!door)
95
97
  return null;
96
98
  door.cells.class = className; // so a restart finds her
97
- if (isPublic)
98
- this.p.public = key;
99
99
  return door;
100
100
  }
101
+ // The inverse of boot, and the only way a being leaves a ward. Her
102
+ // relations go first and by the same calls she would have used herself,
103
+ // so every occupant hears `removed` at the door from the keys it kept,
104
+ // rather than meeting a being who is simply not there any more. What is
105
+ // left after that is her cells and her bind table, and they go with her:
106
+ // a row naming a being no door holds would be read on the next restart
107
+ // and boot her again.
108
+ #unboot(key) {
109
+ const door = this.doors.get(key);
110
+ if (!door || key === this.pk)
111
+ return null;
112
+ const occupants = Object.keys(door.cells.occupants), standings = Object.keys(door.cells.standings);
113
+ for (const id of occupants)
114
+ door.stance.occupants.remove(id);
115
+ for (const id of standings)
116
+ door.stance.standings.remove(id);
117
+ // A ward whose public being is gone answers arrivals for no heir with
118
+ // silence, as a ward that never had one does.
119
+ if (this.p.public === key)
120
+ this.p.public = null;
121
+ drop(this.p.beings, key);
122
+ drop(this.p.bind, key);
123
+ this.doors.delete(key);
124
+ return [...occupants, ...standings];
125
+ }
101
126
  // Nothing is written until there is somebody to write it for: a class the
102
127
  // harbor does not know makes no being, and a boot that made nobody must
103
128
  // leave the partition as it found it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/quo",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Quo: an object asks another object and gets an answer, without knowing where it is. Being, Ward, Harbor.",
5
5
  "keywords": [
6
6
  "quo",
@@ -12,20 +12,22 @@
12
12
  //
13
13
  // So this chapter does not write the sequence. It keeps a model of the graph,
14
14
  // plays legal moves against it from three fixed seeds, and after every move
15
- // asks three things:
15
+ // asks two things:
16
16
  //
17
17
  // the ledger is a graph every arc the wards hold closes on both ends
18
18
  // the ledger is what it can do every arc in the model is asked, and answers
19
19
  // exactly what being that arc means it answers
20
- // the graph is not the topology the same seed, played under one ward, under
21
- // a ward per being, and under two harbors,
22
- // ends in the same graph
23
20
  //
24
- // The third is the one that matters most here. A being never learns where the
25
- // other one is, so a script that is topology-invariant at forty moves is the
26
- // promise of Quo held at a scale a scene cannot reach -- and it is what makes
27
- // a migration honest: if nobody can tell the topology apart, moving a ward
28
- // between harbors cannot be felt.
21
+ // The second is asked under one ward, under a ward per being, and under two
22
+ // harbors, and that is where the promise of Quo is held: a being never learns
23
+ // where the other one is, so every answer, after every move, under every
24
+ // topology, is the answer the model wrote without knowing the topology
25
+ // either. Held forty times a run, it is the promise at a scale a scene
26
+ // cannot reach, and it is what makes a migration honest: if no answer can
27
+ // tell the topology apart, moving a ward between harbors cannot be felt.
28
+ // The model itself is the script's own bookkeeping and takes nothing from a
29
+ // ward, so it is never compared with itself across topologies: that would
30
+ // hold by construction and prove nothing.
29
31
  //
30
32
  // A relation is two arcs and never one edge. The occupant arc is the host's,
31
33
  // born at invite; the standing arc is the guest's, born at take, and only
@@ -60,20 +62,6 @@ const rolls = (seed: number) => () => {
60
62
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
61
63
  };
62
64
 
63
- // The graph, with nothing of the world in it: no key, no ward pk, no harbor.
64
- // Two runs under two topologies are the same estate when these agree.
65
- const abc = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
66
- const fingerprint = (g: Graph): string =>
67
- JSON.stringify({
68
- seats: g.seats.map((s) => JSON.stringify([s.host, s.id, s.guest, s.answered, s.live, s.taken])).sort(abc),
69
- holds: g.holds.map((h) => JSON.stringify([h.owner, h.id, h.seat.host, h.seat.id, h.live])).sort(abc),
70
- });
71
-
72
- // Every run of one seed, under every topology, must land on the same graph.
73
- // The topologies run in one process, so the first to finish records and the
74
- // rest are held to it.
75
- const landed = new Map<string, { by: string; print: string }>();
76
-
77
65
  // ---- the ledger is a graph
78
66
 
79
67
  // Held after every move. Each line is a way the bookkeeping could stop being
@@ -332,13 +320,6 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
332
320
  throw new Error(`${at(step)}\n -> ${e.message}`, { cause: e });
333
321
  });
334
322
  }
335
-
336
- // and the estate that came out is the estate every topology comes out with
337
- const print = fingerprint(g);
338
- const key = `seed ${seed}`;
339
- const first = landed.get(key);
340
- if (!first) landed.set(key, { by: label, print });
341
- else assert.equal(print, first.print, `the estate under ${label} is not the estate under ${first.by}: a being learned the topology`);
342
323
  });
343
324
  }
344
325
  }
@@ -55,6 +55,17 @@ export class Harbor {
55
55
  // carries it: with a single slot, a harbor holding two lines answered
56
56
  // unreached for a pk the other line could have reached.
57
57
  readonly fallbacks: Reach[] = [];
58
+ // How a dialer says what this harbor holds now. An announce is a claim and
59
+ // a claim is worth nothing until the door behind it answers a probe, so
60
+ // saying it again costs a proof and buys nothing to an impostor. What is
61
+ // not free is never saying it again: a ward booted, adopted or dropped
62
+ // after a line opened would be announced only by the next line, and a line
63
+ // that does not drop never opens again, so it would be unreachable through
64
+ // that listener for as long as the socket stayed healthy.
65
+ readonly announcers = new Set<() => void>();
66
+ #announce(): void {
67
+ for (const say of this.announcers) say();
68
+ }
58
69
 
59
70
  constructor(store: Store, loader: Loader = async () => ({})) {
60
71
  this.store = store;
@@ -172,6 +183,7 @@ export class Harbor {
172
183
  await h.save();
173
184
  this.wards.delete(name);
174
185
  this.doors.delete(h.pk);
186
+ this.#announce(); // this harbor no longer claims it: a listener unbinds a pk an announce stops naming
175
187
  }
176
188
  return this.store.take(name);
177
189
  }
@@ -223,6 +235,7 @@ export class Harbor {
223
235
  this.wards.set(name, hosted);
224
236
  this.doors.set(hosted.pk, door);
225
237
  await save();
238
+ this.#announce(); // reachable through every line this harbor already holds, not only the next one
226
239
  return hosted;
227
240
  }
228
241
  }
@@ -33,6 +33,7 @@ export function dial(harbor: Harbor, url: string): Dialer {
33
33
  void harbor.bind(far, s, false); // the listener's claims, each proven at its door: reached through this socket, not held for others
34
34
  },
35
35
  (why) => {
36
+ harbor.announcers.delete(say);
36
37
  harbor.unbind(s);
37
38
  if (d.socket === s) d.socket = null;
38
39
  const at = harbor.fallbacks.indexOf(s);
@@ -46,9 +47,14 @@ export function dial(harbor: Harbor, url: string): Dialer {
46
47
  },
47
48
  );
48
49
  harbor.fallbacks.push(s); // from the moment it is dialed: an ask made before the handshake waits on the socket, and is not unreached
50
+ // What this harbor holds, said when the line opens and again whenever it
51
+ // changes. A ward booted or adopted after the handshake is announced on
52
+ // the line already in hand, and a dropped one stops being claimed at once.
53
+ const say = () => s.announce([...harbor.doors.keys()]);
49
54
  line.addEventListener('open', () => {
50
55
  d.socket = s;
51
- s.announce([...harbor.doors.keys()]);
56
+ harbor.announcers.add(say);
57
+ say();
52
58
  });
53
59
  };
54
60
  d.close = () => {
package/src/ward/owner.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // The owner's asks. The root owner holds the ask pointer; every other owner
3
- // is an occupant of the ward itself, invited by the root. Five asks:
4
- // describe, boot, invite, knock, remove. The knock is a being's knock made
5
- // for her, and the standing is written into her cells under the id the
6
- // owner gave; remove takes a relation out of her by id, the mirror of it.
3
+ // is an occupant of the ward itself, invited by the root. The ward is a
4
+ // being to her owner: the empty ask is her describe, and it carries her asks
5
+ // with a description and an input each, so a side renders them as it
6
+ // renders any being's. Six asks: boot, public, invite, knock, remove and
7
+ // unboot, which is boot's inverse and takes her relations with her. The
8
+ // knock is a being's knock made for her, and the standing is written into
9
+ // her cells under the id the owner gave; remove takes a relation out of her
10
+ // by id, the mirror of it.
7
11
  import { isSilence, isWord, wordOf } from '../being/silence.ts';
8
12
  import { digest } from '../being/digest.ts';
9
13
  import { OWNER } from '../being/types.ts';
10
- import type { Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
14
+ import type { Ask, Asker, Invitation, Json, JsonObject, Wanted } from '../being/types.ts';
11
15
 
12
16
  export type OwnerSide = {
13
17
  pk: string;
@@ -21,10 +25,24 @@ export type OwnerSide = {
21
25
  }
22
26
  >;
23
27
  publicKey(): string | null;
24
- instantiate(key: string, className: string, isPublic?: boolean): { key: string } | null;
28
+ instantiate(key: string, className: string): { key: string } | null;
29
+ unboot(key: string): string[] | null;
30
+ setPublic(key: string): void;
25
31
  };
26
32
 
27
- export const OWNER_ASKS = ['boot', 'invite', 'knock', 'remove'] as const;
33
+ const str = { type: 'string' };
34
+ export const OWNER_ASKS: Ask[] = [
35
+ { name: 'boot', description: 'boot a being by class name, under a key the owner chooses', input: { type: 'object', properties: { key: str, class: str }, required: ['key', 'class'] } },
36
+ { name: 'public', description: 'mark a booted being as the one public being of the ward, reached by anyone at the bare pk', input: { type: 'object', properties: { key: str }, required: ['key'] } },
37
+ { name: 'invite', description: 'mint an invitation on a being of the ward, under the id she will know the occupant by; on the ward pk it mints an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str }, required: ['being', 'id'] } },
38
+ {
39
+ name: 'knock',
40
+ description: 'knock for a being of the ward with an invitation, and take the standing under id if answered; being is a key booted, or { boot: class, key } to boot her first',
41
+ input: { type: 'object', properties: { being: { description: 'a key, or { boot, key }' }, id: str, invitation: { type: 'object' }, method: str, args: { type: 'object' }, wanted: { type: 'object', properties: { time: { type: 'number' } } } }, required: ['being', 'id', 'invitation'] },
42
+ },
43
+ { name: 'remove', description: 'take a relation out of a being of the ward by id, occupant or standing; on the ward pk it unseats an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str }, required: ['being', 'id'] } },
44
+ { name: 'unboot', description: 'take a being out of the ward, with every relation she holds; her occupants hear removed, and the ward itself is refused', input: { type: 'object', properties: { being: str }, required: ['being'] } },
45
+ ];
28
46
 
29
47
  export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | undefined, args: JsonObject): Promise<Json> {
30
48
  // Every name the owner gives is a word. Coerced instead of checked, an
@@ -34,16 +52,22 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
34
52
 
35
53
  if (method === undefined) return describe(w);
36
54
  if (method === 'boot') {
37
- // A ward may have one public being, and no more. Marking a second would
38
- // leave the first holding every relation she had, reachable by nobody at
39
- // the bare pk, and told by nobody that she had been replaced.
40
- if (args.public === true && w.publicKey() !== null) return { error: 'a ward has one public being' };
41
55
  const key = word(args.key),
42
56
  className = word(args.class);
43
57
  if (key === null || className === null) return { error: 'no such class, or key taken' };
44
- const door = w.instantiate(key, className, args.public === true);
58
+ const door = w.instantiate(key, className);
45
59
  return door ? { booted: door.key } : { error: 'no such class, or key taken' };
46
60
  }
61
+ if (method === 'public') {
62
+ // A ward may have one public being, and no more. Marking a second would
63
+ // leave the first holding every relation she had, reachable by nobody at
64
+ // the bare pk, and told by nobody that she had been replaced.
65
+ const key = word(args.key);
66
+ if (key === null || key === w.pk || !w.doors.has(key)) return { error: 'no such being' };
67
+ if (w.publicKey() !== null && w.publicKey() !== key) return { error: 'a ward has one public being' };
68
+ w.setPublic(key);
69
+ return { public: key };
70
+ }
47
71
  if (method === 'invite') {
48
72
  // On the ward itself, only the root may invite: an occupant of the ward
49
73
  // is an owner, and ownership is minted by the ask pointer alone. An owner
@@ -98,6 +122,19 @@ export async function ownerAnswer(w: OwnerSide, asker: Asker, method: string | u
98
122
  else return { error: 'nothing to remove' };
99
123
  return { removed: id };
100
124
  }
125
+ if (method === 'unboot') {
126
+ // The inverse of boot, and the only way a being leaves a ward. Her
127
+ // relations go with her, so an occupant of hers hears `removed` from the
128
+ // keys the door kept, exactly as it would had the owner removed each id
129
+ // by hand; the owner hears which ids those were. The ward itself is
130
+ // refused, for the reason knock is: it is a being to nobody outside, and
131
+ // unbooting it would be a ward deleting itself from inside its own map,
132
+ // leaving its owners bound to a door that is gone.
133
+ const being = word(args.being);
134
+ if (being === null || being === w.pk) return { error: 'no such being' };
135
+ const removed = w.unboot(being);
136
+ return removed === null ? { error: 'no such being' } : { unbooted: being, removed };
137
+ }
101
138
  return { error: 'unknown ask' };
102
139
  }
103
140
 
@@ -117,7 +154,7 @@ async function describe(w: OwnerSide): Promise<Json> {
117
154
  beings[key] = { class: door.cells.class ?? null, public: w.publicKey() === key, digest: d };
118
155
  }
119
156
  return {
120
- asks: OWNER_ASKS.map((name) => ({ name, input: { type: 'object' } })),
157
+ asks: OWNER_ASKS,
121
158
  notes: { pk: w.pk, beings },
122
159
  };
123
160
  }
package/src/ward/ward.ts CHANGED
@@ -8,7 +8,7 @@ import { silence, isSilence, unreached, isWord, word } from '../being/silence.ts
8
8
  import { OWNER } from '../being/types.ts';
9
9
  import type { Asker, BeingLike, Cells, JsonObject, Reply, Stance, Wanted, Word } from '../being/types.ts';
10
10
  import type { Ground, WardPointers } from './ground.ts';
11
- import { at, open, put, emptyBind, emptyCells, type Bind, type Partition, type StandingKeys } from './partition.ts';
11
+ import { at, drop, open, put, emptyBind, emptyCells, type Bind, type Partition, type StandingKeys } from './partition.ts';
12
12
  import { Heirs } from './heirs.ts';
13
13
  import { makeDoor, type Door } from './door.ts';
14
14
  import { buildStance } from './stance.ts';
@@ -90,7 +90,9 @@ class Self implements BeingLike {
90
90
  pk: this.pk,
91
91
  doors: this.doors,
92
92
  publicKey: () => this.p.public,
93
- instantiate: (key, className, isPublic) => this.#instantiate(key, className, isPublic),
93
+ instantiate: (key, className) => this.#instantiate(key, className),
94
+ unboot: (key) => this.#unboot(key),
95
+ setPublic: (key) => void (this.p.public = key),
94
96
  },
95
97
  asker,
96
98
  method,
@@ -99,15 +101,37 @@ class Self implements BeingLike {
99
101
  }
100
102
 
101
103
  // ---- ward functions. on the object. no stance reaches them.
102
- #instantiate(key: string, className: string, isPublic = false): Booted | null {
104
+ #instantiate(key: string, className: string): Booted | null {
103
105
  if (!key || this.doors.has(key)) return null;
104
106
  const door = this.#boot(key, className);
105
107
  if (!door) return null;
106
108
  door.cells.class = className; // so a restart finds her
107
- if (isPublic) this.p.public = key;
108
109
  return door;
109
110
  }
110
111
 
112
+ // The inverse of boot, and the only way a being leaves a ward. Her
113
+ // relations go first and by the same calls she would have used herself,
114
+ // so every occupant hears `removed` at the door from the keys it kept,
115
+ // rather than meeting a being who is simply not there any more. What is
116
+ // left after that is her cells and her bind table, and they go with her:
117
+ // a row naming a being no door holds would be read on the next restart
118
+ // and boot her again.
119
+ #unboot(key: string): string[] | null {
120
+ const door = this.doors.get(key);
121
+ if (!door || key === this.pk) return null;
122
+ const occupants = Object.keys(door.cells.occupants),
123
+ standings = Object.keys(door.cells.standings);
124
+ for (const id of occupants) door.stance.occupants.remove(id);
125
+ for (const id of standings) door.stance.standings.remove(id);
126
+ // A ward whose public being is gone answers arrivals for no heir with
127
+ // silence, as a ward that never had one does.
128
+ if (this.p.public === key) this.p.public = null;
129
+ drop(this.p.beings, key);
130
+ drop(this.p.bind, key);
131
+ this.doors.delete(key);
132
+ return [...occupants, ...standings];
133
+ }
134
+
111
135
  // Nothing is written until there is somebody to write it for: a class the
112
136
  // harbor does not know makes no being, and a boot that made nobody must
113
137
  // leave the partition as it found it.