@real-life/trust-protocol 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,11 +19,15 @@
19
19
  // alte deaktiviert; Evidenz akkumuliert AUF DER BEZIEHUNG (✓ bleibt ✓),
20
20
  // für Dritte unsichtbar. Wer die Probe nicht beantworten kann, IST
21
21
  // protokollisch eine neue Beziehung (Identity 9.3).
22
- import { jcs } from '../core.js';
22
+ import { jcs, makeValidator } from '../core.js';
23
+ import { SCHEMAS } from '../schemas.js';
23
24
  import * as C from './deps.js';
24
- import { hmac } from './trust.js';
25
+ import { hmac, hmacU, reissueTrust, chainAdmission, withContactLock, withIssueLock } from './trust.js';
26
+ import { buildAck, ACK_TYPE } from './acks.js';
25
27
  const say = (p, m) => p.log.push(m);
26
28
  const CHUNK = 256;
29
+ const V = makeValidator(SCHEMAS);
30
+ const schemaOk = (file, data) => V.validate(data, SCHEMAS[file], SCHEMAS[file]).length === 0;
27
31
  // ── Schlüssel (Spec-Infostrings, richtungsgebunden) ─────────────────────
28
32
  // k_p = HKDF(ECDH(newPairX_sender, newPairX_recipient),
29
33
  // 'rltp/visibility/blind/probe/' + senderNewPair + '/' + recipientNewPair)
@@ -37,49 +41,136 @@ export const snapshotPriors = (p) => ({
37
41
  counterpart: [...p.contacts.entries()].filter(([, c]) => !c.deactivated).map(([k]) => k),
38
42
  });
39
43
  // ── continuity-probe@1: bauen ───────────────────────────────────────────
40
- export async function buildProbe(p, newKey, ent = {}) {
44
+ // Sequenzvergabe SYNCHRON (BigInt-String — 18 Stellen exakt), Bau je
45
+ // Kontakt serialisiert: zwei parallele buildProbe-Aufrufe teilen nie
46
+ // eine Sequenz, und der Wire-Wert ist der reservierte (Review 2, B-1)
47
+ const withContLock = withContactLock; // EIN Lock je Kontakt, geteilt mit trust (Review 4, B-1)
48
+ export async function buildProbe(p, newKey, when, ent = {}) {
41
49
  const t = p.contacts.get(newKey);
42
- if (!t?.channel?.own)
43
- return null;
44
- t.probeSeqOut = (t.probeSeqOut ?? 0) + 1;
45
- const kp = await kProbe(t.channel.own.x.priv, t.channel.counterpartKa, t.channel.own.anchor, newKey);
46
- const entries = [];
47
- for (const a of t.priorCands?.own ?? [])
48
- entries.push(await hmac(kp, a));
49
- // Polstern auf das nächste Vielfache von 256 (leerer Bestand → 1 Voll-Polster-Chunk)
50
- const total = Math.max(CHUNK, Math.ceil(entries.length / CHUNK) * CHUNK);
51
- while (entries.length < total)
52
- entries.push(C.b64uOf(ent.pad ?? C.rand(32)));
53
- entries.sort();
54
- const outbound = [];
55
- for (let i = 0; i * CHUNK < entries.length; i++) {
56
- const body = { type: 'continuity-probe@1', probe: String(t.probeSeqOut), seq: String(i + 1), last: (i + 1) * CHUNK >= entries.length, blinded: entries.slice(i * CHUNK, (i + 1) * CHUNK) };
57
- outbound.push({ to: t, kind: 'continuity-probe@1 (geblendet, gepolstert, versiegelt)', env: await C.seal({ ...body, mac: await hmac(kp, jcs(body)) }, t.channel.counterpartKa, ent) });
58
- }
59
- say(p, `Kontinuitäts-Probe an ${t.name}: ${t.priorCands?.own.length ?? 0} eigene Beziehungs-Anker, geblendet + gepolstert (${entries.length})`);
60
- return outbound;
50
+ if (!t?.channel?.own || t.deactivated)
51
+ return null; // deaktivierte Tupel stellen nichts aus (Review 8, B-2)
52
+ return withContLock(t, async () => {
53
+ if (t.deactivated)
54
+ return null; // Re-Check nach dem Lock-Erwerb
55
+ // Sequenz nur GEPEEKT (Review 25): „assigning … and committing are
56
+ // one step" — ein Bau-Fehler verbraucht nichts, auch am letzten
57
+ // Wert entsteht nie falsche Erschöpfung; committet wird synchron
58
+ // mit der Outbox (persisted atomically before send)
59
+ const nextProbe = BigInt(t.probeSeqOut ?? '0') + 1n;
60
+ if (nextProbe > 10n ** 18n - 1n)
61
+ throw new Error('scope exhausted — issues nothing further (2.1)');
62
+ const probeSeq = String(nextProbe);
63
+ const kp = await kProbe(t.channel.own.x.priv, t.channel.counterpartKa, t.channel.own.anchor, newKey);
64
+ const entries = [];
65
+ // §6a: prior ist „nie das frische Tupel" — Hygiene am NUTZUNGSORT:
66
+ // ein Host, der das Set nach der Tupel-Anlage (neu) schnappschießt,
67
+ // darf keine Selbst-Kette provozieren können (Selbst-Match →
68
+ // chainTuple(key, key) deaktivierte den eigenen Kopf)
69
+ for (const a of t.priorCands?.own ?? [])
70
+ if (a !== t.channel.own.anchor)
71
+ entries.push(await hmacU(kp, a));
72
+ // Polstern auf das nächste Vielfache von 256 (leerer Bestand → 1 Voll-Polster-Chunk);
73
+ // KOLLISION → Neusampling (Review 6, MAJOR: „the padding value is
74
+ // resampled") — ein injizierter ent.pad wird höchstens EINMAL
75
+ // verwendet, Duplikate fallen auf frische Zufallswerte zurück
76
+ const total = Math.max(CHUNK, Math.ceil(entries.length / CHUNK) * CHUNK);
77
+ const used = new Set(entries);
78
+ while (entries.length < total) {
79
+ let v = 'u' + C.b64uOf(ent.pad ?? C.rand(32));
80
+ while (used.has(v))
81
+ v = 'u' + C.b64uOf(C.rand(32));
82
+ used.add(v);
83
+ entries.push(v);
84
+ }
85
+ entries.sort();
86
+ // Bau + Seal + Digest sind FEHLBAR und warten — vorbereiten, dann
87
+ // Re-Check, dann Outbox-Schreibungen synchron (Review 9, B-3: ein
88
+ // während der Awaits deaktiviertes Tupel stellt nichts aus)
89
+ const preps = [];
90
+ const threadId = ent.threadId ?? globalThis.crypto.randomUUID();
91
+ for (let i = 0; i * CHUNK < entries.length; i++) {
92
+ const body = { type: 'continuity-probe@1', probe: probeSeq, seq: String(i + 1), last: (i + 1) * CHUNK >= entries.length, blinded: entries.slice(i * CHUNK, (i + 1) * CHUNK) };
93
+ const artifact = { body, proof: { mac: await hmacU(kp, jcs(body)) } };
94
+ const doc = { id: globalThis.crypto.randomUUID(), type: 'https://real-life.org/trust-tasks/continuity-probe/0.1', issuer: t.channel.own.anchor, recipient: newKey, threadId, issuedAt: C.iso(when), payload: artifact };
95
+ const env = await C.seal(doc, t.channel.counterpartKa, ent);
96
+ const digest = await C.digestDoc(doc);
97
+ preps.push({ digest, env });
98
+ }
99
+ if (t.deactivated)
100
+ return null; // Re-Check (B-3), danach kein await
101
+ // COMMIT — Sequenz + Outbox in EINEM synchronen Zug (Review 25):
102
+ // byte-identische Redelivery-Zuordnung fürs Ack-Matching (4.2;
103
+ // erneut SENDEN tut ein Retry nie, §6a)
104
+ t.probeSeqOut = probeSeq;
105
+ const outbound = [];
106
+ for (const { digest, env } of preps) {
107
+ ;
108
+ (t.outbox ??= new Map()).set(digest, { env, threadId, kind: 'continuity-probe' });
109
+ outbound.push({ to: t, kind: 'continuity-probe/0.1 (geblendet, gepolstert, versiegelt)', env });
110
+ }
111
+ say(p, `Kontinuitäts-Probe an ${t.name}: ${t.priorCands?.own.length ?? 0} eigene Beziehungs-Anker, geblendet + gepolstert (${entries.length})`);
112
+ return outbound;
113
+ });
61
114
  }
62
115
  // ── continuity-mapping@1: bauen ─────────────────────────────────────────
63
116
  // prior = EIGENER alter pair-Anker · next = eigener neuer · to = neuer der
64
117
  // Gegenseite; mac1 unter dem ALTEN Beziehungs-Schlüssel, mac2 unter dem
65
118
  // NEUEN — nur derselbe Schlüssel-Inhaber kann beide (für die Adressatin
66
119
  // fälschbar: Klasse V, abstreitbar).
67
- async function buildMapping(p, newKey, oldKey, when, ent = {}) {
120
+ // PREPARE (fehlbar: MACs, Seal, Digest — mutiert nichts) und COMMIT
121
+ // (synchron: Zähler + Outbox) getrennt (Review 12, B-2): die Record-
122
+ // Wahl kettet ATOMAR mit der Ausgabe ihres Mappings — beide Schritte
123
+ // liegen dann in EINEM synchronen Zug. Alle Aufrufer laufen unter dem
124
+ // Kontakt-Lock des Tupels (Handler + Flush), der die Revisionsvergabe
125
+ // serialisiert; buildMapping hält zusätzlich den Issue-Lock.
126
+ async function prepareContMapping(p, newKey, oldKey, when, ent = {}, duty) {
68
127
  const t = p.contacts.get(newKey), old = p.contacts.get(oldKey);
69
- const body = { type: 'continuity-mapping@1', prior: old.channel.own.anchor, next: t.channel.own.anchor, to: newKey, revision: '1', issuedAt: C.iso(when) };
128
+ if (t.deactivated)
129
+ throw new Error('tuple deactivated — issues nothing (§6.4)');
130
+ // 6.4/6a.4: Revision je (next, to) monoton — nur gepeekt, ein
131
+ // Fehlschlag verbraucht nichts (§6.4: one step per scope)
132
+ const nextRev = BigInt(t.contRevOut ?? '0') + 1n;
133
+ if (nextRev > 10n ** 18n - 1n)
134
+ throw new Error('scope exhausted — issues nothing further (2.1)');
135
+ const rev = String(nextRev);
136
+ const body = { type: 'continuity-mapping@1', prior: old.channel.own.anchor, next: t.channel.own.anchor, to: newKey, revision: rev, issuedAt: C.iso(when) };
70
137
  const msg = jcs(body);
71
- const doc = { ...body,
72
- mac1: await hmac(await kMac(old.channel.own.x.priv, old.channel.counterpartKa, 'rltp/visibility/mac/cont1'), msg),
73
- mac2: await hmac(await kMac(t.channel.own.x.priv, t.channel.counterpartKa, 'rltp/visibility/mac/cont2'), msg) };
74
- return { to: t, kind: 'continuity-mapping@1 (mac1 alt · mac2 neu, versiegelt)', env: await C.seal(doc, t.channel.counterpartKa, ent) };
138
+ const artifact = { body, proof: {
139
+ mac1: await hmacU(await kMac(old.channel.own.x.priv, old.channel.counterpartKa, 'rltp/visibility/mac/cont1'), msg),
140
+ mac2: await hmacU(await kMac(t.channel.own.x.priv, t.channel.counterpartKa, 'rltp/visibility/mac/cont2'), msg)
141
+ } };
142
+ const doc = { id: globalThis.crypto.randomUUID(), type: 'https://real-life.org/trust-tasks/continuity-mapping/0.1', issuer: t.channel.own.anchor, recipient: newKey, threadId: ent.threadId ?? globalThis.crypto.randomUUID(), issuedAt: C.iso(when), payload: artifact };
143
+ const env = await C.seal(doc, t.channel.counterpartKa, ent);
144
+ const digest = await C.digestDoc(doc);
145
+ return { t, rev, env, digest, threadId: doc.threadId, duty, prior: old.channel.own.anchor };
146
+ }
147
+ const commitContMapping = (prep) => {
148
+ prep.t.contRevOut = prep.rev;
149
+ (prep.t.outbox ??= new Map()).set(prep.digest, { env: prep.env, threadId: prep.threadId, kind: 'continuity-mapping', duty: prep.duty, prior: prep.prior });
150
+ return { to: prep.t, kind: 'continuity-mapping/0.1 (mac1 alt · mac2 neu, versiegelt)', env: prep.env };
151
+ };
152
+ async function buildMapping(p, newKey, oldKey, when, ent = {}, duty) {
153
+ const t = p.contacts.get(newKey);
154
+ return withIssueLock(t, async () => {
155
+ const prep = await prepareContMapping(p, newKey, oldKey, when, ent, duty);
156
+ if (t.deactivated)
157
+ throw new Error('tuple deactivated — issues nothing (§6.4)'); // Re-Check (B-2), danach kein await
158
+ return commitContMapping(prep);
159
+ });
75
160
  }
76
161
  // ── die Kette: neues Tupel wird Kopf, das alte deaktiviert ──────────────
77
- // Tupel-Ebene bleibt (Stern-Salts, Probe-Sequenzen); KETTEN-Ebene wandert:
78
- // Kontakt-Gedächtnis, Evidenz (✓ akkumuliert), Offenlegungen, Gruppen.
79
- function chainTuple(p, newKey, oldKey) {
162
+ // Visibility 0.29 §6.4: PER-TUPEL-ZUSTAND WANDERT NICHT — Grade,
163
+ // Stern-Salts, Baseline und empfangene Sterne sterben mit dem alten
164
+ // Kopf (der neue beginnt fail-closed; das Fenster schließen 5.5s
165
+ // Neuausstellung und das New-Head-Korollar von 5.4, nie eine
166
+ // Migration). KETTEN-Ebene wandert: Kontakt-Gedächtnis, Evidenz
167
+ // (✓ akkumuliert), das WISSEN um den offengelegten Anker (die
168
+ // Admissionsschicht hängt an der Beziehung), die eigene
169
+ // Vertrauens-ENTSCHEIDUNG (trustGiven) und die Pause.
170
+ function chainTuple(p, newKey, oldKey, when) {
80
171
  const head = p.contacts.get(newKey), old = p.contacts.get(oldKey);
81
- if (!head || !old || old.deactivated)
82
- return false; // idempotent
172
+ if (!head || !old || head === old || old.deactivated)
173
+ return false; // idempotent; NIE Selbst-Kette (§6a)
83
174
  old.deactivated = true;
84
175
  old.chainedInto = newKey;
85
176
  head.chain = [...(old.chain ?? []), oldKey];
@@ -91,18 +182,71 @@ function chainTuple(p, newKey, oldKey) {
91
182
  head.state = '⇄';
92
183
  head.since = [old.since, head.since].filter(Boolean).sort()[0];
93
184
  head.name = old.name; // lokales Kontakt-Gedächtnis
94
- head.selfAnchor ??= old.selfAnchor;
95
- head.mapping ??= old.mapping;
96
- head.trustGiven ??= old.trustGiven;
97
- head.trustReceived ??= old.trustReceived;
98
- head.trustPaused ??= old.trustPaused;
99
- head.sentMapping ??= old.sentMapping;
100
- head.starReceived ??= old.starReceived;
101
- head.starInfo ??= old.starInfo;
185
+ head.selfAnchor ??= old.selfAnchor; // Ketten-WISSEN: wer das ist (das Mapping selbst stirbt mit dem Tupel, §6.4)
186
+ const oldRel = old.relId ?? oldKey;
187
+ // Admission (B-4): der neue Kopf löst über die überlebende Beziehung
188
+ // auf; ein schon existierender Eintrag der frischen Beziehung
189
+ // verschmilzt atomar (früheste Position, Aliase, k−1). Die
190
+ // IDENTITÄT ist die des TATSÄCHLICH überlebenden Eintrags (Review
191
+ // 36: „the merged relationship's identity is the surviving
192
+ // entry's") — existiert nur der frische, bleibt DESSEN Identität;
193
+ // existiert keiner, die des jüngsten Kopfs (Review 4, B-4)
194
+ const survivor = chainAdmission(p, oldRel, newKey, newKey, when);
195
+ // Ketten-Identität = die des Survivors NUR, wenn sie zu DIESER Kette
196
+ // gehört (Review 36 + 37, B-1): die kanonische Identität eines
197
+ // Weg-2-Merges gehört einer ANDEREN, weiterhin aktiven Beziehung —
198
+ // Admission-Identität und Beziehungsstrang bleiben unterscheidbar,
199
+ // der Abgang dieser Kette darf die andere nie mitreißen
200
+ const sid = survivor?.id;
201
+ const sidInChain = sid !== undefined && (sid === oldRel || sid === newKey || (p.contacts.get(sid)?.relId ?? null) === oldRel);
202
+ head.relId = sidInChain ? sid : (survivor ? oldRel : newKey);
203
+ // die GANZE Kette identifiziert sich als DIESE Beziehung — auch der
204
+ // alte Kopf (Review 36: der Abgang räumt sämtliche Ketten-Aliase)
205
+ old.relId = head.relId;
206
+ head.trustGiven ??= old.trustGiven; // die Entscheidung gehört der Beziehung (V2)
207
+ // 5.5: „MUST re-issue its most recently ISSUED grade" — der
208
+ // Ordnungsstempel entscheidet, nie der zufällig frischere Kopf
209
+ // (Review 27: setGrade auf dem alten Kopf NACH dem Trust-Akt des
210
+ // frischen muss die Kettung überleben)
211
+ if (old.gradeOutLast !== undefined && (head.gradeOutLast === undefined || (old.gradeOutOrder ?? 0n) > (head.gradeOutOrder ?? 0n))) {
212
+ head.gradeOutLast = old.gradeOutLast;
213
+ head.gradeOutOrder = old.gradeOutOrder;
214
+ }
215
+ // Pause ist chain-level und ÜBERLEBT die Kettung (Review 37, B-2):
216
+ // der Zustand der Beziehung, auf die gekettet wird, gewinnt gegen
217
+ // Vor-Kettungs-Akte des frischen Tupels
218
+ head.trustPaused = old.trustPaused ?? head.trustPaused;
102
219
  head.sharedGroups = [...new Set([...(old.sharedGroups ?? []), ...(head.sharedGroups ?? [])])];
220
+ // NICHT migriert (per Tupel, stirbt mit dem alten Kopf):
221
+ // trustReceived, starReceived, starInfo, starSaltIn, sentMapping,
222
+ // sub (Baseline/inFlight), starSeqNext. Die 5.5-Neuausstellung ist
223
+ // die stehende Pflicht des neuen Kopfs; ihr Producer ist
224
+ // trust.reissueTrust via starRefreshAll.
225
+ // die Pflicht entsteht, wenn eine DECLARATION ausgestellt war (5.5:
226
+ // „a subject holding an issued declaration … MUST re-issue") — auch
227
+ // ohne Trust-Akt (eigenständiges setGrade); Review 33, B-1
228
+ if (head.trustGiven || head.gradeOutLast !== undefined)
229
+ head.trustReissueDue = true;
103
230
  say(p, `Kontinuität: ${head.name} wiedererkannt — neues Tupel an die Beziehung gekettet (${head.state}, ${head.chain.length + 1} Enactments)`);
104
231
  return true;
105
232
  }
233
+ // V2-Hook (5.5): direkt nach der Kettung wird die Neuausstellung
234
+ // versucht; ein Bau-Fehler lässt die Pflicht (trustReissueDue) stehen —
235
+ // ihr Producer ist starRefreshAll, nie eine Wiederzustellung.
236
+ async function v2Reissue(p, newKey, when, ent, outbound) {
237
+ const t = p.contacts.get(newKey);
238
+ if (!t?.trustReissueDue)
239
+ return undefined;
240
+ try {
241
+ const r = await reissueTrust(p, newKey, when, ent);
242
+ if (r)
243
+ outbound.push(...r.outbound);
244
+ return undefined;
245
+ }
246
+ catch (e) {
247
+ return String(e?.message ?? e);
248
+ }
249
+ }
106
250
  // ── Empfangs-Dispatch ───────────────────────────────────────────────────
107
251
  const iAmRecord = (t, newKey) => t.channel.own.anchor < newKey; // lexikographisch kleinerer NEUER Anker
108
252
  const tupleOf = (p, rkid) => {
@@ -112,93 +256,252 @@ const tupleOf = (p, rkid) => {
112
256
  const e = [...p.contacts.entries()].find(([, c]) => c.channel?.own?.anchor === ctx.anchor);
113
257
  return e ? { key: e[0], t: e[1], ctx } : null;
114
258
  };
259
+ const TT_PROBE = 'https://real-life.org/trust-tasks/continuity-probe/0.1';
260
+ const TT_MAP = 'https://real-life.org/trust-tasks/continuity-mapping/0.1';
115
261
  export async function receiveContinuity(p, env, when, ent = {}) {
116
262
  const opened = await C.openEnvelope(p, env); // Stufen 1–4, Cache-Lesung
117
- if (opened.duplicate)
118
- return { handled: true, duplicate: true };
119
263
  if (opened.error)
120
264
  return { handled: false };
121
- const r = await receiveContinuityInner(p, env, opened.doc, when, ent);
122
- if (r?.handled && !r.error)
123
- C.effectDone(p, opened.digest);
124
- return r;
125
- }
126
- async function receiveContinuityInner(p, env, doc, when, ent = {}) {
127
265
  const hit = tupleOf(p, env.rkid);
128
266
  if (!hit)
129
267
  return { handled: false };
130
- if (doc?.type === 'continuity-probe@1')
131
- return handleProbe(p, hit, doc, when, ent);
132
- if (doc?.type === 'continuity-mapping@1')
133
- return handleMapping(p, hit, doc, when, ent);
134
- return { handled: false, doc };
268
+ if (opened.duplicate) {
269
+ // 4.2: byte-identischer Re-Ack innerhalb der Retention
270
+ const stored = hit.t.ackStore?.get(opened.digest);
271
+ return { handled: true, duplicate: true, ack: stored ? { to: hit.t, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: stored } : undefined };
272
+ }
273
+ const doc = opened.doc;
274
+ if (doc?.type !== TT_PROBE && doc?.type !== TT_MAP)
275
+ return { handled: false, doc };
276
+ // Dokumentprofil (Delivery §3) + Artefakt-Schema (Stufen 5/7)
277
+ if (!schemaOk('rltp-delivery-document.schema.json', doc) || doc.proof !== undefined)
278
+ return { handled: true, error: 'malformed document' };
279
+ // Stufe 5: recipient; die issuer-Bindung (Stufe 8) läuft NACH den
280
+ // Payload-Schemata (Review 32)
281
+ if (doc.recipient !== hit.t.channel.own.anchor)
282
+ return { handled: true, error: 'outer binding (recipient)' };
283
+ // Kalender-Validität des DOKUMENTS (Review 12, B-3); die PAYLOAD-
284
+ // Kalenderprüfung sitzt NACH dem Artefakt-Schema (Review 31)
285
+ if (!C.calOK(doc.issuedAt))
286
+ return { handled: true, error: 'calendar-invalid issuedAt' };
287
+ if (doc.type === TT_PROBE && !schemaOk('visibility-continuity-probe.schema.json', doc.payload))
288
+ return { handled: true, error: 'malformed probe' };
289
+ if (doc.type === TT_MAP && !schemaOk('visibility-continuity-mapping.schema.json', doc.payload))
290
+ return { handled: true, error: 'malformed mapping' };
291
+ if (doc.type === TT_MAP && !C.calOK(doc.payload?.body?.issuedAt))
292
+ return { handled: true, error: 'calendar-invalid issuedAt' }; // Stufe 8, nach dem Schema (Review 31)
293
+ if (doc.issuer !== hit.key)
294
+ return { handled: true, error: 'outer binding (issuer, Stufe 8)' };
295
+ // Profil-Regel (Review 29/30): Stufe 8 NACH dem Payload-Schema —
296
+ // ein präsentes ceremony.enactment kann bei diesen Payloads nie
297
+ // nachrechnen (kein umschlossenes Bindungsmaterial) →
298
+ // failed(validation-failed); andere Framework-Member passieren (§4.11.1)
299
+ if (doc.ceremony?.enactment !== undefined)
300
+ return { handled: true, error: 'ceremony.enactment kann nicht nachrechnen (validation-failed)' };
301
+ // GESAMTER Empfang unter dem Kontakt-Lock (B-1: kein Rollback durch
302
+ // parallele Handler); der Ack entsteht IN den Handlern nach der
303
+ // vollen Validierung (M-2), Commit hier atomar mit Cache+Store
304
+ return withContLock(hit.t, async () => {
305
+ // §6.4: ein deaktiviertes Tupel nimmt nichts mehr an (Review 5, B-1)
306
+ if (hit.t.deactivated)
307
+ return { handled: true, error: 'tuple deactivated' };
308
+ // Stage-4-Reentry nach Lock-Wartezeit: ein inzwischen verarbeitetes
309
+ // Duplikat erhält den gespeicherten Ack byte-identisch
310
+ if ((p.deliveryCache ?? new Set()).has(opened.digest)) {
311
+ const stored = hit.t.ackStore?.get(opened.digest);
312
+ return { handled: true, duplicate: true, ack: stored ? { to: hit.t, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: stored } : undefined };
313
+ }
314
+ const mkAck = () => buildAck(p, hit.t, opened.digest, doc.threadId, when).then((a) => a.env);
315
+ // Effekt + completed-effect cache + gespeicherter Ack committen in
316
+ // EINEM synchronen Zug IM Handler (Review 6, B-5: „inside the
317
+ // effect's transaction") — die Outbound-PRODUKTION (buildMapping,
318
+ // V2) läuft danach; ihr Scheitern hinterlässt eine Pflicht, nie
319
+ // einen halben Empfangs-Commit
320
+ const commitAck = (ackEnv) => {
321
+ C.effectDone(p, opened.digest);
322
+ (hit.t.ackStore ??= new Map()).set(opened.digest, ackEnv);
323
+ return { to: hit.t, kind: 'delivery-ack/0.1', env: ackEnv };
324
+ };
325
+ return doc.type === TT_PROBE
326
+ ? handleProbe(p, hit, doc.payload, doc.threadId, when, ent, mkAck, commitAck)
327
+ : handleMapping(p, hit, doc.payload, when, ent, mkAck, commitAck);
328
+ });
135
329
  }
136
- async function handleProbe(p, { key, t }, doc, when, ent) {
137
- // form BEFORE fields (M-3): mac, sequence and chunk list as used below
138
- if (!C.shaped(doc, { mac: 'string', probe: 'string', seq: 'string', blinded: 'array' }) || typeof doc.last !== 'boolean'
139
- || !C.intStr(doc.probe) || !C.intStr(doc.seq) // NaN in probeSeqIn wäre Replay-Zustandskorruption (Review 9, P-B1)
140
- || doc.blinded.length !== 256 // die Chunkform ist FEST: genau 256 Einträge (Review 10, P-B1-Rest)
141
- || !doc.blinded.every((b) => typeof b === 'string'))
330
+ async function handleProbe(p, { key, t }, artifact, threadId, when, ent, mkAck, commitAck) {
331
+ // Schema lief im Dispatch; hier die wertgebundenen Reste (M-3)
332
+ const doc = artifact.body;
333
+ if (!C.intStr(doc.probe) || !C.intStr(doc.seq) // NaN in probeSeqIn wäre Replay-Zustandskorruption (Review 9, P-B1)
334
+ || doc.blinded.length !== 256) // die Chunkform ist FEST: genau 256 Einträge (Review 10, P-B1-Rest)
142
335
  return { handled: true, error: 'malformed probe' };
143
336
  // Senderseitiger k_p, aus Empfängersicht nachgerechnet (Richtung!)
144
337
  const kp = await kProbe(t.channel.own.x.priv, t.channel.counterpartKa, key, t.channel.own.anchor);
145
- const { mac, ...body } = doc;
146
- if ((await hmac(kp, jcs(body))) !== mac)
338
+ if ((await hmacU(kp, jcs(doc))) !== artifact.proof.mac)
147
339
  return { handled: true, error: 'probe mac' };
148
340
  if (BigInt(doc.probe) <= BigInt(t.probeSeqIn ?? '0'))
149
341
  return { handled: true, error: 'probe replay (nicht streng größer)' };
150
- // Chunk-Sammlung pro (Tupel, probe); höhere probe verwirft die offene
151
- if (t.probeAsm?.probe !== doc.probe)
152
- t.probeAsm = { probe: doc.probe, blinded: [], next: 1 };
153
- if (Number(doc.seq) !== t.probeAsm.next)
154
- return { handled: true, error: 'malformed probe (chunk order)' };
155
- t.probeAsm.next += 1;
156
- t.probeAsm.blinded.push(...doc.blinded);
157
- if (!doc.last)
158
- return { handled: true, partial: true };
159
- // finaler Chunk: union lesen, dann vollzieht sich der EFFEKT (consume
160
- // + Wahl + Kette) VOR dem fehlbaren Mapping-Bau — bewusst, denn die
161
- // Frische-Regel verbietet Chunk-Replays als Retry-Weg; ein Bau-Fehler
162
- // wird als getypte Pflicht persistiert und über flushContinuity()
163
- // eingelöst (Reviews 5–8)
164
- const union = t.probeAsm.blinded;
165
- const consume = () => { t.probeAsm = null; t.probeSeqIn = doc.probe; }; // als STRING — BigInt-Vergleich, 18 Stellen exakt
342
+ // VERGIFTETE Probe (Review 21): nach einem Assembly-Konflikt wird
343
+ // KEIN weiterer Chunk dieser Lieferung quittiert — sonst
344
+ // kombinieren früh geackte, verlorene Pufferwirkungen mit späteren
345
+ // Acks zu einer falschen Completion (§6a.2 übernimmt die
346
+ // 5.2a-Konfliktregeln in kind)
347
+ if (t.probePoison?.has(doc.probe))
348
+ return { handled: true, error: 'probe vergiftet (assembly conflict)' };
349
+ // Re-Check nach den MAC-Awaits (Review 6, B-1): die Assembly ist
350
+ // Tupel-Zustand und nimmt nach einer Deaktivierung nichts mehr auf
351
+ if (t.deactivated)
352
+ return { handled: true, error: 'tuple deactivated' };
353
+ // Chunk-Sammlung pro (Tupel, probe): LÜCKEN PUFFERN (Ankunftsreihen-
354
+ // folge irrelevant, 5.2a in kind), nur eine STRIKT HÖHERE probe
355
+ // verdrängt die offene, eine niedrigere wird verworfen; threadId-
356
+ // Dissens verwirft; seq-Konflikt (andere Bytes) verwirft (byte-
357
+ // identische Wiederholung fängt duplicate-known auf Envelope-Ebene).
358
+ // Entscheidungen auf LOCALS (Review 6, B-1) — GESCHRIEBEN wird erst
359
+ // nach Ack-Bau und letztem Re-Check, synchron mit dem Commit
360
+ const cur = t.probeAsm;
361
+ const displaced = cur && BigInt(doc.probe) > BigInt(cur.probe);
362
+ if (cur && !displaced && BigInt(doc.probe) < BigInt(cur.probe))
363
+ return { handled: true, error: 'below open probe assembly' };
364
+ const a = (!cur || displaced) ? { probe: doc.probe, threadId, chunks: new Map(), lastSeq: undefined } : cur;
365
+ if (threadId !== a.threadId) {
366
+ t.probeAsm = null;
367
+ (t.probePoison ??= new Set()).add(doc.probe);
368
+ return { handled: true, error: 'probe assembly conflict (threadId)' };
369
+ }
370
+ const seq = BigInt(doc.seq);
371
+ if (a.chunks.has(doc.seq)) {
372
+ t.probeAsm = null;
373
+ (t.probePoison ??= new Set()).add(doc.probe);
374
+ return { handled: true, error: 'probe assembly conflict (seq)' };
375
+ }
376
+ if (doc.last && a.lastSeq !== undefined) {
377
+ t.probeAsm = null;
378
+ (t.probePoison ??= new Set()).add(doc.probe);
379
+ return { handled: true, error: 'probe assembly conflict (two last)' };
380
+ }
381
+ const candLast = doc.last ? doc.seq : a.lastSeq;
382
+ if (candLast !== undefined && seq > BigInt(candLast)) {
383
+ t.probeAsm = null;
384
+ (t.probePoison ??= new Set()).add(doc.probe);
385
+ return { handled: true, error: 'seq beyond last' };
386
+ }
387
+ // auch BEREITS GEPUFFERTE Chunks oberhalb von last verwerfen die
388
+ // Assembly (Review 16, B-1 — 5.2a in kind, Ankunftsreihenfolge egal)
389
+ if (candLast !== undefined)
390
+ for (const s of a.chunks.keys())
391
+ if (BigInt(s) > BigInt(candLast)) {
392
+ t.probeAsm = null;
393
+ (t.probePoison ??= new Set()).add(doc.probe);
394
+ return { handled: true, error: 'seq beyond last (gepuffert)' };
395
+ }
396
+ // Komplettierung VOR dem Puffern vorhersagen (Review 5, B-4 — die
397
+ // Star-Struktur gespiegelt): jeder NICHT-komplettierende Chunk (auch
398
+ // ein früh eintreffender last:true) wird nach den Per-Chunk-Gates
399
+ // quittiert und gepuffert; NUR der KOMPLETTIERENDE wartet auf die
400
+ // Union-Validierung (M-2) — Ankunftsreihenfolge ist irrelevant (5.2a)
401
+ const complete = candLast !== undefined && (() => {
402
+ for (let i = 1n; i <= BigInt(candLast); i++)
403
+ if (String(i) !== doc.seq && !a.chunks.has(String(i)))
404
+ return false;
405
+ return true;
406
+ })();
407
+ if (!complete) {
408
+ const __ackEnv = await mkAck();
409
+ if (t.deactivated)
410
+ return { handled: true, error: 'tuple deactivated' }; // Re-Check (B-1), danach kein await
411
+ t.probeAsm = a; // Übernahme JETZT — synchron mit Puffern + Commit (B-5)
412
+ if (doc.last)
413
+ a.lastSeq = doc.seq;
414
+ a.chunks.set(doc.seq, doc.blinded);
415
+ return { handled: true, partial: true, ack: commitAck(__ackEnv) };
416
+ }
417
+ // Union aus Puffer + aktuellem Chunk OHNE Mutation; globale
418
+ // Sortierung + Duplikatfreiheit (§6a.2)
419
+ const union = [];
420
+ for (let i = 1n; i <= BigInt(candLast); i++)
421
+ union.push(...(String(i) === doc.seq ? doc.blinded : a.chunks.get(String(i))));
422
+ if (!union.every((x, i, arr) => i === 0 || arr[i - 1] < x)) {
423
+ t.probeAsm = null;
424
+ (t.probePoison ??= new Set()).add(doc.probe);
425
+ return { handled: true, error: 'probe assembly invalid (order/duplicates)' };
426
+ }
166
427
  // Schnitt gegen die GEGENSEITEN-Anker des eigenen Prior-Candidate-Sets
428
+ // — reine Rechnung, VOR dem Ack-Bau, damit zwischen Re-Check und
429
+ // Übernahme kein await mehr liegt (Review 5, B-1)
167
430
  const matches = [];
168
431
  for (const cp of t.priorCands?.counterpart ?? []) {
169
- if (union.includes(await hmac(kp, cp)))
432
+ if (cp === key)
433
+ continue; // §6a: das frische Tupel selbst ist NIE ein prior (Selbst-Ketten-Schutz)
434
+ if (union.includes(await hmacU(kp, cp)))
170
435
  matches.push(cp);
171
436
  }
437
+ const __ackEnv = await mkAck(); // JETZT ist alles validiert (M-2)
438
+ if (t.deactivated)
439
+ return { handled: true, error: 'tuple deactivated' }; // Re-Check (B-1), danach kein await bis zum Commit
440
+ // Übernahme + Cache + Ack in EINEM synchronen Zug (B-5); die
441
+ // Kettenwahl mutiert ebenfalls synchron — erst DANACH die fehlbare
442
+ // Outbound-Produktion (ihr Scheitern hinterlässt eine Pflicht)
443
+ t.probeAsm = null;
444
+ if (BigInt(doc.probe) > BigInt(t.probeSeqIn ?? '0'))
445
+ t.probeSeqIn = doc.probe; // monoton (B-1)
446
+ if (t.probePoison)
447
+ for (const ps of t.probePoison)
448
+ if (BigInt(ps) <= BigInt(t.probeSeqIn))
449
+ t.probePoison.delete(ps); // Gift unterhalb des High-Water ist gegenstandslos
450
+ const ack = commitAck(__ackEnv);
172
451
  if (!matches.length) {
173
- consume();
174
452
  say(p, `Probe von ${t.name}: kein Match — ehrlich eine neue Beziehung`);
175
- return { handled: true, matches: 0 };
453
+ return { handled: true, matches: 0, ack };
176
454
  }
177
455
  const outbound = [];
178
456
  let chained = false;
179
457
  let outboundError;
180
458
  if (iAmRecord(t, key)) {
181
- // Record-Seite: einmal wählen, endgültig; Kette atomar mit der Wahl
459
+ // Record-Seite: einmal wählen, endgültig — „the record side chains
460
+ // atomically with issuing its choice" (Review 12, B-2): erst das
461
+ // Wahl-Mapping FEHLBAR vorbereiten, dann Wahl + Kette + Ausgabe in
462
+ // EINEM synchronen Zug; scheitert die Vorbereitung, wird NICHT
463
+ // gekettet und die volle Wahl-Pflicht bleibt stehen (Flush holt sie
464
+ // atomar nach)
182
465
  if (!t.contChosen) {
183
- const choice = matches[0]; // Wahl frei — hier: erster Match (reichste Historie wäre App-Sache)
184
- consume();
185
- t.contChosen = choice;
186
- chained = chainTuple(p, key, choice);
187
- try {
188
- outbound.push(await buildMapping(p, key, choice, when, ent));
189
- }
190
- catch (err) {
191
- t.contMappingDue = { prior: choice, kind: 'choice' };
192
- outboundError = String(err?.message ?? err);
466
+ // Wahl frei — hier: der erste LEBENDE, KETTBARE Match (Review 19,
467
+ // B-4: ein von einem konkurrierenden Tupel verbrauchter erster
468
+ // Match blockiert die weiteren gültigen nicht; reichste Historie
469
+ // wäre App-Sache). Kette ZUERST (Review 13, B-1): „chains
470
+ // atomically with issuing its choice" heißt beides oder keins.
471
+ for (const choice of matches) {
472
+ if (p.contacts.get(choice)?.deactivated)
473
+ continue; // toter prior → nächster Match
474
+ let prep;
475
+ try {
476
+ prep = await prepareContMapping(p, key, choice, when, ent);
477
+ }
478
+ catch (err) {
479
+ // die Pflicht trägt ALLE Matches (Review 20, B-3): wird der
480
+ // erste vor dem Flush unkettbar, versucht der Flush die
481
+ // übrigen lebenden Kandidaten
482
+ t.contMappingDue = { prior: choice, kind: 'choice', alts: matches.filter((x) => x !== choice) };
483
+ outboundError = String(err?.message ?? err);
484
+ break;
485
+ }
486
+ if (t.deactivated || t.contChosen)
487
+ break;
488
+ chained = chainTuple(p, key, choice, when);
489
+ if (!chained)
490
+ continue; // Rennen verloren → nächster lebender Match
491
+ t.contChosen = choice;
492
+ if (t.contMappingDue?.kind === 'choice')
493
+ delete t.contMappingDue; // die alte Bau-Pflicht ist ERFÜLLT (Review 14, B-2)
494
+ outbound.push(commitContMapping(prep));
495
+ const v2Err = await v2Reissue(p, key, when, ent, outbound);
496
+ if (v2Err)
497
+ outboundError = outboundError ? outboundError + ' · ' + v2Err : v2Err;
498
+ break;
193
499
  }
194
500
  }
195
- else
196
- consume();
197
501
  }
198
502
  else {
199
503
  // Nicht-Record: Match-Report (löst NIE eine Kette aus)
200
504
  if (!t.contReported) {
201
- consume();
202
505
  t.contReported = matches[0];
203
506
  try {
204
507
  outbound.push(await buildMapping(p, key, matches[0], when, ent));
@@ -208,65 +511,167 @@ async function handleProbe(p, { key, t }, doc, when, ent) {
208
511
  outboundError = String(err?.message ?? err);
209
512
  }
210
513
  }
211
- else
212
- consume();
213
514
  }
214
- return { handled: true, matches: matches.length, chained, name: chained ? t.name : undefined, outbound, outboundError };
515
+ return { handled: true, matches: matches.length, chained, name: chained ? t.name : undefined, outbound, outboundError, ack };
215
516
  }
216
- async function handleMapping(p, { key, t }, doc, when, ent) {
217
- if (!C.shaped(doc, { mac1: 'string', mac2: 'string', prior: 'string', next: 'string', to: 'string' }))
517
+ async function handleMapping(p, { key, t }, artifact, when, ent, mkAck, commitAck) {
518
+ if (!C.shaped(artifact ?? {}, { body: 'object', proof: 'object' })
519
+ || !C.shaped(artifact.body, { prior: 'string', next: 'string', to: 'string' })
520
+ || typeof artifact.proof.mac1 !== 'string' || typeof artifact.proof.mac2 !== 'string')
218
521
  return { handled: true, error: 'malformed mapping' };
219
- const { mac1, mac2, ...body } = doc;
522
+ const body = artifact.body;
523
+ const { mac1, mac2 } = artifact.proof;
220
524
  const fail = (why) => { say(p, `Kontinuitäts-Mapping verworfen: ${why}`); return { handled: true, error: why }; };
221
525
  if (body.to !== t.channel.own.anchor)
222
526
  return fail('to ≠ eigener neuer Anker');
223
527
  if (body.next !== key)
224
528
  return fail('next ≠ neuer Anker der Gegenseite');
225
529
  // prior MUSS im Schnappschuss liegen (nie das frische Tupel, nie tiefere Kettenglieder)
530
+ if (body.prior === key)
531
+ return fail('prior = frisches Tupel (§6a)'); // Selbst-Ketten-Schutz, auch bei vergiftetem Schnappschuss
226
532
  if (!(t.priorCands?.counterpart ?? []).includes(body.prior))
227
533
  return fail('prior nicht im Prior-Candidate-Set');
228
534
  const old = p.contacts.get(body.prior);
229
535
  if (!old?.channel?.own)
230
536
  return fail('alte Beziehung ohne eigenen Kontext');
231
537
  const msg = jcs(body);
232
- if ((await hmac(await kMac(old.channel.own.x.priv, old.channel.counterpartKa, 'rltp/visibility/mac/cont1'), msg)) !== mac1)
538
+ if ((await hmacU(await kMac(old.channel.own.x.priv, old.channel.counterpartKa, 'rltp/visibility/mac/cont1'), msg)) !== mac1)
233
539
  return fail('mac1 (alter Beziehungs-Schlüssel)');
234
- if ((await hmac(await kMac(t.channel.own.x.priv, t.channel.counterpartKa, 'rltp/visibility/mac/cont2'), msg)) !== mac2)
540
+ if ((await hmacU(await kMac(t.channel.own.x.priv, t.channel.counterpartKa, 'rltp/visibility/mac/cont2'), msg)) !== mac2)
235
541
  return fail('mac2 (neuer Beziehungs-Schlüssel)');
542
+ // 6.4, die GENERISCHE Revisionsregel im (next, to)-Scope (Review 11,
543
+ // B-2): höher ersetzt · gleich + JCS-identischer Body = idempotenter
544
+ // Repeat (No-op, geackt) · gleich + anderer Body = Äquivokation ·
545
+ // niedriger = reject. Der Revisionsstand rückt erst NACH allen
546
+ // Ablehnungsgründen vor (Record-Freeze gilt whatever its revision)
547
+ if (BigInt(body.revision) === BigInt(t.contRevIn ?? '0')) {
548
+ if (t.contBodyIn && msg === t.contBodyIn) {
549
+ const __ackEnv = await mkAck();
550
+ if (t.deactivated)
551
+ return fail('tuple deactivated');
552
+ return { handled: true, idempotent: true, ack: commitAck(__ackEnv) };
553
+ }
554
+ return fail('Äquivokation — gleiche Revision, anderer Body');
555
+ }
556
+ if (BigInt(body.revision) < BigInt(t.contRevIn ?? '0'))
557
+ return fail('mapping revision (niedriger)');
236
558
  const senderIsRecord = key < t.channel.own.anchor;
559
+ // Record-Freeze VOR Ack und Revisionsstand (B-3): ein verworfenes
560
+ // Dokument konsumiert weder Revision noch Krypto
561
+ if (senderIsRecord && t.contRecordPrior && t.contRecordPrior !== body.prior)
562
+ return fail('Äquivokation — Record-Wahl ist eingefroren');
563
+ // §6a.4/6.4: „On verification of a record-side mapping, chain" —
564
+ // wurde der genannte alte Kopf von einem KONKURRIERENDEN Tupel
565
+ // gekettet, ist die Kette hier nicht möglich: ablehnen statt
566
+ // Freeze + Ack + Alignment ohne Kette (Review 14, B-3). ABER: „a
567
+ // head deactivated by THIS VERY chaining remains a valid prior for
568
+ // this tuple" (Review 15) — eine höhere Revision derselben
569
+ // eingefrorenen Wahl wird per 6.4 normal verarbeitet
570
+ const priorTaken = () => old.deactivated && old.chainedInto !== key;
571
+ if (senderIsRecord && priorTaken())
572
+ return fail('prior von konkurrierendem Tupel gekettet (§6.4) — nicht kettbar');
573
+ const __ackEnv = await mkAck(); // alle Gates bestanden — VOR der Mutation
574
+ if (t.deactivated)
575
+ return fail('tuple deactivated'); // Re-Check (B-1), danach kein await bis zum Commit
576
+ if (senderIsRecord && priorTaken())
577
+ return fail('prior von konkurrierendem Tupel gekettet (§6.4) — nicht kettbar'); // Re-Check (B-3)
578
+ // Effekt (Revision, Freeze, Kette) + Cache + Ack in EINEM synchronen
579
+ // Zug (Review 6, B-5/B-1); die fehlbare Outbound-Produktion läuft
580
+ // DANACH — ihr Scheitern hinterlässt die Pflicht (contMappingDue),
581
+ // nie einen halben Commit
582
+ t.contRevIn = body.revision;
583
+ t.contBodyIn = msg; // für den Idempotenz-Vergleich (Review 11, B-2)
237
584
  const outbound = [];
585
+ let chainedNow = false; // das ECHTE Kettenergebnis (Review 14, B-3), nie eine Ableitung
586
+ const done = (extra = {}) => ({ handled: true, chained: chainedNow, name: t.name, outbound, ack, ...extra });
587
+ let ack;
238
588
  if (senderIsRecord) {
239
- // Record-Freeze: abweichendes prior derselben (next,to) = Äquivokation
240
- if (t.contRecordPrior && t.contRecordPrior !== body.prior)
241
- return fail('Äquivokation — Record-Wahl ist eingefroren');
242
- // fehlbare Arbeit VOR der Mutation (B-2): erst das Alignment-Mapping
243
- // bauen, dann Freeze + Kette in einem synchronen Zug
244
589
  t.contRecordPrior = body.prior;
245
- chainTuple(p, key, body.prior);
590
+ // nach den Re-Checks garantiert kettbar ODER schon in GENAU dieses
591
+ // Tupel gekettet (Re-Verifikation derselben Wahl, Review 15)
592
+ chainedNow = chainTuple(p, key, body.prior, when) || old.chainedInto === key;
593
+ // „the non-record side's match-report duty … dies on verifying the
594
+ // record side's mapping on that tuple" (Review 11, B-3): der alte
595
+ // Match-Report wird retired — nie mehr gesendet (stale
596
+ // counter-claim MUST NOT be sent), sein Ack-Ref bleibt erhalten
597
+ for (const o of (t.outbox ?? new Map()).values())
598
+ if (o.kind === 'continuity-mapping' && o.duty !== 'align')
599
+ o.retired = true;
600
+ if (t.contMappingDue?.kind === 'report')
601
+ delete t.contMappingDue;
602
+ ack = commitAck(__ackEnv);
246
603
  if (!t.contAligned) {
247
- try {
248
- const mapping = await buildMapping(p, key, body.prior, when, ent);
249
- t.contAligned = true;
250
- if (t.contMappingDue?.prior === body.prior)
251
- delete t.contMappingDue;
252
- outbound.push(mapping);
253
- }
254
- catch (err) {
255
- t.contMappingDue = { prior: body.prior, kind: 'align' };
256
- return { handled: true, chained: p.contacts.get(body.prior)?.deactivated === true, name: t.name, outbound, outboundError: String(err?.message ?? err) };
604
+ // ein noch UNQUITTIERTES Alignment wird byte-identisch erneut
605
+ // gesendet (Review 16, B-2: „is resent — same revision,
606
+ // idempotent by 6.4") — eine höhere Record-Revision derselben
607
+ // Wahl prägt NIE eine zweite Alignment-Revision für dieselbe
608
+ // offene Pflicht
609
+ const pending = [...(t.outbox ?? new Map()).values()].find((o) => o.kind === 'continuity-mapping' && o.duty === 'align' && !o.retired);
610
+ if (pending)
611
+ outbound.push({ to: t, kind: 'continuity-mapping/0.1 (Retry, byte-identisch)', env: pending.env });
612
+ else {
613
+ // Produktion registriert in der Outbox; die Pflicht stirbt erst
614
+ // mit dem QUITTIERTEN Dokument (Ack-Pfad setzt contAligned)
615
+ try {
616
+ outbound.push(await buildMapping(p, key, body.prior, when, ent, 'align'));
617
+ if (t.contMappingDue?.prior === body.prior)
618
+ delete t.contMappingDue;
619
+ }
620
+ catch (err) {
621
+ t.contMappingDue = { prior: body.prior, kind: 'align' };
622
+ return done({ outboundError: String(err?.message ?? err) });
623
+ }
257
624
  }
258
625
  }
626
+ const v2Err = await v2Reissue(p, key, when, ent, outbound);
627
+ if (v2Err)
628
+ return done({ outboundError: v2Err });
259
629
  }
260
630
  else {
261
- // Match-Report der Nicht-Record-Seite: darf die Wahl INFORMIEREN, kettet nie
631
+ // Match-Report der Nicht-Record-Seite: darf die Wahl INFORMIEREN,
632
+ // kettet nie selbst. Die Record-Wahl darauf kettet ATOMAR mit der
633
+ // Ausgabe ihres Mappings (Review 12, B-2): erst vorbereiten, dann
634
+ // Wahl + Kette + Ausgabe synchron; Fehlschlag → volle Wahl-Pflicht
262
635
  if (iAmRecord(t, key) && !t.contChosen) {
263
- const mapping = await buildMapping(p, key, body.prior, when, ent);
264
- t.contChosen = body.prior;
265
- chainTuple(p, key, body.prior);
266
- outbound.push(mapping);
636
+ ack = commitAck(__ackEnv); // der EMPFANGS-Effekt (Revision) committet unabhängig von der Wahl
637
+ let prep;
638
+ try {
639
+ prep = await prepareContMapping(p, key, body.prior, when, ent);
640
+ }
641
+ catch (err) {
642
+ t.contMappingDue = { prior: body.prior, kind: 'choice' };
643
+ return done({ outboundError: String(err?.message ?? err) });
644
+ }
645
+ if (t.deactivated || t.contChosen)
646
+ return done();
647
+ // Kette ZUERST (Review 13, B-1): scheitert sie, keine Wahl, keine Ausgabe
648
+ chainedNow = chainTuple(p, key, body.prior, when);
649
+ if (chainedNow) {
650
+ t.contChosen = body.prior;
651
+ if (t.contMappingDue?.kind === 'choice')
652
+ delete t.contMappingDue; // die alte Bau-Pflicht ist ERFÜLLT (Review 14, B-2)
653
+ outbound.push(commitContMapping(prep));
654
+ const v2Err = await v2Reissue(p, key, when, ent, outbound);
655
+ if (v2Err)
656
+ return done({ outboundError: v2Err });
657
+ }
658
+ }
659
+ else {
660
+ // NUR das Gegen-Mapping AUF DER GEWÄHLTEN Beziehung ist die
661
+ // ALIGNMENT-Antwort (Review 14, B-1): body.prior = der eigene
662
+ // alte Anker des Senders = unser Kontaktschlüssel der gewählten
663
+ // alten Beziehung (t.contChosen). Ein Match-Report für eine
664
+ // ANDERE gemeinsame Altbeziehung beantwortet die Wahl NICHT —
665
+ // erst die echte Antwort beendet die Resend-Pflicht (retiren,
666
+ // Ack-Ref bleibt; Review 13, B-2)
667
+ if (t.contChosen && body.prior === t.contChosen)
668
+ for (const o of (t.outbox ?? new Map()).values())
669
+ if (o.kind === 'continuity-mapping')
670
+ o.retired = true;
671
+ ack = commitAck(__ackEnv);
267
672
  }
268
673
  }
269
- return { handled: true, chained: p.contacts.get(body.prior)?.deactivated === true, name: t.name, outbound };
674
+ return done();
270
675
  }
271
676
  /**
272
677
  * The host's producing call for a mapping the receiver still owes
@@ -276,28 +681,103 @@ async function handleMapping(p, { key, t }, doc, when, ent) {
276
681
  */
277
682
  export async function flushContinuity(p, newKey, when, ent = {}) {
278
683
  const t = p.contacts.get(newKey);
279
- if (!t?.contMappingDue)
280
- return { outbound: [] };
281
- // Single-Flight (Review 7, B-3): zwei parallele Flushes liefern nie
282
- // zwei Mappings für EINE Pflicht
283
- if (t.contFlushPending)
284
- return { outbound: [], pending: true };
285
- t.contFlushPending = true;
286
- try {
287
- const due = t.contMappingDue;
288
- const mapping = await buildMapping(p, newKey, due.prior, when, ent);
289
- // der Flush stellt GENAU den Zustand her, den der gelungene
290
- // Direktpfad hinterlassen hätte — kind-bewusst. Und er löscht NUR
291
- // die Pflicht, die er selbst gebaut hat: ist während des await eine
292
- // NEUERE an ihre Stelle getreten, bleibt sie stehen (Review 8, B-3)
293
- if (due.kind === 'align')
294
- t.contAligned = true;
295
- if (t.contMappingDue === due)
296
- delete t.contMappingDue;
297
- return { outbound: [mapping] };
298
- }
299
- finally {
300
- t.contFlushPending = false;
684
+ if (!t || t.deactivated)
685
+ return { outbound: [] }; // Per-Tupel-Pflichten sterben mit dem Tupel (Review 8, B-2)
686
+ // Retry-ENTSCHEIDUNG und Pflicht-Bau laufen unter dem Kontakt-Lock
687
+ // (Review 12, B-1): der Empfang (der Reports retired) hält denselben
688
+ // Lock — kein Fenster, in dem ein gestarteter Flush eine gerade
689
+ // gestorbene Report-Pflicht doch noch ausstellt („a match report
690
+ // flushed after the choice … MUST NOT be sent"). Nur der frische
691
+ // Probe-Neubau läuft danach (buildProbe nimmt denselben Lock selbst).
692
+ const r = await withContactLock(t, async () => {
693
+ if (t.deactivated)
694
+ return { outbound: [] };
695
+ // unquittierte MAPPINGS: byte-identischer Retry (4.2), retired nie
696
+ // (gestorbene Match-Report-Pflicht, Review 11, B-3). PROBEN nie
697
+ // byte-identisch — §6a: „a resend is always a fresh probe sequence
698
+ // with freshly sampled padding" (Review 5, B-3): stale Einträge
699
+ // werden RETIRED (Ack-Ref bleibt, Review 6, B-3), EINE frische
700
+ // Probe ersetzt sie (Bau nach der Lock-Sektion)
701
+ const retries = [...(t.outbox ?? new Map()).values()]
702
+ .filter((o) => o.kind === 'continuity-mapping' && !o.retired)
703
+ .map((o) => ({ to: t, kind: o.kind + '/0.1 (Retry, byte-identisch)', env: o.env }));
704
+ const staleProbes = [...(t.outbox ?? new Map()).values()].filter((o) => o.kind === 'continuity-probe' && !o.retired);
705
+ let probeDue = false;
706
+ if (staleProbes.length) {
707
+ for (const o of staleProbes)
708
+ o.retired = true;
709
+ probeDue = true;
710
+ }
711
+ if (!t.contMappingDue)
712
+ return { outbound: retries, probeDue };
713
+ // Single-Flight (Review 7, B-3): zwei parallele Flushes liefern nie
714
+ // zwei Mappings für EINE Pflicht
715
+ if (t.contFlushPending)
716
+ return { outbound: retries, pending: true, probeDue };
717
+ t.contFlushPending = true;
718
+ try {
719
+ const due = t.contMappingDue;
720
+ // eine Choice-Pflicht nach bereits GEFALLENER Wahl ist tot
721
+ // (Review 14, B-2): der Direktpfad hat sie erfüllt oder ersetzt —
722
+ // sie darf NIE in den generischen Bau fallen (der eine frische
723
+ // Revision prägte statt der Same-Revision-Wiederholung)
724
+ if (due.kind === 'choice' && t.contChosen) {
725
+ if (t.contMappingDue === due)
726
+ delete t.contMappingDue;
727
+ return { outbound: retries, probeDue };
728
+ }
729
+ if (due.kind === 'choice' && !t.contChosen) {
730
+ // die nachgeholte RECORD-WAHL kettet atomar mit der Ausgabe
731
+ // (Review 12, B-2) und ITERIERT alle bekannten Matches
732
+ // (Review 20, B-3): erster lebender, kettbarer Kandidat
733
+ // gewinnt; sind alle tot, stirbt die Pflicht ehrlich
734
+ // ungekettet (spätere Probes wählen neu). Ein Bau-Fehler
735
+ // wirft → der äußere catch hält die Pflicht am Leben.
736
+ const cands = [...new Set([due.prior, ...(due.alts ?? [])])];
737
+ for (const cand of cands) {
738
+ if (p.contacts.get(cand)?.deactivated)
739
+ continue;
740
+ const prep = await prepareContMapping(p, newKey, cand, when, ent);
741
+ if (t.deactivated)
742
+ return { outbound: retries, probeDue };
743
+ if (t.contChosen)
744
+ break;
745
+ if (!chainTuple(p, newKey, cand, when))
746
+ continue; // Rennen verloren → nächster Kandidat
747
+ t.contChosen = cand;
748
+ retries.push(commitContMapping(prep));
749
+ break;
750
+ }
751
+ if (t.contMappingDue === due)
752
+ delete t.contMappingDue;
753
+ return { outbound: retries, probeDue };
754
+ }
755
+ const mapping = await buildMapping(p, newKey, due.prior, when, ent, due.kind);
756
+ // der Flush stellt GENAU den Zustand her, den der gelungene
757
+ // Direktpfad hinterlassen hätte — kind-bewusst. Und er löscht NUR
758
+ // die Pflicht, die er selbst gebaut hat: ist während des await
759
+ // eine NEUERE an ihre Stelle getreten, bleibt sie stehen (Review
760
+ // 8, B-3); aligned setzt erst der ACK — hier stirbt die BAU-Pflicht
761
+ if (t.contMappingDue === due)
762
+ delete t.contMappingDue;
763
+ return { outbound: [...retries, mapping], probeDue };
764
+ }
765
+ catch (err) {
766
+ return { outbound: retries, probeDue, outboundError: String(err?.message ?? err) };
767
+ }
768
+ finally {
769
+ t.contFlushPending = false;
770
+ }
771
+ });
772
+ if (r.probeDue) {
773
+ try {
774
+ r.outbound.push(...(await buildProbe(p, newKey, when, { ...ent, pad: undefined, threadId: undefined }) ?? []));
775
+ }
776
+ catch (err) {
777
+ r.outboundError = r.outboundError ? r.outboundError + ' · ' + String(err?.message ?? err) : String(err?.message ?? err);
778
+ }
301
779
  }
780
+ delete r.probeDue;
781
+ return r;
302
782
  }
303
783
  //# sourceMappingURL=continuity.js.map