@quo-systems/quo 0.1.1 → 0.2.0

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.
Files changed (46) hide show
  1. package/SPEC.md +185 -114
  2. package/dist/being/index.d.ts +1 -1
  3. package/dist/being/index.js +1 -1
  4. package/dist/being/silence.d.ts +6 -1
  5. package/dist/being/silence.js +25 -7
  6. package/dist/being/types.d.ts +8 -4
  7. package/dist/being/types.js +1 -1
  8. package/dist/conformance/beings.d.ts +1 -1
  9. package/dist/conformance/beings.js +5 -5
  10. package/dist/conformance/estate.js +12 -6
  11. package/dist/conformance/index.js +14 -12
  12. package/dist/harbor/core.d.ts +6 -1
  13. package/dist/harbor/core.js +11 -2
  14. package/dist/harbor/memory.d.ts +5 -1
  15. package/dist/harbor/memory.js +7 -2
  16. package/dist/ward/door.d.ts +6 -2
  17. package/dist/ward/door.js +55 -35
  18. package/dist/ward/ground.d.ts +4 -1
  19. package/dist/ward/heirs.d.ts +4 -2
  20. package/dist/ward/heirs.js +20 -3
  21. package/dist/ward/owner.js +6 -4
  22. package/dist/ward/partition.d.ts +6 -0
  23. package/dist/ward/partition.js +2 -0
  24. package/dist/ward/seal.d.ts +3 -1
  25. package/dist/ward/seal.js +6 -2
  26. package/dist/ward/stance.d.ts +2 -2
  27. package/dist/ward/stance.js +21 -12
  28. package/dist/ward/ward.js +7 -7
  29. package/package.json +19 -31
  30. package/src/being/index.ts +1 -1
  31. package/src/being/silence.ts +26 -8
  32. package/src/being/types.ts +13 -3
  33. package/src/conformance/beings.ts +5 -5
  34. package/src/conformance/estate.ts +11 -6
  35. package/src/conformance/index.ts +14 -12
  36. package/src/harbor/core.ts +11 -2
  37. package/src/harbor/memory.ts +6 -1
  38. package/src/ward/door.ts +54 -32
  39. package/src/ward/ground.ts +6 -2
  40. package/src/ward/heirs.ts +20 -5
  41. package/src/ward/owner.ts +4 -3
  42. package/src/ward/partition.ts +10 -0
  43. package/src/ward/seal.ts +8 -5
  44. package/src/ward/stance.ts +20 -14
  45. package/src/ward/ward.ts +9 -9
  46. package/vectors/framing.json +7 -1
package/dist/ward/seal.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isDoorWord } from '../being/silence.js';
1
2
  import { KEY, SIGNATURE, box, concat, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
2
3
  const utf8 = new TextEncoder();
3
4
  const text = new TextDecoder();
@@ -73,8 +74,9 @@ export async function sealReply(reply, ephemeralPk, wardSign, seed) {
73
74
  // Open a reply with the ephemeral secret kept from the ask, and verify it
74
75
  // came from the ward it was sent to. Null is silence: it did not open, it
75
76
  // lied, or it is not a reply. Every ward on the wire is somebody else's code,
76
- // so the shape is checked here and nowhere later: a reply is `{ silence }`
77
- // or `{ object, seen }` with an object present and seen a digest or null.
77
+ // so the shape is checked here and nowhere later: a reply is `{ silence }`,
78
+ // `{ quo: word }` with a word the door may say, or `{ object, seen }` with an
79
+ // object present and seen a digest or null.
78
80
  // Anything else is bytes that said nothing, and her ward never hands her a
79
81
  // throw or an absent value for what a stranger wrote.
80
82
  export async function openReply(bytes, ephemeralSecret, signPk) {
@@ -93,6 +95,8 @@ export async function openReply(bytes, ephemeralSecret, signPk) {
93
95
  const r = reply;
94
96
  if (r.silence === true)
95
97
  return { silence: true };
98
+ if (Object.hasOwn(r, 'quo'))
99
+ return isDoorWord(r.quo) ? { quo: r.quo } : null;
96
100
  if (!Object.hasOwn(r, 'object') || r.object === undefined)
97
101
  return null;
98
102
  if (r.seen !== null && !(typeof r.seen === 'string' && /^[0-9a-f]{64}$/.test(r.seen)))
@@ -1,4 +1,4 @@
1
- import type { Cells, JsonObject, Silence, Stance, Unreached, Wanted } from '../being/types.ts';
1
+ import type { Cells, JsonObject, Silence, Stance, Wanted, Word } from '../being/types.ts';
2
2
  import { type Bind, type StandingKeys } from './partition.ts';
3
3
  import { type ReplyPayload } from './seal.ts';
4
4
  export type Inside = {
@@ -9,7 +9,7 @@ export type Inside = {
9
9
  }>;
10
10
  openHeir(heir: string, being: string, id: string): void;
11
11
  closeHeir(heir: string): void;
12
- send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Unreached>;
12
+ send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Word>;
13
13
  };
14
14
  export type SealedInvitation = {
15
15
  ward: string;
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // The one stance builder. Used for every being the ward boots. Nothing
3
3
  // outer is in what she holds: ids in, values out, keys in the bind table.
4
- import { silence, isSilence, isUnreached } from '../being/silence.js';
4
+ import { silence, isSilence, isUnreached, isWord, word } from '../being/silence.js';
5
5
  import { digest } from '../being/digest.js';
6
6
  import { RESERVED_IDS } from '../being/types.js';
7
7
  import { at, put, drop } from './partition.js';
@@ -65,14 +65,16 @@ export function buildStance(inside, key, cells, bind) {
65
65
  const out = await within(allow(wanted).time, work(() => !late), () => {
66
66
  late = true;
67
67
  });
68
- return out === LATE ? silence : out;
68
+ return out === LATE ? word('late') : out;
69
69
  };
70
70
  const send = async (keys, method, args, wanted, rec) => {
71
71
  const r = await inside.send(bind, keys, true, method, args, wanted);
72
- if (isSilence(r) || isUnreached(r))
72
+ if (isSilence(r) || isWord(r))
73
73
  return r;
74
74
  if ('silence' in r)
75
75
  return silence;
76
+ if ('quo' in r)
77
+ return word(r.quo); // the far door's word for her, sealed to her lid
76
78
  if (rec && method !== undefined && r.seen !== null)
77
79
  rec.seen = r.seen; // the digest rode along
78
80
  return r.object;
@@ -105,7 +107,7 @@ export function buildStance(inside, key, cells, bind) {
105
107
  k.next = own.next;
106
108
  if (isUnreached(r))
107
109
  return r; // still nothing. she knows no more than before.
108
- if (!isSilence(r) && !('silence' in r)) {
110
+ if (!isSilence(r) && !isWord(r) && 'object' in r) {
109
111
  k.spoke = true; // it was heard the first time, and this is the answer
110
112
  bind.answered[name] = true;
111
113
  return r.object;
@@ -127,9 +129,16 @@ export function buildStance(inside, key, cells, bind) {
127
129
  }
128
130
  if (isUnreached(r))
129
131
  return r; // nothing came back. she does not know whether it was heard.
130
- k.spoke = true;
131
- if (isSilence(r) || 'silence' in r)
132
+ // The heir is spent once the door honoured it, whatever she then said: an
133
+ // object, silence, or that she threw. A refusal wrote nothing, and says
134
+ // so by its word, so the heir still speaks and the next knock is as it.
135
+ k.spoke = !(!isSilence(r) && !isWord(r) && 'quo' in r && r.quo !== 'threw');
136
+ if (isSilence(r) || isWord(r))
137
+ return r;
138
+ if ('silence' in r)
132
139
  return silence;
140
+ if ('quo' in r)
141
+ return word(r.quo);
133
142
  bind.answered[name] = true;
134
143
  return r.object;
135
144
  };
@@ -141,15 +150,15 @@ export function buildStance(inside, key, cells, bind) {
141
150
  const calls = {
142
151
  knock: async (inv, method, args = {}, wanted) => {
143
152
  if (!valid(inv))
144
- return silence;
153
+ return word('invitation'); // S1. nothing is sent
145
154
  // She may knock again, and after take that knock is an ask: the relation
146
155
  // has one home, so it answers on the standing's lane, under the
147
156
  // standing's keys and its count. A second lane here would speak for the
148
157
  // same relation, and the two would refuse each other.
149
158
  const id = takenAs(nameOf(inv));
150
159
  if (id !== undefined)
151
- return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, bind.standings[id], cells.standings[id], method, args, wanted) : Promise.resolve(silence))));
152
- return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(silence))));
160
+ return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, bind.standings[id], cells.standings[id], method, args, wanted) : Promise.resolve(word('late')))));
161
+ return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(word('late')))));
153
162
  },
154
163
  // Take consumes. Until now the relation lived in the knock record, under
155
164
  // the ward and the heir; from now it lives in the standing, under her id.
@@ -201,14 +210,14 @@ export function buildStance(inside, key, cells, bind) {
201
210
  ask: (method, args = {}, wanted) => {
202
211
  const keys = at(bind.standings, id);
203
212
  if (!keys)
204
- return Promise.resolve(silence); // she dropped it between one line and the next
213
+ return Promise.resolve(word('dropped')); // S2. she dropped it between one line and the next
205
214
  const rec = at(cells.standings, id);
206
- return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(silence))));
215
+ return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(word('late')))));
207
216
  },
208
217
  });
209
218
  const asking = async (id, keys, rec, method, args, wanted) => {
210
219
  const out = await send(keys, method, args, wanted, rec);
211
- if (method === undefined && !isSilence(out) && !isUnreached(out)) {
220
+ if (method === undefined && !isSilence(out) && !isWord(out)) {
212
221
  const here = at(cells.standings, id);
213
222
  if (here) {
214
223
  here.blueprint = out;
package/dist/ward/ward.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // the first being in its own map, and every being it later instantiates gets
5
5
  // a stance built by the same code. Mints every key, seals every ask that
6
6
  // leaves, opens every one that arrives.
7
- import { silence, isSilence, unreached, isUnreached } from '../being/silence.js';
7
+ import { silence, isSilence, unreached, isWord, word } from '../being/silence.js';
8
8
  import { OWNER } from '../being/types.js';
9
9
  import { at, open, put, emptyBind, emptyCells } from './partition.js';
10
10
  import { Heirs } from './heirs.js';
@@ -65,7 +65,7 @@ class Self {
65
65
  this.ask = async (method, args = {}) => {
66
66
  try {
67
67
  const out = await this.answer({ id: OWNER }, method, args);
68
- return isSilence(out) || isUnreached(out) ? silence : out;
68
+ return isSilence(out) || isWord(out) ? silence : out;
69
69
  }
70
70
  catch {
71
71
  return silence;
@@ -173,22 +173,22 @@ class Self {
173
173
  // relation that comes back round holds a lane the answer needs, and only a
174
174
  // bound on the wait can break that. What comes back late is not read.
175
175
  //
176
- // A wait that ran out is silence, never unreached. Unreached promises
176
+ // A wait that ran out is `late`, never unreached. Unreached promises
177
177
  // nothing was delivered and is safe to retry; a bound that expired knows
178
178
  // no such thing, because the far door may have heard and be working still.
179
179
  //
180
180
  // A harbor answers bytes or nothing. One that throws instead has answered
181
181
  // nothing in a louder voice, and is read as nothing: no door was reached.
182
- const carried = (keys.ward === this.pk ? this.door(new Uint8Array(bytes)) : this.g.carry(keys.ward, bytes)).then((b) => b, () => undefined);
182
+ const carried = (keys.ward === this.pk ? this.door(new Uint8Array(bytes)).then((r) => r.bytes) : this.g.carry(keys.ward, bytes)).then((b) => b, () => undefined);
183
183
  const out = await within(budget.time, carried);
184
184
  if (out === LATE)
185
- return silence;
185
+ return word('late');
186
186
  if (out === undefined)
187
187
  return unreached(); // nothing came back. no door was reached.
188
188
  const reply = await openReply(out, ephemeral, wardSignPk(keys.ward));
189
189
  if (!reply)
190
- return silence; // did not open, or not from that ward
191
- if (!('silence' in reply) && keys.next !== null) {
190
+ return silence; // did not open, or not from that ward: not Quo's bytes
191
+ if ('object' in reply && keys.next !== null) {
192
192
  keys.current = keys.next; // the far door holds `next` as announced. move to it.
193
193
  keys.next = null;
194
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/quo",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",
@@ -18,40 +18,28 @@
18
18
  "node": ">=22.18"
19
19
  },
20
20
  "exports": {
21
- ".": { "types": "./dist/being/index.d.ts", "default": "./dist/being/index.js" },
22
- "./ward": { "types": "./dist/ward/index.d.ts", "default": "./dist/ward/index.js" },
23
- "./harbor": { "types": "./dist/harbor/index.d.ts", "default": "./dist/harbor/index.js" },
24
- "./conformance": { "types": "./dist/conformance/index.d.ts", "default": "./dist/conformance/index.js" }
21
+ ".": {
22
+ "types": "./dist/being/index.d.ts",
23
+ "default": "./dist/being/index.js"
24
+ },
25
+ "./ward": {
26
+ "types": "./dist/ward/index.d.ts",
27
+ "default": "./dist/ward/index.js"
28
+ },
29
+ "./harbor": {
30
+ "types": "./dist/harbor/index.d.ts",
31
+ "default": "./dist/harbor/index.js"
32
+ },
33
+ "./conformance": {
34
+ "types": "./dist/conformance/index.d.ts",
35
+ "default": "./dist/conformance/index.js"
36
+ }
25
37
  },
26
38
  "scripts": {
27
39
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
28
- "check": "npm run build && npm run typecheck && npm run lint && npm test && npm run check:estate",
29
- "check:estate": "node --test \"estate/test/*.test.ts\"",
30
- "pretypecheck": "npm run build",
31
- "typecheck": "tsc",
32
- "typecheck:watch": "tsc --watch",
33
- "lint": "npm run lint:ts && npm run lint:md",
34
- "lint:ts": "oxlint --type-aware",
35
- "lint:md": "markdownlint-cli2",
36
- "lint:fix": "oxlint --type-aware --fix && markdownlint-cli2 --fix",
37
- "pretest": "npm run build",
38
40
  "test": "node --test \"test/*.test.ts\"",
39
- "check:terrain": "node --test \"test/terrain/*.test.ts\" \"estate/test/terrain/*.test.ts\"",
40
- "test:being": "node --test test/being.test.ts",
41
- "test:ward": "node --test test/ward.test.ts",
42
- "prepublishOnly": "npm run check && npm run check:terrain"
43
- },
44
- "devDependencies": {
45
- "@types/node": "^26.4.1",
46
- "bun": "^1.4.0",
47
- "deno": "^2.9.6",
48
- "esbuild": "^0.28.2",
49
- "markdownlint-cli2": "^0.23.2",
50
- "oxlint": "^1.81.0",
51
- "oxlint-tsgolint": "^7.0.2001",
52
- "playwright": "^1.62.1",
53
- "typescript": "^7.0.2",
54
- "workerd": "^1.20260904.1"
41
+ "check:terrain": "node --test \"test/terrain/*.test.ts\"",
42
+ "prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
55
43
  },
56
44
  "publishConfig": {
57
45
  "access": "public"
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // @quo-systems/quo — the Being side. What a being author imports, if anything.
3
3
  export { Being, type AskSpec } from './being.ts';
4
- export { silence, isSilence, unreached, isUnreached } from './silence.ts';
4
+ export { silence, isSilence, unreached, isUnreached, word, isWord, wordOf, DOOR_WORDS, isDoorWord } from './silence.ts';
5
5
  export { digest, canonical } from './digest.ts';
6
6
  export { OWNER, PUBLIC, RESERVED_IDS } from './types.ts';
7
7
  export type * from './types.ts';
@@ -1,14 +1,32 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Silence is one distinguished value. Null is an answer. Silence is no answer.
3
- import { UNREACHED_KEY, type Unreached } from './types.ts';
3
+ //
4
+ // The ward's words are the other distinguished values: what her ward tells
5
+ // her when no object came back and it knows why. Each is one frozen object
6
+ // under one symbol key, so a being cannot make one by accident, none of them
7
+ // crosses an edge as a value, and a being who compares against them needs
8
+ // no fourth shape. `unreached` is one of them.
9
+ import { WORD_KEY, type DoorWord, type Unreached, type Word, type WordName } from './types.ts';
4
10
 
5
11
  export const silence: unique symbol = Symbol.for('quo.silence');
6
12
  export const isSilence = (x: unknown): x is typeof silence => x === silence;
7
13
 
8
- // Unreached is the ward's own word: no far door was reached. It carries no
9
- // reason. Safe to retry, because nothing was delivered. A being cannot
10
- // produce it: a ward that sees it come out of a being treats it as silence.
11
- const UNREACHED: Unreached = Object.freeze({ [UNREACHED_KEY]: true });
12
- export const unreached = (): Unreached => UNREACHED;
13
- export const isUnreached = (x: unknown): x is Unreached =>
14
- x !== null && typeof x === 'object' && (x as Record<symbol, unknown>)[UNREACHED_KEY] === true;
14
+ // The words a door says to a key it has bound, and no other. They ride on
15
+ // the wire as `{ quo: word }`, so a reply is checked against this list.
16
+ export const DOOR_WORDS: readonly DoorWord[] = ['removed', 'absent', 'unannounced', 'repeated', 'threw'];
17
+ export const isDoorWord = (s: unknown): s is DoorWord => typeof s === 'string' && (DOOR_WORDS as readonly string[]).includes(s);
18
+
19
+ const WORDS = new Map<WordName, Word>();
20
+ export const word = <W extends WordName>(name: W): Word<W> => {
21
+ let w = WORDS.get(name);
22
+ if (!w) WORDS.set(name, (w = Object.freeze({ [WORD_KEY]: name })));
23
+ return w as Word<W>;
24
+ };
25
+ export const isWord = (x: unknown): x is Word => x !== null && typeof x === 'object' && typeof (x as Record<symbol, unknown>)[WORD_KEY] === 'string';
26
+ export const wordOf = (x: Word): WordName => x[WORD_KEY];
27
+
28
+ // Unreached: no far door was reached. Nothing is known to have been
29
+ // delivered, so asking again is safe. A being cannot produce it: a ward that
30
+ // sees a word come out of a being reads it as her having thrown.
31
+ export const unreached = (): Unreached => word('unreached');
32
+ export const isUnreached = (x: unknown): x is Unreached => isWord(x) && wordOf(x) === 'unreached';
@@ -44,11 +44,21 @@ export type Cells = {
44
44
  };
45
45
 
46
46
  export type Silence = typeof silence;
47
- export type Unreached = { readonly [K in typeof UNREACHED_KEY]: true };
48
- export const UNREACHED_KEY: unique symbol = Symbol.for('quo.unreached');
47
+
48
+ // The ward's words: what her ward says when no object came back and it knows
49
+ // why. A door says the first five to a key it has bound, and rides them on
50
+ // the wire as `{ quo: word }`; her own ward says the last four to her and
51
+ // they never leave the ward. Each is one frozen object under one symbol key,
52
+ // so no being makes one and none crosses an edge as a value.
53
+ export type DoorWord = 'removed' | 'absent' | 'unannounced' | 'repeated' | 'threw';
54
+ export type WardWord = 'unreached' | 'late' | 'invitation' | 'dropped';
55
+ export type WordName = DoorWord | WardWord;
56
+ export const WORD_KEY: unique symbol = Symbol.for('quo.word');
57
+ export type Word<W extends WordName = WordName> = { readonly [K in typeof WORD_KEY]: W };
58
+ export type Unreached = Word<'unreached'>;
49
59
 
50
60
  // What comes back from an ask or a knock.
51
- export type Answer = Json | Silence | Unreached;
61
+ export type Answer = Json | Silence | Word;
52
62
  // What a being answers.
53
63
  export type Reply = Json | Silence;
54
64
 
@@ -2,7 +2,7 @@
2
2
  // The fixture beings. Fixed, so that the ward is what varies. A printer, a
3
3
  // shop, a customer on the base class, and one raw being with no base at all.
4
4
  import { Being } from '../being/being.ts';
5
- import { isSilence, isUnreached } from '../being/silence.ts';
5
+ import { isSilence, isUnreached, isWord } from '../being/silence.ts';
6
6
  import type { Asker, Blueprint, Invitation, JsonObject, OccupantRecord, Reply, Stance } from '../being/types.ts';
7
7
 
8
8
  // A printer. Invites whom she is told to, prints for her occupants. Her
@@ -46,7 +46,7 @@ export class Shop extends Being {
46
46
 
47
47
  async keepPrinter(invitation: Invitation): Promise<boolean> {
48
48
  const bp = await this.knock(invitation);
49
- if (isSilence(bp) || isUnreached(bp)) return false;
49
+ if (isSilence(bp) || isWord(bp)) return false;
50
50
  this.cells.printerId = await this.take('prn', invitation);
51
51
  return true;
52
52
  }
@@ -55,7 +55,7 @@ export class Shop extends Being {
55
55
  const rec = this.occupant(asker);
56
56
  if (rec?.notes.tier === 'gold' && invitation) {
57
57
  const back = await this.knock(invitation as Invitation, 'hi');
58
- if (!isSilence(back) && !isUnreached(back)) await this.take(`vip-${asker.id}`, invitation as Invitation);
58
+ if (!isSilence(back) && !isWord(back)) await this.take(`vip-${asker.id}`, invitation as Invitation);
59
59
  }
60
60
  return { welcome: true };
61
61
  }
@@ -67,7 +67,7 @@ export class Shop extends Being {
67
67
  if (!printer) return { error: 'no printer' };
68
68
  const out = await printer.ask('print', { doc: `receipt for ${item}` });
69
69
  if (isUnreached(out)) return { error: 'printer unreachable', retry: true };
70
- if (isSilence(out)) return { error: 'printer refused' };
70
+ if (isSilence(out) || isWord(out)) return { error: 'printer refused' };
71
71
  const st = this.cells.standings[id!];
72
72
  if (st.seen !== st.digest) this.occupant(asker)!.notes.printerChanged = true;
73
73
  (this.cells.sales as JsonObject[]).push({ item: item ?? null, by: asker.id ?? null });
@@ -88,7 +88,7 @@ export class Customer extends Being {
88
88
  async join(invitation: Invitation) {
89
89
  const mine = (await this.invite('shop-back'))!;
90
90
  const out = await this.knock(invitation, 'hello', { invitation: mine as never });
91
- if (isSilence(out) || isUnreached(out)) return out; // nothing was born
91
+ if (isSilence(out) || isWord(out)) return out; // nothing was born
92
92
  await this.take('shop', invitation); // now, and only now
93
93
  return out;
94
94
  }
@@ -33,12 +33,13 @@
33
33
  // A model that kept one symmetric edge would be a wrong model, and would
34
34
  // agree happily with a ward that had drifted.
35
35
  import { assert } from './assert.ts';
36
- import { isSilence, isUnreached } from '../being/silence.ts';
36
+ import { isSilence, isUnreached, isWord, wordOf } from '../being/silence.ts';
37
37
  import type { Invitation } from '../being/types.ts';
38
38
  import { Member } from './beings.ts';
39
39
  import type { Census, Handle, Runner, World } from './index.ts';
40
40
 
41
41
  const HEX64 = /^[0-9a-f]{64}$/;
42
+ const said = (x: unknown, w: string): boolean => isWord(x) && wordOf(x) === w;
42
43
 
43
44
  // One occupant arc, as the host holds it. `guest` is null until somebody
44
45
  // knocks; `live` goes false when she is kicked. Both are the host's business
@@ -161,10 +162,11 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
161
162
  assert.deepEqual(await acme.being.knock(back, 'ping'), { pong: 'acme' });
162
163
  assert.equal(await acme.being.take('them', back), 'them');
163
164
 
164
- // 5. acme kicks her. her standing is still hers to hold -- she is never
165
- // told -- and it is silence from here on.
165
+ // 5. acme kicks her. her standing is still hers to hold, and she is not
166
+ // told until she asks: then the door, under the key it bound for her,
167
+ // says removed, and only to her.
166
168
  acme.being.occupants.remove('partner-1');
167
- assert.ok(isSilence(await partner.being.standings['the-client']!.ask('ping')));
169
+ assert.ok(said(await partner.being.standings['the-client']!.ask('ping'), 'removed'));
168
170
  assert.deepEqual(Object.keys(partner.cells.standings), ['the-client']); // she never learns
169
171
  assert.deepEqual(await acme.being.standings.them!.ask('ping'), { pong: 'acme' }); // the other arc never moved
170
172
 
@@ -174,7 +176,7 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
174
176
  assert.notEqual(second.heir, first.heir);
175
177
  assert.deepEqual(await partner.being.knock(second, 'ping'), { pong: 'partner-2' });
176
178
  assert.equal(await partner.being.take('the-client-again', second), 'the-client-again');
177
- assert.ok(isSilence(await partner.being.standings['the-client']!.ask('ping')));
179
+ assert.ok(said(await partner.being.standings['the-client']!.ask('ping'), 'removed'));
178
180
  ledger(w.census(), undefined, 'the story');
179
181
  });
180
182
 
@@ -221,6 +223,9 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
221
223
  assert.deepEqual(back, { pong: s.id }, `${guest} knocking ${s.host}/${s.id}`);
222
224
  s.guest = guest;
223
225
  s.answered = true;
226
+ } else if (!s.live && (s.guest === null || (s.guest === guest && (hold === undefined || hold.live)))) {
227
+ // kicked, and she speaks under a key the door bound for this seat: she hears why
228
+ assert.ok(said(back, 'removed'), `${guest} knocking kicked ${s.host}/${s.id} should hear removed`);
224
229
  } else assert.ok(isSilence(back), `${guest} knocking ${s.host}/${s.id} should be silence`);
225
230
  return `${guest} knocks ${s.host}/${s.id}`;
226
231
  });
@@ -304,7 +309,7 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
304
309
  }
305
310
  const out = await standing!.ask('ping');
306
311
  if (h.seat.live) assert.deepEqual(out, { pong: h.seat.id }, `step ${step}: ${h.owner}/${h.id} -> ${h.seat.host}/${h.seat.id}`);
307
- else assert.ok(isSilence(out), `step ${step}: ${h.owner}/${h.id} is kicked and should be silence`);
312
+ else assert.ok(said(out, 'removed'), `step ${step}: ${h.owner}/${h.id} is kicked and should hear removed`);
308
313
  }
309
314
  };
310
315
 
@@ -19,7 +19,7 @@
19
19
  //
20
20
  // Every assertion here is the truth's. Nothing here knows how a ward is built.
21
21
  import { assert } from './assert.ts';
22
- import { silence, isSilence, isUnreached } from '../being/silence.ts';
22
+ import { silence, isSilence, isUnreached, isWord, wordOf } from '../being/silence.ts';
23
23
  import { digest } from '../being/digest.ts';
24
24
  import type { Asker, BeingClass, Cells, Invitation, Json, JsonObject } from '../being/types.ts';
25
25
  import { Printer, Shop, Customer, Echo, Member } from './beings.ts';
@@ -54,6 +54,8 @@ export type World = {
54
54
  export { Printer, Shop, Customer, Echo, Member };
55
55
 
56
56
  const HEX64 = /^[0-9a-f]{64}$/;
57
+ // Her ward's word, as she receives it.
58
+ const said = (x: unknown, w: string): boolean => isWord(x) && wordOf(x) === w;
57
59
 
58
60
  // The runner is handed in. Node passes `node:test`; a browser and an edge
59
61
  // worker pass a shim of their own. The suite is the ward's truth, and the
@@ -100,12 +102,12 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
100
102
  assert.equal(i1.ward, i2.ward);
101
103
  });
102
104
 
103
- t('remove before any knock: the heir is forgotten, a knock on it is silence', async () => {
105
+ t('remove before any knock: the heir is forgotten, and a knock on it by whoever holds it hears removed', async () => {
104
106
  const { w, shop, alice } = await world();
105
107
  const inv = await shop.being.invite();
106
108
  shop.being.occupants.remove('cust1');
107
109
  assert.equal(w.heir(inv), undefined);
108
- assert.ok(isSilence(await alice.being.join(inv)));
110
+ assert.ok(said(await alice.being.join(inv), 'removed'));
109
111
  assert.deepEqual(alice.cells.standings, {});
110
112
  });
111
113
 
@@ -189,16 +191,16 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
189
191
  assert.deepEqual(await alice.being.join(inv), { welcome: true }); // the same invitation still works
190
192
  const inv2 = await shop.being.invite();
191
193
  shop.being.occupants.remove('cust2'); // she changed her mind before the knock
192
- assert.ok(isSilence(await alice.being.join(inv2)));
194
+ assert.ok(said(await alice.being.join(inv2), 'removed'));
193
195
  assert.deepEqual(Object.keys(alice.cells.standings), ['shop']);
194
196
  });
195
197
 
196
- t('take without a knock, or after a silent knock, births nothing', async () => {
198
+ t('take without a knock, or after a knock that was not answered, births nothing', async () => {
197
199
  const { shop, alice } = await world();
198
200
  const inv = await shop.being.invite();
199
201
  assert.equal(await alice.being.take('shop', inv), null);
200
202
  shop.being.occupants.remove('cust1');
201
- assert.ok(isSilence(await alice.being.knock(inv, 'hello')));
203
+ assert.ok(said(await alice.being.knock(inv, 'hello'), 'removed'));
202
204
  assert.equal(await alice.being.take('shop', inv), null);
203
205
  assert.deepEqual(alice.cells.standings, {});
204
206
  });
@@ -374,16 +376,16 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
374
376
  assert.deepEqual(await alice.being.buy('hat'), { ok: true, receipt: 'receipt for hat' });
375
377
  });
376
378
 
377
- t('a removed occupant is silence to the far side and her heir is forgotten; a dropped standing is gone', async () => {
379
+ t('a removed occupant hears removed under the key the door bound, her heir is forgotten; a dropped standing is gone', async () => {
378
380
  const { w, alice, shop } = await world();
379
381
  const inv = await shop.being.invite();
380
382
  await alice.being.join(inv);
381
383
  shop.being.occupants.remove('cust1');
382
384
  assert.equal(w.heir(inv), undefined);
383
- assert.ok(isSilence(await alice.being.buy('hat')));
385
+ assert.ok(said(await alice.being.buy('hat'), 'removed'));
384
386
  alice.being.standings.remove('shop');
385
387
  assert.equal(alice.being.standings.shop, undefined);
386
- assert.ok(isSilence(await alice.being.knock(inv, 'hello'))); // and a re-knock finds no heir
388
+ assert.ok(isSilence(await alice.being.knock(inv, 'hello'))); // her bound key went with the standing: a re-knock is as the heir, which is spent, a stranger
387
389
  const again = await shop.being.invite(); // cust2. a new heir
388
390
  assert.notEqual(again.heir, inv.heir);
389
391
  assert.deepEqual(await alice.being.knock(again, 'hello'), { welcome: true });
@@ -398,7 +400,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
398
400
  assert.equal((shop.cells.sales as unknown[]).length, 1);
399
401
  });
400
402
 
401
- t('a being that throws is silence outside; the ward stays up', async () => {
403
+ t('a being that throws is the word threw to whom the door has bound; the ward stays up', async () => {
402
404
  const w = await make();
403
405
  class Bomb {
404
406
  s: unknown;
@@ -413,7 +415,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
413
415
  const x = await w.boot<Bomb>('X', Bomb);
414
416
  const a = await w.boot<Customer>('A', Customer);
415
417
  const inv = (await (x.being.s as Customer['stance']).occupants.invite('a'))!;
416
- assert.ok(isSilence(await a.being.knock(inv, 'any')));
418
+ assert.ok(said(await a.being.knock(inv, 'any'), 'threw'));
417
419
  assert.deepEqual(await a.being.knock(inv), { asks: [], notes: {} });
418
420
  });
419
421
 
@@ -473,7 +475,7 @@ export function conform(label: string, make: () => Promise<World>, { canDown = t
473
475
  assert.deepEqual(pub.cells.occupants, {}); // she never invited anyone
474
476
  if (!w.oneWard) assert.ok(isSilence(await a.being.knock(at(priv), 'time'))); // a ward with no public being: silence
475
477
  assert.ok(isUnreached(await a.being.knock({ ward: 'a'.repeat(128) }, 'time'))); // no door anywhere: unreached
476
- assert.ok(isSilence(await a.being.knock({ ward: 'not a pk' }, 'time'))); // not even an address
478
+ assert.ok(said(await a.being.knock({ ward: 'not a pk' }, 'time'), 'invitation')); // not even an address
477
479
  assert.equal(await a.being.take('clock', at(pub)), 'clock');
478
480
  assert.deepEqual(await a.being.standings.clock!.ask('time'), { time: 12, got: {} });
479
481
  assert.equal(a.cells.standings.clock.seen, await digest((await a.being.standings.clock!.ask()) as Json));
@@ -45,6 +45,7 @@ export class Harbor {
45
45
  readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
46
46
  readonly reaches = new Map<string, Bound>(); // the directory: foreign ward pk -> reach
47
47
  readonly down = new Set<string>(); // pks this harbor will not reach right now: weather, for a test
48
+ readonly refused = new Map<string, number>(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
48
49
  readonly classes: Record<string, BeingClass> = {}; // classes handed in-process, beside the loader's
49
50
  // The dialers' sockets, in the order they were dialed: where a pk nobody
50
51
  // here knows is sent, because the listener a harbor dialed is a rendezvous
@@ -71,10 +72,18 @@ export class Harbor {
71
72
 
72
73
  // ---- the directory
73
74
 
75
+ // Bytes to one of this harbor's own doors, and the one bit the door says
76
+ // beside its reply, counted.
77
+ async #own(pk: string, door: WardPointers['door'], bytes: Uint8Array): Promise<Uint8Array> {
78
+ const r = await door(bytes);
79
+ if (!r.heard) this.refused.set(pk, (this.refused.get(pk) ?? 0) + 1);
80
+ return new Uint8Array(r.bytes);
81
+ }
82
+
74
83
  async carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
75
84
  if (this.down.has(pk)) return undefined;
76
85
  const door = this.doors.get(pk);
77
- if (door) return new Uint8Array(await door(bytes));
86
+ if (door) return this.#own(pk, door, bytes);
78
87
  const bound = this.reaches.get(pk);
79
88
  if (bound) return bound.reach.carry(pk, bytes);
80
89
  for (const reach of this.fallbacks) {
@@ -87,7 +96,7 @@ export class Harbor {
87
96
  async deliver(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
88
97
  if (this.down.has(pk)) return undefined;
89
98
  const door = this.doors.get(pk);
90
- if (door) return new Uint8Array(await door(bytes));
99
+ if (door) return this.#own(pk, door, bytes);
91
100
  const bound = this.reaches.get(pk);
92
101
  return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
93
102
  }
@@ -15,6 +15,7 @@ export class MemoryHarbor {
15
15
  readonly doors = new Map<string, WardPointers['door']>(); // ward pk -> door
16
16
  readonly down = new Set<string>(); // ward pks this harbor cannot reach right now
17
17
  readonly wire: string[] = []; // every byte string that ever crossed, as hex. for inspection
18
+ readonly refused = new Map<string, number>(); // ward pk -> arrivals its door would not admit: strangers, counted
18
19
  readonly peers = new Set<MemoryHarbor>();
19
20
  cut = false;
20
21
  readonly partitions = new Map<string, Record<string, unknown>>(); // seed -> memory. the harbor keeps it and reads nothing
@@ -25,7 +26,11 @@ export class MemoryHarbor {
25
26
  async route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
26
27
  if (this.down.has(farPk)) return undefined;
27
28
  const door = this.doors.get(farPk);
28
- if (door) return door(bytes);
29
+ if (door) {
30
+ const r = await door(bytes);
31
+ if (!r.heard) this.refused.set(farPk, (this.refused.get(farPk) ?? 0) + 1);
32
+ return r.bytes;
33
+ }
29
34
  const peer = [...this.peers].find((h) => h.doors.has(farPk) && !h.down.has(farPk));
30
35
  if (!peer || this.cut) return undefined; // nothing. the harbor has no word silence
31
36
  return peer.route(farPk, bytes);