instar 1.3.526 → 1.3.528

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 (50) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +135 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +27 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CoherenceJournal.d.ts +1 -1
  8. package/dist/core/CoherenceJournal.d.ts.map +1 -1
  9. package/dist/core/CoherenceJournal.js +23 -2
  10. package/dist/core/CoherenceJournal.js.map +1 -1
  11. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  12. package/dist/core/PostUpdateMigrator.js +43 -1
  13. package/dist/core/PostUpdateMigrator.js.map +1 -1
  14. package/dist/core/PreferencesReplicatedStore.d.ts +212 -0
  15. package/dist/core/PreferencesReplicatedStore.d.ts.map +1 -0
  16. package/dist/core/PreferencesReplicatedStore.js +330 -0
  17. package/dist/core/PreferencesReplicatedStore.js.map +1 -0
  18. package/dist/core/PreferencesSync.d.ts +8 -0
  19. package/dist/core/PreferencesSync.d.ts.map +1 -1
  20. package/dist/core/PreferencesSync.js +8 -0
  21. package/dist/core/PreferencesSync.js.map +1 -1
  22. package/dist/core/RelationshipManager.d.ts +61 -1
  23. package/dist/core/RelationshipManager.d.ts.map +1 -1
  24. package/dist/core/RelationshipManager.js +86 -2
  25. package/dist/core/RelationshipManager.js.map +1 -1
  26. package/dist/core/RelationshipsReplicatedStore.d.ts +298 -0
  27. package/dist/core/RelationshipsReplicatedStore.d.ts.map +1 -0
  28. package/dist/core/RelationshipsReplicatedStore.js +660 -0
  29. package/dist/core/RelationshipsReplicatedStore.js.map +1 -0
  30. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  31. package/dist/core/devGatedFeatures.js +10 -0
  32. package/dist/core/devGatedFeatures.js.map +1 -1
  33. package/dist/scaffold/templates.d.ts.map +1 -1
  34. package/dist/scaffold/templates.js +3 -1
  35. package/dist/scaffold/templates.js.map +1 -1
  36. package/dist/server/AgentServer.d.ts +4 -0
  37. package/dist/server/AgentServer.d.ts.map +1 -1
  38. package/dist/server/AgentServer.js +1 -0
  39. package/dist/server/AgentServer.js.map +1 -1
  40. package/dist/server/routes.d.ts +7 -0
  41. package/dist/server/routes.d.ts.map +1 -1
  42. package/dist/server/routes.js +19 -0
  43. package/dist/server/routes.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/data/builtin-manifest.json +64 -64
  46. package/src/scaffold/templates.ts +3 -1
  47. package/upgrades/1.3.527.md +31 -0
  48. package/upgrades/1.3.528.md +34 -0
  49. package/upgrades/side-effects/ws21-preferences-replicated-store.md +91 -0
  50. package/upgrades/side-effects/ws23-relationships.md +150 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * RelationshipsReplicatedStore — the SECOND concrete consumer of the HLC
3
+ * replicated-store foundation (WS2.3) and the FIRST PII kind. It layers the
4
+ * `relationship-record` replicated kind onto the generic substrate
5
+ * (ReplicatedRecordEnvelope / UnionReader / ConflictStore / RollbackUnmerge /
6
+ * ReplicationBudget / StoreSnapshot) so that a person the agent knows on machine
7
+ * A is known on machine B — ONE relationship graph, not one-per-machine.
8
+ *
9
+ * It is the literal analog of `PreferencesReplicatedStore.ts` (the WS2.1 reference
10
+ * consumer), with the PII-specific hardening the security spec
11
+ * (`ws23-relationships-userregistry-security.md`) demands. THIS IS PURE LOGIC. No
12
+ * fs, no Date directly, no network. It defines:
13
+ *
14
+ * A. The `relationship-record` store schema — a STRICT typed validator that
15
+ * TYPE-CLAMPS every known field (REQ-M3): ISO-8601-only dates, finite-number
16
+ * counts, length-clamped free text. A foreign record is fully attacker-
17
+ * controlled (§2.3 injection), so the clamp is the defense that makes markup
18
+ * un-smuggleable through a field that bypasses sanitize() on render. The
19
+ * schema is a DISCRIMINATED UNION on `op` (gap #8): an `op:'put'` VALUE schema
20
+ * AND an `op:'delete'` TOMBSTONE schema coexist under the one kind, so a
21
+ * tombstone is never marked invalid/suspect by the value schema (REQ-D6).
22
+ *
23
+ * B. The disclosure-minimized PROJECTION (REQ-M4) — `buildRelationshipRecordData`
24
+ * emits ONLY the enumerated resolution + merge-relevant fields, NEVER the raw
25
+ * on-disk blob and NEVER the local UUID `id`. `recordKey` is the cross-machine
26
+ * IDENTITY SURFACE (REQ-D17), derived deterministically from the sorted
27
+ * channel-uids (a person is "the same" across machines by their channels,
28
+ * mirroring resolveByChannel) — never the per-machine `randomUUID()` id.
29
+ *
30
+ * C. The TOMBSTONE builder — `buildRelationshipTombstoneData` emits an
31
+ * `op:'delete'` record `{ recordKey, op, hlc, origin, deletedAt }` (§4.2) so a
32
+ * delete/erasure propagates as a positive signal across an offline-then-
33
+ * rejoining peer (§4.3) instead of a record absence that cannot distinguish
34
+ * "deleted" from "never replicated".
35
+ *
36
+ * D. The union-aware read — `mergeUnionToRelationships` collapses a
37
+ * `Map<recordKey, UnionResult>` (from ReplicatedStoreReader.readAll('relationships'))
38
+ * into the merged relationship view. HIGH-impact (preferences/relationships):
39
+ * an OPEN concurrent conflict is APPEND-BOTH-AND-FLAG — both versions surface,
40
+ * never a silent clobber; the read NEVER writes a foreign record into the local
41
+ * store (REQ-M7 read-only union).
42
+ *
43
+ * E. Foreign-record render safety — `renderForeignRelationshipContext` wraps a
44
+ * replicated record in an explicit `<replicated-untrusted-data origin="…">`
45
+ * envelope (§2.3) and sanitizes EVERY rendered field (the ISO-8601/finite-
46
+ * number type-clamps on apply already make the date/count fields injection-
47
+ * safe; the free-text fields are escaped here). There is no "trusted because
48
+ * machine-set" render slot for a foreign record.
49
+ *
50
+ * SAFETY POSTURE (§14): MECHANISM, dark by default. Nothing here blocks a
51
+ * user-initiated action. The local UUID `id`, the local `channelIndex`/`nameIndex`,
52
+ * and any local-only bookkeeping are NEVER part of the replicated schema and are
53
+ * stripped from every emitted projection (REQ-M4 disclosure minimization).
54
+ */
55
+ import { createHash } from 'node:crypto';
56
+ import { jailStoreStringField } from './ReplicatedRecordEnvelope.js';
57
+ // ───────────────────────────────────────────────────────────────────────────
58
+ // A. Identity, tier, schema, bounds, caps
59
+ // ───────────────────────────────────────────────────────────────────────────
60
+ /** The stateSync config sub-key + advert suffix for this store (e.g.
61
+ * `multiMachine.stateSync.relationships.enabled`). Equal to the advert flag key
62
+ * `stateSyncReceive['relationships']`. */
63
+ export const RELATIONSHIP_STORE_KEY = 'relationships';
64
+ /** The JournalKind string this store rides — the DUAL-REGISTRY's dynamic half.
65
+ * MUST also be present in CoherenceJournal.JOURNAL_KINDS (the static half), or the
66
+ * store advertises receive=true yet serves/applies/pulls nothing (§4 callout). */
67
+ export const RELATIONSHIP_RECORD_KIND = 'relationship-record';
68
+ /**
69
+ * Relationships are HIGH-impact (security spec §5 REQ-M9 / parent §WS2): a
70
+ * concurrent divergent VALUE edit to the SAME identity surface from different
71
+ * origins goes through APPEND-BOTH-AND-FLAG — both versions preserved, ONE deduped
72
+ * conflict, never a silent overwrite (auto-merging two divergent people could fuse
73
+ * two distinct humans). The CONSUMER read path injects BOTH variants — see
74
+ * mergeUnionToRelationships.
75
+ */
76
+ export const RELATIONSHIP_IMPACT_TIER = 'high';
77
+ // ── Local-record caps mirrored on RECEIVE (REQ-M3 length-clamp discipline). A
78
+ // value over a cap REJECTS the whole record (never truncate-and-accept), EXCEPT
79
+ // free-text which is length-clamped on receive (the spec's "mirror MAX_NOTES_LENGTH
80
+ // on receive" rule — a flood is bounded, not record-rejected). ──────────────
81
+ /** Mirrors RelationshipManager.MAX_NOTES_LENGTH. */
82
+ export const MAX_NOTES_LENGTH = 10_000;
83
+ /** Mirrors RelationshipManager.MAX_CHANNELS. */
84
+ export const MAX_CHANNELS = 50;
85
+ /** Themes cap (RelationshipManager keeps themes ≤ 20). */
86
+ export const MAX_THEMES = 20;
87
+ /** Recent-interactions cap fallback (config `maxRecentInteractions`; the projection
88
+ * clamps to this hard ceiling so a hostile peer can't inflate the count). */
89
+ export const MAX_RECENT_INTERACTIONS = 50;
90
+ /** Per-free-text-string clamp for name / arcSummary / communicationStyle / category /
91
+ * each tag / each interaction summary / each channel identifier. */
92
+ export const MAX_FREETEXT_LENGTH = 2_000;
93
+ /** A channel `type` is a short slug. */
94
+ export const MAX_CHANNEL_TYPE_LENGTH = 64;
95
+ /** Tags cap. */
96
+ export const MAX_TAGS = 50;
97
+ /**
98
+ * Per-kind replication bounds (§8 / REQ-D1). A PII store is NEVER `rotateKeep: 0`
99
+ * (rotate but never delete) — unbounded PII history is a compliance defect. The
100
+ * relationship store is chatty (recordInteraction fires on every message), so the
101
+ * rate cap COALESCES (latest state per recordKey per interval, REQ-M12). The
102
+ * per-kind maxFileBytes here is the journal-level fallback; the aggregate replicated
103
+ * PII byte ceiling (REQ-D2, 64 MiB) caps the cross-(peer,kind) footprint.
104
+ */
105
+ export const RELATIONSHIP_RECORD_BOUNDS = {
106
+ retention: { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 4 },
107
+ // Chatty store, coalesced: capacity is the burst, refill the sustained rate.
108
+ rateCap: { capacity: 50, refillPerSec: 10 },
109
+ };
110
+ /**
111
+ * Per-entry size cap RAISED to 64KB for this PII kind (REQ-M3, gap #10). The
112
+ * default APPLIER_MAX_ENTRY_BYTES = 8KB is SMALLER than a fat relationship
113
+ * (MAX_NOTES_LENGTH=10_000 alone, PLUS bounded recentInteractions + MAX_CHANNELS),
114
+ * so under it the highest-PII records would never replicate AND would wedge the
115
+ * stream. 64KB is provably above the disclosure-minimized projection's maximum:
116
+ * name(2k) + notes(10k) + 20 themes×2k(40k) is already the dominant term, but
117
+ * themes are SHORT topic slugs in practice and EACH free-text is clamped to 2k —
118
+ * the realistic max (notes 10k + 50 channels×(64+2000) ≈ 103k worst-case) shows
119
+ * why we additionally enforce a HARD post-projection ceiling: a record that STILL
120
+ * exceeds 64KB after projection is REJECTED with a named error (not silent-
121
+ * truncate, not suspect-wedge). See assertProjectionUnderCap.
122
+ */
123
+ export const RELATIONSHIP_MAX_ENTRY_BYTES = 64 * 1024;
124
+ /**
125
+ * The store-specific field names the `relationship-record` VALUE schema OWNS (the
126
+ * unknown-field counter's allowlist). The local UUID `id` is DELIBERATELY ABSENT
127
+ * — it is per-machine and never replicated (REQ-D17 keys on the identity surface,
128
+ * not the id). `recordKey`/`hlc`/`op`/`origin`/`observed` are reserved envelope
129
+ * fields, never store fields.
130
+ */
131
+ export const RELATIONSHIP_STORE_KNOWN_FIELDS = Object.freeze([
132
+ 'name',
133
+ 'channels',
134
+ 'firstInteraction',
135
+ 'lastInteraction',
136
+ 'interactionCount',
137
+ 'themes',
138
+ 'notes',
139
+ 'communicationStyle',
140
+ 'significance',
141
+ 'arcSummary',
142
+ 'category',
143
+ 'tags',
144
+ 'recentInteractions',
145
+ ]);
146
+ /** The tombstone's store-owned fields beyond the reserved envelope set (REQ-D6).
147
+ * `deletedAt` is the only store field a delete carries. */
148
+ export const RELATIONSHIP_TOMBSTONE_KNOWN_FIELDS = Object.freeze([
149
+ 'deletedAt',
150
+ ]);
151
+ /** The full set of known store fields across BOTH op-branches (the schema's
152
+ * knownFields the registry uses for unknown-field counting — a field legal in
153
+ * EITHER branch is "known", and the branch validate() enforces which is legal for
154
+ * THIS op). */
155
+ const ALL_KNOWN_FIELDS = Object.freeze([
156
+ ...RELATIONSHIP_STORE_KNOWN_FIELDS,
157
+ ...RELATIONSHIP_TOMBSTONE_KNOWN_FIELDS,
158
+ ]);
159
+ // ── ISO-8601 type-clamp (REQ-M3): firstInteraction/lastInteraction render
160
+ // UNSANITIZED today, so on a foreign record they MUST validate as a real date
161
+ // or the record is rejected — markup cannot survive the clamp. ───────────────
162
+ /** Is `v` a valid ISO-8601 date string (and ONLY a date — no trailing markup)? A
163
+ * string that Date.parse rejects, or that round-trips to a different string, is not
164
+ * a clean ISO date and is rejected. */
165
+ export function isIso8601(v) {
166
+ if (typeof v !== 'string' || v.length === 0 || v.length > 64)
167
+ return false;
168
+ const ms = Date.parse(v);
169
+ if (!Number.isFinite(ms))
170
+ return false;
171
+ // A clean ISO date round-trips through Date.toISOString within the same instant.
172
+ // We accept any string Date.parse accepts whose value is finite AND that contains
173
+ // no angle brackets/quotes (the injection vectors) — the round-trip equality test
174
+ // would over-reject legitimate offsets (e.g. +00:00 vs Z), so we gate on the
175
+ // injection chars directly: a real date never contains <, >, or a double-quote.
176
+ if (v.includes('<') || v.includes('>') || v.includes('"'))
177
+ return false;
178
+ return true;
179
+ }
180
+ function clampFreeText(v, max = MAX_FREETEXT_LENGTH) {
181
+ if (typeof v !== 'string')
182
+ return null;
183
+ return v.length > max ? v.slice(0, max) : v;
184
+ }
185
+ function finiteNumber(v, fallback) {
186
+ return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
187
+ }
188
+ /** Validate one channel `{ type, identifier }` on RECEIVE: both strings, type a
189
+ * short non-path slug, identifier length-clamped + jailed (no path-shape). Returns
190
+ * the clamped channel or null to reject the whole record. */
191
+ function validateChannel(raw, ctx) {
192
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
193
+ return null;
194
+ const c = raw;
195
+ if (typeof c.type !== 'string' || c.type.length === 0 || c.type.length > MAX_CHANNEL_TYPE_LENGTH)
196
+ return null;
197
+ if (typeof c.identifier !== 'string' || c.identifier.length === 0)
198
+ return null;
199
+ // The channel identifier feeds the recordKey identity surface — a path-shaped
200
+ // identifier is rejected (defense in depth; an email/uid is never path-shaped).
201
+ if (jailStoreStringField(c.type, ctx) === null)
202
+ return null;
203
+ const ident = c.identifier.length > MAX_FREETEXT_LENGTH ? c.identifier.slice(0, MAX_FREETEXT_LENGTH) : c.identifier;
204
+ return { type: c.type, identifier: ident };
205
+ }
206
+ /** Validate one interaction summary on RECEIVE (free text clamped, timestamp ISO). */
207
+ function validateInteraction(raw) {
208
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
209
+ return null;
210
+ const i = raw;
211
+ const summary = clampFreeText(i.summary);
212
+ if (summary === null)
213
+ return null;
214
+ const channel = clampFreeText(i.channel, MAX_CHANNEL_TYPE_LENGTH) ?? '';
215
+ // timestamp clamped to ISO; a non-date timestamp coerces to epoch-0 (never rejects
216
+ // the whole record for one bad interaction — the manager's tolerant-read posture).
217
+ const timestamp = isIso8601(i.timestamp) ? i.timestamp : new Date(0).toISOString();
218
+ const out = { timestamp, channel, summary };
219
+ if (Array.isArray(i.topics)) {
220
+ const topics = i.topics
221
+ .filter((t) => typeof t === 'string')
222
+ .slice(0, MAX_THEMES)
223
+ .map((t) => (t.length > MAX_FREETEXT_LENGTH ? t.slice(0, MAX_FREETEXT_LENGTH) : t));
224
+ if (topics.length > 0)
225
+ out.topics = topics;
226
+ }
227
+ return out;
228
+ }
229
+ /**
230
+ * The `relationship-record` store schema (§4 / REQ-M3) — a DISCRIMINATED UNION on
231
+ * `op` (gap #8). Strict typed validation on top of the envelope: reject free text
232
+ * beyond the known fields, TYPE-CLAMP every known field, validate the date/number
233
+ * fields so markup cannot smuggle through a render slot that bypasses sanitize().
234
+ * Returns the validated store-specific object (known fields only), or null to reject
235
+ * the WHOLE record. PURE (no I/O, no mutation of `raw`).
236
+ *
237
+ * The `op` discriminator: the envelope validator has ALREADY validated `op` ∈
238
+ * {put,delete} before calling this. We branch on it so a tombstone
239
+ * `{recordKey, op:'delete', hlc, origin, deletedAt}` passes (only `deletedAt` is a
240
+ * legal store field for a delete) WITHOUT being marked invalid by the rich VALUE
241
+ * schema (REQ-D6).
242
+ */
243
+ export const relationshipRecordStoreSchema = {
244
+ knownFields: ALL_KNOWN_FIELDS,
245
+ // No store field holds an artifact path, so pathSensitiveFields is empty — the
246
+ // channel `type` is jailed imperatively in validateChannel as defense-in-depth.
247
+ validate(raw, ctx) {
248
+ const op = raw.op;
249
+ // ── DELETE (tombstone) branch (REQ-D6). Only `deletedAt` is a legal store
250
+ // field; any VALUE field present is an unknown-for-this-op field (counted)
251
+ // but does not reject the record — the tombstone's recordKey + hlc + op
252
+ // (envelope, already validated) carry the suppression. ──────────────────
253
+ if (op === 'delete') {
254
+ // deletedAt — ISO-8601 if present; tolerated-absent (the envelope hlc is the
255
+ // load-bearing order). A non-date deletedAt is dropped, not record-rejecting.
256
+ const deletedAt = isIso8601(raw.deletedAt) ? raw.deletedAt : undefined;
257
+ // Count any VALUE field smuggled onto a tombstone as a dropped field.
258
+ for (const k of Object.keys(raw)) {
259
+ if (k === 'op' || k === 'deletedAt')
260
+ continue;
261
+ if (RELATIONSHIP_STORE_KNOWN_FIELDS.includes(k))
262
+ ctx.countDroppedField();
263
+ }
264
+ return deletedAt !== undefined ? { deletedAt } : {};
265
+ }
266
+ // ── VALUE (put) branch. ──────────────────────────────────────────────────
267
+ // name — required non-empty free text, clamped.
268
+ const name = clampFreeText(raw.name);
269
+ if (name === null || name.length === 0)
270
+ return null;
271
+ // channels — an array, each clamped + jailed, ≤ MAX_CHANNELS. A bad channel
272
+ // rejects the whole record (the identity surface must be trustworthy).
273
+ if (!Array.isArray(raw.channels))
274
+ return null;
275
+ if (raw.channels.length > MAX_CHANNELS)
276
+ return null;
277
+ const channels = [];
278
+ for (const c of raw.channels) {
279
+ const vc = validateChannel(c, ctx);
280
+ if (vc === null)
281
+ return null;
282
+ channels.push(vc);
283
+ }
284
+ // firstInteraction / lastInteraction — ISO-8601 ONLY (REQ-M3): these render
285
+ // UNSANITIZED in getContextForPerson, so a non-date string is REJECTED (markup
286
+ // cannot survive). This is the load-bearing injection clamp (gap #4).
287
+ if (!isIso8601(raw.firstInteraction))
288
+ return null;
289
+ if (!isIso8601(raw.lastInteraction))
290
+ return null;
291
+ const firstInteraction = raw.firstInteraction;
292
+ const lastInteraction = raw.lastInteraction;
293
+ // interactionCount / significance — FINITE NUMBERS (REQ-M3): these also render
294
+ // unsanitized, so a string is rejected (markup cannot survive a number slot).
295
+ if (typeof raw.interactionCount !== 'number' || !Number.isFinite(raw.interactionCount))
296
+ return null;
297
+ if (typeof raw.significance !== 'number' || !Number.isFinite(raw.significance))
298
+ return null;
299
+ const interactionCount = Math.max(0, Math.floor(raw.interactionCount));
300
+ const significance = raw.significance;
301
+ // themes — array of clamped strings, ≤ MAX_THEMES.
302
+ const themes = Array.isArray(raw.themes)
303
+ ? raw.themes
304
+ .filter((t) => typeof t === 'string')
305
+ .slice(0, MAX_THEMES)
306
+ .map((t) => (t.length > MAX_FREETEXT_LENGTH ? t.slice(0, MAX_FREETEXT_LENGTH) : t))
307
+ : [];
308
+ // notes — free text, length-clamped on receive (a flood is bounded, REQ-M3).
309
+ const notes = typeof raw.notes === 'string' ? (raw.notes.length > MAX_NOTES_LENGTH ? raw.notes.slice(0, MAX_NOTES_LENGTH) : raw.notes) : '';
310
+ // recentInteractions — array, each validated + clamped, ≤ MAX_RECENT_INTERACTIONS.
311
+ const recentInteractions = [];
312
+ if (Array.isArray(raw.recentInteractions)) {
313
+ for (const i of raw.recentInteractions.slice(0, MAX_RECENT_INTERACTIONS)) {
314
+ const vi = validateInteraction(i);
315
+ if (vi !== null)
316
+ recentInteractions.push(vi);
317
+ }
318
+ }
319
+ const out = {
320
+ name,
321
+ channels,
322
+ firstInteraction,
323
+ lastInteraction,
324
+ interactionCount,
325
+ significance,
326
+ themes,
327
+ notes,
328
+ recentInteractions,
329
+ };
330
+ // Optional clamped free-text fields — present only when valid.
331
+ const communicationStyle = raw.communicationStyle !== undefined ? clampFreeText(raw.communicationStyle) : null;
332
+ if (communicationStyle !== null && communicationStyle.length > 0)
333
+ out.communicationStyle = communicationStyle;
334
+ const arcSummary = raw.arcSummary !== undefined ? clampFreeText(raw.arcSummary) : null;
335
+ if (arcSummary !== null && arcSummary.length > 0)
336
+ out.arcSummary = arcSummary;
337
+ const category = raw.category !== undefined ? clampFreeText(raw.category, MAX_CHANNEL_TYPE_LENGTH) : null;
338
+ if (category !== null && category.length > 0)
339
+ out.category = category;
340
+ if (Array.isArray(raw.tags)) {
341
+ const tags = raw.tags
342
+ .filter((t) => typeof t === 'string')
343
+ .slice(0, MAX_TAGS)
344
+ .map((t) => (t.length > MAX_FREETEXT_LENGTH ? t.slice(0, MAX_FREETEXT_LENGTH) : t));
345
+ if (tags.length > 0)
346
+ out.tags = tags;
347
+ }
348
+ return out;
349
+ },
350
+ };
351
+ // ───────────────────────────────────────────────────────────────────────────
352
+ // recordKey — the cross-machine IDENTITY SURFACE (REQ-D17)
353
+ // ───────────────────────────────────────────────────────────────────────────
354
+ /**
355
+ * Normalize one channel into its stable uid form `type:identifier` (lowercased
356
+ * type, trimmed identifier). This is the SAME `${type}:${identifier}` key
357
+ * RelationshipManager.channelIndex uses to collide a person across platforms.
358
+ */
359
+ export function channelUid(channel) {
360
+ const type = channel.type.trim().toLowerCase();
361
+ const identifier = channel.identifier.trim();
362
+ return `${type}:${identifier}`;
363
+ }
364
+ /**
365
+ * Derive the cross-machine-stable recordKey for a relationship (REQ-D17). A person
366
+ * is "the same" across machines by their CHANNEL SET (mirroring resolveByChannel),
367
+ * NOT by the per-machine `randomUUID()` id — VM-A and VM-B mint different UUIDs for
368
+ * the same human, so a UUID-keyed record could never collide them.
369
+ *
370
+ * The key is a deterministic, collision-resistant hash of the SORTED, de-duplicated
371
+ * channel-uids: `sha256(sorted(channelUids).join('\n'))`, hex-truncated to 32 chars
372
+ * (the same shape UnionReader.conflictId uses). Sorting makes it order-independent
373
+ * (the two machines converge to the SAME key for the same channel set); the hash
374
+ * makes it a bounded, non-path-shaped string (the envelope's recordKey jail accepts
375
+ * it). A relationship with NO channels (a degenerate local-only record) is NOT
376
+ * replicable — it has no cross-machine identity surface — and is reported as null so
377
+ * the caller skips emission (it can never collide a stranger by an empty key).
378
+ *
379
+ * COLLISION SAFETY: two DIFFERENT people share a key ONLY if they share the EXACT
380
+ * same full channel set — which is the manager's own definition of "the same
381
+ * person" (resolveByChannel returns one record per channel-uid). SPLIT-IDENTITY
382
+ * SAFETY: the same person derives the SAME key on both machines IFF both hold the
383
+ * same channel set; when their channel sets differ (one machine learned an extra
384
+ * channel) the keys differ — which is correct (they are not yet provably the same
385
+ * person on the machine missing the channel), and the union/erasure intersection
386
+ * logic (REQ-D17) reconciles them by channel-uid overlap, never by key equality.
387
+ */
388
+ export function deriveRelationshipRecordKey(channels) {
389
+ const uids = Array.from(new Set(channels.map(channelUid))).filter((u) => u.length > 1).sort();
390
+ if (uids.length === 0)
391
+ return null;
392
+ const h = createHash('sha256');
393
+ h.update(uids.join('\n'));
394
+ return h.digest('hex').slice(0, 32);
395
+ }
396
+ /** The named error a record-over-cap surfaces (REQ-M3): not silent-truncate, not
397
+ * suspect-wedge. */
398
+ export class RelationshipRecordTooLargeError extends Error {
399
+ recordKey;
400
+ bytes;
401
+ constructor(recordKey, bytes) {
402
+ super(`relationship-record ${recordKey} is ${bytes} bytes after projection — over the ${RELATIONSHIP_MAX_ENTRY_BYTES}-byte per-entry cap; not replicated`);
403
+ this.recordKey = recordKey;
404
+ this.bytes = bytes;
405
+ this.name = 'RelationshipRecordTooLargeError';
406
+ }
407
+ }
408
+ /**
409
+ * Build the disclosure-minimized `relationship-record` envelope `data` for an
410
+ * `op:'put'` (REQ-M4). Emits ONLY the enumerated resolution + merge-relevant
411
+ * fields — NEVER the raw on-disk blob, NEVER the local UUID `id`. recordKey = the
412
+ * derived channel-set identity surface (REQ-D17).
413
+ *
414
+ * Returns null when the record has NO channels (no cross-machine identity surface ⇒
415
+ * not replicable — the caller skips emission). Throws RelationshipRecordTooLargeError
416
+ * when the projection STILL exceeds the 64KB per-entry cap (a record that big is a
417
+ * NAMED, surfaced rejection, REQ-M3 gap #10 — never silent-truncate).
418
+ */
419
+ export function buildRelationshipRecordData(input) {
420
+ const { record, hlc, origin, observed } = input;
421
+ const recordKey = deriveRelationshipRecordKey(record.channels);
422
+ if (recordKey === null)
423
+ return null;
424
+ // Disclosure-minimized projection (REQ-M4): the enumerated fields ONLY. Every
425
+ // free-text field is clamped to the same maxima the receive-side schema enforces,
426
+ // so a legal record round-trips and a fat one is bounded BEFORE the cap check.
427
+ const data = {
428
+ name: clampFreeTextEmit(record.name),
429
+ channels: record.channels.slice(0, MAX_CHANNELS).map((c) => ({ type: c.type, identifier: c.identifier })),
430
+ firstInteraction: record.firstInteraction,
431
+ lastInteraction: record.lastInteraction,
432
+ interactionCount: finiteNumber(record.interactionCount, 0),
433
+ significance: finiteNumber(record.significance, 1),
434
+ themes: record.themes.slice(0, MAX_THEMES).map((t) => clampFreeTextEmit(t)),
435
+ notes: typeof record.notes === 'string' ? (record.notes.length > MAX_NOTES_LENGTH ? record.notes.slice(0, MAX_NOTES_LENGTH) : record.notes) : '',
436
+ recentInteractions: record.recentInteractions.slice(0, MAX_RECENT_INTERACTIONS).map((i) => ({
437
+ timestamp: i.timestamp,
438
+ channel: clampFreeTextEmit(i.channel, MAX_CHANNEL_TYPE_LENGTH),
439
+ summary: clampFreeTextEmit(i.summary),
440
+ ...(i.topics ? { topics: i.topics.slice(0, MAX_THEMES).map((t) => clampFreeTextEmit(t)) } : {}),
441
+ })),
442
+ // envelope fields (recordKey = identity surface).
443
+ recordKey,
444
+ hlc,
445
+ op: 'put',
446
+ origin,
447
+ ...(observed !== undefined ? { observed } : {}),
448
+ };
449
+ // Optional fields — only when present (the local id is NEVER among them).
450
+ if (record.communicationStyle)
451
+ data.communicationStyle = clampFreeTextEmit(record.communicationStyle);
452
+ if (record.arcSummary)
453
+ data.arcSummary = clampFreeTextEmit(record.arcSummary);
454
+ if (record.category)
455
+ data.category = clampFreeTextEmit(record.category, MAX_CHANNEL_TYPE_LENGTH);
456
+ if (record.tags && record.tags.length > 0)
457
+ data.tags = record.tags.slice(0, MAX_TAGS).map((t) => clampFreeTextEmit(t));
458
+ assertProjectionUnderCap(recordKey, data);
459
+ return data;
460
+ }
461
+ function clampFreeTextEmit(v, max = MAX_FREETEXT_LENGTH) {
462
+ return typeof v === 'string' && v.length > max ? v.slice(0, max) : (v ?? '');
463
+ }
464
+ /** Throw RelationshipRecordTooLargeError if the projected data serializes over the
465
+ * per-entry cap (REQ-M3). The cap is set so a legal disclosure-minimized record can
466
+ * never reach it; this is the belt-and-suspenders named rejection. */
467
+ export function assertProjectionUnderCap(recordKey, data) {
468
+ const bytes = Buffer.byteLength(JSON.stringify(data), 'utf-8');
469
+ if (bytes > RELATIONSHIP_MAX_ENTRY_BYTES) {
470
+ throw new RelationshipRecordTooLargeError(recordKey, bytes);
471
+ }
472
+ }
473
+ /**
474
+ * Build an `op:'delete'` TOMBSTONE `data` for a relationship erasure (§4.2 REQ-D4).
475
+ * recordKey = the SAME channel-set identity surface the value records key on, so the
476
+ * tombstone reaches the same human's record on every machine even though the local
477
+ * UUIDs differ. Returns null when the person has no channels (no identity surface to
478
+ * tombstone). The exact field set is `{ recordKey, op:'delete', hlc, origin,
479
+ * deletedAt }` (the REQ-D6 tombstone schema branch).
480
+ */
481
+ export function buildRelationshipTombstoneData(input) {
482
+ const recordKey = deriveRelationshipRecordKey(input.channels);
483
+ if (recordKey === null)
484
+ return null;
485
+ return {
486
+ deletedAt: input.deletedAt,
487
+ recordKey,
488
+ hlc: input.hlc,
489
+ op: 'delete',
490
+ origin: input.origin,
491
+ ...(input.observed !== undefined ? { observed: input.observed } : {}),
492
+ };
493
+ }
494
+ /**
495
+ * Reconstruct a MergedRelationshipView from an OriginRecord (the envelope stripped).
496
+ */
497
+ function viewFromOriginRecord(rec, conflicted) {
498
+ return { recordKey: rec.envelope.recordKey, origin: rec.origin, data: rec.data, conflicted };
499
+ }
500
+ /**
501
+ * Collapse a `Map<recordKey, UnionResult>` (from
502
+ * ReplicatedStoreReader.readAll('relationships')) into the merged relationship view.
503
+ * HIGH-impact contract (REQ-M9):
504
+ * - A resolved single value ⇒ that one view entry.
505
+ * - An OPEN concurrent conflict ⇒ BOTH (all) `put` variants as separate entries
506
+ * (append-both — the read NEVER suppresses a usable view waiting on operator
507
+ * resolution). A `delete` variant contributes nothing to display.
508
+ * - A delete-resolved key (every origin's latest is a tombstone) ⇒ nothing.
509
+ * The read is READ-ONLY: a replicated record NEVER clobbers a divergent local
510
+ * record (REQ-M7) — the local store files are never written here.
511
+ */
512
+ export function mergeUnionToRelationships(union) {
513
+ const out = [];
514
+ for (const result of union.values()) {
515
+ if (result.conflict) {
516
+ for (const v of result.conflict.versions) {
517
+ if (v.envelope.op === 'delete')
518
+ continue;
519
+ out.push(viewFromOriginRecord(v, true));
520
+ }
521
+ continue;
522
+ }
523
+ if (result.value && result.value.envelope.op !== 'delete') {
524
+ out.push(viewFromOriginRecord(result.value, false));
525
+ }
526
+ }
527
+ return out;
528
+ }
529
+ // ───────────────────────────────────────────────────────────────────────────
530
+ // E. Foreign-record render safety — quoted untrusted data (§2.3)
531
+ // ───────────────────────────────────────────────────────────────────────────
532
+ /** Sanitize a string for inclusion in the context block (mirrors
533
+ * RelationshipManager.getContextForPerson's escaper). */
534
+ function sanitize(s) {
535
+ return String(s).replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
536
+ }
537
+ /**
538
+ * Render a FOREIGN (replicated) relationship record into a session-context block,
539
+ * wrapped in an explicit `<replicated-untrusted-data origin="…">` envelope (§2.3)
540
+ * so the session model treats it as a PEER'S CLAIM to re-ground against, never a
541
+ * directive. EVERY rendered field is escaped — there is no "trusted because machine-
542
+ * set" slot. (The date/count fields were already ISO-8601/finite-number type-clamped
543
+ * on apply, so they cannot carry markup; we escape defensively regardless.)
544
+ *
545
+ * This is the foreign-record analog of RelationshipManager.getContextForPerson — a
546
+ * caller asking "what do my OTHER machines know about this person" gets this block,
547
+ * NEVER the local-authoritative context. A null `data.name` (a malformed view) yields
548
+ * null.
549
+ */
550
+ export function renderForeignRelationshipContext(view) {
551
+ const d = view.data;
552
+ if (typeof d.name !== 'string' || d.name.length === 0)
553
+ return null;
554
+ const safeName = sanitize(d.name);
555
+ const safeOrigin = sanitize(view.origin);
556
+ const lines = [
557
+ `<replicated-untrusted-data origin="${safeOrigin}">`,
558
+ `Name: ${safeName}`,
559
+ ];
560
+ if (typeof d.firstInteraction === 'string')
561
+ lines.push(`Known since: ${sanitize(d.firstInteraction)}`);
562
+ if (typeof d.lastInteraction === 'string')
563
+ lines.push(`Last interaction: ${sanitize(d.lastInteraction)}`);
564
+ if (typeof d.interactionCount === 'number')
565
+ lines.push(`Total interactions: ${sanitize(String(d.interactionCount))}`);
566
+ if (typeof d.significance === 'number')
567
+ lines.push(`Significance: ${sanitize(String(d.significance))}/10`);
568
+ if (Array.isArray(d.channels)) {
569
+ const platforms = [...new Set(d.channels.map((c) => c.type))];
570
+ if (platforms.length > 0)
571
+ lines.push(`Platforms: ${platforms.map(sanitize).join(', ')}`);
572
+ }
573
+ if (typeof d.category === 'string')
574
+ lines.push(`Category: ${sanitize(d.category)}`);
575
+ if (Array.isArray(d.tags) && d.tags.length > 0)
576
+ lines.push(`Tags: ${d.tags.map(sanitize).join(', ')}`);
577
+ if (Array.isArray(d.themes) && d.themes.length > 0)
578
+ lines.push(`Key themes: ${d.themes.map(sanitize).join(', ')}`);
579
+ if (typeof d.communicationStyle === 'string')
580
+ lines.push(`Communication style: ${sanitize(d.communicationStyle)}`);
581
+ if (typeof d.arcSummary === 'string')
582
+ lines.push(`Relationship arc: ${sanitize(d.arcSummary)}`);
583
+ if (typeof d.notes === 'string' && d.notes.length > 0)
584
+ lines.push(`Notes: ${sanitize(d.notes)}`);
585
+ if (Array.isArray(d.recentInteractions) && d.recentInteractions.length > 0) {
586
+ lines.push('Recent interactions:');
587
+ for (const i of d.recentInteractions.slice(-5)) {
588
+ lines.push(` - [${sanitize(i.timestamp)}] ${sanitize(i.summary)}`);
589
+ }
590
+ }
591
+ lines.push('</replicated-untrusted-data>');
592
+ return lines.join('\n');
593
+ }
594
+ // ───────────────────────────────────────────────────────────────────────────
595
+ // Own-origin materialization for the union reader (mirrors WS2.1)
596
+ // ───────────────────────────────────────────────────────────────────────────
597
+ /**
598
+ * Build an OriginRecord for the OWN relationship store (the single-origin
599
+ * materialization the union reader merges against peer replicas). recordKey =
600
+ * derived channel-set identity surface; the envelope carries a SYNTHETIC own-origin
601
+ * HLC stamp derived deterministically from the record's lastInteraction (physical)
602
+ * so the own record has a well-formed, stable position relative to peer records.
603
+ * Returns null for a channel-less record (no identity surface). The local UUID `id`
604
+ * is NEVER carried into the replicated namespace (REQ-M4 / REQ-D17).
605
+ */
606
+ export function relationshipToOriginRecord(record, origin) {
607
+ const recordKey = deriveRelationshipRecordKey(record.channels);
608
+ if (recordKey === null)
609
+ return null;
610
+ const physical = Date.parse(record.lastInteraction);
611
+ const hlc = {
612
+ physical: Number.isFinite(physical) ? physical : 0,
613
+ logical: Math.max(0, Math.floor(record.interactionCount || 0)),
614
+ node: origin,
615
+ };
616
+ const data = {
617
+ name: record.name,
618
+ channels: record.channels.map((c) => ({ type: c.type, identifier: c.identifier })),
619
+ firstInteraction: record.firstInteraction,
620
+ lastInteraction: record.lastInteraction,
621
+ interactionCount: finiteNumber(record.interactionCount, 0),
622
+ significance: finiteNumber(record.significance, 1),
623
+ themes: record.themes,
624
+ notes: record.notes ?? '',
625
+ recentInteractions: record.recentInteractions,
626
+ };
627
+ if (record.communicationStyle)
628
+ data.communicationStyle = record.communicationStyle;
629
+ if (record.arcSummary)
630
+ data.arcSummary = record.arcSummary;
631
+ if (record.category)
632
+ data.category = record.category;
633
+ if (record.tags && record.tags.length > 0)
634
+ data.tags = record.tags;
635
+ const envelope = { recordKey, hlc, op: 'put', origin };
636
+ return { origin, envelope, data };
637
+ }
638
+ // ───────────────────────────────────────────────────────────────────────────
639
+ // Registration descriptor (consumed by server.ts to register the dual registry)
640
+ // ───────────────────────────────────────────────────────────────────────────
641
+ /** The ReplicatedKindRegistry registration for the `relationship-record` store.
642
+ * server.ts registers this onto the shared registry; the dual-registry coupling
643
+ * test asserts `kind` is also present in JOURNAL_KINDS. */
644
+ export const RELATIONSHIP_KIND_REGISTRATION = {
645
+ kind: RELATIONSHIP_RECORD_KIND,
646
+ store: RELATIONSHIP_STORE_KEY,
647
+ schema: relationshipRecordStoreSchema,
648
+ };
649
+ /** Convenience: the store's contributing journal kinds (for rollback-unmerge's
650
+ * kindsForStore('relationships') wiring). */
651
+ export function relationshipContributingKinds() {
652
+ return [RELATIONSHIP_RECORD_KIND];
653
+ }
654
+ /** The store's impact tier resolver, for ReplicatedStoreReader.tierOf. Returns HIGH
655
+ * for the `relationships` store (and HIGH for any unknown store — the conservative
656
+ * append-both-and-flag direction, never a silent clobber). */
657
+ export function relationshipTierOf(_store) {
658
+ return RELATIONSHIP_IMPACT_TIER;
659
+ }
660
+ //# sourceMappingURL=RelationshipsReplicatedStore.js.map