@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
@@ -1,40 +1,48 @@
1
- // trust.mjs — Stufe 2 des Redesigns: der EXPLIZITE VERTRAUENSAKT
2
- // (aufgeschobene Anker-Offenlegung), der geblendete Stern und die
3
- // Pause-Semantik. PROBE, DOM-frei, auf rltp-core + rltp-crypto.
1
+ // trust — der GRADUIERTE Vertrauensakt: seit dem Nachzug auf Visibility
2
+ // 0.29 + Delivery 0.79 baut dieses Modul die ECHTEN Wire-Artefakte
3
+ // (anchor-mapping@2 · grade-declaration@1 · star@1 mit 5.2a-Chunks),
4
+ // getragen von den registrierten Task-Dokumenten (Delivery §4.4:
5
+ // anchor-mapping/0.1 · grade-declaration/0.1 · star/0.1 — Payload = das
6
+ // Artefakt, Dokument proof-frei, ein Träger), quittiert mit dem
7
+ // deniablen delivery-ack (4.2-Klassenregel). Formen und Schlüssel sind
8
+ // byte-kompatibel zu vectors/visibility.json (vectors3.test.mjs rechnet
9
+ // sie unabhängig nach).
4
10
  //
5
- // DAS MODELL (portiert aus graph-web.mjs auf den WebCrypto-Stack):
6
- // Vertrauensakt = EINSEITIGE Offenlegungsentscheidung. A liefert an B:
7
- // 1. das kreuz-MAC-te anchor-mapping pair→self (Designated Verifier:
8
- // BEIDE MACs könnte auch B selbst berechnen — ein geleaktes
9
- // Mapping beweist Dritten NICHTS; forgeMapping demonstriert das),
10
- // 2. die eigene Self-Card (stabiler Anker + keyAgreement),
11
- // 3. den geblendeten Stern-Schnappschuss.
12
- // B VERIFIZIERT das Mapping mit den eigenen Geheimnissen, bevor die
13
- // Verknüpfung akzeptiert wird. Pausieren stoppt nur KÜNFTIGE
14
- // Lieferungen — Geliefertes bleibt (Einweg-Tür, irreversibles Wissen).
15
- //
16
- // Stern (Publikums-Prinzip, Klasse D): der Sender leitet NIE rohe
17
- // Dritt-Anker weiter. Jeder gehaltene Self-Anker reist als
18
- // HMAC(k, anchor) unter dem Beziehungs-Schlüssel k; der Empfänger kann
19
- // nur Anker testen, die er selbst legitim hält („gemeinsame Kontakte"),
20
- // und sonst nichts. Epochales Blenden: salt = monotone Sequenznummer
21
- // pro Beziehung; Listen SORTIERT (Lieferreihenfolge leakt nichts).
22
- // Ehrlicher Rest: 1-Bit-Orakel über gehaltene Anker + die Zählung.
23
- import { jcs } from '../core.js';
11
+ // Die Zustandsmaschinen sind die konvergierten:
12
+ // · Admissionsschicht (Sektion 2): Promotion-Commit beim ERSTEN
13
+ // verifizierten anchor-mapping@2 einer BEZIEHUNG (relId = die
14
+ // Kette, der Idempotenzschlüssel); genau ein Eintrag je Beziehung,
15
+ // self-Korrektur per höherer Revision in place; gleiche self auf
16
+ // zweiter Beziehung = Weg-2-Merge (früheste Position überlebt,
17
+ // Aliase); EINE totale Verifikations-Commit-Ordnung (a.seq).
18
+ // · Rekonziliation (5.4): Sicht = {(member, grade)} der admittierten,
19
+ // unmaskierten Mitglieder (Maske: 24h grade-wait ab Admission, fällt
20
+ // mit verifizierter Declaration oder Ablauf → fail-closed count);
21
+ // Baseline-Automat monoton über Ack-Completions, highWater überlebt
22
+ // ⊥, kein Failure-Ereignis, nie Rollback.
23
+ // · Byte-identische Redelivery (4.2): jede unquittierte Zustellung
24
+ // wird als DASSELBE Dokument erneut gesendet (outbox hält die Envs);
25
+ // der Empfänger re-ackt duplicate-known byte-identisch (ackStore).
26
+ import { jcs, makeValidator, sameDigest } from '../core.js';
27
+ import { SCHEMAS } from '../schemas.js';
24
28
  import * as C from './deps.js';
29
+ import { buildAck, verifyAck, ACK_TYPE } from './acks.js';
25
30
  const te = new TextEncoder();
26
31
  const S = globalThis.crypto.subtle;
27
32
  const TT = 'https://real-life.org/trust-tasks/';
28
33
  const say = (p, m) => p.log.push(m);
29
34
  const uuid = (ent) => ent ?? globalThis.crypto.randomUUID();
35
+ const V = makeValidator(SCHEMAS);
36
+ const schemaOk = (file, data) => V.validate(data, SCHEMAS[file], SCHEMAS[file]).length === 0;
37
+ export const GRADE_WAIT = 86_400_000; // 5.4: PT24H, in when-Millisekunden
38
+ export const ADMISSION_BOUND = 10n ** 18n - 1n; // Sektion 2 — unerreichbar, geführt für die Totalität
39
+ const CHUNK_MAX = 1024; // 5.2a: blinded[] je Chunk
30
40
  // ── Primitiven ──────────────────────────────────────────────────────────
31
41
  export async function hmac(keyBytes, msg) {
32
42
  const k = await S.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
33
43
  return C.b64uOf(new Uint8Array(await S.sign('HMAC', k, te.encode(msg))));
34
44
  }
35
- // der stabile soziale Anker = COMMUNITY-ANKER (Identity 0.13, S-DID-Schnitt):
36
- // gewöhnlicher Gruppen-Kontext über die Genesis der persönlichen Community —
37
- // nie mehr die zero-input-Recovery-Strings
45
+ export const hmacU = async (keyBytes, msg) => 'u' + await hmac(keyBytes, msg);
38
46
  export async function communityContext(p) {
39
47
  if (!p.selfCtx)
40
48
  p.selfCtx = await C.communityContext(p.rootIkm, p.communityGenesis);
@@ -44,122 +52,394 @@ export const selfCard = async (p, whenIso) => {
44
52
  const s = await communityContext(p);
45
53
  return C.signCard(s, C.cardBody(s, { name: p.name }), whenIso);
46
54
  };
47
- // Beziehungs-Schlüssel: X25519 zwischen den pair-Kontexten des Kanals
55
+ // self-card@1 (§6.2): { body: {type, anchor, keyAgreement}, proof:
56
+ // {proofValue} } — ROHE Ed25519-Signatur über JCS(body), 'z'+base58
57
+ // (§2.1: bewusst KEINE DI-Suite; zwei Artefakt-Familien)
58
+ export async function selfCard1(p) {
59
+ const s = await communityContext(p);
60
+ const body = { type: 'self-card@1', anchor: s.anchor, keyAgreement: s.keyAgreement };
61
+ const sig = new Uint8Array(await S.sign({ name: 'Ed25519' }, s.ed.priv, te.encode(jcs(body))));
62
+ return { body, proof: { proofValue: 'z' + C.base58(sig) } };
63
+ }
64
+ const verifyRawSig = async (did, body, proofValue) => {
65
+ try {
66
+ if (typeof proofValue !== 'string' || proofValue[0] !== 'z')
67
+ return false;
68
+ const raw = C.edRawOfAnchor(did);
69
+ const sig = C.fromBase58(proofValue.slice(1));
70
+ if (!raw || !sig || sig.length !== 64)
71
+ return false;
72
+ const key = await S.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']);
73
+ return await S.verify({ name: 'Ed25519' }, key, sig, te.encode(jcs(body)));
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ };
48
79
  const chShared = (contact) => C.ecdh(contact.channel.own.x.priv, C.xRawOfMk(contact.channel.counterpartKa));
49
80
  const relKey = async (contact, info) => C.hkdf(await chShared(contact), info);
50
- // ── anchor-mapping@2 (Designated Verifier, Doppel-MAC) ──────────────────
51
- // mac1: Kanal-DH (pairA × pairB) · mac2: selfA × pairB — der Empfänger
52
- // rechnet mac2 mit SEINEM pair-Privkey gegen die Self-Card nach. Beide
53
- // Schlüssel kann auch der Empfänger allein ableiten: Abstreitbarkeit.
54
- const mappingBody = (pairAnchor, selfAnchor, toPairAnchor, whenIso) => ({ type: 'anchor-mapping@2', pair: pairAnchor, self: selfAnchor, to: toPairAnchor, issuedAt: whenIso });
55
- export async function makeMapping(p, counterpartAnchor, when) {
56
- const contact = p.contacts.get(counterpartAnchor);
81
+ // ── anchor-mapping@2 — die normative Wire-Form (§6.1–6.3) ──────────────
82
+ // body = { type, pair, self, to, card, revision, issuedAt } ·
83
+ // proof = { mac1 (Kanal-DH, info map1) · mac2 (selfX × pairX-Adressat,
84
+ // info map2) }, beide 'u'-MACs über JCS(body). Die Card reist IM Body.
85
+ // der reine BAU (fehlbar, mutiert nichts) — modul-lokal: nur Aufrufer,
86
+ // die den Issue-Lock halten und selbst committen (setTrust/
87
+ // reissueTrust), erreichen ihn mit vorreservierter Revision. Der
88
+ // explizite Revisionspfad ist NICHT mehr exportiert (Review 9, B-2:
89
+ // er umging Lock, Deaktivierungs-Check und Persistenz).
90
+ async function buildMappingArtifact(p, contact, counterpartAnchor, when, revision) {
57
91
  const s = await communityContext(p);
58
92
  const whenIso = C.iso(when);
59
- const card = await selfCard(p, whenIso);
60
- const body = mappingBody(contact.channel.own.anchor, s.anchor, counterpartAnchor, whenIso);
93
+ const card = await selfCard1(p);
94
+ const body = { type: 'anchor-mapping@2', pair: contact.channel.own.anchor, self: s.anchor, to: counterpartAnchor, card, revision, issuedAt: whenIso };
61
95
  const msg = jcs(body);
62
96
  const theirKa = C.xRawOfMk(contact.channel.counterpartKa);
63
- return { body, card,
64
- mac1: await hmac(await relKey(contact, 'rltp/trust/mac/map1'), msg),
65
- mac2: await hmac(await C.hkdf(await C.ecdh(s.x.priv, theirKa), 'rltp/trust/mac/map2'), msg) };
97
+ return { body, proof: {
98
+ mac1: await hmacU(await relKey(contact, 'rltp/visibility/mac/map1'), msg),
99
+ mac2: await hmacU(await C.hkdf(await C.ecdh(s.x.priv, theirKa), 'rltp/visibility/mac/map2'), msg)
100
+ } };
101
+ }
102
+ export async function makeMapping(p, counterpartAnchor, when) {
103
+ const contact = p.contacts.get(counterpartAnchor);
104
+ // Ausstellungs-Disziplin (Review 8/9): Peek → fehlbar bauen →
105
+ // Zähler UND Body synchron committen (sentMapping = der persistierte
106
+ // ausgestellte Body, §6.4: one step per scope), seriell je Kontakt;
107
+ // ein deaktiviertes Tupel stellt nichts aus
108
+ return withIssueLock(contact, async () => {
109
+ if (contact.deactivated)
110
+ throw new Error('tuple deactivated — issues nothing (§6.4)');
111
+ const revision = peekRev(contact, 'mapRevOut');
112
+ const m = await buildMappingArtifact(p, contact, counterpartAnchor, when, revision);
113
+ if (contact.deactivated)
114
+ throw new Error('tuple deactivated — issues nothing (§6.4)');
115
+ contact.mapRevOut = revision;
116
+ contact.sentMapping = m;
117
+ return m;
118
+ });
66
119
  }
67
- // Verifikation ist EMPFÄNGER-PRIVAT: sie BRAUCHT die eigenen Geheimnisse
68
- // (das ist der Punkt — ein Dritter kann nicht einmal die Gültigkeit prüfen)
69
- export async function verifyMapping(p, m) {
120
+ // Verifikation ist EMPFÄNGER-PRIVAT (§6.3, geschlossene Liste) — und
121
+ // die Liste wird IN IHRER REIHENFOLGE ausgewertet (Review 19, B-1:
122
+ // „all of the following hold, evaluated in this order"): 1 Schema ·
123
+ // 2 to · 3 pair · 4 Card verifiziert unter IHREM Anker · 5
124
+ // card.anchor == self · 6 k2 aus card.keyAgreement · 7 MACs.
125
+ // arrivalKey = das Ankunftstupel (Empfangspfad); standalone wählt
126
+ // body.pair (Schritt 3 dann trivial).
127
+ export async function verifyMapping(p, m, arrivalKey) {
70
128
  try {
71
- const b = m?.body;
72
- if (!b || b.type !== 'anchor-mapping@2')
73
- return false;
74
- const entry = p.contacts.get(b.pair);
75
- if (!entry?.channel?.own)
129
+ if (!schemaOk('visibility-anchor-mapping.schema.json', m))
130
+ return false; // 1
131
+ const b = m.body;
132
+ if (!C.calOK(b.issuedAt))
133
+ return false; // Kalender-Validität gehört zur Parse-Ebene (Review 12, B-3)
134
+ const key = arrivalKey ?? b.pair;
135
+ const entry = p.contacts.get(key);
136
+ if (!entry?.channel?.own || entry.deactivated)
76
137
  return false;
77
138
  if (b.to !== entry.channel.own.anchor)
78
- return false;
79
- const card = m.card;
80
- if (card?.anchor !== b.self || !(await C.diVerify(card, b.self)))
81
- return false;
139
+ return false; // 2
140
+ if (b.pair !== key)
141
+ return false; // 3 (Cross-Tupel-Bindung, Review 9)
142
+ if (!(await verifyRawSig(b.card?.body?.anchor, b.card?.body, b.card?.proof?.proofValue)))
143
+ return false; // 4: unter IHREM Anker
144
+ if (b.card.body.anchor !== b.self)
145
+ return false; // 5
82
146
  const msg = jcs(b);
83
- if ((await hmac(await relKey(entry, 'rltp/trust/mac/map1'), msg)) !== m.mac1)
84
- return false;
85
- const k2 = await C.hkdf(await C.ecdh(entry.channel.own.x.priv, C.xRawOfMk(card.keyAgreement)), 'rltp/trust/mac/map2');
86
- if ((await hmac(k2, msg)) !== m.mac2)
87
- return false;
147
+ const k2 = await C.hkdf(await C.ecdh(entry.channel.own.x.priv, C.xRawOfMk(b.card.body.keyAgreement)), 'rltp/visibility/mac/map2'); // 6: k2 aus card.keyAgreement — VOR den MACs (Review 20, B-1)
148
+ if ((await hmacU(await relKey(entry, 'rltp/visibility/mac/map1'), msg)) !== m.proof.mac1)
149
+ return false; // 7 (mac1)
150
+ if ((await hmacU(k2, msg)) !== m.proof.mac2)
151
+ return false; // 7 (mac2)
88
152
  return true;
89
153
  }
90
154
  catch {
91
155
  return false;
92
156
  }
93
157
  }
94
- // die Abstreitbarkeits-Demo: der EMPFÄNGER fabriziert ein Mapping, das
95
- // einen beliebigen pair-Anker an ein beliebiges Self bindet, dessen Card
96
- // er hält — es verifiziert identisch. Genau darum überzeugt ein
97
- // geleaktes Mapping niemanden.
158
+ // Abstreitbarkeits-Demo: der Empfänger fabriziert ein identisch
159
+ // verifizierendes Mapping — ein Leak beweist Dritten nichts (Klasse V)
98
160
  export async function forgeMapping(forger, victimCard, pairAnchor, when) {
99
161
  const entry = forger.contacts.get(pairAnchor);
100
162
  const whenIso = C.iso(when);
101
- const body = mappingBody(pairAnchor, victimCard.anchor, entry.channel.own.anchor, whenIso);
163
+ const body = { type: 'anchor-mapping@2', pair: pairAnchor, self: victimCard.body.anchor, to: entry.channel.own.anchor, card: victimCard, revision: '1', issuedAt: whenIso };
102
164
  const msg = jcs(body);
103
- const k2 = await C.hkdf(await C.ecdh(entry.channel.own.x.priv, C.xRawOfMk(victimCard.keyAgreement)), 'rltp/trust/mac/map2');
104
- return { body, card: victimCard,
105
- mac1: await hmac(await relKey(entry, 'rltp/trust/mac/map1'), msg),
106
- mac2: await hmac(k2, msg) };
165
+ const k2 = await C.hkdf(await C.ecdh(entry.channel.own.x.priv, C.xRawOfMk(victimCard.body.keyAgreement)), 'rltp/visibility/mac/map2');
166
+ return { body, proof: {
167
+ mac1: await hmacU(await relKey(entry, 'rltp/visibility/mac/map1'), msg),
168
+ mac2: await hmacU(k2, msg)
169
+ } };
107
170
  }
108
- // ── der geblendete Stern ────────────────────────────────────────────────
109
- // Sequenz-Reservierung: SYNCHRON vor jedem await, damit zwei
110
- // nebenläufige Aufrufe nie denselben Salt ziehen (Review 6, B-1).
111
- // Ein bei Fehlschlag verbrannter Salt ist erlaubt — die Sequenz ist
112
- // strikt monoton, nicht lückenlos.
113
- const reserveStarSeq = (contact) => {
114
- contact.starSeqNext = Math.max(contact.starSeqNext ?? 0, contact.starSeq ?? 0) + 1;
115
- return contact.starSeqNext;
171
+ // ── die Admissionsschicht (Sektion 2) ───────────────────────────────────
172
+ const relIdOf = (p, contactKey) => {
173
+ const c = p.contacts.get(contactKey);
174
+ return c ? (c.relId ??= contactKey) : contactKey;
175
+ };
176
+ // Bound testbar: p.admissionBound (BigInt) überschreibt den
177
+ // unerreichbaren Spec-Bound — die MECHANIK (pending, k−1, Departure)
178
+ // ist dieselbe (Sektion 2, „the rule exists for totality")
179
+ const admission = (p) => (p.admission ??= { seq: 0n, byRel: new Map(), admitted: 0n });
180
+ const boundOf = (p) => p.admissionBound ?? ADMISSION_BOUND;
181
+ // grade-wait läuft nur ab, wenn ein Admissionszeitpunkt EXISTIERT
182
+ // (Review 5, MAJOR): jede Zustandsoperation verlangt eine endliche Uhr
183
+ const finite = (when) => {
184
+ if (!Number.isFinite(when))
185
+ throw new Error('admission requires a finite clock (5.4 grade-wait)');
186
+ return when;
116
187
  };
117
- const commitStar = (contact, seq, snap) => {
118
- // das Journal hält NUR den High-Water-Stern: ein verspätet fertig
119
- // gewordener kleinerer Salt rollt sentStar nicht zurück (Review 7, B-2)
120
- if (seq > (contact.starSeq ?? 0)) {
121
- contact.starSeq = seq;
122
- contact.sentStar = snap;
188
+ // Scope-Preflight (Review 5, B-5): prüft OHNE zu verbrauchen — ein Akt,
189
+ // der in einem erschöpften Scope ausstellen müsste, suspendiert atomar
190
+ const scopeOk = (cur) => BigInt(cur ?? '0') < ADMISSION_BOUND;
191
+ // EIN atomarer Merge zweier Einträge: früheste Position überlebt,
192
+ // Aliase lösen auf, Grade nach commitOrder; k admitted → k−1 Slots
193
+ // frei → die nächsten Pending admittieren (Set-Änderung via reconcile)
194
+ function mergeEntries(p, a, survivorIn, goneIn, when) {
195
+ const survivor = goneIn.pos < survivorIn.pos ? goneIn : survivorIn;
196
+ const gone = survivor === goneIn ? survivorIn : goneIn;
197
+ for (const rid of gone.relIds) {
198
+ survivor.relIds.add(rid);
199
+ a.byRel.set(rid, survivor);
200
+ }
201
+ for (const ck of gone.rels)
202
+ survivor.rels.add(ck);
203
+ // Declarations bleiben je Herkunftstupel (B-2): höchste order je Tupel
204
+ for (const [ck, g] of gone.grades ?? []) {
205
+ const cur = survivor.grades?.get(ck);
206
+ if (!cur || g.order > cur.order)
207
+ (survivor.grades ??= new Map()).set(ck, g);
208
+ }
209
+ // NUMERISCHES Minimum (Review 22): admittedAt ist eine Zahl — der
210
+ // Default-Sort verglich Strings (9 > 10) und konnte den grade-wait
211
+ // des verschmolzenen Members fälschlich verlängern
212
+ survivor.admittedAt = [survivor.admittedAt, gone.admittedAt].filter((x) => x !== undefined).sort((x, y) => x - y)[0];
213
+ if (survivor.status === 'admitted' && gone.status === 'admitted') {
214
+ a.admitted -= 1n;
215
+ admitPending(p, a, when);
216
+ }
217
+ else if (gone.status === 'admitted') {
218
+ survivor.status = 'admitted';
219
+ }
220
+ return survivor;
221
+ }
222
+ const admitPending = (p, a, when) => {
223
+ // k−1-Nachrücken in Promotion-Commit-Ordnung (Sektion 2)
224
+ while (a.admitted < boundOf(p)) {
225
+ const next = [...new Set(a.byRel.values())].filter((x) => x.status === 'pending').sort((x, y) => x.pos < y.pos ? -1 : 1)[0];
226
+ if (!next)
227
+ break;
228
+ next.status = 'admitted';
229
+ next.admittedAt = when;
230
+ a.admitted += 1n;
123
231
  }
124
232
  };
125
- // Produktion pro Kontakt SERIALISIERT: die Reservierung macht Salts
126
- // eindeutig, erst die Kette macht ihre AUSLIEFERUNG streng steigend —
127
- // der Empfänger verwirft normativ jeden nicht strikt größeren Salt
128
- const withStarLock = (contact, fn) => {
129
- const run = (contact.starLock ?? Promise.resolve()).then(fn, fn);
130
- contact.starLock = run.catch(() => { });
131
- return run;
233
+ /** Departure (Sektion 2): der Abgang EINER Beziehung entfernt nur
234
+ * ihren Alias — „the relationships themselves remain fully active"
235
+ * (Review 16, B-3): der verschmolzene Eintrag (und sein Slot) stirbt
236
+ * erst mit der LETZTEN Beziehung */
237
+ export function departMember(p, relId, when) {
238
+ finite(when);
239
+ const a = admission(p);
240
+ const e = a.byRel.get(relId);
241
+ if (!e)
242
+ return;
243
+ // die KETTE ist EINE Beziehung (Review 35): ihr Abgang entfernt auch
244
+ // die Ketten-Aliase (byRel-Schlüssel, deren Kontakt sich als DIESE
245
+ // Beziehung identifiziert — z. B. der frische Tupelschlüssel nach
246
+ // einer Kettung mit vorbestehendem Eintrag); Aliase ANDERER
247
+ // Beziehungen (Weg-2-Merge) bleiben
248
+ for (const rid of [...e.relIds]) {
249
+ if (rid === relId || (p.contacts.get(rid)?.relId ?? rid) === relId) {
250
+ e.relIds.delete(rid);
251
+ a.byRel.delete(rid);
252
+ }
253
+ }
254
+ e.relIds.delete(relId);
255
+ a.byRel.delete(relId);
256
+ // der Grade-Strang der GEGANGENEN Beziehung stirbt mit ihr (Review
257
+ // 17, B-1): „across the member's LIVE relationships' active heads" —
258
+ // Kontaktschlüssel dieser Beziehung verlassen die effektive
259
+ // Grade-Auswertung atomar mit dem Departure
260
+ for (const ck of [...e.rels]) {
261
+ const c = p.contacts.get(ck);
262
+ if ((c?.relId ?? ck) === relId) {
263
+ e.rels.delete(ck);
264
+ e.grades?.delete(ck);
265
+ }
266
+ }
267
+ if (e.relIds.size > 0)
268
+ return; // Aliase bleiben, der Member bleibt admittiert
269
+ if (e.status === 'admitted') {
270
+ a.admitted -= 1n;
271
+ admitPending(p, a, when);
272
+ }
273
+ }
274
+ export function promotionCommit(p, relId, self, contactKey, when) {
275
+ finite(when);
276
+ const a = admission(p);
277
+ let e = a.byRel.get(relId);
278
+ if (e) {
279
+ // spätere Verifikation derselben Beziehung: in place — auch eine
280
+ // self-KORREKTUR per höherer Revision ändert nur den Inhalt, nie
281
+ // Position oder Status (Sektion 2). Trifft die Korrektur die self
282
+ // einer ANDEREN Beziehung, ist das der Weg-2-Merge
283
+ if (e.self !== self) {
284
+ const twin = [...a.byRel.values()].find((x) => x !== e && x.self === self);
285
+ if (twin) {
286
+ e.self = self;
287
+ e = mergeEntries(p, a, e, twin, when);
288
+ }
289
+ else
290
+ e.self = self;
291
+ }
292
+ e.rels.add(contactKey);
293
+ }
294
+ else {
295
+ // gleiche self auf einer ANDEREN Beziehung = der Weg-2-Merge
296
+ const twin = [...a.byRel.values()].find((x) => x.self === self);
297
+ if (twin) {
298
+ twin.relIds.add(relId);
299
+ twin.rels.add(contactKey);
300
+ a.byRel.set(relId, twin);
301
+ e = twin;
302
+ }
303
+ else {
304
+ // Admission ist TOTAL: unter dem Bound admitted (der Normalfall —
305
+ // die Promotion selbst ist die Admission), am Bound
306
+ // deliverable-pending (triggert nichts, rückt bei Abgang nach)
307
+ const status = a.admitted < boundOf(p) ? 'admitted' : 'pending';
308
+ if (status === 'admitted')
309
+ a.admitted += 1n;
310
+ e = { id: relId, pos: (a.seq += 1n), relIds: new Set([relId]), self, rels: new Set([contactKey]), status, admittedAt: status === 'admitted' ? when : undefined, grades: new Map() }; // id = kanonische Identität (Review 36)
311
+ a.byRel.set(relId, e);
312
+ }
313
+ }
314
+ return e;
315
+ }
316
+ /** Kettung (B-4): der neue Kopf löst über die überlebende Beziehung auf;
317
+ * hält die frische Beziehung schon einen EIGENEN Eintrag, verschmilzt
318
+ * er atomar mit dem der alten (früheste Position, Aliase, k−1) */
319
+ export function chainAdmission(p, survivingRelId, freshRelId, newContactKey, when) {
320
+ finite(when);
321
+ const a = admission(p);
322
+ const oldE = a.byRel.get(survivingRelId);
323
+ const freshE = a.byRel.get(freshRelId);
324
+ let e = oldE;
325
+ if (oldE && freshE && oldE !== freshE)
326
+ e = mergeEntries(p, a, oldE, freshE, when);
327
+ else if (!oldE && freshE) {
328
+ e = freshE;
329
+ freshE.relIds.add(survivingRelId);
330
+ a.byRel.set(survivingRelId, freshE);
331
+ }
332
+ if (e) {
333
+ e.relIds.add(freshRelId);
334
+ a.byRel.set(freshRelId, e);
335
+ e.rels.add(newContactKey);
336
+ }
337
+ return e;
338
+ }
339
+ // Declarations je HERKUNFTSTUPEL (Review 5, B-2): §6.4 — „a deactivated
340
+ // head's declarations are dead with their tuple". order = dieselbe
341
+ // totale Verifikations-Commit-Ordnung wie die Promotions.
342
+ // order stammt aus dem Moment der VERIFIKATION (Review 6, B-2): eine
343
+ // vor dem Mapping verifizierte Declaration trägt ihren Stempel schon
344
+ // und erhält beim späteren Commit KEINEN neuen — sonst schlüge sie
345
+ // nach einem Merge eine echt später verifizierte Declaration
346
+ const declCommit = (p, relId, grade, contactKey, order) => {
347
+ const e = admission(p).byRel.get(relId);
348
+ if (e)
349
+ (e.grades ??= new Map()).set(contactKey, { grade, order: order ?? (admission(p).seq += 1n) });
350
+ return e;
132
351
  };
133
- // reine Berechnung — mutiert NICHTS; Sequenz kommt aus reserveStarSeq
134
- async function buildStarPure(p, contact, seq) {
135
- const salt = String(seq);
136
- const k = await relKey(contact, 'rltp/trust/blind/star/' + salt);
137
- const blinded = [];
138
- let count = 0;
139
- for (const e of p.contacts.values()) {
140
- if (!e.selfAnchor)
352
+ // die effektive Grade beim LESEN über die aktiven Köpfe berechnet —
353
+ // Deaktivierung tötet die Declaration ohne Migrations- oder Aufräumlogik
354
+ const effectiveGrade = (p, e) => {
355
+ let best = null;
356
+ for (const [ck, g] of e.grades ?? []) {
357
+ const c = p.contacts.get(ck);
358
+ if (!c || c.deactivated)
359
+ continue;
360
+ if (!best || g.order > best.order)
361
+ best = g;
362
+ }
363
+ return best;
364
+ };
365
+ /**
366
+ * Die Rekonziliations-Sicht (5.4): {(member, grade)} der admittierten,
367
+ * unmaskierten Mitglieder. Maske: keine verifizierte Declaration UND
368
+ * grade-wait (24h ab Admission) offen → nicht in der Sicht; nach
369
+ * Ablauf fail-closed 'count'.
370
+ */
371
+ export function viewOf(p, when) {
372
+ const seen = new Set();
373
+ const out = [];
374
+ for (const e of admission(p).byRel.values()) {
375
+ if (seen.has(e))
141
376
  continue;
142
- count++;
143
- blinded.push(await hmac(k, e.selfAnchor));
377
+ seen.add(e);
378
+ if (e.status !== 'admitted')
379
+ continue; // deliverable-pending triggert nichts (Sektion 2)
380
+ const g = effectiveGrade(p, e); // nur AKTIVE Köpfe zählen (B-2)
381
+ if (g)
382
+ out.push({ member: e.self, grade: g.grade });
383
+ // Grenzzeitpunkt: „the window elapses only strictly afterwards" —
384
+ // bei exakter Gleichheit bleibt maskiert (Review 11, B-4)
385
+ else if (e.admittedAt === undefined || when <= e.admittedAt + GRADE_WAIT)
386
+ continue; // provisorisch maskiert (B-3)
387
+ else
388
+ out.push({ member: e.self, grade: 'count' });
144
389
  }
390
+ return out.sort((x, y) => x.member < y.member ? -1 : x.member > y.member ? 1 : 0);
391
+ }
392
+ // ── star@1 — die normative Wire-Form (5.2/5.2a) ────────────────────────
393
+ const kStar = (contact, counterpartAnchor, salt) => relKey(contact, `rltp/visibility/blind/star/${contact.channel.own.anchor}/${counterpartAnchor}/${salt}`);
394
+ const kStarIn = (contact, counterpartAnchor, salt) => relKey(contact, `rltp/visibility/blind/star/${counterpartAnchor}/${contact.channel.own.anchor}/${salt}`);
395
+ // reine Berechnung: die 5.2a-Chunk-Serie einer Zustellung — count zählt
396
+ // ALLE Mitglieder der Sicht, blinded[] nur die blinded-graded Teilmenge
397
+ async function buildStarChunks(contact, counterpartAnchor, salt, view) {
398
+ const k = await kStar(contact, counterpartAnchor, salt);
399
+ const blinded = [];
400
+ for (const v of view)
401
+ if (v.grade === 'blinded')
402
+ blinded.push(await hmacU(k, v.member));
145
403
  blinded.sort();
146
- return { salt, count, blinded };
404
+ const count = String(view.length);
405
+ const n = Math.max(1, Math.ceil(blinded.length / CHUNK_MAX));
406
+ const chunks = [];
407
+ for (let i = 0; i < n; i++) {
408
+ const body = { type: 'star@1', salt, seq: String(i + 1), last: i === n - 1, count, blinded: blinded.slice(i * CHUNK_MAX, (i + 1) * CHUNK_MAX) };
409
+ chunks.push({ body, proof: { mac: await hmacU(k, jcs(body)) } });
410
+ }
411
+ return chunks;
147
412
  }
148
- export function buildStar(p, contact) {
413
+ export async function buildStar(p, contact, when) {
414
+ if (contact.deactivated)
415
+ return null; // deaktivierte Tupel stellen nichts aus (Review 9, B-3)
149
416
  return withStarLock(contact, async () => {
150
- const seq = reserveStarSeq(contact);
151
- const snap = await buildStarPure(p, contact, seq);
152
- commitStar(contact, seq, snap); // Sender-Journal: was ich hier zuletzt geliefert habe
153
- return snap;
417
+ if (contact.deactivated)
418
+ return null; // Re-Check nach dem Lock-Erwerb
419
+ const salt = peekStarSeq(contact); // nur gepeekt (Review 25)
420
+ const key = [...p.contacts.entries()].find(([, c]) => c === contact)?.[0];
421
+ // dieselbe Commit-Revalidierung wie reconcile (Review 26): wanderte
422
+ // die Sicht während der MAC-Awaits, wird mit der aktuellen neu
423
+ // gebaut — „every delivery carries the set current at its
424
+ // production commit"
425
+ for (;;) {
426
+ const view = viewOf(p, when);
427
+ const chunks = await buildStarChunks(contact, key, salt, view);
428
+ if (contact.deactivated)
429
+ return null; // Re-Check nach den MAC-Awaits (Review 10) — danach kein await
430
+ if (!sameView(view, viewOf(p, when)))
431
+ continue;
432
+ contact.starSeqNext = salt; // COMMIT synchron mit der Ausgabe
433
+ return chunks[0].body;
434
+ }
154
435
  });
155
436
  }
156
437
  // Empfängerseite: denselben Lieferschlüssel nachrechnen, EINEN Anker testen
157
438
  export async function starMatch(p, entry, snap, anchor) {
158
- const k = await relKey(entry, 'rltp/trust/blind/star/' + snap.salt);
159
- return snap.blinded.includes(await hmac(k, anchor));
439
+ const key = [...p.contacts.entries()].find(([, c]) => c === entry)?.[0];
440
+ const k = await kStarIn(entry, key, snap.salt);
441
+ return snap.blinded.includes(await hmacU(k, anchor));
160
442
  }
161
- // Cache für die synchrone UI: pro Kontakt { count, knownNames } aus dem
162
- // empfangenen Schnappschuss gegen die EIGENEN gehaltenen Self-Anker.
163
443
  export async function refreshStarInfo(p) {
164
444
  for (const [key, entry] of p.contacts) {
165
445
  if (!entry.starReceived || !entry.channel?.own)
@@ -173,60 +453,264 @@ export async function refreshStarInfo(p) {
173
453
  if (await starMatch(p, entry, entry.starReceived, e.selfAnchor))
174
454
  known.push(e.name);
175
455
  }
176
- entry.starInfo = { count: entry.starReceived.count, knownNames: known.sort() };
456
+ entry.starInfo = { count: entry.starReceived.count, knownNames: known.sort() }; // count bleibt String — 18 Stellen exakt
177
457
  }
178
458
  }
179
- // ── der Vertrauensakt ───────────────────────────────────────────────────
180
- // Erlaubt für ✓ (Zeremonie) UND ⇄ (vorgestellt, beidseitig) — Vertrauen
181
- // ist eine OFFENLEGUNGS-Entscheidung, keine Verifikations-Behauptung
182
- // (Antons Entscheid 24.08.); die UI benennt bei ⇄ ehrlich die
183
- // Vermittler-Abhängigkeit. Gesperrt bleibt ◇: einseitig hast du nichts
184
- // freigegeben — es existiert kein eigener Kanal-Kontext für das Mapping.
185
- // Einweg-Tür: trustGiven bleibt; Pausieren stoppt nur das Abo.
459
+ // ── grade-declaration@1 (5.5) ──────────────────────────────────────────
460
+ const kGrade = (subjectCtx, holderKa, subjectAnchor, holderAnchor) => C.ecdh(subjectCtx.x.priv, C.xRawOfMk(holderKa)).then((sh) => C.hkdf(sh, `rltp/visibility/mac/grade/${subjectAnchor}/${holderAnchor}`));
461
+ // revision IMMER vorreserviert (gepeekt) vom Aufrufer, der den
462
+ // Issue-Lock hält und den Zähler synchron committet (Review 8, B-1:
463
+ // kein Defaultpfad, der vor fehlbarer Arbeit verbraucht)
464
+ async function buildGradeDecl(p, counterpartAnchor, grade, when, revision) {
465
+ const contact = p.contacts.get(counterpartAnchor);
466
+ const body = { type: 'grade-declaration@1', subject: contact.channel.own.anchor, holder: counterpartAnchor, grade, revision, issuedAt: C.iso(when) };
467
+ const k = await kGrade(contact.channel.own, contact.channel.counterpartKa, body.subject, body.holder);
468
+ return { body, proof: { mac: await hmacU(k, jcs(body)) } };
469
+ }
470
+ // ── Dokumente, Outbox, byte-identische Redelivery (Delivery §3/4.2/4.4) ─
471
+ // Payload = das Artefakt selbst; das Dokument trägt KEINEN Proof (die
472
+ // Artefakt-MACs sind der eine Träger). threadId frisch je Zustellung;
473
+ // Chunks eines Salts teilen sie. Die outbox hält jede unquittierte
474
+ // Zustellung als DENSELBEN Env — Redelivery ist byte-identisch (4.2).
475
+ const outboxOf = (contact) => (contact.outbox ??= new Map());
476
+ async function sendDoc(p, counterpartAnchor, slug, payload, when, threadId, meta = {}, ent = {}) {
477
+ const contact = p.contacts.get(counterpartAnchor);
478
+ const doc = {
479
+ id: uuid(ent.id), type: TT + slug + '/0.1',
480
+ issuer: contact.channel.own.anchor, recipient: counterpartAnchor,
481
+ threadId, issuedAt: C.iso(when), payload,
482
+ };
483
+ const env = await C.seal(doc, contact.channel.counterpartKa, ent);
484
+ const digest = await C.digestDoc(doc);
485
+ outboxOf(contact).set(digest, { env, threadId, kind: slug, ...meta });
486
+ return { to: contact, kind: slug + '/0.1', env };
487
+ }
488
+ // ── die Ausstellungs-Disziplin (Review 7, B-1/B-2) ─────────────────────
489
+ // „Assigning a revision and persisting the issued body are one step per
490
+ // scope" (§6.4): je Kontakt serialisiert EIN Issue-Lock alle
491
+ // Revisions-Producer; die Revision wird nur GEPEEKT, alles Fehlbare
492
+ // (Bau, Seal, Digest) läuft davor, und Zähler + Outbox + Zustand
493
+ // committen in EINEM synchronen Zug am Ende — ein Fehlschlag
494
+ // verbraucht NICHTS, und gradeOutLast folgt der Ausstellungsordnung.
495
+ export const withIssueLock = (contact, fn) => {
496
+ const run = (contact.issueLock ?? Promise.resolve()).then(fn, fn);
497
+ contact.issueLock = run.catch(() => { });
498
+ return run;
499
+ };
500
+ export const peekRev = (contact, field) => {
501
+ const next = BigInt(contact[field] ?? '0') + 1n;
502
+ if (next > ADMISSION_BOUND)
503
+ throw new Error('scope exhausted — issues nothing further (2.1)');
504
+ return String(next);
505
+ };
506
+ // prepare (fehlbar: Seal + Digest) / commit (synchron: Outbox-Eintrag)
507
+ async function prepareDoc(p, counterpartAnchor, slug, payload, when, threadId, meta = {}, ent = {}) {
508
+ const contact = p.contacts.get(counterpartAnchor);
509
+ const doc = {
510
+ id: uuid(ent.id), type: TT + slug + '/0.1',
511
+ issuer: contact.channel.own.anchor, recipient: counterpartAnchor,
512
+ threadId, issuedAt: C.iso(when), payload,
513
+ };
514
+ const env = await C.seal(doc, contact.channel.counterpartKa, ent);
515
+ const digest = await C.digestDoc(doc);
516
+ return { contact, digest, entry: { env, threadId, kind: slug, ...meta }, out: { to: contact, kind: slug + '/0.1', env } };
517
+ }
518
+ const commitDoc = (prep) => { outboxOf(prep.contact).set(prep.digest, prep.entry); return prep.out; };
519
+ // Salt nur GEPEEKT (Review 25): strikt monoton, nie dicht (5.2) —
520
+ // aber „taking the set snapshot, assigning the salt, and committing
521
+ // are one step": ein Bau-Fehler verbraucht nichts, auch am letzten
522
+ // Wert entsteht nie falsche Erschöpfung. Der Aufrufer committet
523
+ // synchron mit der Persistenz (unter dem Star-Lock serialisiert).
524
+ const peekStarSeq = (contact) => {
525
+ const next = BigInt(contact.starSeqNext ?? '0') + 1n;
526
+ if (next > ADMISSION_BOUND)
527
+ throw new Error('scope exhausted — issues nothing further (2.1)');
528
+ return String(next);
529
+ };
530
+ // EIN Lock je Kontakt für Produktion UND Empfang, geteilt mit dem
531
+ // Continuity-Pfad (Review 4, B-1/B-2: Trust- und Continuity-Effekte
532
+ // desselben Kontakts serialisieren gemeinsam; Reentry-Checks im Lock)
533
+ export const withContactLock = (contact, fn) => {
534
+ const run = (contact.contLock ?? Promise.resolve()).then(fn, fn);
535
+ contact.contLock = run.catch(() => { });
536
+ return run;
537
+ };
538
+ const withStarLock = withContactLock;
539
+ // der Baseline-Automat (5.4, totale Tabelle): completion(salt), sobald
540
+ // JEDER Chunk-Digest des Salts quittiert ist
541
+ const subOf = (contact) => (contact.sub ??= { highWater: 0n, baseline: null, inFlight: new Map() });
542
+ const sameView = (a, b) => a.length === b.length && a.every((x, i) => x.member === b[i].member && x.grade === b[i].grade);
543
+ /**
544
+ * reconcile (5.4 — die eine Regel): divergiert die Sicht von der
545
+ * Baseline (⊥ = divergent, das New-Head-Korollar), und ist nicht
546
+ * pausiert, wird der AKTUELLE Stand geliefert — an JEDEM weiteren
547
+ * Delivery-Kontakt unter frischem, höherem Salt (5.4 wörtlich,
548
+ * Review 23; Salts sind ausdrücklich nicht dicht). Es gibt keinen
549
+ * Same-View-Retry; die 4.4-Byte-Identität ist eine Formvorschrift
550
+ * für Chunk-Retries, keine Anordnung.
551
+ */
552
+ export async function reconcile(p, counterpartAnchor, when, ent = {}) {
553
+ const contact = p.contacts.get(counterpartAnchor);
554
+ if (!contact || contact.deactivated || !contact.trustGiven || contact.trustPaused)
555
+ return null;
556
+ return withStarLock(contact, async () => {
557
+ // Re-Check nach dem Lock-Erwerb (Review 9 B-3 / Review 11 B-1):
558
+ // auch die Pause — „while paused, the delivery duty is suppressed"
559
+ if (contact.deactivated || contact.trustPaused)
560
+ return null;
561
+ let view = viewOf(p, when);
562
+ const s = subOf(contact);
563
+ if (s.baseline && sameView(s.baseline, view))
564
+ return null;
565
+ // überholte In-Flight-Salts (≤ highWater) sind tot: der Empfänger
566
+ // MUSS sie ablehnen — purgen, nie wiederverwenden (Review 2, B-7)
567
+ for (const [salt, f] of [...s.inFlight]) {
568
+ if (BigInt(salt) <= s.highWater) {
569
+ for (const d of f.pending.keys())
570
+ outboxOf(contact).delete(d);
571
+ s.inFlight.delete(salt);
572
+ }
573
+ }
574
+ // KEIN Same-View-Retry (Review 23, B-1): 5.4 wörtlich — „a
575
+ // delivery whose chunks are never all acknowledged … simply never
576
+ // completes … and the next available delivery contact ships the
577
+ // current view under a fresh, higher salt." Die 4.4-Byte-
578
+ // Identitäts-Regel ist eine FORMVORSCHRIFT für den Fall eines
579
+ // Chunk-Retries, keine Anordnung eines solchen — jeder weitere
580
+ // Delivery-Kontakt prägt frisch und höher (Salts sind ausdrücklich
581
+ // nicht dicht). So löst der Sender auch ein beim Empfänger
582
+ // vergiftetes in-flight-Salt ab, ohne je vom Gift zu wissen; alte
583
+ // Ack-Referenzen bleiben bis zur Completion-Purge sicher stehen.
584
+ const salt = peekStarSeq(contact); // nur gepeekt (Review 25) — committet synchron mit Outbox + inFlight
585
+ const threadId = ent.threadId ?? globalThis.crypto.randomUUID();
586
+ // „taking the set snapshot, assigning the salt, and committing are
587
+ // one step" (Review 9, B-4): der Bau ist fehlbar und wartet — am
588
+ // Commit wird die Sicht SYNCHRON neu verglichen; wanderte sie
589
+ // während des Baus (Promotion/Declaration eines ANDEREN Kontakts),
590
+ // wird mit der aktuellen Sicht neu gebaut, unter demselben Salt
591
+ // (nichts wurde persistiert). Jede Zustellung trägt so den Stand
592
+ // ihres Production-Commits.
593
+ for (;;) {
594
+ const chunks = await buildStarChunks(contact, counterpartAnchor, salt, view);
595
+ const preps = [];
596
+ for (const chunk of chunks)
597
+ preps.push(await prepareDoc(p, counterpartAnchor, 'star', chunk, when, threadId, { salt }, ent));
598
+ // Re-Check (Review 9 B-3 / Review 11 B-1), danach kein await:
599
+ // eine während des Baus gesetzte Pause supprimiert die Zustellung
600
+ if (contact.deactivated || contact.trustPaused)
601
+ return null;
602
+ const now = viewOf(p, when);
603
+ if (!sameView(view, now)) {
604
+ view = now;
605
+ if (s.baseline && sameView(s.baseline, view))
606
+ return null; // konvergiert während des Baus → Schweigen
607
+ continue;
608
+ }
609
+ // COMMIT — ein synchroner Zug: Salt + Outbox + inFlight + Sicht
610
+ contact.starSeqNext = salt;
611
+ const pending = new Map();
612
+ const envs = [];
613
+ for (const prep of preps) {
614
+ const out = commitDoc(prep);
615
+ pending.set(prep.digest, { env: out.env });
616
+ envs.push(out.env);
617
+ }
618
+ s.inFlight.set(salt, { view, pending, threadId });
619
+ return { to: contact, kind: 'star/0.1', envs, salt };
620
+ }
621
+ });
622
+ }
623
+ // die eigene Grade-ENTSCHEIDUNG trägt einen Ausstellungs-Ordnungsstempel
624
+ // (Review 27): beim Chaining gewinnt die ZULETZT AUSGESTELLTE („MUST
625
+ // re-issue its most recently issued grade"), nie die des zufällig
626
+ // frischeren Kopfs
627
+ export const stampGradeOut = (p, contact, grade) => {
628
+ contact.gradeOutLast = grade;
629
+ contact.gradeOutOrder = (p.gradeIssueSeq = (p.gradeIssueSeq ?? 0n) + 1n);
630
+ };
631
+ // ── der Vertrauensakt (register no. 3: EIN menschlicher Akt) ────────────
632
+ // setTrust stellt aus: anchor-mapping@2 (Offenlegung) + grade-
633
+ // declaration@1 'blinded' (die Wahl) — zwei registrierte Dokumente.
634
+ // Der Stern folgt über reconcile (5.4: die Promotion der Gegenseite
635
+ // erzeugt dort die Divergenz; hier entsteht nur die eigene Sicht).
186
636
  export async function setTrust(p, counterpartAnchor, when, ent = {}) {
187
637
  const contact = p.contacts.get(counterpartAnchor);
188
638
  if (!contact)
189
639
  return { error: 'kein Kontakt' };
190
640
  if (!contact.channel?.own)
191
641
  return { error: 'kein eigener Kanal — einseitig (◇) hast du nichts freigegeben' };
192
- if (contact.trustGiven)
193
- return { error: 'bereits geschenkt (Einweg-Tür)' };
194
- // In-flight-Latch (Review 6, B-1): der Latch fällt SYNCHRON — zwei
195
- // nebenläufige Aufrufe können die offene Tür nicht beide bestehen
196
- if (contact.trustPending)
197
- return { error: 'bereits unterwegs (Einweg-Tür)' };
198
- contact.trustPending = true;
199
- try {
200
- // ALLES Fehlbare — auch die Zeitform (when=NaN wirft) — liegt im
201
- // try, dessen finally den Latch räumt (Review 7, B-1)
202
- const whenIso = C.iso(when);
203
- // die Tür schließt erst, wenn die Disclosure WIRKLICH existiert
204
- // (Review 5, B-5): alles Fehlbare läuft vor den Journal-Mutationen
205
- const mapping = await makeMapping(p, counterpartAnchor, when);
206
- // Stern-Abschnitt unter dem Kontakt-Lock: Reservierung, Bau, Siegel
207
- // und Commit in EINEM geordneten Zug (Review 7, B-2)
208
- const { env, star, seq } = await withStarLock(contact, async () => {
209
- const seq = reserveStarSeq(contact);
210
- const star = await buildStarPure(p, contact, seq);
211
- const body = {
212
- id: uuid(ent.id), type: TT + 'trust-disclosure@probe',
213
- issuer: contact.channel.own.anchor, recipient: counterpartAnchor,
214
- issuedAt: whenIso, payload: { mapping, star },
215
- };
216
- const doc = await C.diSign(contact.channel.own, body, whenIso);
217
- const env = await C.seal(doc, contact.channel.counterpartKa, ent);
218
- commitStar(contact, seq, star);
219
- return { env, star, seq };
220
- });
221
- contact.trustGiven = whenIso;
222
- contact.sentMapping = mapping; // Sender-Journal: die geöffnete Einweg-Tür
223
- say(p, `Vertrauen geschenkt an ${contact.name}: stabiler Anker offengelegt (DV-Mapping) + Kontakt-Updates aktiv`);
224
- return { to: contact, env };
225
- }
226
- finally {
227
- contact.trustPending = false;
228
- }
642
+ // GANZER Akt unter dem Issue-Lock (Review 7, B-1/B-2): Revisionen
643
+ // werden nur gepeekt, alles Fehlbare läuft davor, Zähler + Outbox +
644
+ // Zustand committen synchron am Ende — ein Seal-Fehler verbraucht
645
+ // NICHTS, und kein Producer schiebt sich zwischen Peek und Commit
646
+ return withIssueLock(contact, async () => {
647
+ // ein deaktiviertes Tupel stellt nichts aus (Review 8, B-2 —
648
+ // §6.4/„a chain append is atomic with the deactivation"): geprüft
649
+ // nach dem Lock-Erwerb UND synchron vor dem Commit
650
+ if (contact.deactivated)
651
+ return { error: 'tuple deactivated — Kopf der Beziehung ist gewandert' };
652
+ if (contact.trustGiven)
653
+ return { error: 'bereits geschenkt (Einweg-Tür)' };
654
+ if (contact.trustPending)
655
+ return { error: 'bereits unterwegs (Einweg-Tür)' }; // Latch (Review 6, B-1)
656
+ if (!scopeOk(contact.mapRevOut) || !scopeOk(contact.gradeRevOut))
657
+ return { error: 'scope exhausted — act suspended (2.1)' };
658
+ contact.trustPending = true;
659
+ try {
660
+ const mapRev = peekRev(contact, 'mapRevOut');
661
+ const gradeRev = peekRev(contact, 'gradeRevOut');
662
+ const whenIso = C.iso(when);
663
+ const mapping = await buildMappingArtifact(p, contact, counterpartAnchor, when, mapRev);
664
+ const grade = await buildGradeDecl(p, counterpartAnchor, 'blinded', when, gradeRev);
665
+ const prepM = await prepareDoc(p, counterpartAnchor, 'anchor-mapping', mapping, when, uuid(ent.threadId), {}, ent);
666
+ const prepG = await prepareDoc(p, counterpartAnchor, 'grade-declaration', grade, when, uuid(), {}, ent);
667
+ if (contact.deactivated)
668
+ return { error: 'tuple deactivated — Kopf der Beziehung ist gewandert' }; // Re-Check (B-2), danach kein await
669
+ // COMMIT — ein synchroner Zug (§6.4: assigning + persisting = one step)
670
+ contact.mapRevOut = mapRev;
671
+ contact.gradeRevOut = gradeRev;
672
+ const outbound = [commitDoc(prepM), commitDoc(prepG)];
673
+ contact.trustGiven = whenIso;
674
+ stampGradeOut(p, contact, 'blinded'); // die zuletzt ausgestellte Entscheidung (für V2), mit Ordnungsstempel
675
+ contact.sentMapping = mapping;
676
+ say(p, `Vertrauen geschenkt an ${contact.name}: Anker offengelegt (anchor-mapping@2) + Grade 'blinded' erklärt`);
677
+ return { to: contact, outbound, env: outbound[0].env };
678
+ }
679
+ finally {
680
+ contact.trustPending = false;
681
+ }
682
+ });
683
+ }
684
+ /**
685
+ * setGrade (5.5): die spätere Grade-Entscheidung des Subjekts — auch
686
+ * 'count' (die Wahl gehört dem Kontakt; register no. 3 kollabiert sie
687
+ * nur im Default-UX). Producer der Declaration, Revision monoton.
688
+ */
689
+ export async function setGrade(p, counterpartAnchor, grade, when, ent = {}) {
690
+ const contact = p.contacts.get(counterpartAnchor);
691
+ if (!contact?.channel?.own || contact.deactivated)
692
+ return { error: 'kein aktiver Kanal' };
693
+ return withIssueLock(contact, async () => {
694
+ // Re-Check nach dem Lock-Erwerb (Review 8, B-2): eine Kettung
695
+ // während der Wartezeit deaktiviert dieses Tupel — es stellt nichts
696
+ // mehr aus, gradeOutLast des neuen Kopfs bleibt die Wahrheit
697
+ if (contact.deactivated)
698
+ return { error: 'tuple deactivated — Kopf der Beziehung ist gewandert' };
699
+ const gradeRev = peekRev(contact, 'gradeRevOut'); // wirft bei Erschöpfung, verbraucht nichts (B-1)
700
+ const g = await buildGradeDecl(p, counterpartAnchor, grade, when, gradeRev);
701
+ const prep = await prepareDoc(p, counterpartAnchor, 'grade-declaration', g, when, uuid(), {}, ent);
702
+ if (contact.deactivated)
703
+ return { error: 'tuple deactivated — Kopf der Beziehung ist gewandert' }; // Re-Check (B-2), danach kein await
704
+ // COMMIT — ein synchroner Zug (Review 7, B-1)
705
+ contact.gradeRevOut = gradeRev;
706
+ const out = commitDoc(prep);
707
+ stampGradeOut(p, contact, grade);
708
+ say(p, `Grade '${grade}' erklärt an ${contact.name}`);
709
+ return { to: contact, outbound: [out] };
710
+ });
229
711
  }
712
+ // Pause (5.4): initial unpaused, EINZIGE Transition ist dieser Akt;
713
+ // Unpause re-evaluiert die Divergenz (Producer: starRefreshAll)
230
714
  export function setTrustPaused(p, counterpartAnchor, paused) {
231
715
  const contact = p.contacts.get(counterpartAnchor);
232
716
  if (!contact?.trustGiven)
@@ -234,39 +718,99 @@ export function setTrustPaused(p, counterpartAnchor, paused) {
234
718
  contact.trustPaused = !!paused;
235
719
  say(p, paused ? `Kontakt-Updates an ${contact.name} pausiert (Geliefertes bleibt)` : `Kontakt-Updates an ${contact.name} wieder aktiv`);
236
720
  }
237
- // Bestand geändert (neuer Self-Anker gehalten) → Stern überall auffrischen,
238
- // wo ich vertraue und nicht pausiert habe. Ohne das frieren Sterne in der
239
- // Toggle-Reihenfolge ein (Antons Erstlauf-Befund im Graph-Simulator).
240
721
  /**
241
- * Refresh every deliverable star. Per-recipient resilience (review 5,
242
- * M-1): a failure for ONE recipient never discards the stars already
243
- * built for others — the result carries the sealed outbound plus a
244
- * named failure list. THE CONTRACT: the caller (host application) owns
245
- * redelivery of failed refreshes, e.g. by calling this again; a failed
246
- * refresh is a SEND problem, never grounds to redeliver the inbound
247
- * document (its effect is complete and cached).
722
+ * V2 (5.5): auf dem neuen Kopf stellt das Subjekt seinen zuletzt
723
+ * ausgestellten Grade neu aus — Revision startet im neuen Tupel-Scope,
724
+ * keine Nutzerfrage; das Mapping reist als MAY (6a.4) mit. Die Pause
725
+ * supprimiert dies NICHT (5.5 kennt keine Pause-Ausnahme — sie
726
+ * supprimiert nur die Stern-Delivery); die Pflicht (trustReissueDue)
727
+ * stirbt erst mit der QUITTIERTEN Zustellung der Declaration.
728
+ */
729
+ export async function reissueTrust(p, counterpartAnchor, when, ent = {}) {
730
+ const contact = p.contacts.get(counterpartAnchor);
731
+ // Gate: eine AUSGESTELLTE Declaration ODER der Trust-Akt trägt die
732
+ // Pflicht (Review 33, B-1) — nie nur trustGiven
733
+ if (!contact?.channel?.own || contact.deactivated || (!contact.trustGiven && contact.gradeOutLast === undefined))
734
+ return null;
735
+ // KEINE Pause-Suppression (Review 12, B-4): 5.4 supprimiert die
736
+ // DELIVERY-Pflicht (Stern); die Register-5-Neuausstellung läuft
737
+ // „on the next available delivery contact" ohne Pause-Ausnahme (5.5)
738
+ if (contact.reissuePending)
739
+ return null; // Single-Flight
740
+ contact.reissuePending = true;
741
+ try {
742
+ return await withIssueLock(contact, async () => {
743
+ if (contact.deactivated)
744
+ return null; // Re-Check nach dem Lock-Erwerb (Review 8, B-2)
745
+ // unquittierte Neuausstellung → byte-identischer Retry
746
+ for (const [digest, o] of outboxOf(contact))
747
+ if (o.kind === 'grade-declaration' && o.v2) {
748
+ return { to: contact, outbound: [{ to: contact, kind: 'grade-declaration/0.1 (Retry, byte-identisch)', env: o.env }] };
749
+ }
750
+ // die MUST-Grade hängt NIE am MAY-Mapping (Review 5, B-5): Grade-
751
+ // Scope erschöpft → Pflicht suspendiert (2.1, trustReissueDue
752
+ // bleibt); Revisionen nur gepeekt, Commit synchron (Review 7, B-1)
753
+ if (!scopeOk(contact.gradeRevOut))
754
+ return null;
755
+ const gradeRev = peekRev(contact, 'gradeRevOut');
756
+ const grade = await buildGradeDecl(p, counterpartAnchor, contact.gradeOutLast ?? 'blinded', when, gradeRev);
757
+ const prepG = await prepareDoc(p, counterpartAnchor, 'grade-declaration', grade, when, uuid(), { v2: true }, ent);
758
+ let prepM, mapping, mapRev;
759
+ if (contact.trustGiven && scopeOk(contact.mapRevOut)) { // das MAY-Mapping gehört zum TRUST-Akt (6a.4)
760
+ try {
761
+ mapRev = peekRev(contact, 'mapRevOut');
762
+ mapping = await buildMappingArtifact(p, contact, counterpartAnchor, when, mapRev);
763
+ prepM = await prepareDoc(p, counterpartAnchor, 'anchor-mapping', mapping, when, uuid(), {}, ent);
764
+ }
765
+ catch {
766
+ prepM = undefined; /* MAY (6a.4) — ein Mapping-Fehler suspendiert nur das Mapping, verbraucht nichts */
767
+ }
768
+ }
769
+ if (contact.deactivated)
770
+ return null; // Re-Check (B-2), danach kein await
771
+ // COMMIT — ein synchroner Zug (§6.4)
772
+ contact.gradeRevOut = gradeRev;
773
+ stampGradeOut(p, contact, grade.body.grade); // die Neuausstellung IST eine Ausstellung (Review 27)
774
+ const outbound = [commitDoc(prepG)];
775
+ if (prepM) {
776
+ contact.mapRevOut = mapRev;
777
+ outbound.push(commitDoc(prepM));
778
+ contact.sentMapping = mapping;
779
+ }
780
+ say(p, `Vertrauen auf dem neuen Kopf neu ausgestellt an ${contact.name} (5.5 — ohne neue Nutzerfrage)`);
781
+ return { to: contact, outbound };
782
+ });
783
+ }
784
+ finally {
785
+ contact.reissuePending = false;
786
+ }
787
+ }
788
+ /**
789
+ * Der Producer-Sweep (Host-Vertrag): erst die V2-Pflichten, dann die
790
+ * 5.4-Rekonziliation je Empfänger. Idempotent, per-recipient resilient,
791
+ * Retries byte-identisch.
248
792
  */
249
793
  export async function starRefreshAll(p, when, ent = {}) {
250
794
  const outbound = [];
251
795
  const failures = [];
252
796
  for (const [anchor, contact] of p.contacts) {
253
- if (!contact.trustGiven || contact.trustPaused)
797
+ if (contact.deactivated)
254
798
  continue;
255
799
  try {
256
- const env = await withStarLock(contact, async () => {
257
- const seq = reserveStarSeq(contact);
258
- const star = await buildStarPure(p, contact, seq);
259
- const whenIso = C.iso(when);
260
- const body = {
261
- id: uuid(), type: TT + 'trust-star@probe',
262
- issuer: contact.channel.own.anchor, recipient: anchor,
263
- issuedAt: whenIso, payload: { star },
264
- };
265
- const sealed = await C.seal(await C.diSign(contact.channel.own, body, whenIso), contact.channel.counterpartKa, ent);
266
- commitStar(contact, seq, star);
267
- return sealed;
268
- });
269
- outbound.push({ to: contact, env });
800
+ // die V2-Pflicht läuft AUCH pausiert (Review 12, B-4) und AUCH
801
+ // ohne Trust-Akt (Review 33, B-1: eine ausgestellte Declaration
802
+ // genügt); nur die Stern-Delivery braucht trustGiven
803
+ if (contact.trustReissueDue) {
804
+ const r = await reissueTrust(p, anchor, when, ent);
805
+ if (r)
806
+ outbound.push(...r.outbound);
807
+ }
808
+ if (!contact.trustGiven || contact.trustPaused)
809
+ continue;
810
+ const r = await reconcile(p, anchor, when, ent);
811
+ if (r)
812
+ for (const env of r.envs)
813
+ outbound.push({ to: contact, kind: r.kind, env });
270
814
  }
271
815
  catch (e) {
272
816
  failures.push({ to: contact.name ?? anchor, error: String(e?.message ?? e) });
@@ -274,72 +818,416 @@ export async function starRefreshAll(p, when, ent = {}) {
274
818
  }
275
819
  return Object.assign(outbound, { failures });
276
820
  }
277
- // ── Empfangs-Dispatch (Form wie groups.receiveDoc) ──────────────────────
821
+ // ── Empfang: registrierte Dokumente, Schema-validiert, atomar geackt ────
822
+ const asmOf = (contact) => contact.asm;
278
823
  export async function receiveTrustDoc(p, env, when, ent = {}) {
279
- const opened = await C.openEnvelope(p, env); // Stufen 1–4, Cache-Lesung
280
- if (opened.duplicate)
281
- return { handled: true, duplicate: true };
824
+ const opened = await C.openEnvelope(p, env);
282
825
  if (opened.error)
283
826
  return { handled: false };
284
- const r = await receiveTrustDocInner(p, env, opened.doc, when, ent);
285
- if (r?.handled && !r.error)
286
- C.effectDone(p, opened.digest);
287
- return r;
288
- }
289
- async function receiveTrustDocInner(p, env, doc, when, ent = {}) {
290
827
  const ctx = p.contexts.get(env.rkid);
291
- if (!ctx)
292
- return { handled: false };
293
- if (typeof doc?.type !== 'string' || !doc.type.startsWith(TT + 'trust-'))
294
- return { handled: false, doc };
295
- // form BEFORE fields (M-3): issuer, proof and payload as used below
296
- // form BEFORE fields (M-3, verschärft in Runde 3): jeder Typ verlangt
297
- // SEIN Payload — star@probe ohne star ist formwidrig, nicht ein Throw
298
- const kind = doc.type.slice((TT + 'trust-').length);
299
- const starOk = C.shaped(doc.payload ?? {}, { star: 'object' }) && C.shaped(doc.payload.star, { salt: 'string', count: 'number', blinded: 'array' })
300
- && C.intStr(doc.payload.star.salt) && Number.isInteger(doc.payload.star.count) && doc.payload.star.count >= 0
301
- && doc.payload.star.count === doc.payload.star.blinded.length
302
- && doc.payload.star.blinded.every((b) => typeof b === 'string');
303
- const mappingOk = C.shaped(doc.payload ?? {}, { mapping: 'object' }) && C.shaped(doc.payload.mapping, { mac1: 'string', mac2: 'string', card: 'object' });
304
- if (!C.shaped(doc, { issuer: 'string', proof: 'object', payload: 'object' })
305
- || (kind === 'disclosure@probe' && !(mappingOk && starOk))
306
- || (kind === 'star@probe' && !starOk))
307
- return { handled: true, error: 'malformed' };
308
- const fromEntry = [...p.contacts.entries()].find(([, c]) => c.channel?.own?.anchor === ctx.anchor);
828
+ const fromEntry = ctx && [...p.contacts.entries()].find(([, c]) => c.channel?.own?.anchor === ctx.anchor);
309
829
  if (!fromEntry)
310
- return { handled: true, error: 'kein Kanal' };
830
+ return { handled: false };
311
831
  const [fromKey, from] = fromEntry;
312
- if (doc.issuer !== fromKey || !(await C.diVerify(doc, doc.issuer)))
313
- return { handled: true, error: 'trust doc proof' };
314
- switch (doc.type.slice((TT + 'trust-').length)) {
315
- case 'disclosure@probe': {
316
- const m = doc.payload?.mapping;
317
- if (!(await verifyMapping(p, m))) {
318
- say(p, `Mapping von ${from.name} verifiziert NICHT — verworfen`);
319
- return { handled: true, error: 'mapping' };
832
+ const doc = opened.doc;
833
+ if (opened.duplicate) {
834
+ // 4.2: innerhalb der Retention wird EXAKT der gespeicherte Ack
835
+ // byte-identisch erneut gesendet — nie ein neu erzeugter
836
+ const stored = from.ackStore?.get(opened.digest);
837
+ // der Store behält sein Exemplar — der Host bekommt eine Kopie (Encounter-Review 8, B-1)
838
+ return { handled: true, duplicate: true, ack: stored ? { to: from, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: structuredClone(stored) } : undefined };
839
+ }
840
+ // Acks laufen durch dieselbe kritische Sektion (Delivery §6.2:
841
+ // „stage 9 is a critical section whose lock set is the document
842
+ // digest … it re-enters at stage 4"): der Verlierer zweier
843
+ // gleichzeitiger identischer Acks sieht nach der Lock-Wartezeit den
844
+ // completed-effect cache und meldet duplicate-known — nie
845
+ // 'ack unknown ref' (Review 13, B-3)
846
+ if (doc?.type === ACK_TYPE) {
847
+ // deaktivierte Tupel nehmen keine NEUEN Acks für VISIBILITY-Dokumente
848
+ // an (Review 37, B-3: „not the active head … MUST be rejected") —
849
+ // ein Ack auf ein ENCOUNTER-Dokument bleibt gültig (Delivery 6.1:
850
+ // „a late valid acknowledgement … transitions the status to
851
+ // delivered"); die Unterscheidung fällt am referenzierten
852
+ // Outbox-Eintrag in receiveAckInner (Encounter-Review 2, B-5)
853
+ // Stufe 5 VOR dem Payload-Gate auch für Acks (Review 33, B-2), in
854
+ // ihrer Ordnung: erst das DOKUMENTPROFIL (malformed), dann recipient =
855
+ // eigener Anker (Empfänger-Prinzip) — Encounter-Review 8, B-2
856
+ if (!schemaOk('rltp-delivery-document.schema.json', doc))
857
+ return { handled: true, error: 'malformed document' };
858
+ if (doc.recipient !== from.channel.own.anchor)
859
+ return { handled: true, error: 'wrong-recipient' }; // 6.2 stage 5 disposition
860
+ return withContactLock(from, async () => {
861
+ if ((p.deliveryCache ?? new Set()).has(opened.digest))
862
+ return { handled: true, duplicate: true };
863
+ return receiveAckInner(p, fromKey, from, opened);
864
+ });
865
+ }
866
+ if (typeof doc?.type !== 'string' || !doc.type.startsWith(TT))
867
+ return { handled: false, doc };
868
+ const slug = doc.type.slice(TT.length);
869
+ if (!['anchor-mapping/0.1', 'grade-declaration/0.1', 'star/0.1'].includes(slug))
870
+ return { handled: false, doc };
871
+ // Dokumentprofil (Delivery §3): Schema-validiert, proof-frei, Bindungen
872
+ if (!schemaOk('rltp-delivery-document.schema.json', doc))
873
+ return { handled: true, error: 'malformed document' };
874
+ if (doc.proof !== undefined)
875
+ return { handled: true, error: 'document proof forbidden (one carrier)' };
876
+ // Stufe 5: recipient = eigener Anker (Empfänger-Prinzip). Die
877
+ // issuer-Bindung ist eine TYP-Konsistenzregel (Stufe 8) und läuft je
878
+ // Zweig NACH dem Payload-Schema (Review 32)
879
+ if (doc.recipient !== from.channel.own.anchor)
880
+ return { handled: true, error: 'wrong-recipient' }; // 6.2 stage 5 disposition
881
+ // Kalender-Validität des DOKUMENTS (Review 12, B-3): „a verifier
882
+ // MUST reject a date its RFC 3339 parser refuses" — das Dokument-
883
+ // Schema lief bereits; die PAYLOAD-Kalenderprüfung sitzt je Typ NACH
884
+ // dem Payload-Schema (Review 31: Stufe 7 vor Stufe 8)
885
+ if (!C.calOK(doc.issuedAt))
886
+ return { handled: true, error: 'calendar-invalid issuedAt' };
887
+ // Ack erst NACH der Payload-Validierung bauen (M-3: keine Krypto
888
+ // für formwidrige Fracht), aber VOR jeder Zustandsmutation
889
+ // (poisoning rule: ein Ack-Bau-Fehler konsumiert nichts); Effekt +
890
+ // Cache + ackStore committen in EINEM synchronen Zug (4.2/§6.2)
891
+ const mkAck = () => buildAck(p, from, opened.digest, doc.threadId, when).then((a) => a.env);
892
+ const commit = (result, ackEnv) => {
893
+ C.effectDone(p, opened.digest);
894
+ (from.ackStore ??= new Map()).set(opened.digest, ackEnv);
895
+ return { ...result, ack: { to: from, kind: 'delivery-ack/0.1', env: ackEnv } };
896
+ };
897
+ const r0 = await (async () => {
898
+ switch (slug) {
899
+ case 'anchor-mapping/0.1': {
900
+ return withContactLock(from, async () => {
901
+ // Reentry (B-1/B-2): Kopf noch aktiv? Duplikat inzwischen? Revision noch frisch?
902
+ if (from.deactivated)
903
+ return { handled: true, error: 'tuple deactivated' };
904
+ if ((p.deliveryCache ?? new Set()).has(opened.digest)) {
905
+ const stored = from.ackStore?.get(opened.digest);
906
+ return { handled: true, duplicate: true, ack: stored ? { to: from, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: stored } : undefined };
907
+ }
908
+ const m = doc.payload;
909
+ // Stufe 7 zuerst: Payload-Schema → failed(malformed) (Review 30)
910
+ if (!schemaOk('visibility-anchor-mapping.schema.json', m))
911
+ return { handled: true, error: 'malformed mapping' };
912
+ if (doc.issuer !== fromKey)
913
+ return { handled: true, error: 'outer binding (issuer, Stufe 8)' };
914
+ if (!C.calOK(m.body.issuedAt))
915
+ return { handled: true, error: 'calendar-invalid issuedAt' }; // Stufe 8, nach dem Schema (Review 31)
916
+ if (doc.ceremony?.enactment !== undefined)
917
+ return { handled: true, error: 'ceremony.enactment kann nicht nachrechnen (validation-failed)' }; // Stufe 8 — NACH dem Payload-Schema (Review 30)
918
+ // §6.3 in Spec-REIHENFOLGE (Review 19, B-1), Tupel = Ankunftskanal
919
+ // (Review 9, B-1: Cross-Tupel-Bindung = Schritt 3 der Liste)
920
+ if (!(await verifyMapping(p, m, fromKey))) {
921
+ say(p, `Mapping von ${from.name} verifiziert NICHT — verworfen`);
922
+ return { handled: true, error: 'mapping' };
923
+ }
924
+ // 6.4, die GENERISCHE Revisionsregel (Review 11, B-2): höher
925
+ // ersetzt · gleich + JCS-identischer Body = idempotenter Repeat
926
+ // (No-op-Effekt, geackt — resends bleiben harmlos) · gleich +
927
+ // anderer Body = Äquivokation (reject, Zustand bleibt) ·
928
+ // niedriger = reject
929
+ if (BigInt(m.body.revision) === BigInt(from.mapRevIn ?? '0')) {
930
+ if (from.mapping && jcs(m.body) === jcs(from.mapping.body)) {
931
+ const ackEnv = await mkAck();
932
+ if (from.deactivated)
933
+ return { handled: true, error: 'tuple deactivated' };
934
+ return commit({ handled: true, idempotent: true }, ackEnv);
935
+ }
936
+ return { handled: true, error: 'equivocation (gleiche Revision, anderer Body)' };
937
+ }
938
+ if (BigInt(m.body.revision) < BigInt(from.mapRevIn ?? '0'))
939
+ return { handled: true, error: 'mapping revision (niedriger)' };
940
+ const ackEnv = await mkAck(); // fehlbar VOR jeder Mutation
941
+ // synchroner Re-Check nach dem LETZTEN await (Review 5, B-1): eine
942
+ // Kettung unter dem Lock eines ANDEREN Kontakts kann dieses Tupel
943
+ // während verify/mkAck deaktiviert haben — danach kein await mehr
944
+ if (from.deactivated)
945
+ return { handled: true, error: 'tuple deactivated' };
946
+ if (BigInt(m.body.revision) <= BigInt(from.mapRevIn ?? '0'))
947
+ return { handled: true, error: 'mapping revision (nicht strikt größer)' };
948
+ from.mapRevIn = m.body.revision;
949
+ from.selfAnchor = m.body.self;
950
+ from.mapping = m;
951
+ from.trustReceived = doc.issuedAt;
952
+ const e = promotionCommit(p, relIdOf(p, fromKey), m.body.self, fromKey, when);
953
+ e.admittedAt ??= when; // grade-wait ankert an der Admission (5.4)
954
+ // eine VOR dem Mapping verifizierte Declaration steht und wirkt
955
+ // bei der Admission sofort (Sektion 2 / 5.4)
956
+ if (from.gradeIn && !e.grades?.has(fromKey))
957
+ declCommit(p, relIdOf(p, fromKey), from.gradeIn, fromKey, from.gradeInOrder);
958
+ say(p, `${from.name} vertraut dir: stabiler Anker geprüft übernommen (nur für dich beweisend)`);
959
+ // der Producer-Sweep läuft NACH der Lock-Sektion (Reentranz!)
960
+ return commit({ handled: true, disclosed: m.body.self, fromName: from.name, __sweep: true }, ackEnv);
961
+ });
962
+ }
963
+ case 'grade-declaration/0.1': {
964
+ return withContactLock(from, async () => {
965
+ if (from.deactivated)
966
+ return { handled: true, error: 'tuple deactivated' };
967
+ if ((p.deliveryCache ?? new Set()).has(opened.digest)) {
968
+ const stored = from.ackStore?.get(opened.digest);
969
+ return { handled: true, duplicate: true, ack: stored ? { to: from, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: stored } : undefined };
970
+ }
971
+ const g = doc.payload;
972
+ if (!schemaOk('visibility-grade-declaration.schema.json', g))
973
+ return { handled: true, error: 'malformed grade' };
974
+ if (doc.issuer !== fromKey)
975
+ return { handled: true, error: 'outer binding (issuer, Stufe 8)' };
976
+ if (!C.calOK(g.body.issuedAt))
977
+ return { handled: true, error: 'calendar-invalid issuedAt' }; // Stufe 8, nach dem Schema (Review 31)
978
+ if (doc.ceremony?.enactment !== undefined)
979
+ return { handled: true, error: 'ceremony.enactment kann nicht nachrechnen (validation-failed)' }; // Stufe 8 — NACH dem Payload-Schema (Review 30)
980
+ if (g.body.subject !== fromKey || g.body.holder !== from.channel.own.anchor)
981
+ return { handled: true, error: 'grade binding' };
982
+ const k = await kGrade(from.channel.own, from.channel.counterpartKa, g.body.subject, g.body.holder);
983
+ if ((await hmacU(k, jcs(g.body))) !== g.proof.mac)
984
+ return { handled: true, error: 'grade mac' };
985
+ // 6.4, die GENERISCHE Revisionsregel (Review 11, B-2):
986
+ // dreiteilig — siehe Mapping-Pfad
987
+ if (BigInt(g.body.revision) === BigInt(from.gradeRevIn ?? '0')) {
988
+ if (from.gradeBodyIn && jcs(g.body) === from.gradeBodyIn) {
989
+ const ackEnv = await mkAck();
990
+ if (from.deactivated)
991
+ return { handled: true, error: 'tuple deactivated' };
992
+ return commit({ handled: true, idempotent: true }, ackEnv);
993
+ }
994
+ return { handled: true, error: 'equivocation (gleiche Revision, anderer Body)' };
995
+ }
996
+ if (BigInt(g.body.revision) < BigInt(from.gradeRevIn ?? '0'))
997
+ return { handled: true, error: 'grade revision (niedriger)' };
998
+ const ackEnv = await mkAck(); // fehlbar VOR jeder Mutation
999
+ if (from.deactivated)
1000
+ return { handled: true, error: 'tuple deactivated' }; // Re-Check (B-1), danach kein await
1001
+ if (BigInt(g.body.revision) <= BigInt(from.gradeRevIn ?? '0'))
1002
+ return { handled: true, error: 'grade revision (nicht strikt größer)' };
1003
+ from.gradeRevIn = g.body.revision;
1004
+ from.gradeBodyIn = jcs(g.body); // für den Idempotenz-Vergleich (Review 11, B-2)
1005
+ from.gradeIn = g.body.grade;
1006
+ from.gradeInOrder = (admission(p).seq += 1n); // Verifikations-Commit-Ordnung JETZT (B-2)
1007
+ if (from.selfAnchor)
1008
+ declCommit(p, relIdOf(p, fromKey), g.body.grade, fromKey, from.gradeInOrder);
1009
+ say(p, `${from.name} erklärt Grade '${g.body.grade}'`);
1010
+ return commit({ handled: true, grade: g.body.grade, __sweep: true }, ackEnv);
1011
+ });
320
1012
  }
321
- from.selfAnchor = m.body.self;
322
- from.mapping = m;
323
- from.trustReceived = doc.issuedAt;
324
- from.starReceived = doc.payload.star;
325
- await refreshStarInfo(p);
326
- say(p, `${from.name} vertraut dir: stabiler Anker geprüft übernommen (nur für dich beweisend)`);
327
- // mein Bestand wuchs → mein lieferbarer Stern auch: auffrischen.
328
- // Der INBOUND-Effekt ist hier komplett und bleibt cached;
329
- // starRefreshAll ist pro Empfänger resilient — Teilergebnisse
330
- // gehen nie verloren, Fehlschläge stehen benannt in failures und
331
- // gehören dem HOST (Nachliefer-Vertrag, Review 5 M-1)
332
- const outbound = await starRefreshAll(p, when, ent);
333
- const outboundError = outbound.failures.length ? outbound.failures.map((f) => `${f.to}: ${f.error}`).join(' · ') : undefined;
334
- return { handled: true, disclosed: m.body.self, fromName: from.name, outbound: [...outbound], outboundError };
1013
+ case 'star/0.1': {
1014
+ // Stage-9-Lockmenge in kind: konkurrierende Chunks derselben
1015
+ // Assembly serialisieren über den Kontakt-Lock (§4.4)
1016
+ return withStarLock(from, async () => {
1017
+ if (from.deactivated)
1018
+ return { handled: true, error: 'tuple deactivated' }; // §6.4 — auch der Star-Pfad (Review 5, B-1)
1019
+ // Stage-4-Reentry nach Lock-Wartezeit (§6.2): ein inzwischen
1020
+ // verarbeitetes Duplikat erhält den gespeicherten Ack byte-identisch
1021
+ if ((p.deliveryCache ?? new Set()).has(opened.digest)) {
1022
+ const stored = from.ackStore?.get(opened.digest);
1023
+ return { handled: true, duplicate: true, ack: stored ? { to: from, kind: 'delivery-ack/0.1 (Re-Ack, byte-identisch)', env: stored } : undefined };
1024
+ }
1025
+ const chunk = doc.payload;
1026
+ if (!schemaOk('visibility-star.schema.json', chunk))
1027
+ return { handled: true, error: 'malformed star' };
1028
+ if (doc.issuer !== fromKey)
1029
+ return { handled: true, error: 'outer binding (issuer, Stufe 8)' };
1030
+ if (doc.ceremony?.enactment !== undefined)
1031
+ return { handled: true, error: 'ceremony.enactment kann nicht nachrechnen (validation-failed)' }; // Stufe 8 — NACH dem Payload-Schema (Review 30)
1032
+ const b = chunk.body;
1033
+ // Empfänger-Monotonie (5.2): nicht strikt größer als der
1034
+ // completed High-Water → verworfen
1035
+ if (BigInt(b.salt) <= BigInt(from.starSaltIn ?? '0'))
1036
+ return { handled: true, error: 'star replay (nicht strikt größer)' };
1037
+ // VERGIFTETER Salt (Review 21): nach einem Assembly-Konflikt
1038
+ // wird KEIN weiterer Chunk dieser Lieferung quittiert — sonst
1039
+ // kombinieren früh geackte, verlorene Pufferwirkungen mit
1040
+ // späteren Acks zu einer falschen Completion („the delivery
1041
+ // simply cannot complete until every seq is held")
1042
+ if (from.starPoison?.has(b.salt))
1043
+ return { handled: true, error: 'star salt vergiftet (assembly conflict)' };
1044
+ const k = await kStarIn(from, fromKey, b.salt);
1045
+ if ((await hmacU(k, jcs(b))) !== chunk.proof.mac)
1046
+ return { handled: true, error: 'star mac' };
1047
+ // Re-Check nach den MAC-Awaits (Review 6, B-1): ab hier wird bis
1048
+ // zum jeweils nächsten await NUR NOCH LOKAL entschieden — die
1049
+ // Assembly ist Tupel-Zustand und darf nach einer Deaktivierung
1050
+ // keinen Chunk mehr aufnehmen („dies, atomically, with its
1051
+ // tuple's deactivation")
1052
+ if (from.deactivated)
1053
+ return { handled: true, error: 'tuple deactivated' };
1054
+ // 5.2a-Entscheidungen auf LOCALS (Review 6, B-1): höherer Salt
1055
+ // verdrängt, niedrigerer wird verworfen, threadId-Dissens
1056
+ // verwirft, Lücken puffern — GESCHRIEBEN wird erst nach Ack-Bau
1057
+ // und letztem Re-Check, in einem synchronen Zug
1058
+ const cur = asmOf(from);
1059
+ const displaced = cur && BigInt(b.salt) > BigInt(cur.salt);
1060
+ if (cur && !displaced && BigInt(b.salt) < BigInt(cur.salt))
1061
+ return { handled: true, error: 'below open assembly' };
1062
+ const a = (!cur || displaced) ? { salt: b.salt, threadId: doc.threadId, count: b.count, chunks: new Map(), lastSeq: undefined } : cur;
1063
+ if (doc.threadId !== a.threadId || b.count !== a.count) {
1064
+ from.asm = undefined;
1065
+ (from.starPoison ??= new Set()).add(b.salt);
1066
+ return { handled: true, error: 'assembly conflict (threadId/count)' };
1067
+ }
1068
+ const seq = b.seq;
1069
+ if (a.chunks.has(seq)) {
1070
+ from.asm = undefined;
1071
+ (from.starPoison ??= new Set()).add(b.salt);
1072
+ return { handled: true, error: 'assembly conflict (seq)' };
1073
+ }
1074
+ if (b.last && a.lastSeq !== undefined) {
1075
+ from.asm = undefined;
1076
+ (from.starPoison ??= new Set()).add(b.salt);
1077
+ return { handled: true, error: 'assembly conflict (two last)' };
1078
+ }
1079
+ const candLast = b.last ? seq : a.lastSeq;
1080
+ if (candLast !== undefined && BigInt(seq) > BigInt(candLast)) {
1081
+ from.asm = undefined;
1082
+ (from.starPoison ??= new Set()).add(b.salt);
1083
+ return { handled: true, error: 'seq beyond last' };
1084
+ }
1085
+ // auch BEREITS GEPUFFERTE Chunks oberhalb von last verwerfen die
1086
+ // Assembly (Review 16, B-1): „a seq greater than the held last
1087
+ // chunk's … reject" gilt unabhängig von der Ankunftsreihenfolge
1088
+ if (candLast !== undefined)
1089
+ for (const s of a.chunks.keys())
1090
+ if (BigInt(s) > BigInt(candLast)) {
1091
+ from.asm = undefined;
1092
+ (from.starPoison ??= new Set()).add(b.salt);
1093
+ return { handled: true, error: 'seq beyond last (gepuffert)' };
1094
+ }
1095
+ let ackEnv;
1096
+ const complete0 = candLast !== undefined && (() => { for (let i = 1n; i <= BigInt(candLast); i++)
1097
+ if (String(i) !== seq && !a.chunks.has(String(i)))
1098
+ return false; return true; })();
1099
+ if (!complete0) {
1100
+ ackEnv = await mkAck();
1101
+ if (from.deactivated)
1102
+ return { handled: true, error: 'tuple deactivated' }; // Re-Check (B-1), danach kein await
1103
+ from.asm = a; // Übernahme JETZT — synchron mit dem Puffern
1104
+ if (b.last)
1105
+ a.lastSeq = seq;
1106
+ a.chunks.set(seq, b);
1107
+ return commit({ handled: true, partial: true }, ackEnv);
1108
+ }
1109
+ // der KOMPLETTIERENDE Chunk: Union aus Puffer + aktuellem Chunk
1110
+ // OHNE Mutation berechnen und validieren, dann Ack + Re-Check,
1111
+ // dann Übernahme + completed-salt + Cache + Ack in EINEM
1112
+ // SYNCHRONEN Zug (§4.4); die UI-Auffrischung läuft NACH dem Commit
1113
+ const union = [];
1114
+ for (let i = 1n; i <= BigInt(candLast); i++)
1115
+ union.push(...(String(i) === seq ? b : a.chunks.get(String(i))).blinded);
1116
+ const sorted = [...union].every((x, i, arr) => i === 0 || arr[i - 1] < x);
1117
+ if (!sorted || BigInt(union.length) > BigInt(a.count)) {
1118
+ from.asm = undefined;
1119
+ (from.starPoison ??= new Set()).add(a.salt);
1120
+ return { handled: true, error: 'assembly invalid (order/|union|>count)' };
1121
+ }
1122
+ ackEnv = await mkAck(); // JETZT ist alles validiert (M-2), vor der Übernahme
1123
+ if (from.deactivated)
1124
+ return { handled: true, error: 'tuple deactivated' }; // Re-Check (B-1), danach kein await
1125
+ from.asm = undefined;
1126
+ from.starSaltIn = a.salt;
1127
+ from.starReceived = { salt: a.salt, count: a.count, blinded: union };
1128
+ if (from.starPoison)
1129
+ for (const ps of from.starPoison)
1130
+ if (BigInt(ps) <= BigInt(a.salt))
1131
+ from.starPoison.delete(ps); // Gift unterhalb des High-Water ist gegenstandslos
1132
+ const out = commit({ handled: true, star: true }, ackEnv);
1133
+ await refreshStarInfo(p);
1134
+ say(p, `Kontakt-Update von ${from.name}: ${a.count} Kontakte (geblendet: ${union.length})`);
1135
+ return out;
1136
+ });
1137
+ }
1138
+ }
1139
+ return { handled: false, doc };
1140
+ })();
1141
+ // Effekt fertig, Lock frei — jetzt der Producer-Sweep (5.4), dessen
1142
+ // reconcile die Empfänger-Locks selbst nimmt
1143
+ if (r0?.__sweep) {
1144
+ delete r0.__sweep;
1145
+ const outbound = await starRefreshAll(p, when, ent);
1146
+ r0.outbound = [...outbound];
1147
+ if (outbound.failures.length)
1148
+ r0.outboundError = outbound.failures.map((f) => `${f.to}: ${f.error}`).join(' · ');
1149
+ }
1150
+ return r0;
1151
+ }
1152
+ // completion (5.4-Automat): jeder Chunk-Ack streicht seinen Digest;
1153
+ // der letzte lässt den Automaten laufen
1154
+ async function receiveAckInner(p, fromKey, from, opened) {
1155
+ // der referenzierte Outbox-Eintrag zuerst — seine KLASSE wählt die
1156
+ // Proof-Form des Acks (4.2/4.4: Encounter-Payloads tragen übertragbare
1157
+ // Signaturen → signierter Ack; Visibility-Payloads sind DV → MAC).
1158
+ // Digest-Gleichheit über die dekodierten Multihash-Bytes (Encounter
1159
+ // 2.3) — ein `z`-gerenderter ref trifft den `u`-Outbox-Schlüssel
1160
+ // Stufe 7 zuerst: das Payload-Schema (malformed) — VOR jeder Klassen-
1161
+ // oder Konsistenzregel der Stufe 8 (Encounter-Review 16, B-1)
1162
+ if (!schemaOk('payload-delivery-ack.schema.json', opened.doc?.payload))
1163
+ return { handled: true, error: 'malformed' };
1164
+ const ref0 = opened.doc.payload.ref;
1165
+ let refKey, o;
1166
+ for (const [k, e] of outboxOf(from))
1167
+ if (sameDigest(k, ref0)) {
1168
+ refKey = k;
1169
+ o = e;
1170
+ break;
335
1171
  }
336
- case 'star@probe': {
337
- from.starReceived = doc.payload.star;
338
- await refreshStarInfo(p);
339
- say(p, `Kontakt-Update von ${from.name}: ${doc.payload.star.count} Kontakte (geblendet)`);
340
- return { handled: true, star: true };
1172
+ if (!o)
1173
+ return { handled: true, error: 'ack unknown ref' }; // Stufe 8: ref trifft kein gesendetes Dokument — keine Klasse wählbar
1174
+ const ENCOUNTER_KIND = o.kind === 'encounter-bundle' || o.kind === 'encounter-credential-delivery';
1175
+ const v = await verifyAck(p, from, opened.doc, ENCOUNTER_KIND ? 'signature' : 'dv');
1176
+ if (!v)
1177
+ return { handled: true, error: 'ack invalid' };
1178
+ if (o.threadId !== v.threadId)
1179
+ return { handled: true, error: 'ack unknown ref' };
1180
+ if (from.deactivated && !ENCOUNTER_KIND)
1181
+ return { handled: true, error: 'tuple deactivated' }; // nach den Awaits (Review 37, B-3), danach kein await
1182
+ // ZWEI Pflicht-Ebenen (Review 19, B-3): der Delivery-Ack erledigt den
1183
+ // TRANSPORT — die Visibility-Pflicht eines choice/report-Mappings
1184
+ // („resent … until answered by the counterpart's aligned mapping")
1185
+ // lebt weiter: der Eintrag bleibt für byte-identische Resends, bis
1186
+ // die Alignment-Antwort ihn retired. Die ALIGNMENT-Pflicht selbst
1187
+ // stirbt mit dem Ack (completed delivery, §6a.3).
1188
+ if (o.kind === 'continuity-mapping' && o.duty !== 'align' && !o.retired)
1189
+ o.acked = true;
1190
+ else
1191
+ outboxOf(from).delete(refKey);
1192
+ C.effectDone(p, opened.digest);
1193
+ if (o.kind === 'star') {
1194
+ const s = subOf(from);
1195
+ const f = s.inFlight.get(o.salt);
1196
+ if (f) {
1197
+ f.pending.delete(refKey);
1198
+ if (f.pending.size === 0) {
1199
+ s.inFlight.delete(o.salt);
1200
+ if (BigInt(o.salt) > s.highWater) {
1201
+ s.highWater = BigInt(o.salt);
1202
+ s.baseline = f.view;
1203
+ }
1204
+ // Completion räumt alle niedrigeren In-Flights (tot beim Empfänger)
1205
+ for (const [salt2, f2] of [...s.inFlight])
1206
+ if (BigInt(salt2) < s.highWater) {
1207
+ for (const d of f2.pending.keys())
1208
+ outboxOf(from).delete(d);
1209
+ s.inFlight.delete(salt2);
1210
+ }
1211
+ return { handled: true, completed: o.salt, advanced: true };
1212
+ }
1213
+ return { handled: true, partialAck: true };
341
1214
  }
342
1215
  }
343
- return { handled: false, doc };
1216
+ if (o.v2)
1217
+ delete from.trustReissueDue; // die V2-Pflicht stirbt mit der QUITTIERTEN Zustellung
1218
+ if (o.duty === 'align')
1219
+ from.contAligned = true; // 6a.3: Alignment-Pflicht stirbt mit der Completion
1220
+ // Encounter (Delivery 6.1): „delivered" ist der Sender-Status NUR auf
1221
+ // den gültigen Ack — der Host liest diese Marken, nie die Ankunft
1222
+ if (o.kind === 'encounter-bundle')
1223
+ from.bundleAcked = true;
1224
+ if (o.kind === 'encounter-credential-delivery')
1225
+ from.counterAcked = true;
1226
+ // the host correlates the ack with ITS active encounter (Encounter-Review 11, B-3):
1227
+ // the acked document's digest, thread and the counterpart anchor travel along
1228
+ return { handled: true, acked: o.kind, ref: refKey, threadId: o.threadId, peerAnchor: fromKey };
1229
+ }
1230
+ export async function receiveAck(p, env) {
1231
+ return receiveTrustDoc(p, env, 0);
344
1232
  }
345
1233
  //# sourceMappingURL=trust.js.map