@quo-systems/quo 0.2.10 → 0.2.12

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 (72) hide show
  1. package/SPEC.md +141 -22
  2. package/dist/being/being.js +7 -1
  3. package/dist/being/digest.js +15 -1
  4. package/dist/being/index.d.ts +2 -2
  5. package/dist/being/index.js +2 -2
  6. package/dist/being/silence.d.ts +2 -0
  7. package/dist/being/silence.js +12 -0
  8. package/dist/being/types.d.ts +2 -0
  9. package/dist/being/types.js +25 -0
  10. package/dist/conformance/assert.js +40 -6
  11. package/dist/conformance/beings.d.ts +48 -5
  12. package/dist/conformance/beings.js +39 -8
  13. package/dist/conformance/estate.js +109 -18
  14. package/dist/conformance/index.d.ts +5 -2
  15. package/dist/conformance/index.js +164 -6
  16. package/dist/harbor/core.d.ts +3 -2
  17. package/dist/harbor/core.js +86 -25
  18. package/dist/harbor/dial.js +28 -13
  19. package/dist/harbor/index.d.ts +1 -0
  20. package/dist/harbor/index.js +3 -0
  21. package/dist/harbor/memory.d.ts +3 -3
  22. package/dist/harbor/memory.js +6 -12
  23. package/dist/harbor/reach.js +10 -10
  24. package/dist/ward/allowance.js +7 -2
  25. package/dist/ward/arithmetic.d.ts +1 -0
  26. package/dist/ward/arithmetic.js +11 -3
  27. package/dist/ward/door.d.ts +2 -2
  28. package/dist/ward/door.js +14 -5
  29. package/dist/ward/ground.d.ts +4 -1
  30. package/dist/ward/ground.js +38 -1
  31. package/dist/ward/heirs.d.ts +1 -2
  32. package/dist/ward/heirs.js +13 -14
  33. package/dist/ward/index.d.ts +1 -0
  34. package/dist/ward/index.js +3 -0
  35. package/dist/ward/owner.d.ts +4 -4
  36. package/dist/ward/owner.js +36 -19
  37. package/dist/ward/partition.d.ts +3 -0
  38. package/dist/ward/partition.js +109 -4
  39. package/dist/ward/seal.d.ts +1 -0
  40. package/dist/ward/seal.js +38 -13
  41. package/dist/ward/stance.d.ts +3 -2
  42. package/dist/ward/stance.js +48 -20
  43. package/dist/ward/ward.d.ts +1 -1
  44. package/dist/ward/ward.js +40 -19
  45. package/package.json +1 -1
  46. package/src/being/being.ts +7 -1
  47. package/src/being/digest.ts +15 -2
  48. package/src/being/index.ts +2 -2
  49. package/src/being/silence.ts +14 -0
  50. package/src/being/types.ts +29 -1
  51. package/src/conformance/assert.ts +37 -4
  52. package/src/conformance/beings.ts +41 -10
  53. package/src/conformance/estate.ts +106 -17
  54. package/src/conformance/index.ts +187 -12
  55. package/src/harbor/core.ts +98 -25
  56. package/src/harbor/dial.ts +41 -14
  57. package/src/harbor/index.ts +3 -0
  58. package/src/harbor/memory.ts +8 -14
  59. package/src/harbor/reach.ts +10 -11
  60. package/src/ward/allowance.ts +7 -2
  61. package/src/ward/arithmetic.ts +14 -5
  62. package/src/ward/door.ts +16 -7
  63. package/src/ward/ground.ts +43 -1
  64. package/src/ward/heirs.ts +13 -14
  65. package/src/ward/index.ts +3 -0
  66. package/src/ward/owner.ts +45 -26
  67. package/src/ward/partition.ts +109 -5
  68. package/src/ward/seal.ts +39 -12
  69. package/src/ward/stance.ts +53 -23
  70. package/src/ward/ward.ts +46 -25
  71. package/vectors/framing.json +16 -10
  72. package/vectors/wire.json +4 -4
@@ -1,6 +1,7 @@
1
1
  export { Ward } from './ward.ts';
2
2
  export type { Ground, WardPointers } from './ground.ts';
3
3
  export type { Partition, Heir, Bind, StandingKeys } from './partition.ts';
4
+ export { GONE, MINTED } from './partition.ts';
4
5
  export type { AskPayload, ReplyPayload } from './seal.ts';
5
6
  export * as seal from './seal.ts';
6
7
  export * as arithmetic from './arithmetic.ts';
@@ -1,6 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // @quo-systems/quo/ward — one function. Every ward is the same ward.
3
3
  export { Ward } from './ward.js';
4
+ // The two bounds on what a partition may hold, for a kit that must keep the
5
+ // same ones and for a test that pins them.
6
+ export { GONE, MINTED } from './partition.js';
4
7
  // The seal and the arithmetic, for a kit in another language to check its
5
8
  // bytes against, and for tests that speak to a door directly.
6
9
  export * as seal from './seal.js';
@@ -1,13 +1,13 @@
1
1
  import type { Ask, Asker, Json, JsonObject } from '../being/types.ts';
2
- import type { Booted } from './ward.ts';
2
+ import type { Resident } from './ward.ts';
3
3
  export type OwnerSide = {
4
4
  pk: string;
5
- doors: Map<string, Booted>;
5
+ doors: Map<string, Resident>;
6
6
  absent(): Record<string, string | null>;
7
7
  publicKey(): string | null;
8
- instantiate(key: string, className: string): Booted | 'threw' | null;
8
+ instantiate(className: string, key: string): Resident | 'threw' | null;
9
9
  unboot(key: string): string[] | null;
10
- setPublic(key: string): void;
10
+ setPublic(key: string | null): void;
11
11
  };
12
12
  export declare const OWNER_ASKS: Ask[];
13
13
  export declare function ownerAnswer(w: OwnerSide, asker: Asker, method: string | undefined, args: JsonObject): Promise<Json>;
@@ -12,10 +12,11 @@ import { isSilence, isWord, wordOf } from '../being/silence.js';
12
12
  import { OWNER } from '../being/types.js';
13
13
  import { put } from './partition.js';
14
14
  import { seen } from './door.js';
15
+ import { within, DEFAULT, LATE } from './allowance.js';
15
16
  const str = { type: 'string' };
16
17
  export const OWNER_ASKS = [
17
18
  { 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'] } },
18
- { 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'] } },
19
+ { name: 'public', description: 'mark a booted being as the one public being of the ward, reached by anyone at the bare pk; null takes the mark off and leaves her booted', input: { type: 'object', properties: { key: str }, required: ['key'] } },
19
20
  { name: 'invite', description: 'mint an invitation on a being of the ward, under the id she will know the occupant by, with the notes she will read on it; on the ward pk it mints an owner, and only the root may', input: { type: 'object', properties: { being: str, id: str, notes: { type: 'object' } }, required: ['being', 'id'] } },
20
21
  {
21
22
  name: 'knock',
@@ -26,17 +27,19 @@ export const OWNER_ASKS = [
26
27
  { 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'] } },
27
28
  ];
28
29
  export async function ownerAnswer(w, asker, method, args) {
29
- // Every name the owner gives is a word. Coerced instead of checked, an
30
- // object passed where a name belongs becomes the string "[object Object]",
31
- // and two owners who each got the shape wrong would be handed one being.
32
- const word = (v) => (typeof v === 'string' ? v : null);
30
+ // Every name the owner gives is a string, or it is nothing. Coerced instead
31
+ // of checked, an object passed where a name belongs becomes the string
32
+ // "[object Object]", and two owners who each got the shape wrong would be
33
+ // handed one being. Not `word`: a word in this tree is what a ward says when
34
+ // no object came back, and this file imports those.
35
+ const named = (v) => (typeof v === 'string' ? v : null);
33
36
  if (method === undefined)
34
37
  return describe(w);
35
38
  if (method === 'boot') {
36
- const key = word(args.key), className = word(args.class);
39
+ const key = named(args.key), className = named(args.class);
37
40
  if (key === null || className === null)
38
41
  return { error: 'no such class, or key taken' };
39
- const door = w.instantiate(key, className);
42
+ const door = w.instantiate(className, key);
40
43
  if (door === 'threw')
41
44
  return { error: 'threw at birth' };
42
45
  return door ? { booted: door.key } : { error: 'no such class, or key taken' };
@@ -46,7 +49,14 @@ export async function ownerAnswer(w, asker, method, args) {
46
49
  // leave the first holding every relation she had, reachable by nobody at
47
50
  // the bare pk, and told by nobody that she had been replaced. One absent
48
51
  // this run is reachable by nobody already, so the mark may move off her.
49
- const key = word(args.key);
52
+ // Null takes the mark off: the being stays and keeps every relation she
53
+ // holds, and the ward answers arrivals for no heir with silence, as one
54
+ // that never had a public being does. Retiring her is not destroying her.
55
+ if (args.key === null) {
56
+ w.setPublic(null);
57
+ return { public: null };
58
+ }
59
+ const key = named(args.key);
50
60
  if (key === null || key === w.pk || !w.doors.has(key))
51
61
  return { error: 'no such being' };
52
62
  const standing = w.publicKey();
@@ -60,7 +70,7 @@ export async function ownerAnswer(w, asker, method, args) {
60
70
  // is an owner, and ownership is minted by the ask pointer alone. An owner
61
71
  // at the door asking to invite on the ward is refused, so that a carried
62
72
  // key can pilot but never hand piloting on.
63
- const being = word(args.being), id = word(args.id);
73
+ const being = named(args.being), id = named(args.id);
64
74
  if (being === null || id === null)
65
75
  return { error: 'no such being' };
66
76
  if (being === w.pk && asker.id !== OWNER)
@@ -80,15 +90,17 @@ export async function ownerAnswer(w, asker, method, args) {
80
90
  if (!door && b && typeof b === 'object') {
81
91
  // new or existing: the owner names a being of theirs, and a key already
82
92
  // booted is a being of theirs, not a class that failed to instantiate.
83
- const key = word(b.key), className = word(b.boot);
93
+ const key = named(b.key), className = named(b.boot);
84
94
  if (key === null || className === null)
85
95
  return { error: 'no such being' };
86
- const made = w.instantiate(key, className);
96
+ const made = w.instantiate(className, key);
87
97
  if (made === 'threw')
88
98
  return { error: 'threw at birth' };
89
- door = made ? w.doors.get(made.key) : w.doors.get(key);
99
+ // Made or already there, the door is the one under the key she named:
100
+ // instantiate boots under that key or refuses, and never another.
101
+ door = w.doors.get(key);
90
102
  }
91
- const id = word(args.id);
103
+ const id = named(args.id);
92
104
  // Never for the ward itself, for the reason invite refuses it: the ward
93
105
  // is a being to nobody outside, and holds no relation of its own. A
94
106
  // standing written into its cells would be the owner's reach dressed as
@@ -110,7 +122,7 @@ export async function ownerAnswer(w, asker, method, args) {
110
122
  // standing, since the two share one namespace. On the ward itself the
111
123
  // id is an owner, and only the root may unseat one, for the reason only
112
124
  // the root may invite one: ownership moves by the root alone.
113
- const being = word(args.being), id = word(args.id);
125
+ const being = named(args.being), id = named(args.id);
114
126
  if (being === null || id === null)
115
127
  return { error: 'no such being' };
116
128
  if (being === w.pk && asker.id !== OWNER)
@@ -134,7 +146,7 @@ export async function ownerAnswer(w, asker, method, args) {
134
146
  // refused, for the reason knock is: it is a being to nobody outside, and
135
147
  // unbooting it would be a ward deleting itself from inside its own map,
136
148
  // leaving its owners bound to a door that is gone.
137
- const being = word(args.being);
149
+ const being = named(args.being);
138
150
  if (being === null || being === w.pk)
139
151
  return { error: 'no such being' };
140
152
  const removed = w.unboot(being);
@@ -148,10 +160,15 @@ export async function ownerAnswer(w, asker, method, args) {
148
160
  // and take her out.
149
161
  async function describe(w) {
150
162
  const beings = {};
151
- for (const [key, door] of w.doors) {
152
- if (key === w.pk)
153
- continue;
154
- put(beings, key, { class: door.cells.class ?? null, public: w.publicKey() === key, digest: await seen(door, { id: OWNER }) });
163
+ // Every being is asked at once and each is bounded on her own. One being
164
+ // whose describe never settles is one null digest, not a ward that never
165
+ // answers its owner: the harbor learns the pk by this very ask at boot, so
166
+ // an unbounded wait here is a ward no restart can bring back.
167
+ const asked = [...w.doors].filter(([key]) => key !== w.pk);
168
+ const digests = await Promise.all(asked.map(async ([, door]) => (await within(DEFAULT.time, seen(door, { id: OWNER })))));
169
+ for (const [i, [key, door]] of asked.entries()) {
170
+ const d = digests[i];
171
+ put(beings, key, { class: door.cells.class ?? null, public: w.publicKey() === key, digest: d === LATE ? null : d });
155
172
  }
156
173
  for (const [key, cls] of Object.entries(w.absent()))
157
174
  put(beings, key, { class: cls, public: w.publicKey() === key, digest: null, absent: true });
@@ -39,6 +39,9 @@ export type Gone = {
39
39
  announced: string | null;
40
40
  };
41
41
  export declare const GONE = 256;
42
+ export declare const MINTED = 8;
43
+ export declare const KNOCKS = 64;
44
+ export declare const prune: (knocks: Record<string, unknown>, answered: Record<string, true>) => void;
42
45
  export type Partition = {
43
46
  version: string;
44
47
  beings: Record<string, Cells & {
@@ -26,11 +26,116 @@ function upgrade(p) {
26
26
  return p;
27
27
  }
28
28
  export const GONE = 256;
29
+ // The last few pks a relation minted, and no more. A relation rotates on
30
+ // every ask, so a list of all of them is a partition that grows for as long
31
+ // as she keeps talking and can never be written down. It lives here, beside
32
+ // the other bound on the durable shape, because it is a fact about what a
33
+ // partition may hold and not about how a ward mints.
34
+ export const MINTED = 8;
35
+ // A knock record is a key she minted for one invitation, kept until she takes
36
+ // it. Only take deletes one, so a being who knocks and never takes would grow
37
+ // her bind table for the life of the ward, one secret seed at a time. The
38
+ // list is bounded like `gone`: past the count the oldest go, and a relation
39
+ // that answered outlives one that never did, since an answered knock is one
40
+ // she may still take.
41
+ export const KNOCKS = 64;
42
+ export const prune = (knocks, answered) => {
43
+ const names = Object.keys(knocks);
44
+ if (names.length <= KNOCKS)
45
+ return;
46
+ const order = [...names.filter((n) => !answered[n]), ...names.filter((n) => answered[n])];
47
+ for (const old of order.slice(0, names.length - KNOCKS)) {
48
+ delete knocks[old];
49
+ delete answered[old];
50
+ }
51
+ };
52
+ // The shape, read before anything acts on it. A partition this ward wrote is
53
+ // this shape by construction; one it adopted was written by a hand, another
54
+ // kit, or a file that was cut short, and every field below is one the ward,
55
+ // the door or the heirs then act on without looking again. A `spent` that is
56
+ // not an array rejects the door's promise on the first bound ask, a `mark`
57
+ // that is not a number honours every number ever sent and grows without
58
+ // bound, a being row that is null refuses the whole ward at birth by
59
+ // accident and says nothing about why. So the shape is read once, here, and
60
+ // what is not it throws with the path that failed, which names the trouble
61
+ // to whoever is holding the file.
62
+ //
63
+ // The rule, so the reader does not grow into a schema: read every field the
64
+ // ward acts on, and refuse only what would make it act wrongly. So `minted`
65
+ // must be a list, because she pushes to it, and the truth in `answered` is
66
+ // not read, because anything there says answered and nothing else follows.
67
+ // `last` is reserved and nobody reads it, so nobody reads its shape either;
68
+ // the run that gives it a reader gives it a line here. A being's own cells
69
+ // are hers, of any shape JSON carries, and only the two records the ward
70
+ // keeps inside them are read.
71
+ const fault = (path, want) => {
72
+ throw new Error(`partition ${path} is not ${want}`);
73
+ };
74
+ const table = (v, path) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : fault(path, 'an object'));
75
+ const list = (v, path) => (Array.isArray(v) ? v : fault(path, 'an array'));
76
+ const text = (v, path) => void (typeof v === 'string' || fault(path, 'a string'));
77
+ const orNull = (v, path) => void (v === null || typeof v === 'string' || fault(path, 'a string or null'));
78
+ const whole = (v, path) => void (Number.isSafeInteger(v) || fault(path, 'a whole number'));
79
+ const flag = (v, path) => void (typeof v === 'boolean' || fault(path, 'true or false'));
80
+ function read(p) {
81
+ for (const [key, cells] of Object.entries(table(p.beings, 'beings'))) {
82
+ const c = table(cells, `beings.${key}`);
83
+ table(c.standings, `beings.${key}.standings`);
84
+ table(c.occupants, `beings.${key}.occupants`);
85
+ if (c.class !== undefined)
86
+ text(c.class, `beings.${key}.class`);
87
+ }
88
+ for (const [key, bind] of Object.entries(table(p.bind, 'bind'))) {
89
+ const b = table(bind, `bind.${key}`);
90
+ for (const [id, keys] of Object.entries(table(b.standings, `bind.${key}.standings`))) {
91
+ const w = `bind.${key}.standings.${id}`;
92
+ const s = table(keys, w);
93
+ text(s.ward, `${w}.ward`);
94
+ orNull(s.heir, `${w}.heir`);
95
+ text(s.current, `${w}.current`);
96
+ orNull(s.next, `${w}.next`);
97
+ whole(s.seq, `${w}.seq`);
98
+ }
99
+ for (const [id, pk] of Object.entries(table(b.occupants, `bind.${key}.occupants`)))
100
+ text(pk, `bind.${key}.occupants.${id}`);
101
+ for (const [name, keys] of Object.entries(table(b.knocks, `bind.${key}.knocks`))) {
102
+ const w = `bind.${key}.knocks.${name}`;
103
+ const k = table(keys, w);
104
+ text(k.current, `${w}.current`);
105
+ orNull(k.next, `${w}.next`);
106
+ flag(k.spoke, `${w}.spoke`);
107
+ flag(k.sent, `${w}.sent`);
108
+ whole(k.seq, `${w}.seq`);
109
+ }
110
+ table(b.answered, `bind.${key}.answered`);
111
+ for (const [i, pk] of list(b.minted, `bind.${key}.minted`).entries())
112
+ text(pk, `bind.${key}.minted[${i}]`);
113
+ }
114
+ for (const [pk, heir] of Object.entries(table(p.heirs, 'heirs'))) {
115
+ const h = table(heir, `heirs.${pk}`);
116
+ text(h.being, `heirs.${pk}.being`);
117
+ text(h.id, `heirs.${pk}.id`);
118
+ text(h.current, `heirs.${pk}.current`);
119
+ orNull(h.announced, `heirs.${pk}.announced`);
120
+ flag(h.fresh, `heirs.${pk}.fresh`);
121
+ whole(h.mark, `heirs.${pk}.mark`);
122
+ for (const [i, n] of list(h.spent, `heirs.${pk}.spent`).entries())
123
+ whole(n, `heirs.${pk}.spent[${i}]`);
124
+ }
125
+ for (const [pk, gone] of Object.entries(table(p.gone, 'gone'))) {
126
+ const g = table(gone, `gone.${pk}`);
127
+ text(g.current, `gone.${pk}.current`);
128
+ orNull(g.announced, `gone.${pk}.announced`);
129
+ }
130
+ orNull(p.public, 'public');
131
+ return p;
132
+ }
29
133
  // The one place a ward reads state that another ward wrote. A fresh
30
134
  // memory becomes a partition of this version; anything already written must
31
- // say it is this version, and a ward that cannot read it does not boot. Birth is
32
- // where a ward is allowed to be loud: silence is the door's word, for asks
33
- // that were made, and no ask has been made yet.
135
+ // say it is this version and be the shape this version names, and a ward that
136
+ // cannot read it does not boot. Birth is where a ward is allowed to be loud:
137
+ // silence is the door's word, for asks that were made, and no ask has been
138
+ // made yet.
34
139
  export function open(memory) {
35
140
  const p = memory;
36
141
  if (Object.keys(memory).length > 0 && p.version !== VERSION) {
@@ -44,7 +149,7 @@ export function open(memory) {
44
149
  p.heirs ??= {};
45
150
  p.gone ??= {};
46
151
  p.public ??= null;
47
- return p;
152
+ return read(p);
48
153
  }
49
154
  // One record, reached by a key a being or an owner chose. A bare lookup finds
50
155
  // every name on Object's prototype, so `__proto__` answers with the prototype
@@ -10,6 +10,7 @@ export declare function wardKey(seed: string | Uint8Array): Promise<WardKey>;
10
10
  export declare const wardSignPk: (pk: string) => Uint8Array;
11
11
  export declare const wardPadlock: (pk: string) => Uint8Array;
12
12
  export declare const isWardPk: (pk: unknown) => pk is string;
13
+ export declare const isHex: (v: unknown) => v is string;
13
14
  export declare function beingKey(seed: Uint8Array): Promise<{
14
15
  seed: string;
15
16
  pk: string;
package/dist/ward/seal.js CHANGED
@@ -1,19 +1,45 @@
1
1
  import { isDoorWord } from '../being/silence.js';
2
- import { KEY, SIGNATURE, box, concat, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
2
+ import { KEY, SIGNATURE, box, concat, derive, hex, sha256, sign, signingPair, sealingPair, unbox, unhex, verify } from './arithmetic.js';
3
3
  import { cellFault } from './cells.js';
4
4
  const utf8 = new TextEncoder();
5
5
  const text = new TextDecoder();
6
+ // The two labels a ward's keys are derived under. They are here and not in
7
+ // arithmetic.ts because a label is a decision and that file is arithmetic.
8
+ // `quo-seal` is taken: it is the label the message cipher derives its key and
9
+ // nonce under, from an agreement rather than from a seed, and two derivations
10
+ // answering to one name is how a kit is read wrong.
11
+ const WARD_SIGN = new TextEncoder().encode('quo-ward-sign');
12
+ const WARD_SEAL = new TextEncoder().encode('quo-ward-seal');
6
13
  // The ward's key from its seed. Its pk on the wire is the signing pk then the padlock, 128 hex.
14
+ //
15
+ // One seed, two curves, and each secret derived from it under its own label.
16
+ // Fed the seed straight, the two scalars differ only because Ed25519 hashes
17
+ // what it is given and X25519 clamps it raw, which is an accident of the two
18
+ // designs and no separation at all: one secret would be doing two jobs with
19
+ // nothing said about it, and a second kit would have to reproduce a
20
+ // construction nobody named. HKDF-SHA-256 under a label is the separation
21
+ // said out loud, and it is what `vectors/framing.json` pins.
22
+ //
23
+ // Bytes are key material and text is not. A seed handed in as bytes of the
24
+ // key length is taken as it stands, which is what a harbor mints; anything
25
+ // else, of any length, is hashed to that length first. So a thirty-two
26
+ // character name is a name like any other, and not a key because it happened
27
+ // to be the right size.
7
28
  export async function wardKey(seed) {
8
- const raw = typeof seed === 'string' ? utf8.encode(seed) : seed;
9
- const seed32 = raw.length === KEY ? raw : await sha256(raw);
10
- const s = await signingPair(seed32);
11
- const p = await sealingPair(seed32);
29
+ const seed32 = typeof seed === 'string' ? await sha256(utf8.encode(seed)) : seed.length === KEY ? seed : await sha256(seed);
30
+ const s = await signingPair(await derive(seed32, WARD_SIGN, KEY));
31
+ const p = await sealingPair(await derive(seed32, WARD_SEAL, KEY));
12
32
  return { sign: s.secret, padlock: p.secret, signPk: s.pk, padlockPk: p.pk, pk: hex(s.pk) + hex(p.pk) };
13
33
  }
14
34
  export const wardSignPk = (pk) => unhex(pk.slice(0, KEY * 2));
15
35
  export const wardPadlock = (pk) => unhex(pk.slice(KEY * 2));
16
36
  export const isWardPk = (pk) => typeof pk === 'string' && /^[0-9a-f]{128}$/.test(pk);
37
+ // Thirty-two bytes as hex, which is how this kit writes every single key: a
38
+ // heir pk, a heir secret, a being's own key, and a digest, which is the same
39
+ // thirty-two bytes of SHA-256. A ward pk is two of these and has its own
40
+ // shape above. The two are spelled here and nowhere else, so a door, a
41
+ // stance, a harbor and a conformance suite all refuse the same string.
42
+ export const isHex = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
17
43
  // A being's relation key: a seed, and the pk it signs as.
18
44
  export async function beingKey(seed) {
19
45
  return { seed: hex(seed), pk: hex((await signingPair(seed)).pk) };
@@ -40,11 +66,11 @@ export async function openAsk(bytes, padlockSecret) {
40
66
  // The heir, or null for the public being. Absent is not null: a payload
41
67
  // that never named one is malformed, like any other missing field.
42
68
  const to = payload.to;
43
- if (to !== null && !(typeof to === 'string' && /^[0-9a-f]{64}$/.test(to)))
69
+ if (to !== null && !isHex(to))
44
70
  return null;
45
- if (typeof payload.by !== 'string' || !/^[0-9a-f]{64}$/.test(payload.by))
71
+ if (!isHex(payload.by))
46
72
  return null;
47
- if (payload.next !== null && !(typeof payload.next === 'string' && /^[0-9a-f]{64}$/.test(payload.next)))
73
+ if (payload.next !== null && !isHex(payload.next))
48
74
  return null;
49
75
  if (payload.method !== undefined && typeof payload.method !== 'string')
50
76
  return null; // a name, or the empty ask. never a number, never an object.
@@ -54,10 +80,9 @@ export async function openAsk(bytes, padlockSecret) {
54
80
  return null; // and values all the way down: no key named __proto__, no nesting past the bound
55
81
  if (!Number.isSafeInteger(payload.seq) || payload.seq < 1)
56
82
  return null; // her count for this relation. one and up, and a whole number.
57
- // The allowance. A whole number, and one that has already run out is not an
58
- // ask this door will open: it is refused as the bytes it is, above.
59
- if (!Number.isSafeInteger(payload.time) || payload.time <= 0)
60
- return null;
83
+ // The allowance is not read here. It is the door's, D2, and the door is
84
+ // its one reader: `spent` in allowance.ts says what a budget is and what
85
+ // is left of it, in one sentence, for a payload that opened like any other.
61
86
  // The hop count, if this ask carries one: a whole number, never below
62
87
  // zero. Absent is the ordinary ask and always will be.
63
88
  if (payload.hops !== undefined && (!Number.isSafeInteger(payload.hops) || payload.hops < 0))
@@ -102,7 +127,7 @@ export async function openReply(bytes, ephemeralSecret, signPk) {
102
127
  return isDoorWord(r.quo) ? { quo: r.quo } : null;
103
128
  if (!Object.hasOwn(r, 'object') || r.object === undefined)
104
129
  return null;
105
- if (r.seen !== null && !(typeof r.seen === 'string' && /^[0-9a-f]{64}$/.test(r.seen)))
130
+ if (r.seen !== null && !isHex(r.seen))
106
131
  return null;
107
132
  return { object: r.object, seen: r.seen };
108
133
  }
@@ -3,14 +3,15 @@ import { type Bind, type StandingKeys } from './partition.ts';
3
3
  import { type ReplyPayload } from './seal.ts';
4
4
  export type Inside = {
5
5
  pk: string;
6
+ live(): boolean;
6
7
  mintKey(bind: Bind): Promise<{
7
8
  seed: string;
8
9
  pk: string;
9
10
  }>;
10
11
  openHeir(heir: string, being: string, id: string): void;
11
12
  closeHeir(heir: string): void;
12
- send(bind: Bind, keys: StandingKeys, announce: boolean, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Word>;
13
- instantiate(key: string, className: string): string | null;
13
+ send(bind: Bind, keys: StandingKeys, method: string | undefined, args: JsonObject, wanted: Wanted | undefined): Promise<ReplyPayload | Silence | Word>;
14
+ instantiate(className: string, key: string): string | null;
14
15
  relate(key: string, id: string): Promise<Invitation | null>;
15
16
  unmake(key: string): void;
16
17
  wrote(): void;
@@ -3,10 +3,10 @@
3
3
  // outer is in what she holds: ids in, values out, keys in the bind table.
4
4
  import { silence, isSilence, isWord, word, wordOf } from '../being/silence.js';
5
5
  import { digest } from '../being/digest.js';
6
- import { RESERVED_IDS } from '../being/types.js';
7
- import { at, put, drop } from './partition.js';
6
+ import { RESERVED_IDS, isBlueprint } from '../being/types.js';
7
+ import { at, put, drop, prune } from './partition.js';
8
8
  import { allow, within, LATE } from './allowance.js';
9
- import { isWardPk } from './seal.js';
9
+ import { isHex, isWardPk } from './seal.js';
10
10
  import { cellFault } from './cells.js';
11
11
  // The three calls on `standings` share the object with the ids she takes, so
12
12
  // a standing named after one of them would be unreachable: `standings.knock`
@@ -29,7 +29,7 @@ export function buildStance(inside, key, cells, bind) {
29
29
  return false;
30
30
  if (i.heir === undefined)
31
31
  return i.secret === undefined;
32
- return typeof i.heir === 'string' && typeof i.secret === 'string' && /^[0-9a-f]{64}$/.test(i.heir) && /^[0-9a-f]{64}$/.test(i.secret);
32
+ return isHex(i.heir) && isHex(i.secret);
33
33
  };
34
34
  // One relation, one ask at a time. The rotation is a conversation: a send
35
35
  // reads the key that speaks now, announces the next, and moves to it once
@@ -82,13 +82,19 @@ export function buildStance(inside, key, cells, bind) {
82
82
  // nothing and says so. Anything that is not the door speaking says nothing.
83
83
  const honoured = (r) => r.door && !(isWord(r.answer) && wordOf(r.answer) !== 'threw');
84
84
  const answered = (r) => r.door && !isSilence(r.answer) && !isWord(r.answer);
85
- const send = async (keys, method, args, wanted, rec) => {
86
- const r = read(await inside.send(bind, keys, true, method, args, wanted));
87
- if (rec && method !== undefined && r.seen !== null)
85
+ // The ask is bounded twice with one budget, once around the lane and once
86
+ // around the wire, and the two bells are microseconds apart. A reply that
87
+ // lands between them is a reply she was already told was late: its keys are
88
+ // rotated, since the far door rotated too and nothing may un-rotate that,
89
+ // but nothing of it is written into her cells. What she was handed is the
90
+ // last word on what she saw.
91
+ const send = async (keys, method, args, wanted, rec, live = () => true) => {
92
+ const r = read(await inside.send(bind, keys, method, args, wanted));
93
+ if (rec && method !== undefined && r.seen !== null && live())
88
94
  rec.seen = r.seen; // the digest rode along
89
95
  return r.answer;
90
96
  };
91
- const knocking = async (inv, method, args, wanted) => {
97
+ const knocking = async (inv, method, args, wanted, live = () => true) => {
92
98
  const name = nameOf(inv);
93
99
  // Taken while this knock waited its turn: the relation lives in the
94
100
  // standing now and speaks on its lane, under its keys and its count.
@@ -97,10 +103,11 @@ export function buildStance(inside, key, cells, bind) {
97
103
  const keys = at(bind.standings, taken);
98
104
  if (!keys)
99
105
  return word('dropped');
100
- return lane(`ask:${taken}`, () => asking(taken, keys, at(cells.standings, taken), method, args, wanted));
106
+ return lane(`ask:${taken}`, () => asking(taken, keys, at(cells.standings, taken), method, args, wanted, live));
101
107
  }
102
108
  // her own key for this relation, minted once: rotation two. reused if she knocks again.
103
109
  const k = (bind.knocks[name] ??= { current: (await inside.mintKey(bind)).seed, next: null, spoke: false, sent: false, seq: 0 });
110
+ prune(bind.knocks, bind.answered); // this one is the newest, so it stays and the oldest never answered goes
104
111
  // The first knock on a heir speaks as the heir, the key the inviter
105
112
  // handed out, and announces her own. Once the door has honoured it the
106
113
  // heir is spent, whatever she then said, and her own key speaks; a heir
@@ -125,7 +132,7 @@ export function buildStance(inside, key, cells, bind) {
125
132
  // door already bound it to her.
126
133
  if (inv.heir !== undefined && !k.spoke && k.sent) {
127
134
  const own = { ward: inv.ward, heir: inv.heir, current: k.current, next: k.next, seq: k.seq };
128
- const r = read(await inside.send(bind, own, true, method, args, wanted));
135
+ const r = read(await inside.send(bind, own, method, args, wanted));
129
136
  k.seq = own.seq;
130
137
  k.current = own.current;
131
138
  k.next = own.next;
@@ -149,7 +156,7 @@ export function buildStance(inside, key, cells, bind) {
149
156
  }
150
157
  const first = inv.heir !== undefined && !k.spoke;
151
158
  const keys = first ? { ward: inv.ward, heir: inv.heir, current: inv.secret, next: k.current, seq: k.seq } : { ward: inv.ward, heir: inv.heir ?? null, current: k.current, next: k.next, seq: k.seq };
152
- const r = read(await inside.send(bind, keys, true, method, args, wanted));
159
+ const r = read(await inside.send(bind, keys, method, args, wanted));
153
160
  k.sent = true; // bytes went out. whether they arrived is the far door's to know.
154
161
  // The number she spoke under, and the key she announced, kept whoever
155
162
  // answered and kept when nobody did. A reply lost on the way back is a
@@ -172,8 +179,16 @@ export function buildStance(inside, key, cells, bind) {
172
179
  // invitation answer to the same name.
173
180
  const keyName = (k) => (k.heir === null ? `public:${k.ward}` : `${k.ward}:${k.heir}`);
174
181
  const takenAs = (name) => Object.keys(bind.standings).find((id) => keyName(bind.standings[id]) === name);
182
+ // A being the ward has taken out reaches nothing through the stance she
183
+ // still holds. Her relations went with her, so every one of them reads as
184
+ // dropped, and nothing she asks for is made: a heir written here would name
185
+ // a being no door holds, and an id minted here would be handed to whoever
186
+ // is booted under her key next.
187
+ const gone = () => !inside.live();
175
188
  const calls = {
176
189
  knock: async (inv, method, args = {}, wanted) => {
190
+ if (gone())
191
+ return word('dropped');
177
192
  if (!valid(inv))
178
193
  return word('invitation'); // S1. nothing is sent
179
194
  // She may knock again, and after take that knock is an ask: the relation
@@ -183,7 +198,7 @@ export function buildStance(inside, key, cells, bind) {
183
198
  const id = takenAs(nameOf(inv));
184
199
  if (id !== undefined)
185
200
  return standingAt(id).ask(method, args, wanted);
186
- return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted) : Promise.resolve(word('late')))));
201
+ return bounded(wanted, (live) => lane(`knock:${nameOf(inv)}`, () => (live() ? knocking(inv, method, args, wanted, live) : Promise.resolve(word('late')))));
187
202
  },
188
203
  // Take consumes. Until now the relation lived in the knock record, under
189
204
  // the ward and the heir; from now it lives in the standing, under her id.
@@ -203,7 +218,7 @@ export function buildStance(inside, key, cells, bind) {
203
218
  // ask on it is refused. The relation she took correctly would be dead
204
219
  // because a second knock on the same invitation was careless.
205
220
  take: async (id, inv) => {
206
- if (!valid(inv))
221
+ if (gone() || !valid(inv))
207
222
  return null;
208
223
  const name = nameOf(inv);
209
224
  return lane(`knock:${name}`, () => {
@@ -224,6 +239,8 @@ export function buildStance(inside, key, cells, bind) {
224
239
  });
225
240
  },
226
241
  remove: (id) => {
242
+ if (gone())
243
+ return;
227
244
  drop(cells.standings, id);
228
245
  drop(bind.standings, id);
229
246
  inside.wrote();
@@ -235,25 +252,32 @@ export function buildStance(inside, key, cells, bind) {
235
252
  // issued while the standing stood is answered even if she drops it in
236
253
  // the next line. Waiting for the lane is not a reason to lose it.
237
254
  ask: (method, args = {}, wanted) => {
255
+ if (gone())
256
+ return Promise.resolve(word('dropped'));
238
257
  const keys = at(bind.standings, id);
239
258
  if (!keys)
240
259
  return Promise.resolve(word('dropped')); // S2. she dropped it between one line and the next
241
260
  const rec = at(cells.standings, id);
242
- return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(word('late')))));
261
+ return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted, live) : Promise.resolve(word('late')))));
243
262
  },
244
263
  });
245
264
  // The record an ask writes into is the one it was issued on. A standing
246
265
  // dropped and re-taken under the same id while the ask was in flight is
247
266
  // another relation, and the blueprint that came back is not hers.
248
- const asking = async (id, keys, rec, method, args, wanted) => {
249
- const out = await send(keys, method, args, wanted, rec);
267
+ const asking = async (id, keys, rec, method, args, wanted, live = () => true) => {
268
+ const out = await send(keys, method, args, wanted, rec, live);
250
269
  if (method === undefined && !isSilence(out) && !isWord(out)) {
251
270
  // A blueprint her cells cannot keep, nested past the bound or carrying
252
271
  // a key no harbor writes, is not Quo's bytes: silence, and nothing written.
253
272
  if (cellFault(out, 'blueprint') !== null)
254
273
  return silence;
255
- if (rec && at(cells.standings, id) === rec) {
256
- rec.blueprint = out;
274
+ if (rec && live() && at(cells.standings, id) === rec) {
275
+ // Read as a blueprint before it is kept as one. A far describe is
276
+ // somebody else's code and may answer anything; what is not a
277
+ // blueprint is not written as one, and she holds no blueprint for
278
+ // that relation rather than one a side would walk and break on. The
279
+ // answer itself is hers either way: it is returned below, unread.
280
+ rec.blueprint = isBlueprint(out) ? out : null;
257
281
  rec.digest = rec.seen = await digest(out);
258
282
  }
259
283
  }
@@ -278,9 +302,11 @@ export function buildStance(inside, key, cells, bind) {
278
302
  // Nothing half-lives: a relation that could not be made unmakes the
279
303
  // being, who is a moment old and known to nobody.
280
304
  boot: async (className, made, id) => {
305
+ if (gone())
306
+ return null;
281
307
  if (id !== undefined && (reserved(id) || at(cells.standings, id) || at(cells.occupants, id)))
282
308
  return null;
283
- const born = inside.instantiate(made, className);
309
+ const born = inside.instantiate(className, made);
284
310
  if (born === null || id === undefined)
285
311
  return born;
286
312
  const inv = await inside.relate(born, key);
@@ -296,7 +322,7 @@ export function buildStance(inside, key, cells, bind) {
296
322
  // rotation one: the ward mints the occupant's first key and gives the secret away.
297
323
  // it keeps the pk beside the id and nothing else. the invitation IS the key.
298
324
  invite: async (id, notes) => {
299
- if (reserved(id) || at(cells.occupants, id) || at(cells.standings, id))
325
+ if (gone() || reserved(id) || at(cells.occupants, id) || at(cells.standings, id))
300
326
  return null;
301
327
  // The key first, and nothing written until it exists: a record put
302
328
  // before the mint would be a record a remove in the meantime drops
@@ -314,6 +340,8 @@ export function buildStance(inside, key, cells, bind) {
314
340
  return { ward: inside.pk, heir: k.pk, secret: k.seed };
315
341
  },
316
342
  remove: (id) => {
343
+ if (gone())
344
+ return;
317
345
  drop(cells.occupants, id);
318
346
  const heir = at(bind.occupants, id);
319
347
  drop(bind.occupants, id);
@@ -2,7 +2,7 @@ import type { Cells, Stance } from '../being/types.ts';
2
2
  import type { Ground, WardPointers } from './ground.ts';
3
3
  import { type Bind } from './partition.ts';
4
4
  import { type Door } from './door.ts';
5
- export type Booted = Door & {
5
+ export type Resident = Door & {
6
6
  bind: Bind;
7
7
  stance: Stance;
8
8
  cells: Cells & {