@real-life/trust-protocol 0.1.1 → 0.3.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 +18 -11
- package/dist/carrier-identity.d.ts +9 -0
- package/dist/carrier-identity.d.ts.map +1 -1
- package/dist/carrier-identity.js +32 -2
- package/dist/carrier-identity.js.map +1 -1
- package/dist/ceremony.d.ts +3 -0
- package/dist/ceremony.d.ts.map +1 -0
- package/dist/ceremony.js +29 -0
- package/dist/ceremony.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/probe/acks.d.ts +27 -0
- package/dist/probe/acks.d.ts.map +1 -0
- package/dist/probe/acks.js +152 -0
- package/dist/probe/acks.js.map +1 -0
- package/dist/probe/continuity.d.ts +3 -82
- package/dist/probe/continuity.d.ts.map +1 -1
- package/dist/probe/continuity.js +636 -147
- package/dist/probe/continuity.js.map +1 -1
- package/dist/probe/encounter.d.ts +26 -0
- package/dist/probe/encounter.d.ts.map +1 -0
- package/dist/probe/encounter.js +938 -0
- package/dist/probe/encounter.js.map +1 -0
- package/dist/probe/trust.d.ts +114 -74
- package/dist/probe/trust.d.ts.map +1 -1
- package/dist/probe/trust.js +1120 -232
- package/dist/probe/trust.js.map +1 -1
- package/dist/probe.d.ts +0 -2
- package/dist/probe.d.ts.map +1 -1
- package/dist/probe.js +8 -7
- package/dist/probe.js.map +1 -1
- package/dist/schemas.js +22 -22
- package/dist/schemas.js.map +1 -1
- package/dist/visibility.d.ts +6 -0
- package/dist/visibility.d.ts.map +1 -0
- package/dist/visibility.js +31 -0
- package/dist/visibility.js.map +1 -0
- package/dist/wire.d.ts +28 -26
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
// encounter — the ceremony's TRANSMISSION on the normative forms:
|
|
2
|
+
// Encounter 0.29 (wire 0.25: rltp-card/0.25 · encounter-scan@0.25)
|
|
3
|
+
// carried by Delivery 0.79 — encounter-bundle/0.1 (§4.1),
|
|
4
|
+
// encounter-credential-delivery/0.1 (§4.3), acknowledged with the
|
|
5
|
+
// SIGNED delivery-ack/0.1 (§4.2/4.4 class rule: signature-class payloads
|
|
6
|
+
// get an eddsa-jcs-2022 ack; only DV payloads get the deniable MAC form).
|
|
7
|
+
// The Encounter-Nachzug (design/encounter-nachzug-plan-2026-09.md)
|
|
8
|
+
// replaces the host's `encounter-verify@probe` with this module.
|
|
9
|
+
//
|
|
10
|
+
// The machine (Encounter 5.3–5.8, in the probe WORLD of a Person):
|
|
11
|
+
// · own-challenge STATE MODEL (5.3): issuance → open; record creation
|
|
12
|
+
// → recorded (atomic, in-lock); aging latch → unknown (set-only,
|
|
13
|
+
// whole-second normalized, LOCK-FREE). resolve() is total and by
|
|
14
|
+
// precedence.
|
|
15
|
+
// · the ENACTMENT RECORD (5.5), keyed by the own challenge, unique and
|
|
16
|
+
// idempotent; the record IS the contact tuple; the record key is one
|
|
17
|
+
// lock namespace for bundles, the optical leg, the scanner's own
|
|
18
|
+
// record and the counter-step.
|
|
19
|
+
// · ONE COMMIT DISCIPLINE for every effect (design/encounter-review11-
|
|
20
|
+
// halt-2026-09.md, Option 1): `effect()` runs, under the lock set,
|
|
21
|
+
// prepare — async, FALLIBLE, no mutation (checks, tuple, ack);
|
|
22
|
+
// verify — SYNCHRONOUS re-check of EVERY state-dependent
|
|
23
|
+
// condition (cache, authoritative resolution, thread
|
|
24
|
+
// freshness, counterpart/card identity, uniqueness);
|
|
25
|
+
// commit — SYNCHRONOUS, no await (record/tuple/buffer + accepted
|
|
26
|
+
// digest + cache + retained ack).
|
|
27
|
+
// Nothing read before an await ever decides a commit; a state that
|
|
28
|
+
// moved makes verify say `retry` — the waiter rule of 6.2, re-entering
|
|
29
|
+
// at stage 4 — or names the disposition. `p.__beforeVerify` is a test
|
|
30
|
+
// hook that perturbs state between prepare and verify.
|
|
31
|
+
// · receiveEncounter: the §6.2 stage order — profile + recipient (5) →
|
|
32
|
+
// payload schema (7) → the pre-lock checks of 4.1 IN ORDER (8) → the
|
|
33
|
+
// lock set {digest, record key} (9, bundles) / {digest} (deliveries).
|
|
34
|
+
// · encounter-credential-delivery (4.3): the effect is BUFFERING; the
|
|
35
|
+
// ack is sent at buffering; Encounter acceptance (5.6) runs
|
|
36
|
+
// separately (own record-key section) and is never signaled (7.4).
|
|
37
|
+
// · digest equality is over decoded multihash bytes (2.3: sameDigest).
|
|
38
|
+
// · "delivered" is the sender's status ONLY on a valid ack (6.1) —
|
|
39
|
+
// trust.receiveAckInner marks bundleAcked / counterAcked.
|
|
40
|
+
import { jcs, makeValidator, sameDigest } from '../core.js';
|
|
41
|
+
import { SCHEMAS } from '../schemas.js';
|
|
42
|
+
import * as C from './deps.js';
|
|
43
|
+
import { snapshotPriors } from './continuity.js';
|
|
44
|
+
import { buildAck } from './acks.js';
|
|
45
|
+
const say = (p, m) => p.log.push(m);
|
|
46
|
+
const V = makeValidator(SCHEMAS);
|
|
47
|
+
const schemaOk = (file, data) => V.validate(data, SCHEMAS[file], SCHEMAS[file]).length === 0;
|
|
48
|
+
const TT = 'https://real-life.org/trust-tasks/';
|
|
49
|
+
export const BUNDLE_TYPE = TT + 'encounter-bundle/0.1';
|
|
50
|
+
export const DELIVERY_TYPE = TT + 'encounter-credential-delivery/0.1';
|
|
51
|
+
// Encounter §9 operational constants (ms)
|
|
52
|
+
export const CHALLENGE_MAX_AGE = 5 * 60_000; // PT5M
|
|
53
|
+
export const ISSUANCE_WINDOW = 24 * 3_600_000; // PT24H
|
|
54
|
+
export const SKEW_TOLERANCE = 5 * 60_000; // PT5M
|
|
55
|
+
const sec = (ms) => Math.floor(ms / 1000); // 2.3: whole seconds
|
|
56
|
+
// producer-side profile discipline (Delivery 3 / Encounter 6): UUID v4 ids and
|
|
57
|
+
// threads, a display name inside the card schema's bound (200 characters)
|
|
58
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
59
|
+
const uuidOk = (v) => typeof v === 'string' && UUID_V4.test(v);
|
|
60
|
+
const displayName = (p) => String(p.name ?? '').slice(0, 200);
|
|
61
|
+
const secIso = (iso) => Math.floor(Date.parse(iso) / 1000);
|
|
62
|
+
// ── 5.3 the own-challenge state model ───────────────────────────────────
|
|
63
|
+
// p.challenges: value → { tCh (ms), ctxAnchor, aged? } — every issued
|
|
64
|
+
// value is RETAINED until recorded or aged (retention is mandatory).
|
|
65
|
+
// p.records: own challenge value → record (5.5); the record's contact
|
|
66
|
+
// tuple lives in p.contacts under the counterparty anchor.
|
|
67
|
+
const chal = (p) => (p.challenges ??= new Map());
|
|
68
|
+
const recs = (p) => (p.records ??= new Map());
|
|
69
|
+
/** total resolution by precedence: recorded → open → unknown; the one
|
|
70
|
+
* write is the aging latch (set-only, whole-second boundary, lock-free) */
|
|
71
|
+
export function resolve(p, value, now) {
|
|
72
|
+
if (recs(p).has(value))
|
|
73
|
+
return 'recorded';
|
|
74
|
+
const c = chal(p).get(value);
|
|
75
|
+
if (!c || c.aged)
|
|
76
|
+
return 'unknown';
|
|
77
|
+
if (sec(now) > sec(c.tCh) + (CHALLENGE_MAX_AGE + SKEW_TOLERANCE) / 1000) {
|
|
78
|
+
c.aged = true;
|
|
79
|
+
return 'unknown';
|
|
80
|
+
} // latch
|
|
81
|
+
return 'open';
|
|
82
|
+
}
|
|
83
|
+
const futureOk = (tCh, now) => sec(tCh) <= sec(now) + SKEW_TOLERANCE / 1000; // 5.5 explicit future check
|
|
84
|
+
// the producer's mirror of 5.6 step 6: may a credential binding a challenge issued at tChMs still be issued now?
|
|
85
|
+
const issuanceOpen = (tChMs, now) => { const skew = SKEW_TOLERANCE / 1000; return sec(now) >= sec(tChMs) - skew && sec(now) <= sec(tChMs) + (CHALLENGE_MAX_AGE + ISSUANCE_WINDOW) / 1000 + skew; };
|
|
86
|
+
const lockState = (p) => (p.lockSet ??= { held: new Set(), waiters: [] });
|
|
87
|
+
const release = (L, keys) => { for (const k of keys)
|
|
88
|
+
L.held.delete(k); const w = L.waiters; L.waiters = []; for (const r of w)
|
|
89
|
+
r(); };
|
|
90
|
+
/** acquire the whole set at once if free (synchronous); else null */
|
|
91
|
+
const tryAcquire = (p, keys) => {
|
|
92
|
+
const L = lockState(p);
|
|
93
|
+
if (keys.some((k) => L.held.has(k)))
|
|
94
|
+
return null;
|
|
95
|
+
for (const k of keys)
|
|
96
|
+
L.held.add(k);
|
|
97
|
+
return L;
|
|
98
|
+
};
|
|
99
|
+
const awaitRelease = (p) => new Promise((res) => lockState(p).waiters.push(res));
|
|
100
|
+
/** producer-side critical section (own actions, no stage evaluation): wait, then run */
|
|
101
|
+
async function withLockSet(p, keys, fn) {
|
|
102
|
+
let L = tryAcquire(p, keys);
|
|
103
|
+
while (!L) {
|
|
104
|
+
await awaitRelease(p);
|
|
105
|
+
L = tryAcquire(p, keys);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return await fn();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
release(L, keys);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function effect(p, keys, e, fail) {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const pr = await e.prepare(); // stages 4–8, holding nothing
|
|
117
|
+
if (pr.error !== undefined)
|
|
118
|
+
return fail(pr.error);
|
|
119
|
+
if (pr.done !== undefined)
|
|
120
|
+
return pr.done;
|
|
121
|
+
// stage 9: the lock set, acquired ATOMICALLY and only if free — a
|
|
122
|
+
// waiter holds nothing and, when the way is free, does NOT resume:
|
|
123
|
+
// it re-enters at stage 4 (prepare again, 6.2)
|
|
124
|
+
const L = tryAcquire(p, keys);
|
|
125
|
+
if (!L) {
|
|
126
|
+
await awaitRelease(p);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const hook = p.__beforeVerify;
|
|
131
|
+
if (typeof hook === 'function')
|
|
132
|
+
hook();
|
|
133
|
+
// ── from here to the end of commit: NO await ──
|
|
134
|
+
const v = e.verify(pr.prep);
|
|
135
|
+
if (v === 'retry')
|
|
136
|
+
continue; // the state moved: re-enter at stage 4
|
|
137
|
+
if (v !== null)
|
|
138
|
+
return fail(v);
|
|
139
|
+
return e.commit(pr.prep);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
release(L, keys);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const failed = (err) => ({ handled: true, error: err });
|
|
147
|
+
// ── cards (Encounter 6): schema-valid, decoded keys, proof under the anchor
|
|
148
|
+
const CARD_SCHEMA = 'contact-card-0.25.schema.json';
|
|
149
|
+
async function cardOk(card) {
|
|
150
|
+
if (!schemaOk(CARD_SCHEMA, card))
|
|
151
|
+
return 'card fails contact-card-0.25 schema';
|
|
152
|
+
if (card.version !== C.CARD_VERSION)
|
|
153
|
+
return 'card version unknown';
|
|
154
|
+
if (!card.challenge)
|
|
155
|
+
return 'card without challenge (enactment use requires one)';
|
|
156
|
+
if (!C.calOK(card.challenge.issuedAt) || !C.calOK(card.proof?.created))
|
|
157
|
+
return 'card: calendar-invalid timestamp';
|
|
158
|
+
if (!C.xRawOfMk(card.keyAgreement) || !C.edRawOfAnchor(card.anchor))
|
|
159
|
+
return 'card keyAgreement does not decode (x25519-pub + 32 bytes, 2.3)';
|
|
160
|
+
if (!(await C.diVerify(card, card.anchor)))
|
|
161
|
+
return 'card proof';
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// ── show: a displayed card (5.3 displayed challenge) ─────────────────────
|
|
165
|
+
export async function show(p, when, ent = {}) {
|
|
166
|
+
const ctx = await C.pairContext(p.rootIkm, ent.nonce ?? C.rand(32));
|
|
167
|
+
if (p.contexts.has(ctx.anchor))
|
|
168
|
+
return { error: 'validation-failed (pair anchor already used — fresh-always, 4.4)' };
|
|
169
|
+
const ch = C.challengeOf(ent.ch ?? C.rand(17));
|
|
170
|
+
const iso = C.iso(when);
|
|
171
|
+
// single use + monotone latch (5.3): a value that is recorded, still
|
|
172
|
+
// held open, or aged is NEVER re-issued
|
|
173
|
+
if (recs(p).has(ch) || chal(p).has(ch))
|
|
174
|
+
return { error: 'validation-failed (challenge value already issued)' };
|
|
175
|
+
p.contexts.set(ctx.anchor, ctx);
|
|
176
|
+
p.contexts.set(ctx.keyAgreement, ctx);
|
|
177
|
+
chal(p).set(ch, { tCh: when, ctxAnchor: ctx.anchor }); // enters as OPEN
|
|
178
|
+
const card = await C.signCard(ctx, C.cardBody(ctx, { name: displayName(p), challenge: { value: ch, issuedAt: iso } }), iso);
|
|
179
|
+
return { ctx, ch, card, tCh: when };
|
|
180
|
+
}
|
|
181
|
+
// ── the record (5.5): build (pure) + one SYNCHRONOUS commit ─────────────
|
|
182
|
+
// Encounter 4.2: the edge's direction is DERIVED from what exists — issued
|
|
183
|
+
// (own credential) and received (counterparty credential accepted); a
|
|
184
|
+
// record alone has no direction yet ('◇': met, recorded)
|
|
185
|
+
const edgeState = (t) => t.credential && t.credentialIn ? '✓' : t.credential ? '→' : t.credentialIn ? '←' : '◇';
|
|
186
|
+
function buildTuple(p, ownCtx, peerCard, ownValue, peerValue, bind, when, extra = {}) {
|
|
187
|
+
const tuple = {
|
|
188
|
+
name: peerCard.name, card: peerCard, provenance: 'ceremony', since: C.iso(when),
|
|
189
|
+
channel: { own: ownCtx, counterpartKa: peerCard.keyAgreement }, priorCands: [],
|
|
190
|
+
ownChallenge: ownValue, peerChallenge: peerValue, bind, ...extra.tuple,
|
|
191
|
+
};
|
|
192
|
+
tuple.state = edgeState(tuple);
|
|
193
|
+
return tuple;
|
|
194
|
+
}
|
|
195
|
+
function commitRecord(p, tuple, when) {
|
|
196
|
+
tuple.priorCands = snapshotPriors(p); // BEFORE the fresh tuple (6a)
|
|
197
|
+
const c = chal(p).get(tuple.ownChallenge);
|
|
198
|
+
p.contacts.set(tuple.card.anchor, tuple);
|
|
199
|
+
recs(p).set(tuple.ownChallenge, { ceremony: C.CEREMONY, counterpart: tuple.card.anchor, card: tuple.card, ownValue: tuple.ownChallenge, peerValue: tuple.peerChallenge, tCh: c?.tCh ?? when, bind: tuple.bind, at: when, ctxAnchor: tuple.channel.own.anchor, accepted: {} });
|
|
200
|
+
chal(p).delete(tuple.ownChallenge); // open → recorded
|
|
201
|
+
return tuple;
|
|
202
|
+
}
|
|
203
|
+
// ── outbox / threads / acks ─────────────────────────────────────────────
|
|
204
|
+
const outboxOf = (t) => (t.outbox ??= new Map());
|
|
205
|
+
// a thread-opening document carries a FRESH threadId (Delivery 3 / 4.1) —
|
|
206
|
+
// fresh means: used by no bundle thread, no reserved inbound thread, no
|
|
207
|
+
// outbox entry of this person (a re-issued bundle for the same enactment
|
|
208
|
+
// opens a fresh thread too; 4.1 "Thread freshness under re-issue")
|
|
209
|
+
const threadInUse = (p, id) => {
|
|
210
|
+
for (const t of p.contacts.values()) {
|
|
211
|
+
if (t.bundleThread === id || t.inThreads?.has(id))
|
|
212
|
+
return true;
|
|
213
|
+
for (const o of (t.outbox ?? new Map()).values())
|
|
214
|
+
if (o.threadId === id)
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
async function issueDoc(p, tuple, type, payload, when, threadId, kind, ent = {}, ceremony, onCommit) {
|
|
220
|
+
const doc = { id: ent.id ?? globalThis.crypto.randomUUID(), type, issuer: tuple.channel.own.anchor, recipient: tuple.card.anchor, threadId, issuedAt: C.iso(when), payload };
|
|
221
|
+
if (ceremony)
|
|
222
|
+
doc.ceremony = ceremony;
|
|
223
|
+
// the outer document validates against the profile BEFORE anything is committed (a malformed document would be refused at stage 5 by every receiver)
|
|
224
|
+
if (!uuidOk(doc.id) || !uuidOk(threadId) || !schemaOk('rltp-delivery-document.schema.json', doc))
|
|
225
|
+
throw new Error('document profile violated (id/threadId UUID v4, schema)');
|
|
226
|
+
const env = await C.seal(doc, tuple.channel.counterpartKa, ent.envNonce ? { nonce: ent.envNonce } : {}); // fallible — nothing persisted yet
|
|
227
|
+
const digest = await C.digestDoc(doc);
|
|
228
|
+
outboxOf(tuple).set(digest, { env: structuredClone(env), threadId, kind }); // COMMIT — the store's own copy …
|
|
229
|
+
onCommit?.(); // … and the duty flag falls in the SAME synchronous span
|
|
230
|
+
return { to: tuple, kind: kind + '/0.1', env, threadId, digest };
|
|
231
|
+
}
|
|
232
|
+
// Encounter payloads carry transferable signatures → the ack is the
|
|
233
|
+
// SIGNED form (4.2/4.4 acknowledgement class rule)
|
|
234
|
+
const mkAck = (p, tuple, digest, threadId, when, recipient) => buildAck(p, tuple, digest, threadId, when, { cls: 'signature', ...(recipient ? { recipient } : {}) }).then((a) => a.env);
|
|
235
|
+
// the retained ack is the store's OWN copy — the host's handle can never
|
|
236
|
+
// alter what a redelivery re-sends (4.2 byte-identical)
|
|
237
|
+
// the retained ack shares the completed-effect cache's LIFETIME (4.2), not
|
|
238
|
+
// the tuple's: p.ackRetained keeps digest → { env, to } so a redelivery after
|
|
239
|
+
// the relation was deleted still re-sends exactly the stored document
|
|
240
|
+
const retained = (p) => (p.ackRetained ??= new Map());
|
|
241
|
+
const commitAck = (p, tuple, digest, ackEnv) => {
|
|
242
|
+
C.effectDone(p, digest);
|
|
243
|
+
const copy = structuredClone(ackEnv);
|
|
244
|
+
(tuple.ackStore ??= new Map()).set(digest, copy);
|
|
245
|
+
retained(p).set(digest, { env: copy, to: tuple });
|
|
246
|
+
return { to: tuple, kind: 'delivery-ack/0.1', env: ackEnv };
|
|
247
|
+
};
|
|
248
|
+
const dupResult = (p, digest, anchor) => {
|
|
249
|
+
const r = retained(p).get(digest);
|
|
250
|
+
const to = p.contacts.get(anchor) ?? r?.to;
|
|
251
|
+
return { handled: true, duplicate: true, ack: r ? { to, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: structuredClone(r.env) } : undefined };
|
|
252
|
+
};
|
|
253
|
+
const cached = (p, digest) => (p.deliveryCache ?? new Set()).has(digest);
|
|
254
|
+
// ── scan: the scanner's step (5.8 trunk 2–3) → encounter-bundle/0.1 ──────
|
|
255
|
+
// A generates a FRESH pair context (4.4 fresh-always) and a FRESH
|
|
256
|
+
// challenge c_A created now (5.3: never the display challenge). Under the
|
|
257
|
+
// record key: prepare = the binding; verify = single use of the own
|
|
258
|
+
// value, pair-anchor freshness, thread freshness, future check; commit =
|
|
259
|
+
// the value enters `open`, resolves AUTHORITATIVELY in the same span, the
|
|
260
|
+
// RECORD (5.5, before issuing). Credential, sent card and bundle follow.
|
|
261
|
+
export async function scan(p, peerCardIn, when, ent = {}) {
|
|
262
|
+
const peerCard = structuredClone(peerCardIn); // SNAPSHOT before the first await: verified = recorded
|
|
263
|
+
const ce = await cardOk(peerCard);
|
|
264
|
+
if (ce)
|
|
265
|
+
return { error: `displayed card invalid (${ce})` };
|
|
266
|
+
if (peerCard.sentTo !== undefined || peerCard.boundTo !== undefined)
|
|
267
|
+
return { error: 'a displayed card carries neither sentTo nor boundTo' };
|
|
268
|
+
const ctx = await C.pairContext(p.rootIkm, ent.nonce ?? C.rand(32));
|
|
269
|
+
if (p.contexts.has(ctx.anchor))
|
|
270
|
+
return { error: 'validation-failed (pair anchor already used — fresh-always, 4.4)' };
|
|
271
|
+
let ownValue = C.challengeOf(ent.ch ?? C.rand(17));
|
|
272
|
+
while (!ent.ch && ownValue === peerCard.challenge.value)
|
|
273
|
+
ownValue = C.challengeOf(C.rand(17));
|
|
274
|
+
if (ownValue === peerCard.challenge.value)
|
|
275
|
+
return { error: 'validation-failed (c_A equals the displayed challenge — never reused in a sent card, 5.3)' };
|
|
276
|
+
const peerValue = peerCard.challenge.value;
|
|
277
|
+
// 5.3 single use, from the scanner's side too: a displayed value already
|
|
278
|
+
// present in one of OUR records never enters a second enactment (a double
|
|
279
|
+
// scan of one code would otherwise overwrite the anchor pair's tuple) —
|
|
280
|
+
// decided in verify, under a lock key for THIS display (anchor + value),
|
|
281
|
+
// so parallel scans of one code linearize
|
|
282
|
+
const alreadyScanned = () => { for (const r of recs(p).values())
|
|
283
|
+
if (r.peerValue === peerValue && r.counterpart === peerCard.anchor)
|
|
284
|
+
return true; return false; };
|
|
285
|
+
if (alreadyScanned())
|
|
286
|
+
return { error: 'validation-failed (this code was already scanned — its challenge is in a record, 5.3)' };
|
|
287
|
+
const displayKey = 'display:' + peerCard.anchor + ':' + peerValue;
|
|
288
|
+
const threadId = ent.threadId ?? globalThis.crypto.randomUUID();
|
|
289
|
+
if (!uuidOk(threadId) || (ent.id !== undefined && !uuidOk(ent.id)))
|
|
290
|
+
return { error: 'validation-failed (id/threadId must be UUID v4 — Delivery 3)' }; // before the record gate
|
|
291
|
+
const iso = C.iso(when);
|
|
292
|
+
const recorded = await effect(p, [ownValue, displayKey], {
|
|
293
|
+
prepare: async () => ({ prep: { bind: await C.binding(C.CEREMONY, ownValue, peerValue) } }),
|
|
294
|
+
verify: () => {
|
|
295
|
+
if (alreadyScanned())
|
|
296
|
+
return 'validation-failed (this code was already scanned — its challenge is in a record, 5.3)';
|
|
297
|
+
if (recs(p).has(ownValue) || chal(p).has(ownValue))
|
|
298
|
+
return 'validation-failed (own challenge value already in use)'; // single use (5.3/5.5)
|
|
299
|
+
if (p.contexts.has(ctx.anchor))
|
|
300
|
+
return 'validation-failed (pair anchor already used — fresh-always, 4.4)';
|
|
301
|
+
if (threadInUse(p, threadId))
|
|
302
|
+
return 'validation-failed (threadId not fresh — a bundle opens its exchange, 4.1)';
|
|
303
|
+
if (!futureOk(when, when))
|
|
304
|
+
return 'gate-future';
|
|
305
|
+
return null;
|
|
306
|
+
},
|
|
307
|
+
commit: ({ bind }) => {
|
|
308
|
+
p.contexts.set(ctx.anchor, ctx);
|
|
309
|
+
p.contexts.set(ctx.keyAgreement, ctx);
|
|
310
|
+
chal(p).set(ownValue, { tCh: when, ctxAnchor: ctx.anchor }); // enters as OPEN, t_ch = now …
|
|
311
|
+
if (resolve(p, ownValue, when) !== 'open') {
|
|
312
|
+
chal(p).delete(ownValue);
|
|
313
|
+
return { error: 'validation-failed (own challenge does not resolve open)' };
|
|
314
|
+
} // … resolved AUTHORITATIVELY in the same span
|
|
315
|
+
// 5.5: the record BEFORE issuing — `bundlePending` marks the issuance
|
|
316
|
+
// still owed (credential · sent card · bundle), resumable via resumeEncounter
|
|
317
|
+
const tuple = commitRecord(p, buildTuple(p, ctx, peerCard, ownValue, peerValue, bind, when, { tuple: { bundleThread: threadId, bundlePending: true, tChOwn: when } }), when);
|
|
318
|
+
return { tuple };
|
|
319
|
+
},
|
|
320
|
+
}, (err) => ({ error: err }));
|
|
321
|
+
if (recorded.error)
|
|
322
|
+
return recorded;
|
|
323
|
+
const out = await withLockSet(p, [ownValue], () => completeScan(p, recorded.tuple, when, ent));
|
|
324
|
+
if (!out)
|
|
325
|
+
return { error: 'stale-issuance (issuance window closed before the credential could be issued)' };
|
|
326
|
+
return { ...out, ctx };
|
|
327
|
+
}
|
|
328
|
+
// the scanner's owed issuance after the record (idempotent, resumable):
|
|
329
|
+
// credential → sent card → bundle into the outbox. Each piece that exists
|
|
330
|
+
// is kept; only the missing ones are produced. Errors propagate — the
|
|
331
|
+
// record stands and `bundlePending` keeps the duty visible to the resume.
|
|
332
|
+
async function completeScan(p, tuple, when, ent = {}) {
|
|
333
|
+
const ctx = tuple.channel.own, rec = recs(p).get(tuple.ownChallenge);
|
|
334
|
+
const tCh = tuple.tChOwn ?? when;
|
|
335
|
+
// the credential carries its ACTUAL issuance time (7.2) — a resume long
|
|
336
|
+
// after the record must not backdate; the issuance window (5.6 step 6)
|
|
337
|
+
// is anchored at the SUBJECT's challenge — the one the credential binds
|
|
338
|
+
// (c_B, whose t_ch the receiver's record holds) — not at our own c_A;
|
|
339
|
+
// outside it no encounter credential is issued any more: the record
|
|
340
|
+
// stands, the duty lapses honestly
|
|
341
|
+
const now = C.iso(when);
|
|
342
|
+
if (!tuple.credential) {
|
|
343
|
+
if (!issuanceOpen(Date.parse(tuple.card.challenge.issuedAt), when)) {
|
|
344
|
+
delete tuple.bundlePending;
|
|
345
|
+
tuple.bundleLapsed = 'stale-issuance';
|
|
346
|
+
say(p, `${tuple.name}: Ausstellung nach dem Issuance-Window — kein Credential mehr (Record bleibt)`);
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const cred = await C.issueCredential(ctx, tuple.card.anchor, C.CEREMONY, tuple.peerChallenge, tuple.bind, now);
|
|
350
|
+
const d = await C.digestDoc(cred);
|
|
351
|
+
if (!tuple.credential) {
|
|
352
|
+
tuple.credential = cred;
|
|
353
|
+
rec.accepted.out = d;
|
|
354
|
+
tuple.state = edgeState(tuple);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!tuple.sentCard) {
|
|
358
|
+
// the sent challenge keeps ITS issuance time (t_ch); the card proof is dated now
|
|
359
|
+
const sc = await C.signCard(ctx, C.cardBody(ctx, { name: displayName(p), challenge: { value: tuple.ownChallenge, issuedAt: C.iso(tCh) }, sentTo: tuple.card.anchor, boundTo: tuple.peerChallenge }), now);
|
|
360
|
+
tuple.sentCard ??= sc;
|
|
361
|
+
}
|
|
362
|
+
const existing = [...outboxOf(tuple).entries()].find(([, o]) => o.kind === 'encounter-bundle');
|
|
363
|
+
if (existing) {
|
|
364
|
+
delete tuple.bundlePending;
|
|
365
|
+
return { to: tuple, kind: 'encounter-bundle/0.1', env: structuredClone(existing[1].env), threadId: existing[1].threadId, digest: existing[0], sentCard: tuple.sentCard, credential: tuple.credential };
|
|
366
|
+
}
|
|
367
|
+
say(p, `${tuple.name} verifiziert — Credential unterwegs (Ankunft ≠ Annahme)`);
|
|
368
|
+
const out = await issueDoc(p, tuple, BUNDLE_TYPE, { card: tuple.sentCard, credential: tuple.credential }, when, tuple.bundleThread, 'encounter-bundle', ent, undefined, () => { delete tuple.bundlePending; });
|
|
369
|
+
return { ...out, sentCard: tuple.sentCard, credential: tuple.credential };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Resume owed issuance after a failure between record and outbox (the
|
|
373
|
+
* scanner's bundle, the deferred counter-step document). Idempotent; the
|
|
374
|
+
* host calls it before flushEncounter. Returns the documents produced now.
|
|
375
|
+
*/
|
|
376
|
+
export async function resumeEncounter(p, when) {
|
|
377
|
+
const outbound = [];
|
|
378
|
+
for (const [anchor, t] of [...p.contacts]) {
|
|
379
|
+
if (!t.ownChallenge)
|
|
380
|
+
continue;
|
|
381
|
+
// only STANDING duties survive deactivation: an owed bundle (record
|
|
382
|
+
// stands, 5.8 step 3) and an owed counter document (5.8 step 4)
|
|
383
|
+
if (t.deactivated && !(t.credential && t.counterDocPending) && !t.bundlePending)
|
|
384
|
+
continue;
|
|
385
|
+
try {
|
|
386
|
+
if (t.bundlePending) {
|
|
387
|
+
const o = await withLockSet(p, [t.ownChallenge], async () => t.bundlePending ? completeScan(p, t, when) : null);
|
|
388
|
+
if (o?.env)
|
|
389
|
+
outbound.push({ to: t, kind: o.kind + ' (resumed)', env: o.env, sentCard: o.sentCard, threadId: o.threadId, peerAnchor: t.card.anchor });
|
|
390
|
+
}
|
|
391
|
+
if (t.counterDocPending && t.bundleThread) {
|
|
392
|
+
const o = await counterEffect(p, anchor, when, {}, [t.ownChallenge]);
|
|
393
|
+
if (o?.env)
|
|
394
|
+
outbound.push({ to: t, kind: o.kind + ' (resumed)', env: o.env });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
say(p, `resume: ${t.name}: ${String(err?.message ?? err)} — bleibt ausstehend`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { outbound };
|
|
402
|
+
}
|
|
403
|
+
// ── counter: the counter-step (5.8 trunk 4) → encounter-credential-delivery/0.1
|
|
404
|
+
// Issues (and commits) our step credential binding c_peer at once; the
|
|
405
|
+
// DELIVERY DOCUMENT carries step "counter" + enactment and the BUNDLE's
|
|
406
|
+
// threadId (4.3) — when the record arose on the optical leg and the
|
|
407
|
+
// bundle has not arrived yet, the document is deferred
|
|
408
|
+
// (`counterDocPending`) and issued the moment the bundle lands. No
|
|
409
|
+
// optical carrier exists for the counter-step (5.8 step 4; 5.3).
|
|
410
|
+
export async function counter(p, peerAnchor, when, ent = {}) {
|
|
411
|
+
if (ent.id !== undefined && !uuidOk(ent.id))
|
|
412
|
+
return { error: 'validation-failed (id must be UUID v4 — Delivery 3)' };
|
|
413
|
+
const t0 = p.contacts.get(peerAnchor);
|
|
414
|
+
if (!t0?.ownChallenge)
|
|
415
|
+
return { error: 'no enactment record for this contact' };
|
|
416
|
+
// a NEW issuance needs an active tuple; MATERIALIZING an already issued,
|
|
417
|
+
// pending credential is a standing duty that deactivation never cancels
|
|
418
|
+
if (t0.deactivated && !(t0.credential && t0.counterDocPending))
|
|
419
|
+
return { error: 'no enactment record for this contact' };
|
|
420
|
+
return counterEffect(p, peerAnchor, when, ent, [t0.ownChallenge]);
|
|
421
|
+
}
|
|
422
|
+
// the counter-step's body; `keys` = [] when the caller already holds the record key
|
|
423
|
+
async function counterEffect(p, peerAnchor, when, ent, keys) {
|
|
424
|
+
// the WHOLE step (credential commit + document issue) under the record
|
|
425
|
+
// key: a concurrent caller sees the finished state, never the gap
|
|
426
|
+
return withLockSet(p, keys, () => counterBody(p, peerAnchor, when, ent));
|
|
427
|
+
}
|
|
428
|
+
async function counterBody(p, peerAnchor, when, ent) {
|
|
429
|
+
const issued = await effect(p, [], {
|
|
430
|
+
prepare: async () => {
|
|
431
|
+
const t = p.contacts.get(peerAnchor);
|
|
432
|
+
if (!t?.ownChallenge || !recs(p).has(t.ownChallenge))
|
|
433
|
+
return { error: 'no enactment record for this contact' };
|
|
434
|
+
if (t.credential && !t.counterDocPending)
|
|
435
|
+
return { error: 'counter-step already issued' };
|
|
436
|
+
if (t.credential)
|
|
437
|
+
return { prep: { t, cred: t.credential, fresh: false } }; // materialization — allowed even when deactivated
|
|
438
|
+
if (t.deactivated)
|
|
439
|
+
return { error: 'no enactment record for this contact' }; // no NEW issuance on a deactivated tuple
|
|
440
|
+
// §9 issuance-window, anchored at the SUBJECT's challenge (c_A, the one
|
|
441
|
+
// this credential binds — its t_ch is the sent card's challenge time):
|
|
442
|
+
// past it, no counter credential is issued (it could never be accepted)
|
|
443
|
+
if (!issuanceOpen(Date.parse(t.card.challenge.issuedAt), when))
|
|
444
|
+
return { error: 'stale-issuance (issuance window closed — no counter-step any more)' };
|
|
445
|
+
const cred = await C.issueCredential(t.channel.own, peerAnchor, C.CEREMONY, t.peerChallenge, t.bind, C.iso(when));
|
|
446
|
+
return { prep: { t, cred, d: await C.digestDoc(cred), fresh: true } };
|
|
447
|
+
},
|
|
448
|
+
verify: ({ t, fresh }) => {
|
|
449
|
+
if (!recs(p).has(t.ownChallenge))
|
|
450
|
+
return 'no enactment record for this contact';
|
|
451
|
+
if (fresh && t.deactivated)
|
|
452
|
+
return 'no enactment record for this contact';
|
|
453
|
+
if (fresh && t.credential)
|
|
454
|
+
return 'counter-step already issued'; // a concurrent issue won
|
|
455
|
+
return null;
|
|
456
|
+
},
|
|
457
|
+
commit: ({ t, cred, d, fresh }) => {
|
|
458
|
+
if (fresh) {
|
|
459
|
+
t.credential = cred;
|
|
460
|
+
recs(p).get(t.ownChallenge).accepted.out = d;
|
|
461
|
+
t.counterDocPending = true;
|
|
462
|
+
t.state = edgeState(t);
|
|
463
|
+
}
|
|
464
|
+
return { t };
|
|
465
|
+
},
|
|
466
|
+
}, (err) => ({ error: err }));
|
|
467
|
+
if (issued.error)
|
|
468
|
+
return issued;
|
|
469
|
+
const t = issued.t;
|
|
470
|
+
if (!t.bundleThread) {
|
|
471
|
+
say(p, `Gegenschritt an ${t.name} ausgestellt — das Zustelldokument wartet auf den Bundle-Thread (4.3: step "counter")`);
|
|
472
|
+
return { deferred: true, credential: t.credential, to: t };
|
|
473
|
+
}
|
|
474
|
+
// a crash after the outbox write but before the flag fell: the document exists — take it, never a second one
|
|
475
|
+
const existing = [...outboxOf(t).entries()].find(([, o]) => o.kind === 'encounter-credential-delivery' && o.threadId === t.bundleThread);
|
|
476
|
+
if (existing) {
|
|
477
|
+
delete t.counterDocPending;
|
|
478
|
+
return { to: t, kind: 'encounter-credential-delivery/0.1', env: structuredClone(existing[1].env), threadId: existing[1].threadId, digest: existing[0], credential: t.credential };
|
|
479
|
+
}
|
|
480
|
+
const out = await issueDoc(p, t, DELIVERY_TYPE, { credential: t.credential }, when, t.bundleThread, 'encounter-credential-delivery', ent, { step: 'counter', enactment: t.bind }, () => { delete t.counterDocPending; });
|
|
481
|
+
say(p, `Gegenschritt an ${t.name} ausgestellt (counter)`);
|
|
482
|
+
return { ...out, credential: t.credential };
|
|
483
|
+
}
|
|
484
|
+
// ── the optical leg (5.8): the SENT CARD as ceremony-level input ─────────
|
|
485
|
+
export async function captureSentCard(p, cardIn, when) {
|
|
486
|
+
const card = structuredClone(cardIn); // SNAPSHOT before the first await (TOCTOU): verified = recorded
|
|
487
|
+
const ce = await cardOk(card);
|
|
488
|
+
if (ce)
|
|
489
|
+
return { error: `sent card invalid (${ce})` };
|
|
490
|
+
if (typeof card.sentTo !== 'string' || typeof card.boundTo !== 'string')
|
|
491
|
+
return { error: 'not a sent card' };
|
|
492
|
+
if (card.challenge.value === card.boundTo)
|
|
493
|
+
return { error: 'validation-failed (sent challenge equals the displayed challenge — 5.3)' };
|
|
494
|
+
const ctx = p.contexts.get(card.sentTo);
|
|
495
|
+
if (!ctx || ctx.anchor !== card.sentTo)
|
|
496
|
+
return { error: 'sentTo ≠ own anchor' };
|
|
497
|
+
// the 5.5 taxonomy for a bound challenge that resolves `recorded`
|
|
498
|
+
const recordedTaxonomy = () => {
|
|
499
|
+
const r = recs(p).get(card.boundTo);
|
|
500
|
+
if (r.ctxAnchor !== ctx.anchor)
|
|
501
|
+
return 'validation-failed (challenge bound to another anchor)';
|
|
502
|
+
if (r.counterpart !== card.anchor)
|
|
503
|
+
return 'consumed-challenge';
|
|
504
|
+
if (jcs(r.card) !== jcs(card))
|
|
505
|
+
return 'validation-failed (card differs from record)';
|
|
506
|
+
return { handled: true, idempotent: true, to: card.anchor };
|
|
507
|
+
};
|
|
508
|
+
return effect(p, [card.boundTo], {
|
|
509
|
+
// prepare is pure: the binding only — EVERY decision (unknown → gate-
|
|
510
|
+
// expired · recorded → the 5.5 taxonomy · open → record) is produced at
|
|
511
|
+
// the serialization point, inside the lock (5.5)
|
|
512
|
+
prepare: async () => ({ prep: { bind: await C.binding(C.CEREMONY, card.boundTo, card.challenge.value), idem: false } }),
|
|
513
|
+
verify: (q) => {
|
|
514
|
+
const st = resolve(p, card.boundTo, when); // AUTHORITATIVE, same synchronous span as the commit
|
|
515
|
+
if (st === 'unknown')
|
|
516
|
+
return 'gate-expired';
|
|
517
|
+
if (st === 'recorded') {
|
|
518
|
+
const r = recordedTaxonomy();
|
|
519
|
+
if (typeof r === 'string')
|
|
520
|
+
return r;
|
|
521
|
+
q.idem = true;
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
if (chal(p).get(card.boundTo).ctxAnchor !== ctx.anchor)
|
|
525
|
+
return 'validation-failed (challenge bound to another anchor)'; // 4.4: sentTo = the anchor that displayed c_B
|
|
526
|
+
if (!futureOk(chal(p).get(card.boundTo).tCh, when))
|
|
527
|
+
return 'gate-future';
|
|
528
|
+
return null;
|
|
529
|
+
},
|
|
530
|
+
commit: ({ bind, idem }) => {
|
|
531
|
+
if (idem)
|
|
532
|
+
return { handled: true, idempotent: true, to: card.anchor }; // JCS-identical card on the existing record: no-op
|
|
533
|
+
const tuple = commitRecord(p, buildTuple(p, ctx, card, card.boundTo, card.challenge.value, bind, when, { tuple: { credentialPending: true } }), when);
|
|
534
|
+
say(p, `${card.name}'s sent card captured optically — same encounter, the confirmation follows over the network`);
|
|
535
|
+
return { handled: true, recorded: true, to: card.anchor, name: tuple.name }; // the record itself is never handed out
|
|
536
|
+
},
|
|
537
|
+
}, (err) => ({ error: err }));
|
|
538
|
+
}
|
|
539
|
+
// ── receive: encounter-bundle / encounter-credential-delivery ────────────
|
|
540
|
+
export async function receiveEncounter(p, env, when, ent = {}) {
|
|
541
|
+
const opened = await C.openEnvelope(p, env); // stages 1–4
|
|
542
|
+
if (opened.error)
|
|
543
|
+
return { handled: false };
|
|
544
|
+
const ctx = p.contexts.get(env.rkid);
|
|
545
|
+
if (!ctx)
|
|
546
|
+
return { handled: false };
|
|
547
|
+
const doc = opened.doc;
|
|
548
|
+
if (doc?.type !== BUNDLE_TYPE && doc?.type !== DELIVERY_TYPE)
|
|
549
|
+
return { handled: false, doc };
|
|
550
|
+
if (opened.duplicate)
|
|
551
|
+
return reAck(p, opened.digest);
|
|
552
|
+
// stage 5: document profile (schema) + recipient = own anchor
|
|
553
|
+
if (!schemaOk('rltp-delivery-document.schema.json', doc) || doc.proof !== undefined)
|
|
554
|
+
return { handled: true, error: 'malformed document' };
|
|
555
|
+
if (doc.recipient !== ctx.anchor)
|
|
556
|
+
return { handled: true, error: 'wrong-recipient' }; // 6.2 stage 5 disposition
|
|
557
|
+
return doc.type === BUNDLE_TYPE ? receiveBundle(p, ctx, doc, opened.digest, when, ent) : receiveDelivery(p, ctx, doc, opened.digest, when, ent);
|
|
558
|
+
}
|
|
559
|
+
// 4.2 / 6.2 stage 4: duplicate-known → the STORED ack, byte-identical (a copy)
|
|
560
|
+
function reAck(p, digest) {
|
|
561
|
+
const r = retained(p).get(digest);
|
|
562
|
+
if (r)
|
|
563
|
+
return { handled: true, duplicate: true, ack: { to: r.to, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: structuredClone(r.env) } };
|
|
564
|
+
for (const t of p.contacts.values()) {
|
|
565
|
+
const stored = t.ackStore?.get(digest);
|
|
566
|
+
if (stored)
|
|
567
|
+
return { handled: true, duplicate: true, ack: { to: t, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: structuredClone(stored) } };
|
|
568
|
+
}
|
|
569
|
+
return { handled: true, duplicate: true };
|
|
570
|
+
}
|
|
571
|
+
// ── the credential checks, in normative order ───────────────────────────
|
|
572
|
+
// 5.6 step 1 — FORMAT: schema, format, ceremony AND version KNOWN
|
|
573
|
+
// (registered: encounter-scan@0.25 only), calendar-valid timestamps,
|
|
574
|
+
// digest parsed as a multihash, keys decode (2.3) — else ERR_VERSION
|
|
575
|
+
function credFormat(cred, ceremonyHere = true) {
|
|
576
|
+
if (!schemaOk('encounter-credential-0.25.schema.json', cred) || cred.credentialSubject?.format !== C.CRED_FORMAT)
|
|
577
|
+
return 'ERR_VERSION';
|
|
578
|
+
// 5.6 step 1 knows the ceremony here; Delivery 4.1 places the ceremony
|
|
579
|
+
// equality at its step 5 (after card proof, credential proof, addressee)
|
|
580
|
+
if (ceremonyHere && cred.credentialSubject.ceremony !== C.CEREMONY)
|
|
581
|
+
return 'ERR_VERSION';
|
|
582
|
+
if (!C.calOK(cred.validFrom) || !C.calOK(cred.proof?.created))
|
|
583
|
+
return 'ERR_VERSION';
|
|
584
|
+
if (!sameDigest(cred.credentialSubject.enactmentBinding, cred.credentialSubject.enactmentBinding))
|
|
585
|
+
return 'ERR_VERSION';
|
|
586
|
+
if (!C.edRawOfAnchor(cred.issuer) || !C.edRawOfAnchor(cred.credentialSubject.id))
|
|
587
|
+
return 'ERR_VERSION';
|
|
588
|
+
const vm = String(cred.proof?.verificationMethod ?? ''), hash = vm.indexOf('#');
|
|
589
|
+
if (hash < 0 || !C.edRawOfAnchor(vm.slice(0, hash)) || !C.edRawOfAnchor('did:key:' + vm.slice(hash + 1)))
|
|
590
|
+
return 'ERR_VERSION';
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
// steps 1–3: format · signature · addressee
|
|
594
|
+
async function credHead(cred, ctx, ceremonyHere = true) {
|
|
595
|
+
const f = credFormat(cred, ceremonyHere);
|
|
596
|
+
if (f)
|
|
597
|
+
return f;
|
|
598
|
+
if (!(await C.diVerify(cred, cred.issuer)))
|
|
599
|
+
return 'ERR_SIG';
|
|
600
|
+
if (cred.credentialSubject.id !== ctx.anchor)
|
|
601
|
+
return 'ERR_ADDRESSEE';
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
// step 6: the issuance window, whole seconds, inclusive, skew widens
|
|
605
|
+
function windowOk(cred, tChMs) {
|
|
606
|
+
const tCh = sec(tChMs), vf = secIso(cred.validFrom), cr = secIso(cred.proof.created), skew = SKEW_TOLERANCE / 1000;
|
|
607
|
+
const lo = tCh - skew, hi = tCh + (CHALLENGE_MAX_AGE + ISSUANCE_WINDOW) / 1000 + skew;
|
|
608
|
+
return !(vf < lo || vf > hi || cr < lo || cr > hi || cr < vf - skew);
|
|
609
|
+
}
|
|
610
|
+
// steps 4–7 against a record (SYNCHRONOUS — usable inside verify). `order`:
|
|
611
|
+
// 'credential' — 5.6: record · ceremony · WINDOW · BINDING
|
|
612
|
+
// 'bundle' — Delivery 4.1 pre-lock: record · ceremony · BINDING · WINDOW
|
|
613
|
+
function acceptTail(cred, rec, order) {
|
|
614
|
+
if (!rec || rec.counterpart !== cred.issuer)
|
|
615
|
+
return 'ERR_NO_RECORD';
|
|
616
|
+
if (cred.credentialSubject.ceremony !== rec.ceremony)
|
|
617
|
+
return 'ERR_CEREMONY';
|
|
618
|
+
const bindOk = sameDigest(cred.credentialSubject.enactmentBinding, rec.bind);
|
|
619
|
+
if (order === 'bundle' && !bindOk)
|
|
620
|
+
return 'ERR_BINDING';
|
|
621
|
+
if (!windowOk(cred, rec.tCh))
|
|
622
|
+
return 'ERR_STALE_ISSUANCE';
|
|
623
|
+
if (!bindOk)
|
|
624
|
+
return 'ERR_BINDING';
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const disposition = (e) => e === 'ERR_STALE_ISSUANCE' ? 'stale-issuance' : `validation-failed (${e})`;
|
|
628
|
+
// ── bundles ─────────────────────────────────────────────────────────────
|
|
629
|
+
// stages 7–8 for a bundle (stage 5 ran in receiveEncounter): the pre-lock
|
|
630
|
+
// checks of 4.1 IN ORDER. The only state read is the resolution (which
|
|
631
|
+
// latches) and thread freshness — both re-checked synchronously in verify.
|
|
632
|
+
// the ORIGINAL bundle re-evaluated after cache/ack retention (4.2: "a bundle
|
|
633
|
+
// whose enactment record still exists lands in the record-aware effect") is
|
|
634
|
+
// the same document, not a new one on a used thread
|
|
635
|
+
// … the same holds for every re-issued bundle that landed record-aware on
|
|
636
|
+
// its own fresh thread: the record keeps thread → document digest
|
|
637
|
+
const sameOpeningBundle = (p, anchor, threadId, digest) => {
|
|
638
|
+
const t = p.contacts.get(anchor);
|
|
639
|
+
if (!t)
|
|
640
|
+
return false;
|
|
641
|
+
if (t.bundleThread === threadId)
|
|
642
|
+
return sameDigest(t.bundleDigest, digest);
|
|
643
|
+
const d = t.inThreadDigests?.get(threadId);
|
|
644
|
+
return d !== undefined && sameDigest(d, digest);
|
|
645
|
+
};
|
|
646
|
+
async function bundleChecks(p, ctx, doc, digest, when) {
|
|
647
|
+
const pl = doc.payload;
|
|
648
|
+
if (!schemaOk('payload-encounter-bundle.schema.json', pl))
|
|
649
|
+
return { error: 'malformed bundle' }; // stage 7
|
|
650
|
+
const { card, credential } = pl;
|
|
651
|
+
// (1) profile/structure: calendars, outer/inner equalities, card version, keys decode, thread fresh, credential FORMAT
|
|
652
|
+
if (!C.calOK(doc.issuedAt) || !C.calOK(card.challenge?.issuedAt) || !C.calOK(card.proof?.created))
|
|
653
|
+
return { error: 'calendar-invalid issuedAt' };
|
|
654
|
+
if (doc.issuer !== card.anchor || card.anchor !== credential.issuer)
|
|
655
|
+
return { error: 'outer binding (issuer = card.anchor = credential.issuer)' };
|
|
656
|
+
if (credential.credentialSubject?.id !== doc.recipient)
|
|
657
|
+
return { error: 'outer binding (recipient = subject)' };
|
|
658
|
+
if (card.sentTo !== doc.recipient || card.boundTo !== credential.credentialSubject.challenge)
|
|
659
|
+
return { error: 'sent card binding (sentTo/boundTo)' };
|
|
660
|
+
if (card.challenge.value === card.boundTo)
|
|
661
|
+
return { error: 'validation-failed (sent challenge equals the displayed challenge — 5.3)' };
|
|
662
|
+
if (doc.ceremony?.enactment !== undefined && !sameDigest(doc.ceremony.enactment, credential.credentialSubject.enactmentBinding))
|
|
663
|
+
return { error: 'ceremony.enactment recomputes not (validation-failed)' };
|
|
664
|
+
if (card.version !== C.CARD_VERSION || !card.challenge)
|
|
665
|
+
return { error: 'validation-failed (card version/challenge)' };
|
|
666
|
+
if (!C.xRawOfMk(card.keyAgreement) || !C.edRawOfAnchor(card.anchor))
|
|
667
|
+
return { error: 'validation-failed (card keys do not decode)' };
|
|
668
|
+
if (threadInUse(p, doc.threadId) && !sameOpeningBundle(p, card.anchor, doc.threadId, digest))
|
|
669
|
+
return { error: 'validation-failed (threadId not fresh — a bundle opens its exchange)' };
|
|
670
|
+
const f = credFormat(credential, false);
|
|
671
|
+
if (f)
|
|
672
|
+
return { error: disposition(f) };
|
|
673
|
+
// (2) card proof · (3) credential proof · (4) addressee
|
|
674
|
+
if (!(await C.diVerify(card, card.anchor)))
|
|
675
|
+
return { error: 'card proof' };
|
|
676
|
+
const h = await credHead(credential, ctx, false);
|
|
677
|
+
if (h)
|
|
678
|
+
return { error: disposition(h) };
|
|
679
|
+
// (5) provisional RESOLUTION first (it latches an aged held value) → an
|
|
680
|
+
// `unknown` ends the pre-lock checks and goes to the lock; otherwise
|
|
681
|
+
// ceremony → binding · (6) window
|
|
682
|
+
const own = credential.credentialSubject.challenge;
|
|
683
|
+
const bind = await C.binding(C.CEREMONY, own, card.challenge.value);
|
|
684
|
+
const st = resolve(p, own, when);
|
|
685
|
+
if (st !== 'unknown') {
|
|
686
|
+
if (credential.credentialSubject.ceremony !== C.CEREMONY)
|
|
687
|
+
return { error: 'validation-failed (ceremony)' };
|
|
688
|
+
// the bound challenge belongs to THIS pair context (4.4: the enacting
|
|
689
|
+
// anchor is the one that displayed it — never another local anchor)
|
|
690
|
+
if (ctxOfChallenge(p, own, st) !== ctx.anchor)
|
|
691
|
+
return { error: 'validation-failed (challenge bound to another anchor)' };
|
|
692
|
+
// checks 5–6 with t_ch from the resolution — for a recorded value TOO,
|
|
693
|
+
// whatever its counterparty (consumed-challenge is a stage-9 verdict;
|
|
694
|
+
// 4.1: "only after 1–6 pass does evaluation reach the final stage")
|
|
695
|
+
const tCh = st === 'recorded' ? recs(p).get(own).tCh : chal(p).get(own).tCh;
|
|
696
|
+
if (!sameDigest(credential.credentialSubject.enactmentBinding, bind))
|
|
697
|
+
return { error: 'validation-failed (ERR_BINDING)' };
|
|
698
|
+
if (!windowOk(credential, tCh))
|
|
699
|
+
return { error: 'stale-issuance' };
|
|
700
|
+
}
|
|
701
|
+
return { own, bind, card, credential, st };
|
|
702
|
+
}
|
|
703
|
+
const ctxOfChallenge = (p, value, st) => st === 'recorded' ? recs(p).get(value)?.ctxAnchor : chal(p).get(value)?.ctxAnchor;
|
|
704
|
+
async function receiveBundle(p, ctx, doc, digest, when, ent) {
|
|
705
|
+
const pre = await bundleChecks(p, ctx, doc, digest, when);
|
|
706
|
+
if (pre.error)
|
|
707
|
+
return failed(pre.error);
|
|
708
|
+
const { own, card } = pre;
|
|
709
|
+
// stage 9 under the LOCK SET {digest, record key}
|
|
710
|
+
const res = await effect(p, [digest, own], {
|
|
711
|
+
prepare: async () => {
|
|
712
|
+
if (cached(p, digest))
|
|
713
|
+
return { done: dupResult(p, digest, card.anchor) };
|
|
714
|
+
const r = await bundleChecks(p, ctx, doc, digest, when); // re-entry at stage 4: every check under the state found
|
|
715
|
+
if (r.error)
|
|
716
|
+
return { error: r.error };
|
|
717
|
+
const credDigest = await C.digestDoc(r.credential);
|
|
718
|
+
if (r.st === 'unknown')
|
|
719
|
+
return { prep: { ...r, credDigest, mode: 'unknown' } };
|
|
720
|
+
if (r.st === 'open') {
|
|
721
|
+
const tuple = buildTuple(p, ctx, card, own, card.challenge.value, r.bind, when, { tuple: { credentialIn: r.credential, bundleThread: doc.threadId } });
|
|
722
|
+
const ackEnv = await mkAck(p, tuple, digest, doc.threadId, when, card.anchor);
|
|
723
|
+
return { prep: { ...r, credDigest, mode: 'open', tuple, ackEnv } };
|
|
724
|
+
}
|
|
725
|
+
// recorded: the ack can only be built if OUR tuple exists; the verdict
|
|
726
|
+
// (consumed-challenge / record-aware / moved) is verify's, under the lock
|
|
727
|
+
const tuple = p.contacts.get(card.anchor);
|
|
728
|
+
const ackEnv = tuple ? await mkAck(p, tuple, digest, doc.threadId, when) : undefined;
|
|
729
|
+
return { prep: { ...r, credDigest, mode: 'recorded', tuple, ackEnv } };
|
|
730
|
+
},
|
|
731
|
+
verify: (q) => {
|
|
732
|
+
if (cached(p, digest))
|
|
733
|
+
return 'retry'; // a concurrent identical document committed → duplicate-known
|
|
734
|
+
const st = resolve(p, own, when); // AUTHORITATIVE — same synchronous span as the commit
|
|
735
|
+
if (st === 'unknown')
|
|
736
|
+
return 'validation-failed (challenge unknown)';
|
|
737
|
+
if (st !== q.mode)
|
|
738
|
+
return 'retry'; // the state moved: re-enter at stage 4
|
|
739
|
+
if (ctxOfChallenge(p, own, st) !== ctx.anchor)
|
|
740
|
+
return 'validation-failed (challenge bound to another anchor)';
|
|
741
|
+
if (threadInUse(p, doc.threadId) && !sameOpeningBundle(p, card.anchor, doc.threadId, digest))
|
|
742
|
+
return 'validation-failed (threadId not fresh — a bundle opens its exchange)';
|
|
743
|
+
if (st === 'open') {
|
|
744
|
+
if (!futureOk(chal(p).get(own).tCh, when))
|
|
745
|
+
return 'gate-future';
|
|
746
|
+
const e = acceptTail(q.credential, { counterpart: card.anchor, ceremony: C.CEREMONY, tCh: chal(p).get(own).tCh, bind: q.bind }, 'bundle');
|
|
747
|
+
return e ? disposition(e) : null;
|
|
748
|
+
}
|
|
749
|
+
// recorded → the record decides
|
|
750
|
+
const rec = recs(p).get(own);
|
|
751
|
+
if (rec.counterpart !== card.anchor)
|
|
752
|
+
return 'consumed-challenge';
|
|
753
|
+
if (jcs(rec.card) !== jcs(card))
|
|
754
|
+
return 'validation-failed (card differs from record)';
|
|
755
|
+
const e = acceptTail(q.credential, rec, 'bundle');
|
|
756
|
+
if (e)
|
|
757
|
+
return disposition(e);
|
|
758
|
+
if (rec.accepted.in && !sameDigest(rec.accepted.in, q.credDigest))
|
|
759
|
+
return 'validation-failed (ERR_CONFLICT)';
|
|
760
|
+
if (!q.tuple || !q.ackEnv || p.contacts.get(card.anchor) !== q.tuple)
|
|
761
|
+
return 'retry'; // tuple moved since prepare: re-enter
|
|
762
|
+
return null;
|
|
763
|
+
},
|
|
764
|
+
commit: (q) => {
|
|
765
|
+
if (q.mode === 'open') {
|
|
766
|
+
// record-creating effect: record + tuple + accepted credential + cache + retained ack
|
|
767
|
+
commitRecord(p, q.tuple, when);
|
|
768
|
+
q.tuple.bundleDigest = digest; // the bundle that opened this exchange (retention-independent identity)
|
|
769
|
+
recs(p).get(own).accepted.in = q.credDigest;
|
|
770
|
+
const ack = commitAck(p, q.tuple, digest, q.ackEnv);
|
|
771
|
+
say(p, `${card.name} verified you — arrival, not acceptance: you decide`);
|
|
772
|
+
return { handled: true, recorded: true, prompt: { peerAnchor: card.anchor, from: card.name }, ack };
|
|
773
|
+
}
|
|
774
|
+
// record-aware effect: credential accepted, no gate, no second record;
|
|
775
|
+
// the record keeps the thread of the bundle that committed first
|
|
776
|
+
const rec = recs(p).get(own), tuple = q.tuple;
|
|
777
|
+
rec.accepted.in = q.credDigest;
|
|
778
|
+
tuple.credentialIn = q.credential;
|
|
779
|
+
tuple.bundleThread ??= doc.threadId;
|
|
780
|
+
tuple.bundleDigest ??= digest;
|
|
781
|
+
if (tuple.bundleThread !== doc.threadId) {
|
|
782
|
+
(tuple.inThreads ??= new Set()).add(doc.threadId);
|
|
783
|
+
(tuple.inThreadDigests ??= new Map()).set(doc.threadId, digest);
|
|
784
|
+
} // a re-issue's fresh thread is used now (4.1) — and remembered with its document
|
|
785
|
+
delete tuple.credentialPending;
|
|
786
|
+
tuple.state = edgeState(tuple);
|
|
787
|
+
carryToHead(p, tuple);
|
|
788
|
+
const ack = commitAck(p, tuple, digest, q.ackEnv);
|
|
789
|
+
say(p, `${card.name} — Credential angekommen (Record stand bereits, optischer Leg)`);
|
|
790
|
+
return { handled: true, accepted: true, mutual: tuple.state === '✓', from: card.name, peerAnchor: card.anchor, ack, counterPending: !!tuple.counterDocPending };
|
|
791
|
+
},
|
|
792
|
+
}, failed);
|
|
793
|
+
// a counter-step issued on the optical leg waited for this thread (4.3)
|
|
794
|
+
if (res?.counterPending) {
|
|
795
|
+
delete res.counterPending;
|
|
796
|
+
try {
|
|
797
|
+
res.counterOut = await counterEffect(p, card.anchor, when, {}, [own]);
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
say(p, `Gegenschritt-Dokument: ${String(err?.message ?? err)} — bleibt ausstehend`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
else if (res)
|
|
804
|
+
delete res.counterPending;
|
|
805
|
+
return res;
|
|
806
|
+
}
|
|
807
|
+
// ── credential deliveries (4.3) ─────────────────────────────────────────
|
|
808
|
+
// stage 7 + the 4.3 outer/inner consistency (MUST, before any effect) — a
|
|
809
|
+
// violation is validation-failed and earns NO ack. SYNCHRONOUS, so it is
|
|
810
|
+
// the verify step as well.
|
|
811
|
+
function deliveryChecks(p, ctx, doc) {
|
|
812
|
+
const pl = doc.payload;
|
|
813
|
+
if (!schemaOk('payload-encounter-credential-delivery.schema.json', pl))
|
|
814
|
+
return { error: 'malformed credential delivery' };
|
|
815
|
+
const cred = pl.credential;
|
|
816
|
+
if (!C.calOK(doc.issuedAt))
|
|
817
|
+
return { error: 'calendar-invalid issuedAt' };
|
|
818
|
+
if (doc.issuer !== cred.issuer)
|
|
819
|
+
return { error: 'outer binding (issuer = credential.issuer)' };
|
|
820
|
+
if (cred.credentialSubject?.id !== doc.recipient)
|
|
821
|
+
return { error: 'outer binding (recipient = subject)' };
|
|
822
|
+
if (doc.ceremony?.enactment !== undefined && !sameDigest(doc.ceremony.enactment, cred.credentialSubject.enactmentBinding))
|
|
823
|
+
return { error: 'ceremony.enactment recomputes not (validation-failed)' };
|
|
824
|
+
// the relationship channel to the issuer — the ack's only legitimate
|
|
825
|
+
// path; a DEACTIVATED (chained-away) tuple still takes a late credential
|
|
826
|
+
// (Encounter 4.2: accepted against that enactment's record, harmlessly)
|
|
827
|
+
const tuple = p.contacts.get(cred.issuer);
|
|
828
|
+
if (!tuple?.channel?.own || tuple.channel.own.anchor !== ctx.anchor)
|
|
829
|
+
return { error: 'validation-failed (no relationship channel to the issuer)' };
|
|
830
|
+
// 4.3 thread rule: step "counter" = the bundle's threadId; step
|
|
831
|
+
// "deliver" = a fresh thread; the step set is closed
|
|
832
|
+
// Delivery §3: the ceremony member is "entirely unconstrained" — an
|
|
833
|
+
// unknown `step` passes through unrejected; 4.3 gives MEANING to
|
|
834
|
+
// "counter" and "deliver" only, and the thread rule binds those two
|
|
835
|
+
const step = doc.ceremony?.step;
|
|
836
|
+
const isBundleThread = tuple.bundleThread !== undefined && doc.threadId === tuple.bundleThread;
|
|
837
|
+
if (step === 'counter' && !isBundleThread)
|
|
838
|
+
return { error: 'validation-failed (counter-step outside the bundle thread)' };
|
|
839
|
+
if (step === 'deliver' && isBundleThread)
|
|
840
|
+
return { error: 'validation-failed (standalone delivery inside the bundle thread)' };
|
|
841
|
+
if (!isBundleThread && threadInUse(p, doc.threadId))
|
|
842
|
+
return { error: 'validation-failed (threadId not fresh)' };
|
|
843
|
+
return { cred, tuple, isBundleThread };
|
|
844
|
+
}
|
|
845
|
+
// 4.3: effect = durable BUFFERING; the ack is sent at buffering (lock set
|
|
846
|
+
// = the document digest alone); acceptance (5.6) runs SEPARATELY in its
|
|
847
|
+
// own record-key section and is never signaled (7.4).
|
|
848
|
+
async function receiveDelivery(p, ctx, doc, digest, when, ent) {
|
|
849
|
+
const pre = deliveryChecks(p, ctx, doc);
|
|
850
|
+
if (pre.error)
|
|
851
|
+
return failed(pre.error);
|
|
852
|
+
const buffered = await effect(p, [digest], {
|
|
853
|
+
prepare: async () => {
|
|
854
|
+
if (cached(p, digest))
|
|
855
|
+
return { done: dupResult(p, digest, pre.cred.issuer) };
|
|
856
|
+
const r = deliveryChecks(p, ctx, doc);
|
|
857
|
+
if (r.error)
|
|
858
|
+
return { error: r.error };
|
|
859
|
+
return { prep: { ...r, credDigest: await C.digestDoc(r.cred), ackEnv: await mkAck(p, r.tuple, digest, doc.threadId, when) } };
|
|
860
|
+
},
|
|
861
|
+
verify: (q) => {
|
|
862
|
+
if (cached(p, digest))
|
|
863
|
+
return 'retry';
|
|
864
|
+
const r = deliveryChecks(p, ctx, doc); // synchronous — every consistency + thread condition again
|
|
865
|
+
if (r.error)
|
|
866
|
+
return r.error;
|
|
867
|
+
if (r.tuple !== q.tuple)
|
|
868
|
+
return 'retry';
|
|
869
|
+
return null;
|
|
870
|
+
},
|
|
871
|
+
commit: (q) => {
|
|
872
|
+
// buffer + thread reservation + completed-effect cache + retained ack
|
|
873
|
+
;
|
|
874
|
+
(q.tuple.buffered ??= new Map()).set(q.credDigest, q.cred);
|
|
875
|
+
if (!q.isBundleThread)
|
|
876
|
+
(q.tuple.inThreads ??= new Set()).add(doc.threadId);
|
|
877
|
+
const ack = commitAck(p, q.tuple, digest, q.ackEnv);
|
|
878
|
+
return { handled: true, buffered: true, ack, cred: q.cred, tuple: q.tuple, credDigest: q.credDigest };
|
|
879
|
+
},
|
|
880
|
+
}, failed);
|
|
881
|
+
if (!buffered.buffered)
|
|
882
|
+
return buffered;
|
|
883
|
+
const { cred, tuple, credDigest, ack } = buffered;
|
|
884
|
+
// the ack is available to the host NOW (4.3: sent at buffering); Encounter
|
|
885
|
+
// acceptance runs separately — `acceptance` resolves with its local outcome
|
|
886
|
+
// (never signaled to the issuer, 7.4)
|
|
887
|
+
const acceptance = withLockSet(p, [cred.credentialSubject.challenge], () => acceptBuffered(p, tuple, cred, credDigest, ctx, when))
|
|
888
|
+
.then((verdict) => ({ accepted: verdict === null, verdict, mutual: tuple.state === '✓' }));
|
|
889
|
+
return { handled: true, buffered: true, from: tuple.name, peerAnchor: cred.issuer, ack, acceptance };
|
|
890
|
+
}
|
|
891
|
+
async function acceptBuffered(p, tuple, cred, credDigest, ctx, when) {
|
|
892
|
+
// 5.6 IN ORDER: format · signature · addressee FIRST (steps 1–3, no
|
|
893
|
+
// state touched); then — in ONE synchronous span — step 4 via 5.3
|
|
894
|
+
// RESOLUTION (latching), steps 5–8 and the commit
|
|
895
|
+
let e = await credHead(cred, ctx);
|
|
896
|
+
const own = cred.credentialSubject.challenge;
|
|
897
|
+
// ── no await from here ──
|
|
898
|
+
const rec = !e && resolve(p, own, when) === 'recorded' ? recs(p).get(own) : undefined;
|
|
899
|
+
if (!e)
|
|
900
|
+
e = acceptTail(cred, rec, 'credential');
|
|
901
|
+
if (!e && rec.accepted.in && !sameDigest(rec.accepted.in, credDigest))
|
|
902
|
+
e = 'ERR_CONFLICT'; // step 8
|
|
903
|
+
tuple.buffered.delete(credDigest); // evaluated — the buffer held it until now
|
|
904
|
+
if (e) {
|
|
905
|
+
say(p, `${tuple.name} — gepuffertes Credential nicht angenommen (${e}); der Aussteller erfährt davon nichts`);
|
|
906
|
+
return e;
|
|
907
|
+
}
|
|
908
|
+
rec.accepted.in = credDigest;
|
|
909
|
+
tuple.credentialIn = cred;
|
|
910
|
+
delete tuple.credentialPending;
|
|
911
|
+
tuple.state = edgeState(tuple);
|
|
912
|
+
carryToHead(p, tuple);
|
|
913
|
+
say(p, `${tuple.name} — Credential angekommen${tuple.state === '✓' ? ', ihr seid beidseitig verifiziert ✓' : ' (einseitig)'}`);
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
// one edge per relationship (4.2): mutuality reached on a chained-away link
|
|
917
|
+
// is carried by the chain's active head (the continuity rule) — same for
|
|
918
|
+
// both carriers of the credential (bundle and credential delivery)
|
|
919
|
+
function carryToHead(p, tuple) {
|
|
920
|
+
if (!tuple.deactivated || tuple.state !== '✓')
|
|
921
|
+
return;
|
|
922
|
+
const head = p.contacts.get(tuple.chainedInto);
|
|
923
|
+
if (head && !head.deactivated) {
|
|
924
|
+
head.state = '✓';
|
|
925
|
+
head.provenance = 'ceremony';
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// ── the host's producing call: byte-identical redelivery of unacked docs (4.2)
|
|
929
|
+
export function flushEncounter(p) {
|
|
930
|
+
const outbound = [];
|
|
931
|
+
for (const t of p.contacts.values())
|
|
932
|
+
for (const o of (t.outbox ?? new Map()).values()) {
|
|
933
|
+
if ((o.kind === 'encounter-bundle' || o.kind === 'encounter-credential-delivery') && !o.acked)
|
|
934
|
+
outbound.push({ to: t, kind: o.kind + '/0.1 (Retry, byte-identisch)', env: structuredClone(o.env) }); // the store keeps its own copy
|
|
935
|
+
}
|
|
936
|
+
return { outbound };
|
|
937
|
+
}
|
|
938
|
+
//# sourceMappingURL=encounter.js.map
|