@quo-systems/quo 0.1.0 → 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.
- package/README.md +9 -6
- package/SPEC.md +235 -137
- package/dist/being/being.d.ts +22 -0
- package/dist/being/being.js +78 -0
- package/dist/being/digest.d.ts +3 -0
- package/dist/being/digest.js +19 -0
- package/dist/being/index.d.ts +5 -0
- package/dist/being/index.js +6 -0
- package/dist/being/silence.d.ts +10 -0
- package/dist/being/silence.js +29 -0
- package/dist/being/types.d.ts +84 -0
- package/dist/being/types.js +16 -0
- package/dist/conformance/assert.d.ts +11 -0
- package/dist/conformance/assert.js +72 -0
- package/dist/conformance/beings.d.ts +114 -0
- package/dist/conformance/beings.js +126 -0
- package/dist/conformance/estate.d.ts +5 -0
- package/dist/conformance/estate.js +316 -0
- package/dist/conformance/index.d.ts +65 -0
- package/dist/conformance/index.js +448 -0
- package/dist/conformance/reach.d.ts +10 -0
- package/dist/conformance/reach.js +72 -0
- package/dist/conformance/store.d.ts +5 -0
- package/dist/conformance/store.js +97 -0
- package/dist/harbor/core.d.ts +46 -0
- package/dist/harbor/core.js +218 -0
- package/dist/harbor/dial.d.ts +8 -0
- package/dist/harbor/dial.js +45 -0
- package/dist/harbor/index.d.ts +6 -0
- package/dist/harbor/index.js +10 -0
- package/dist/harbor/memory.d.ts +24 -0
- package/dist/harbor/memory.js +68 -0
- package/dist/harbor/reach.d.ts +36 -0
- package/dist/harbor/reach.js +166 -0
- package/dist/harbor/store.d.ts +33 -0
- package/dist/harbor/store.js +41 -0
- package/dist/ward/allowance.d.ts +10 -0
- package/dist/ward/allowance.js +60 -0
- package/dist/ward/arithmetic.d.ts +26 -0
- package/dist/ward/arithmetic.js +159 -0
- package/dist/ward/cells.d.ts +3 -0
- package/dist/ward/cells.js +79 -0
- package/dist/ward/door.d.ts +16 -0
- package/dist/ward/door.js +127 -0
- package/dist/ward/ground.d.ts +15 -0
- package/dist/ward/ground.js +1 -0
- package/dist/ward/heirs.d.ts +14 -0
- package/dist/ward/heirs.js +102 -0
- package/dist/ward/index.d.ts +8 -0
- package/dist/ward/index.js +10 -0
- package/dist/ward/owner.d.ts +32 -0
- package/dist/ward/owner.js +116 -0
- package/dist/ward/partition.d.ts +57 -0
- package/dist/ward/partition.js +64 -0
- package/dist/ward/seal.d.ts +50 -0
- package/dist/ward/seal.js +109 -0
- package/dist/ward/stance.d.ts +19 -0
- package/dist/ward/stance.js +259 -0
- package/dist/ward/ward.d.ts +2 -0
- package/dist/ward/ward.js +197 -0
- package/package.json +21 -29
- package/src/being/index.ts +1 -1
- package/src/being/silence.ts +26 -8
- package/src/being/types.ts +13 -3
- package/src/conformance/beings.ts +5 -5
- package/src/conformance/estate.ts +11 -6
- package/src/conformance/index.ts +14 -12
- package/src/harbor/core.ts +56 -6
- package/src/harbor/dial.ts +1 -1
- package/src/harbor/memory.ts +6 -1
- package/src/ward/door.ts +54 -32
- package/src/ward/ground.ts +6 -2
- package/src/ward/heirs.ts +20 -5
- package/src/ward/owner.ts +4 -3
- package/src/ward/partition.ts +10 -0
- package/src/ward/seal.ts +8 -5
- package/src/ward/stance.ts +20 -14
- package/src/ward/ward.ts +9 -9
- package/vectors/framing.json +7 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The one stance builder. Used for every being the ward boots. Nothing
|
|
3
|
+
// outer is in what she holds: ids in, values out, keys in the bind table.
|
|
4
|
+
import { silence, isSilence, isUnreached, isWord, word } from '../being/silence.js';
|
|
5
|
+
import { digest } from '../being/digest.js';
|
|
6
|
+
import { RESERVED_IDS } from '../being/types.js';
|
|
7
|
+
import { at, put, drop } from './partition.js';
|
|
8
|
+
import { allow, within, LATE } from './allowance.js';
|
|
9
|
+
import { isWardPk } from './seal.js';
|
|
10
|
+
// The three calls on `standings` share the object with the ids she takes, so
|
|
11
|
+
// a standing named after one of them would be unreachable: `standings.knock`
|
|
12
|
+
// is the call, whatever record sits under that name. One namespace means one
|
|
13
|
+
// list: these are refused at invite and at take, beside the ward's own words.
|
|
14
|
+
const CALLS = ['knock', 'take', 'remove'];
|
|
15
|
+
const reserved = (id) => RESERVED_IDS.includes(id) || CALLS.includes(id);
|
|
16
|
+
export function buildStance(inside, key, cells, bind) {
|
|
17
|
+
// The name of one relation, as her side files it. A relation is a ward and
|
|
18
|
+
// a heir, never a heir alone: the heir pk is outer, it rides in the clear on
|
|
19
|
+
// every lid, and anyone who reads one can quote it back inside an invitation
|
|
20
|
+
// naming a ward of their own. Filed under the heir alone, that invitation
|
|
21
|
+
// would share her knock record with the relation the heir really belongs to:
|
|
22
|
+
// it would sign with that relation's key, and an answer to it would set the
|
|
23
|
+
// answered flag the other relation is taken on. Both halves, always.
|
|
24
|
+
const nameOf = (inv) => (inv.heir === undefined ? `public:${inv.ward}` : `${inv.ward}:${inv.heir}`);
|
|
25
|
+
const valid = (inv) => {
|
|
26
|
+
const i = inv;
|
|
27
|
+
if (!i || typeof i !== 'object' || !isWardPk(i.ward))
|
|
28
|
+
return false;
|
|
29
|
+
if (i.heir === undefined)
|
|
30
|
+
return i.secret === undefined;
|
|
31
|
+
return typeof i.heir === 'string' && typeof i.secret === 'string' && /^[0-9a-f]{64}$/.test(i.heir) && /^[0-9a-f]{64}$/.test(i.secret);
|
|
32
|
+
};
|
|
33
|
+
// One relation, one ask at a time. The rotation is a conversation: a send
|
|
34
|
+
// reads the key that speaks now, announces the next, and moves to it once
|
|
35
|
+
// the far door has answered. Two sends interleaving on one relation read
|
|
36
|
+
// each other's half-written keys and the two sides diverge, which no later
|
|
37
|
+
// ask can repair. So each relation has a lane, and a send waits for the one
|
|
38
|
+
// before it. Beings may still ask concurrently: the lanes are per relation,
|
|
39
|
+
// and a slow relation never holds up another.
|
|
40
|
+
const lanes = new Map();
|
|
41
|
+
const lane = (name, run) => {
|
|
42
|
+
const mine = (lanes.get(name) ?? Promise.resolve()).then(run, run);
|
|
43
|
+
const quiet = mine.then(() => { }, () => { });
|
|
44
|
+
lanes.set(name, quiet);
|
|
45
|
+
void quiet.then(() => {
|
|
46
|
+
if (lanes.get(name) === quiet)
|
|
47
|
+
lanes.delete(name); // the lane is idle. nothing to remember.
|
|
48
|
+
});
|
|
49
|
+
return mine;
|
|
50
|
+
};
|
|
51
|
+
// Every ask a being makes, bounded. The bound is outside the lane on
|
|
52
|
+
// purpose: an ask that waits its turn behind a relation that comes back
|
|
53
|
+
// round never reaches a send, and a bound that only watched the wire would
|
|
54
|
+
// never see it hang. A bound that ran out is silence, never unreached:
|
|
55
|
+
// unreached promises nothing was delivered and is safe to ask again, and a
|
|
56
|
+
// wait that gave up knows no such thing.
|
|
57
|
+
//
|
|
58
|
+
// The bell rings before the work is reached, too: an ask that was still
|
|
59
|
+
// waiting its turn when the wait ran out is never sent. Sent then, it would
|
|
60
|
+
// be a fresh ask nobody is waiting on, the far being would do the work, and
|
|
61
|
+
// what came back would be read into her cells behind a silence she was
|
|
62
|
+
// already handed. So the work is given the bell, and reads it at the lane.
|
|
63
|
+
const bounded = async (wanted, work) => {
|
|
64
|
+
let late = false;
|
|
65
|
+
const out = await within(allow(wanted).time, work(() => !late), () => {
|
|
66
|
+
late = true;
|
|
67
|
+
});
|
|
68
|
+
return out === LATE ? word('late') : out;
|
|
69
|
+
};
|
|
70
|
+
const send = async (keys, method, args, wanted, rec) => {
|
|
71
|
+
const r = await inside.send(bind, keys, true, method, args, wanted);
|
|
72
|
+
if (isSilence(r) || isWord(r))
|
|
73
|
+
return r;
|
|
74
|
+
if ('silence' in r)
|
|
75
|
+
return silence;
|
|
76
|
+
if ('quo' in r)
|
|
77
|
+
return word(r.quo); // the far door's word for her, sealed to her lid
|
|
78
|
+
if (rec && method !== undefined && r.seen !== null)
|
|
79
|
+
rec.seen = r.seen; // the digest rode along
|
|
80
|
+
return r.object;
|
|
81
|
+
};
|
|
82
|
+
const knocking = async (inv, method, args, wanted) => {
|
|
83
|
+
const name = nameOf(inv);
|
|
84
|
+
// her own key for this relation, minted once: rotation two. reused if she knocks again.
|
|
85
|
+
const k = (bind.knocks[name] ??= { current: (await inside.mintKey(bind)).seed, next: null, spoke: false, sent: false, seq: 0 });
|
|
86
|
+
// the first knock on a heir speaks as the heir, the key the inviter handed out, and announces her own.
|
|
87
|
+
// once bytes have come back, the heir is spent whatever they said, and her own key speaks.
|
|
88
|
+
// She sent once as the heir and nothing came back. Either the door heard,
|
|
89
|
+
// spent the heir and rotated to the key she announced, or nothing arrived
|
|
90
|
+
// and the heir still stands. Only the far door knows, and it will not say.
|
|
91
|
+
//
|
|
92
|
+
// So she asks, and the asking tells her. Under her own key: if the door
|
|
93
|
+
// heard, that key is the one it admits, and she is answered. If it did
|
|
94
|
+
// not, the key means nothing there, the ask is refused, and a refusal at
|
|
95
|
+
// that door writes nothing — the heir is untouched and still speaks. Then
|
|
96
|
+
// she knocks as the heir, as she would have. One extra round trip in the
|
|
97
|
+
// one case where a reply was lost, and the relation is not stranded.
|
|
98
|
+
//
|
|
99
|
+
// No stranger gains anything by it: whoever holds the invitation could
|
|
100
|
+
// always knock as the heir, and her own key is admitted only where the
|
|
101
|
+
// door already bound it to her.
|
|
102
|
+
if (inv.heir !== undefined && !k.spoke && k.sent) {
|
|
103
|
+
const own = { ward: inv.ward, heir: inv.heir, current: k.current, next: k.next, seq: k.seq };
|
|
104
|
+
const r = await inside.send(bind, own, true, method, args, wanted);
|
|
105
|
+
k.seq = own.seq;
|
|
106
|
+
k.current = own.current;
|
|
107
|
+
k.next = own.next;
|
|
108
|
+
if (isUnreached(r))
|
|
109
|
+
return r; // still nothing. she knows no more than before.
|
|
110
|
+
if (!isSilence(r) && !isWord(r) && 'object' in r) {
|
|
111
|
+
k.spoke = true; // it was heard the first time, and this is the answer
|
|
112
|
+
bind.answered[name] = true;
|
|
113
|
+
return r.object;
|
|
114
|
+
}
|
|
115
|
+
// Refused. The door never heard her, so the heir is still hers to spend.
|
|
116
|
+
}
|
|
117
|
+
const first = inv.heir !== undefined && !k.spoke;
|
|
118
|
+
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 };
|
|
119
|
+
const r = await inside.send(bind, keys, true, method, args, wanted);
|
|
120
|
+
k.sent = true; // bytes went out. whether they arrived is the far door's to know.
|
|
121
|
+
// The number she spoke under, and the key she announced, kept whoever
|
|
122
|
+
// answered and kept when nobody did. A reply lost on the way back is a
|
|
123
|
+
// door that has already honoured the number: she must not offer it twice,
|
|
124
|
+
// or a road that drops one reply silences the relation for good.
|
|
125
|
+
k.seq = keys.seq;
|
|
126
|
+
if (!first) {
|
|
127
|
+
k.current = keys.current;
|
|
128
|
+
k.next = keys.next;
|
|
129
|
+
}
|
|
130
|
+
if (isUnreached(r))
|
|
131
|
+
return r; // nothing came back. she does not know whether it was heard.
|
|
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)
|
|
139
|
+
return silence;
|
|
140
|
+
if ('quo' in r)
|
|
141
|
+
return word(r.quo);
|
|
142
|
+
bind.answered[name] = true;
|
|
143
|
+
return r.object;
|
|
144
|
+
};
|
|
145
|
+
// The id she took this relation under, if she took it. One relation is one
|
|
146
|
+
// ward and one heir whichever door it is reached by, so the standing and the
|
|
147
|
+
// invitation answer to the same name.
|
|
148
|
+
const keyName = (k) => (k.heir === null ? `public:${k.ward}` : `${k.ward}:${k.heir}`);
|
|
149
|
+
const takenAs = (name) => Object.keys(bind.standings).find((id) => keyName(bind.standings[id]) === name);
|
|
150
|
+
const calls = {
|
|
151
|
+
knock: async (inv, method, args = {}, wanted) => {
|
|
152
|
+
if (!valid(inv))
|
|
153
|
+
return word('invitation'); // S1. nothing is sent
|
|
154
|
+
// She may knock again, and after take that knock is an ask: the relation
|
|
155
|
+
// has one home, so it answers on the standing's lane, under the
|
|
156
|
+
// standing's keys and its count. A second lane here would speak for the
|
|
157
|
+
// same relation, and the two would refuse each other.
|
|
158
|
+
const id = takenAs(nameOf(inv));
|
|
159
|
+
if (id !== undefined)
|
|
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')))));
|
|
162
|
+
},
|
|
163
|
+
// Take consumes. Until now the relation lived in the knock record, under
|
|
164
|
+
// the ward and the heir; from now it lives in the standing, under her id.
|
|
165
|
+
// It never lives in both: two records seeded from one relation are two
|
|
166
|
+
// lanes speaking for one line of keys and one count, and whichever spoke
|
|
167
|
+
// last holds it while the other is refused. She would read that refusal as
|
|
168
|
+
// the far being falling quiet, which is a thing a sovereign being may do,
|
|
169
|
+
// so the loss would look exactly like an answer she is owed nothing of.
|
|
170
|
+
// One relation, one home, and the invitation is spent: knocking it again
|
|
171
|
+
// is another consumed it, and taking it again births nothing.
|
|
172
|
+
//
|
|
173
|
+
// So it is spoken on the relation's lane, like every other word spoken
|
|
174
|
+
// for it. A take reads the count and the keys a knock writes, and copies
|
|
175
|
+
// them into the standing. Off the lane it reads them between a knock's
|
|
176
|
+
// send and its answer: the standing is born holding a number the far door
|
|
177
|
+
// is about to honour and a key it is about to rotate past, and her first
|
|
178
|
+
// ask on it is refused. The relation she took correctly would be dead
|
|
179
|
+
// because a second knock on the same invitation was careless.
|
|
180
|
+
take: async (id, inv) => {
|
|
181
|
+
if (!valid(inv))
|
|
182
|
+
return null;
|
|
183
|
+
const name = nameOf(inv);
|
|
184
|
+
return lane(`knock:${name}`, () => {
|
|
185
|
+
if (!bind.answered[name])
|
|
186
|
+
return Promise.resolve(null);
|
|
187
|
+
// Standings and occupants are one namespace — invite and take each
|
|
188
|
+
// refuse a name the other holds — so a word reserved in one is
|
|
189
|
+
// reserved in both.
|
|
190
|
+
if (reserved(id) || at(cells.standings, id) || at(cells.occupants, id))
|
|
191
|
+
return Promise.resolve(null);
|
|
192
|
+
const k = bind.knocks[name];
|
|
193
|
+
put(bind.standings, id, { ward: inv.ward, heir: inv.heir ?? null, current: k.current, next: k.next, seq: k.seq }); // the count carries over: the standing goes on where the knock left off
|
|
194
|
+
put(cells.standings, id, { id, digest: null, blueprint: null, seen: null });
|
|
195
|
+
delete bind.knocks[name];
|
|
196
|
+
delete bind.answered[name];
|
|
197
|
+
return Promise.resolve(id);
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
remove: (id) => {
|
|
201
|
+
drop(cells.standings, id);
|
|
202
|
+
drop(bind.standings, id);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const standingAt = (id) => ({
|
|
206
|
+
id,
|
|
207
|
+
// The keys are read now, not when the lane reaches this ask: an ask
|
|
208
|
+
// issued while the standing stood is answered even if she drops it in
|
|
209
|
+
// the next line. Waiting for the lane is not a reason to lose it.
|
|
210
|
+
ask: (method, args = {}, wanted) => {
|
|
211
|
+
const keys = at(bind.standings, id);
|
|
212
|
+
if (!keys)
|
|
213
|
+
return Promise.resolve(word('dropped')); // S2. she dropped it between one line and the next
|
|
214
|
+
const rec = at(cells.standings, id);
|
|
215
|
+
return bounded(wanted, (live) => lane(`ask:${id}`, () => (live() ? asking(id, keys, rec, method, args, wanted) : Promise.resolve(word('late')))));
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
const asking = async (id, keys, rec, method, args, wanted) => {
|
|
219
|
+
const out = await send(keys, method, args, wanted, rec);
|
|
220
|
+
if (method === undefined && !isSilence(out) && !isWord(out)) {
|
|
221
|
+
const here = at(cells.standings, id);
|
|
222
|
+
if (here) {
|
|
223
|
+
here.blueprint = out;
|
|
224
|
+
here.digest = here.seen = await digest(out);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
};
|
|
229
|
+
const standings = new Proxy(calls, {
|
|
230
|
+
// Own keys only: `in` would also find every name on Object's prototype,
|
|
231
|
+
// and a standing she took as `constructor` or `toString` would answer with
|
|
232
|
+
// Object's, not hers.
|
|
233
|
+
get: (t, id) => (typeof id === 'string' && Object.hasOwn(t, id) ? t[id] : typeof id === 'string' && at(cells.standings, id) ? standingAt(id) : undefined),
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
cells,
|
|
237
|
+
occupants: {
|
|
238
|
+
// rotation one: the ward mints the occupant's first key and gives the secret away.
|
|
239
|
+
// it keeps the pk beside the id and nothing else. the invitation IS the key.
|
|
240
|
+
invite: async (id) => {
|
|
241
|
+
if (reserved(id) || at(cells.occupants, id) || at(cells.standings, id))
|
|
242
|
+
return null;
|
|
243
|
+
put(cells.occupants, id, { id, notes: {} });
|
|
244
|
+
const k = await inside.mintKey(bind);
|
|
245
|
+
inside.openHeir(k.pk, key, id);
|
|
246
|
+
put(bind.occupants, id, k.pk);
|
|
247
|
+
return { ward: inside.pk, heir: k.pk, secret: k.seed };
|
|
248
|
+
},
|
|
249
|
+
remove: (id) => {
|
|
250
|
+
drop(cells.occupants, id);
|
|
251
|
+
const heir = at(bind.occupants, id);
|
|
252
|
+
drop(bind.occupants, id);
|
|
253
|
+
if (heir)
|
|
254
|
+
inside.closeHeir(heir); // the heir dies with the id
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
standings,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A ward that is its own ward. Booted by a harbor with one object, the
|
|
3
|
+
// ground, and hands back two pointers: the door and the ask. Boots itself as
|
|
4
|
+
// the first being in its own map, and every being it later instantiates gets
|
|
5
|
+
// a stance built by the same code. Mints every key, seals every ask that
|
|
6
|
+
// leaves, opens every one that arrives.
|
|
7
|
+
import { silence, isSilence, unreached, isWord, word } from '../being/silence.js';
|
|
8
|
+
import { OWNER } from '../being/types.js';
|
|
9
|
+
import { at, open, put, emptyBind, emptyCells } from './partition.js';
|
|
10
|
+
import { Heirs } from './heirs.js';
|
|
11
|
+
import { makeDoor } from './door.js';
|
|
12
|
+
import { buildStance } from './stance.js';
|
|
13
|
+
import { ownerAnswer } from './owner.js';
|
|
14
|
+
import { allow, within, LATE } from './allowance.js';
|
|
15
|
+
import { beingKey, openReply, sealAsk, wardKey, wardPadlock, wardSignPk } from './seal.js';
|
|
16
|
+
import { unhex } from './arithmetic.js';
|
|
17
|
+
import { guardCells, cellFault } from './cells.js';
|
|
18
|
+
// How many minted pks she keeps behind her. Enough to name the keys in play.
|
|
19
|
+
const MINTED = 8;
|
|
20
|
+
// The ward. One call in, two pointers out. Async because keys are derived.
|
|
21
|
+
export async function Ward(ground) {
|
|
22
|
+
const w = await Self.boot(ground);
|
|
23
|
+
return { door: w.door, ask: w.ask };
|
|
24
|
+
}
|
|
25
|
+
class Self {
|
|
26
|
+
key;
|
|
27
|
+
pk;
|
|
28
|
+
g;
|
|
29
|
+
p;
|
|
30
|
+
heirs;
|
|
31
|
+
doors = new Map();
|
|
32
|
+
door;
|
|
33
|
+
ask;
|
|
34
|
+
static async boot(ground) {
|
|
35
|
+
return new Self(ground, await wardKey(ground.seed));
|
|
36
|
+
}
|
|
37
|
+
constructor(ground, key) {
|
|
38
|
+
this.g = ground;
|
|
39
|
+
this.key = key;
|
|
40
|
+
this.pk = key.pk;
|
|
41
|
+
this.p = open(ground.memory); // the partition IS the ward's cells
|
|
42
|
+
this.heirs = new Heirs(this.p);
|
|
43
|
+
this.door = makeDoor(key, this.heirs, this.doors, () => this.p.public, (n) => ground.random(n)); // pointer one
|
|
44
|
+
this.#boot(this.pk, this); // the ward's ward is itself
|
|
45
|
+
// a restart is silent: every being in the cells is constructed again, unasked.
|
|
46
|
+
// A throw on a boot the owner asked for is that boot's answer: silence,
|
|
47
|
+
// and nothing half-lives. This boot nobody asked for, and there is nobody
|
|
48
|
+
// to hand a silence to. Any constructor may throw — a default that is not
|
|
49
|
+
// a value, an author's mistake, a shape she no longer recognises after an
|
|
50
|
+
// upgrade — and let through here it is one being taking down the ward and
|
|
51
|
+
// every other being in it, on a restart. She is absent instead: no door,
|
|
52
|
+
// so silence at the door, and her cells sit untouched, waiting for the
|
|
53
|
+
// run that can read them.
|
|
54
|
+
for (const [k, c] of Object.entries(this.p.beings)) {
|
|
55
|
+
if (k === this.pk || !c.class)
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
this.#instantiate(k, c.class);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* she is not here this run. the ward is, and so is everyone else. */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// pointer two. the owner. the same answer every named asker gets, asker filled in as owner.
|
|
65
|
+
this.ask = async (method, args = {}) => {
|
|
66
|
+
try {
|
|
67
|
+
const out = await this.answer({ id: OWNER }, method, args);
|
|
68
|
+
return isSilence(out) || isWord(out) ? silence : out;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return silence;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ---- the ward as a being. asked only by its owners: the root, through
|
|
76
|
+
// the ask pointer, and its occupants, through the door, whom only the root
|
|
77
|
+
// can invite. A stranger cannot be a named asker here, because the door
|
|
78
|
+
// names nobody the ward did not bind.
|
|
79
|
+
async answer(asker, method, args = {}) {
|
|
80
|
+
if (asker.id === undefined)
|
|
81
|
+
return { error: 'unknown ask' };
|
|
82
|
+
return ownerAnswer({
|
|
83
|
+
pk: this.pk,
|
|
84
|
+
doors: this.doors,
|
|
85
|
+
publicKey: () => this.p.public,
|
|
86
|
+
instantiate: (key, className, isPublic) => this.#instantiate(key, className, isPublic),
|
|
87
|
+
}, asker, method, args);
|
|
88
|
+
}
|
|
89
|
+
// ---- ward functions. on the object. no stance reaches them.
|
|
90
|
+
#instantiate(key, className, isPublic = false) {
|
|
91
|
+
if (!key || this.doors.has(key))
|
|
92
|
+
return null;
|
|
93
|
+
const door = this.#boot(key, className);
|
|
94
|
+
if (!door)
|
|
95
|
+
return null;
|
|
96
|
+
door.cells.class = className; // so a restart finds her
|
|
97
|
+
if (isPublic)
|
|
98
|
+
this.p.public = key;
|
|
99
|
+
return door;
|
|
100
|
+
}
|
|
101
|
+
// Nothing is written until there is somebody to write it for: a class the
|
|
102
|
+
// harbor does not know makes no being, and a boot that made nobody must
|
|
103
|
+
// leave the partition as it found it.
|
|
104
|
+
#boot(key, classNameOrSelf) {
|
|
105
|
+
const cells = guardCells(at(this.p.beings, key) ?? emptyCells());
|
|
106
|
+
const bind = at(this.p.bind, key) ?? emptyBind();
|
|
107
|
+
const stance = buildStance({
|
|
108
|
+
pk: this.pk,
|
|
109
|
+
mintKey: (b) => this.#mintKey(b),
|
|
110
|
+
openHeir: (heir, being, id) => this.heirs.open(heir, being, id),
|
|
111
|
+
closeHeir: (heir) => this.heirs.close(heir),
|
|
112
|
+
send: (b, keys, announce, method, args, wanted) => this.#send(b, keys, announce, method, args, wanted),
|
|
113
|
+
}, key, cells, bind);
|
|
114
|
+
const being = classNameOrSelf === this ? this : this.g.instantiate(classNameOrSelf, stance);
|
|
115
|
+
if (!being)
|
|
116
|
+
return null;
|
|
117
|
+
put(this.p.beings, key, cells);
|
|
118
|
+
put(this.p.bind, key, bind);
|
|
119
|
+
const door = { key, cells, bind, stance, being };
|
|
120
|
+
this.doors.set(key, door);
|
|
121
|
+
return door;
|
|
122
|
+
}
|
|
123
|
+
async #mintKey(bind) {
|
|
124
|
+
const k = await beingKey(this.g.random(32));
|
|
125
|
+
// The last few she minted, and no more. A relation rotates on every ask,
|
|
126
|
+
// so a list of all of them is a partition that grows for as long as she
|
|
127
|
+
// keeps talking and can never be written down. Nothing is read from this
|
|
128
|
+
// but the keys of the moment.
|
|
129
|
+
bind.minted.push(k.pk);
|
|
130
|
+
if (bind.minted.length > MINTED)
|
|
131
|
+
bind.minted.splice(0, bind.minted.length - MINTED);
|
|
132
|
+
return k;
|
|
133
|
+
}
|
|
134
|
+
// One send for every door, the ward's own included. Mine: never leaves.
|
|
135
|
+
// Not mine: carry. Signs as the standing's current key, announces its
|
|
136
|
+
// next, seals to the far ward, opens the reply with the ephemeral secret,
|
|
137
|
+
// and rotates to the announced key once the far door has answered under
|
|
138
|
+
// the current one.
|
|
139
|
+
async #send(bind, keys, announce, method, args, wanted) {
|
|
140
|
+
// What she asked for, held to what this ward allows. Asking for nothing is
|
|
141
|
+
// the default, and asking for more than the ceiling is the ceiling: budget
|
|
142
|
+
// is granted by a ward, never minted by a being.
|
|
143
|
+
const budget = allow(wanted);
|
|
144
|
+
const by = (await beingKey(unhex(keys.current))).pk;
|
|
145
|
+
// A public being is reached without a heir, and the door keeps nothing for
|
|
146
|
+
// whoever asked: no key it vouched for, so nothing to announce and nothing
|
|
147
|
+
// to rotate to. A standing on her signs with one key for life.
|
|
148
|
+
if (announce && keys.next === null && keys.heir !== null)
|
|
149
|
+
keys.next = (await this.#mintKey(bind)).seed;
|
|
150
|
+
const next = keys.next === null ? null : (await beingKey(unhex(keys.next))).pk;
|
|
151
|
+
// Her count for this relation, one higher every call and never reused. The
|
|
152
|
+
// far door honours each number once. One relation sends one at a time, so
|
|
153
|
+
// the numbers leave in the order they were taken.
|
|
154
|
+
const seq = (keys.seq += 1);
|
|
155
|
+
// Args that will not go through the seal are args that never left. A
|
|
156
|
+
// being holds three answers and a throw is not one of them, so an ask
|
|
157
|
+
// that cannot be sealed is told what is true of it: no door was reached.
|
|
158
|
+
// Values only, by the same rule her cells keep: a Date would arrive as a
|
|
159
|
+
// string, a function would not arrive at all, and she would never know
|
|
160
|
+
// the far being was asked something other than what she said.
|
|
161
|
+
if (cellFault(args, 'args') !== null)
|
|
162
|
+
return unreached();
|
|
163
|
+
let sealed;
|
|
164
|
+
try {
|
|
165
|
+
sealed = await sealAsk(keys.heir, { by, next, seq, time: budget.time, method, args }, unhex(keys.current), wardPadlock(keys.ward), this.g.random(32));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return unreached();
|
|
169
|
+
}
|
|
170
|
+
const { bytes, ephemeral } = sealed;
|
|
171
|
+
// The wait is bounded, and this is the one thing the ward times. A being
|
|
172
|
+
// holds three answers and a wait that does not end is none of them: a
|
|
173
|
+
// relation that comes back round holds a lane the answer needs, and only a
|
|
174
|
+
// bound on the wait can break that. What comes back late is not read.
|
|
175
|
+
//
|
|
176
|
+
// A wait that ran out is `late`, never unreached. Unreached promises
|
|
177
|
+
// nothing was delivered and is safe to retry; a bound that expired knows
|
|
178
|
+
// no such thing, because the far door may have heard and be working still.
|
|
179
|
+
//
|
|
180
|
+
// A harbor answers bytes or nothing. One that throws instead has answered
|
|
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)).then((r) => r.bytes) : this.g.carry(keys.ward, bytes)).then((b) => b, () => undefined);
|
|
183
|
+
const out = await within(budget.time, carried);
|
|
184
|
+
if (out === LATE)
|
|
185
|
+
return word('late');
|
|
186
|
+
if (out === undefined)
|
|
187
|
+
return unreached(); // nothing came back. no door was reached.
|
|
188
|
+
const reply = await openReply(out, ephemeral, wardSignPk(keys.ward));
|
|
189
|
+
if (!reply)
|
|
190
|
+
return silence; // did not open, or not from that ward: not Quo's bytes
|
|
191
|
+
if ('object' in reply && keys.next !== null) {
|
|
192
|
+
keys.current = keys.next; // the far door holds `next` as announced. move to it.
|
|
193
|
+
keys.next = null;
|
|
194
|
+
}
|
|
195
|
+
return reply;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quo-systems/quo",
|
|
3
|
-
"version": "0.
|
|
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,42 +18,34 @@
|
|
|
18
18
|
"node": ">=22.18"
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
|
-
".":
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
"
|
|
28
|
-
"check:estate": "node --test \"estate/test/*.test.ts\"",
|
|
29
|
-
"typecheck": "tsc",
|
|
30
|
-
"typecheck:watch": "tsc --watch",
|
|
31
|
-
"lint": "npm run lint:ts && npm run lint:md",
|
|
32
|
-
"lint:ts": "oxlint --type-aware",
|
|
33
|
-
"lint:md": "markdownlint-cli2",
|
|
34
|
-
"lint:fix": "oxlint --type-aware --fix && markdownlint-cli2 --fix",
|
|
39
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
35
40
|
"test": "node --test \"test/*.test.ts\"",
|
|
36
|
-
"check:terrain": "node --test \"test/terrain/*.test.ts\"
|
|
37
|
-
"
|
|
38
|
-
"test:ward": "node --test test/ward.test.ts",
|
|
39
|
-
"prepublishOnly": "npm run check && npm run check:terrain"
|
|
40
|
-
},
|
|
41
|
-
"devDependencies": {
|
|
42
|
-
"@types/node": "^26.4.1",
|
|
43
|
-
"bun": "^1.4.0",
|
|
44
|
-
"deno": "^2.9.6",
|
|
45
|
-
"esbuild": "^0.28.2",
|
|
46
|
-
"markdownlint-cli2": "^0.23.2",
|
|
47
|
-
"oxlint": "^1.81.0",
|
|
48
|
-
"oxlint-tsgolint": "^7.0.2001",
|
|
49
|
-
"playwright": "^1.62.1",
|
|
50
|
-
"typescript": "^7.0.2",
|
|
51
|
-
"workerd": "^1.20260904.1"
|
|
41
|
+
"check:terrain": "node --test \"test/terrain/*.test.ts\"",
|
|
42
|
+
"prepublishOnly": "cd ../.. && npm run check && npm run check:terrain"
|
|
52
43
|
},
|
|
53
44
|
"publishConfig": {
|
|
54
45
|
"access": "public"
|
|
55
46
|
},
|
|
56
47
|
"files": [
|
|
48
|
+
"dist",
|
|
57
49
|
"src",
|
|
58
50
|
"vectors",
|
|
59
51
|
"SPEC.md",
|
package/src/being/index.ts
CHANGED
|
@@ -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';
|
package/src/being/silence.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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';
|
package/src/being/types.ts
CHANGED
|
@@ -44,11 +44,21 @@ export type Cells = {
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
export type Silence = typeof silence;
|
|
47
|
-
|
|
48
|
-
|
|
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 |
|
|
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) ||
|
|
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) && !
|
|
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) ||
|
|
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
|
}
|