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