@oxygen-agent/cli 1.720.7 → 1.739.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +27 -12
  3. package/dist/index.js +227 -63
  4. package/dist/skills.js +2 -2
  5. package/node_modules/@oxygen/shared/dist/cli-result.js +2 -0
  6. package/node_modules/@oxygen/shared/dist/crm-activity-events.d.ts +20 -0
  7. package/node_modules/@oxygen/shared/dist/crm-activity-events.js +5 -0
  8. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +27 -0
  9. package/node_modules/@oxygen/shared/dist/future-signup-events.js +90 -0
  10. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +320 -0
  11. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1233 -0
  12. package/node_modules/@oxygen/shared/dist/index.d.ts +6 -0
  13. package/node_modules/@oxygen/shared/dist/index.js +6 -0
  14. package/node_modules/@oxygen/shared/dist/notetaker-events.d.ts +4 -0
  15. package/node_modules/@oxygen/shared/dist/notetaker-events.js +23 -0
  16. package/node_modules/@oxygen/shared/dist/plain-support-events.d.ts +40 -0
  17. package/node_modules/@oxygen/shared/dist/plain-support-events.js +43 -0
  18. package/node_modules/@oxygen/shared/dist/redaction.js +6 -1
  19. package/node_modules/@oxygen/shared/dist/user-capability-routing.d.ts +2 -0
  20. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +23 -0
  21. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  22. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  23. package/node_modules/@oxygen/shared/dist/webhook-headers.d.ts +10 -0
  24. package/node_modules/@oxygen/shared/dist/webhook-headers.js +48 -0
  25. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.d.ts +31 -1
  26. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +112 -1
  27. package/node_modules/@oxygen/workflows/dist/graph/expression.js +7 -5
  28. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +9 -0
  29. package/node_modules/@oxygen/workflows/dist/graph/lint.js +409 -7
  30. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +513 -0
  31. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +104 -1
  32. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +2 -0
  33. package/node_modules/@oxygen/workflows/dist/graph/params.js +15 -0
  34. package/node_modules/@oxygen/workflows/dist/graph/remap.js +12 -3
  35. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +9 -0
  36. package/node_modules/@oxygen/workflows/dist/graph/topology.js +33 -1
  37. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +123 -16
  38. package/node_modules/@oxygen/workflows/dist/graph/types.js +45 -0
  39. package/node_modules/@oxygen/workflows/dist/index.d.ts +94 -2
  40. package/node_modules/@oxygen/workflows/dist/index.js +169 -26
  41. package/node_modules/@oxygen/workflows/dist/portable.js +3 -1
  42. package/package.json +1 -1
@@ -0,0 +1,1233 @@
1
+ import { createHash } from "node:crypto";
2
+ import { FUTURE_SIGNUP_LIFECYCLE_CONTRACT_VERSION, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_CONTRACT_VERSION, futureSignupJourneyId, futureSignupLifecycleEventId, futureSignupLifecycleProjectionFactId, futureSignupLifecycleProjectionRefreshFactId, futureSignupLostFactId, futureSignupPaidFactId, futureSignupSubscriptionEndedFactId, futureSignupTrialStartedFactId, futureSignupWorkspaceDeletedFactId, } from "./future-signup-events.js";
3
+ /** Fixed destination-head authority. Contract upgrades cannot reset this stream. */
4
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_AUTHORITY = "oxygen_product_lifecycle";
5
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_FACT_TYPES = [
6
+ "trial_started",
7
+ "paid",
8
+ "lost",
9
+ "subscription_ended",
10
+ "workspace_deleted",
11
+ "projection_refreshed",
12
+ ];
13
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_BILLING_STAGES = [
14
+ "free_trial",
15
+ "paid",
16
+ "lost",
17
+ ];
18
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_PREBILLING_STAGES = [
19
+ "lead",
20
+ "interested",
21
+ "demo_booked",
22
+ ];
23
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_SEMANTIC_STAGES = [
24
+ ...FUTURE_SIGNUP_LIFECYCLE_PROJECTION_PREBILLING_STAGES,
25
+ ...FUTURE_SIGNUP_LIFECYCLE_PROJECTION_BILLING_STAGES,
26
+ ];
27
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_ALIAS_TYPES = [
28
+ "clerk_organization",
29
+ "clerk_user",
30
+ "domain",
31
+ "email",
32
+ "stripe_customer",
33
+ ];
34
+ export const FUTURE_SIGNUP_LIFECYCLE_PROJECTION_ACTIVITY_TYPES = [
35
+ "trial_started",
36
+ "plan_paid",
37
+ "customer_lost",
38
+ "workspace_deleted",
39
+ ];
40
+ /** Typed, PII-free parse failure. Raw values are intentionally never reflected. */
41
+ export class FutureSignupLifecycleProjectionContractError extends TypeError {
42
+ path;
43
+ rule;
44
+ code = "future_signup_lifecycle_projection_invalid";
45
+ constructor(path, rule) {
46
+ super(`${path}: ${rule}`);
47
+ this.path = path;
48
+ this.rule = rule;
49
+ this.name = "FutureSignupLifecycleProjectionContractError";
50
+ }
51
+ }
52
+ /** Exact fingerprint algorithm used by the shipped v1 control builder. */
53
+ export function futureSignupLifecycleProjectionFingerprints(input) {
54
+ const sourceFactFingerprint = sha256Canonical(input.sourceFact);
55
+ const subjectSetFingerprint = sha256Canonical(input.snapshot.subjects.map((subject) => ({
56
+ kind: subject.kind,
57
+ key: subject.key,
58
+ aggregate_revision: subject.aggregate_revision,
59
+ aggregate_fingerprint: subject.aggregate_fingerprint,
60
+ })));
61
+ const snapshotFingerprint = sha256Canonical(input.snapshot);
62
+ const projectionFingerprint = sha256Canonical({
63
+ contract_version: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_CONTRACT_VERSION,
64
+ event_id: input.eventId,
65
+ source_fact_fingerprint: sourceFactFingerprint,
66
+ snapshot_fingerprint: snapshotFingerprint,
67
+ subject_set_fingerprint: subjectSetFingerprint,
68
+ });
69
+ return {
70
+ source_fact_fingerprint: sourceFactFingerprint,
71
+ subject_set_fingerprint: subjectSetFingerprint,
72
+ snapshot_fingerprint: snapshotFingerprint,
73
+ projection_fingerprint: projectionFingerprint,
74
+ };
75
+ }
76
+ export function parseFutureSignupLifecycleProjectionJson(body) {
77
+ let value;
78
+ try {
79
+ value = JSON.parse(body);
80
+ }
81
+ catch {
82
+ invalid("$", "must be valid JSON");
83
+ }
84
+ return normalizeFutureSignupLifecycleProjectionEnvelope(value);
85
+ }
86
+ /** Validate shipped v1 and derive the closed destination view. No writes or sends. */
87
+ export function normalizeFutureSignupLifecycleProjectionEnvelope(value) {
88
+ const root = exactRecord(value, "$", [
89
+ "contract_version",
90
+ "event_id",
91
+ "event_type",
92
+ "fact_id",
93
+ "fact_revision",
94
+ "occurred_at",
95
+ "provider",
96
+ "provider_event_id",
97
+ "upstream",
98
+ "source_fact",
99
+ "snapshot",
100
+ "activity",
101
+ "producer",
102
+ "validation",
103
+ ]);
104
+ literal(root.contract_version, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_CONTRACT_VERSION, "$.contract_version");
105
+ literal(root.event_type, "lifecycle_projection", "$.event_type");
106
+ literal(root.provider, "oxygen", "$.provider");
107
+ const eventId = canonicalText(root.event_id, "$.event_id");
108
+ const factId = canonicalText(root.fact_id, "$.fact_id");
109
+ const factRevision = positiveInteger(root.fact_revision, "$.fact_revision");
110
+ const occurredAt = timestamp(root.occurred_at, "$.occurred_at");
111
+ const providerEventId = canonicalText(root.provider_event_id, "$.provider_event_id");
112
+ const upstreamRaw = exactRecord(root.upstream, "$.upstream", [
113
+ "provider",
114
+ "provider_event_id",
115
+ "source_revision",
116
+ ]);
117
+ const upstream = {
118
+ provider: enumValue(upstreamRaw.provider, ["clerk", "clerk_org_deletion", "stripe"], "$.upstream.provider"),
119
+ provider_event_id: canonicalText(upstreamRaw.provider_event_id, "$.upstream.provider_event_id"),
120
+ source_revision: positiveInteger(upstreamRaw.source_revision, "$.upstream.source_revision"),
121
+ };
122
+ const sourceFact = parseSourceFact(root.source_fact);
123
+ const snapshot = parseSnapshot(root.snapshot);
124
+ const activity = parseShippedActivity(root.activity);
125
+ const producerRaw = exactRecord(root.producer, "$.producer", [
126
+ "environment",
127
+ "version",
128
+ "commitSha",
129
+ ]);
130
+ const producer = {
131
+ environment: enumValue(producerRaw.environment, ["dev", "production"], "$.producer.environment"),
132
+ version: canonicalText(producerRaw.version, "$.producer.version"),
133
+ commitSha: canonicalText(producerRaw.commitSha, "$.producer.commitSha"),
134
+ };
135
+ const validationRaw = exactRecord(root.validation, "$.validation", ["synthetic", "no_send"]);
136
+ literal(validationRaw.synthetic, false, "$.validation.synthetic");
137
+ literal(validationRaw.no_send, true, "$.validation.no_send");
138
+ const expectedProjectionFactId = futureSignupLifecycleProjectionFactId(sourceFact.fact_id);
139
+ const expectedEventId = futureSignupLifecycleEventId(expectedProjectionFactId, sourceFact.fact_revision);
140
+ same(factId, expectedProjectionFactId, "$.fact_id", "must match the source fact identity");
141
+ same(factRevision, sourceFact.fact_revision, "$.fact_revision", "must match source_fact.fact_revision");
142
+ same(eventId, expectedEventId, "$.event_id", "must match the semantic projection event identity");
143
+ same(providerEventId, eventId, "$.provider_event_id", "must equal event_id");
144
+ same(occurredAt, sourceFact.occurred_at, "$.occurred_at", "must match source_fact.occurred_at");
145
+ same(upstream.provider, sourceFact.source, "$.upstream.provider", "must match source_fact.source");
146
+ same(upstream.provider_event_id, sourceFact.source_event_id, "$.upstream.provider_event_id", "must match source_fact.source_event_id");
147
+ same(upstream.source_revision, sourceFact.source_revision, "$.upstream.source_revision", "must match source_fact.source_revision");
148
+ same(snapshot.causal_clerk_org_id, sourceFact.clerk_org_id, "$.snapshot.causal_clerk_org_id", "must match source_fact.clerk_org_id");
149
+ assertProjectionClosure(snapshot);
150
+ assertShippedActivity(activity, sourceFact, snapshot);
151
+ const snapshotCore = {
152
+ complete: true,
153
+ causal_clerk_org_id: snapshot.causal_clerk_org_id,
154
+ organization_states: snapshot.organization_states,
155
+ membership_states: snapshot.membership_states,
156
+ subjects: snapshot.subjects,
157
+ };
158
+ const fingerprints = futureSignupLifecycleProjectionFingerprints({
159
+ eventId,
160
+ sourceFact,
161
+ snapshot: snapshotCore,
162
+ });
163
+ same(snapshot.subject_set_fingerprint, fingerprints.subject_set_fingerprint, "$.snapshot.subject_set_fingerprint", "does not match the canonical subject set");
164
+ same(snapshot.snapshot_fingerprint, fingerprints.snapshot_fingerprint, "$.snapshot.snapshot_fingerprint", "does not match the canonical shipped-v1 snapshot");
165
+ const envelope = {
166
+ contract_version: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_CONTRACT_VERSION,
167
+ event_id: eventId,
168
+ event_type: "lifecycle_projection",
169
+ fact_id: factId,
170
+ fact_revision: factRevision,
171
+ occurred_at: occurredAt,
172
+ provider: "oxygen",
173
+ provider_event_id: providerEventId,
174
+ upstream,
175
+ source_fact: sourceFact,
176
+ snapshot,
177
+ activity,
178
+ producer,
179
+ validation: { synthetic: false, no_send: true },
180
+ };
181
+ const normalizedSubjects = normalizeSubjects(snapshot, sourceFact);
182
+ const normalizedActivity = normalizeActivity(activity, sourceFact, normalizedSubjects);
183
+ return {
184
+ envelope,
185
+ projection: {
186
+ contract_version: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_CONTRACT_VERSION,
187
+ authority: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_AUTHORITY,
188
+ event_id: eventId,
189
+ occurred_at: occurredAt,
190
+ source_fact: sourceFact,
191
+ snapshot: {
192
+ complete: true,
193
+ causal_clerk_org_id: snapshot.causal_clerk_org_id,
194
+ subjects: normalizedSubjects,
195
+ },
196
+ activity: normalizedActivity,
197
+ validation: { synthetic: false, no_send: true },
198
+ },
199
+ fingerprints,
200
+ idempotency_keys: {
201
+ delivery_receipt: eventId,
202
+ activity: normalizedActivity.emit ? eventId : null,
203
+ subjects: normalizedSubjects.map((subject) => ({
204
+ kind: subject.kind,
205
+ key: subject.key,
206
+ idempotency_key: `${eventId}:subject:${subject.kind}:${subject.key}`,
207
+ })),
208
+ },
209
+ };
210
+ }
211
+ export function normalizeFutureSignupLifecycleProjectionDestinationApplyOptions(value) {
212
+ const root = exactRecord(value, "$", [
213
+ "objects",
214
+ "stage_tokens",
215
+ "owner_if_blank",
216
+ "activity_policy",
217
+ ]);
218
+ const objectsRaw = exactRecord(root.objects, "$.objects", ["people", "companies"]);
219
+ literal(objectsRaw.people, "people", "$.objects.people");
220
+ literal(objectsRaw.companies, "companies", "$.objects.companies");
221
+ const stagesRaw = exactRecord(root.stage_tokens, "$.stage_tokens", ["people", "companies"]);
222
+ const peopleStages = parseStageMapping(stagesRaw.people, "$.stage_tokens.people");
223
+ const companyStages = parseStageMapping(stagesRaw.companies, "$.stage_tokens.companies");
224
+ let owner = null;
225
+ if (root.owner_if_blank !== null) {
226
+ const ownerRaw = exactRecord(root.owner_if_blank, "$.owner_if_blank", ["workspace_user_id"]);
227
+ owner = {
228
+ workspace_user_id: canonicalUuid(ownerRaw.workspace_user_id, "$.owner_if_blank.workspace_user_id"),
229
+ };
230
+ }
231
+ const policyRaw = exactRecord(root.activity_policy, "$.activity_policy", [
232
+ "customer_lifecycle_facts",
233
+ "projection_refreshed",
234
+ "subscription_ended",
235
+ "participants",
236
+ "idempotency",
237
+ ]);
238
+ literal(policyRaw.customer_lifecycle_facts, "emit_envelope_activity", "$.activity_policy.customer_lifecycle_facts");
239
+ literal(policyRaw.projection_refreshed, "suppress", "$.activity_policy.projection_refreshed");
240
+ literal(policyRaw.subscription_ended, "suppress", "$.activity_policy.subscription_ended");
241
+ literal(policyRaw.participants, "envelope_subjects_only", "$.activity_policy.participants");
242
+ literal(policyRaw.idempotency, "event_id", "$.activity_policy.idempotency");
243
+ const applyOptions = {
244
+ objects: { people: "people", companies: "companies" },
245
+ stage_tokens: { people: peopleStages, companies: companyStages },
246
+ owner_if_blank: owner,
247
+ activity_policy: {
248
+ customer_lifecycle_facts: "emit_envelope_activity",
249
+ projection_refreshed: "suppress",
250
+ subscription_ended: "suppress",
251
+ participants: "envelope_subjects_only",
252
+ idempotency: "event_id",
253
+ },
254
+ };
255
+ return {
256
+ apply_options: applyOptions,
257
+ apply_options_fingerprint: sha256Canonical(applyOptions),
258
+ };
259
+ }
260
+ function parseSourceFact(value) {
261
+ const path = "$.source_fact";
262
+ const row = exactRecord(value, path, [
263
+ "fact_id",
264
+ "fact_revision",
265
+ "contract_version",
266
+ "fact_type",
267
+ "clerk_org_id",
268
+ "occurred_at",
269
+ "source",
270
+ "source_event_id",
271
+ "source_revision",
272
+ "stripe_customer_id",
273
+ "stripe_subscription_id",
274
+ "stripe_invoice_id",
275
+ "stripe_period_start",
276
+ "stripe_period_end",
277
+ "amount_paid_cents",
278
+ "currency",
279
+ "loss_kind",
280
+ "termination_kind",
281
+ ]);
282
+ literal(row.contract_version, FUTURE_SIGNUP_LIFECYCLE_CONTRACT_VERSION, `${path}.contract_version`);
283
+ const fact = {
284
+ fact_id: canonicalText(row.fact_id, `${path}.fact_id`),
285
+ fact_revision: positiveInteger(row.fact_revision, `${path}.fact_revision`),
286
+ contract_version: FUTURE_SIGNUP_LIFECYCLE_CONTRACT_VERSION,
287
+ fact_type: enumValue(row.fact_type, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_FACT_TYPES, `${path}.fact_type`),
288
+ clerk_org_id: canonicalText(row.clerk_org_id, `${path}.clerk_org_id`),
289
+ occurred_at: timestamp(row.occurred_at, `${path}.occurred_at`),
290
+ source: enumValue(row.source, ["clerk", "clerk_org_deletion", "stripe"], `${path}.source`),
291
+ source_event_id: canonicalText(row.source_event_id, `${path}.source_event_id`),
292
+ source_revision: positiveInteger(row.source_revision, `${path}.source_revision`),
293
+ stripe_customer_id: nullableText(row.stripe_customer_id, `${path}.stripe_customer_id`),
294
+ stripe_subscription_id: nullableText(row.stripe_subscription_id, `${path}.stripe_subscription_id`),
295
+ stripe_invoice_id: nullableText(row.stripe_invoice_id, `${path}.stripe_invoice_id`),
296
+ stripe_period_start: nullableTimestamp(row.stripe_period_start, `${path}.stripe_period_start`),
297
+ stripe_period_end: nullableTimestamp(row.stripe_period_end, `${path}.stripe_period_end`),
298
+ amount_paid_cents: nullableNonNegativeInteger(row.amount_paid_cents, `${path}.amount_paid_cents`),
299
+ currency: nullableCurrency(row.currency, `${path}.currency`),
300
+ loss_kind: nullableEnum(row.loss_kind, ["paid_churn", "trial_non_conversion"], `${path}.loss_kind`),
301
+ termination_kind: nullableEnum(row.termination_kind, ["replaced", "duplicate", "unqualified"], `${path}.termination_kind`),
302
+ };
303
+ assertSourceFactShape(fact);
304
+ return fact;
305
+ }
306
+ function assertSourceFactShape(fact) {
307
+ const path = "$.source_fact";
308
+ if (fact.fact_type === "trial_started") {
309
+ same(fact.source, "stripe", `${path}.source`, "must be stripe for trial_started");
310
+ present(fact.stripe_customer_id, `${path}.stripe_customer_id`);
311
+ present(fact.stripe_subscription_id, `${path}.stripe_subscription_id`);
312
+ allNull(fact, [
313
+ "stripe_invoice_id",
314
+ "stripe_period_start",
315
+ "stripe_period_end",
316
+ "amount_paid_cents",
317
+ "currency",
318
+ "loss_kind",
319
+ "termination_kind",
320
+ ], path);
321
+ same(fact.fact_id, futureSignupTrialStartedFactId(fact.stripe_subscription_id), `${path}.fact_id`, "must match the immutable Trial entry edge");
322
+ return;
323
+ }
324
+ if (fact.fact_type === "paid") {
325
+ same(fact.source, "stripe", `${path}.source`, "must be stripe for paid");
326
+ present(fact.stripe_customer_id, `${path}.stripe_customer_id`);
327
+ present(fact.stripe_subscription_id, `${path}.stripe_subscription_id`);
328
+ present(fact.stripe_invoice_id, `${path}.stripe_invoice_id`);
329
+ present(fact.stripe_period_start, `${path}.stripe_period_start`);
330
+ present(fact.stripe_period_end, `${path}.stripe_period_end`);
331
+ if (Date.parse(fact.stripe_period_end) <= Date.parse(fact.stripe_period_start)) {
332
+ invalid(`${path}.stripe_period_end`, "must be later than stripe_period_start");
333
+ }
334
+ if (fact.amount_paid_cents === null || fact.amount_paid_cents <= 0) {
335
+ invalid(`${path}.amount_paid_cents`, "must be a strict positive-payment witness");
336
+ }
337
+ present(fact.currency, `${path}.currency`);
338
+ allNull(fact, ["loss_kind", "termination_kind"], path);
339
+ same(fact.fact_id, futureSignupPaidFactId(fact.stripe_invoice_id), `${path}.fact_id`, "must match the immutable Paid invoice identity");
340
+ return;
341
+ }
342
+ if (fact.fact_type === "lost") {
343
+ if (fact.source !== "stripe" && fact.source !== "clerk_org_deletion") {
344
+ invalid(`${path}.source`, "must be stripe or clerk_org_deletion for lost");
345
+ }
346
+ present(fact.stripe_subscription_id, `${path}.stripe_subscription_id`);
347
+ present(fact.loss_kind, `${path}.loss_kind`);
348
+ allNull(fact, [
349
+ "stripe_invoice_id",
350
+ "stripe_period_start",
351
+ "stripe_period_end",
352
+ "amount_paid_cents",
353
+ "currency",
354
+ "termination_kind",
355
+ ], path);
356
+ same(fact.fact_id, futureSignupLostFactId(fact.stripe_subscription_id, fact.loss_kind), `${path}.fact_id`, "must match the classified Lost identity");
357
+ return;
358
+ }
359
+ if (fact.fact_type === "subscription_ended") {
360
+ if (fact.source !== "stripe" && fact.source !== "clerk_org_deletion") {
361
+ invalid(`${path}.source`, "must be stripe or clerk_org_deletion for subscription_ended");
362
+ }
363
+ present(fact.stripe_subscription_id, `${path}.stripe_subscription_id`);
364
+ present(fact.termination_kind, `${path}.termination_kind`);
365
+ allNull(fact, [
366
+ "stripe_invoice_id",
367
+ "stripe_period_start",
368
+ "stripe_period_end",
369
+ "amount_paid_cents",
370
+ "currency",
371
+ "loss_kind",
372
+ ], path);
373
+ same(fact.fact_id, futureSignupSubscriptionEndedFactId(fact.stripe_subscription_id, fact.termination_kind), `${path}.fact_id`, "must match the neutral subscription edge");
374
+ return;
375
+ }
376
+ allNull(fact, [
377
+ "stripe_customer_id",
378
+ "stripe_subscription_id",
379
+ "stripe_invoice_id",
380
+ "stripe_period_start",
381
+ "stripe_period_end",
382
+ "amount_paid_cents",
383
+ "currency",
384
+ "loss_kind",
385
+ "termination_kind",
386
+ ], path);
387
+ same(fact.source, "clerk", `${path}.source`, `must be clerk for ${fact.fact_type}`);
388
+ const expected = fact.fact_type === "workspace_deleted"
389
+ ? futureSignupWorkspaceDeletedFactId(fact.clerk_org_id)
390
+ : futureSignupLifecycleProjectionRefreshFactId(fact.clerk_org_id, fact.source_event_id);
391
+ same(fact.fact_id, expected, `${path}.fact_id`, "must match the immutable Clerk cause");
392
+ }
393
+ function parseSnapshot(value) {
394
+ const path = "$.snapshot";
395
+ const row = exactRecord(value, path, [
396
+ "complete",
397
+ "causal_clerk_org_id",
398
+ "organization_states",
399
+ "membership_states",
400
+ "subjects",
401
+ "subject_set_fingerprint",
402
+ "snapshot_fingerprint",
403
+ ]);
404
+ literal(row.complete, true, `${path}.complete`);
405
+ if (!Array.isArray(row.organization_states))
406
+ invalid(`${path}.organization_states`, "must be an array");
407
+ if (!Array.isArray(row.membership_states))
408
+ invalid(`${path}.membership_states`, "must be an array");
409
+ if (!Array.isArray(row.subjects))
410
+ invalid(`${path}.subjects`, "must be an array");
411
+ const organizations = row.organization_states.map((item, index) => parseOrganizationState(item, `${path}.organization_states[${index}]`));
412
+ assertSortedUnique(organizations, (item) => item.clerk_org_id, `${path}.organization_states`);
413
+ const memberships = row.membership_states.map((item, index) => parseMembershipState(item, `${path}.membership_states[${index}]`));
414
+ assertSortedUnique(memberships, (item) => `${item.clerk_user_id}\u0000${item.clerk_org_id}\u0000${item.clerk_membership_id}`, `${path}.membership_states`);
415
+ const subjects = row.subjects.map((item, index) => parseSubject(item, `${path}.subjects[${index}]`));
416
+ assertSortedUnique(subjects, (item) => `${item.kind}\u0000${item.key}`, `${path}.subjects`);
417
+ const subjectKeys = subjects.map((subject) => subject.key);
418
+ if (new Set(subjectKeys).size !== subjectKeys.length) {
419
+ invalid(`${path}.subjects`, "subject keys must be globally unique");
420
+ }
421
+ return {
422
+ complete: true,
423
+ causal_clerk_org_id: canonicalText(row.causal_clerk_org_id, `${path}.causal_clerk_org_id`),
424
+ organization_states: organizations,
425
+ membership_states: memberships,
426
+ subjects,
427
+ subject_set_fingerprint: fingerprint(row.subject_set_fingerprint, `${path}.subject_set_fingerprint`),
428
+ snapshot_fingerprint: fingerprint(row.snapshot_fingerprint, `${path}.snapshot_fingerprint`),
429
+ };
430
+ }
431
+ function parseOrganizationState(value, path) {
432
+ const row = exactRecord(value, path, [
433
+ "clerk_org_id",
434
+ "company_key",
435
+ "company_key_history",
436
+ "normalized_domain",
437
+ "billing_stage",
438
+ "stage_since",
439
+ "loss_kind",
440
+ "winning_fact_id",
441
+ "winning_fact_revision",
442
+ "organization_revision",
443
+ "billing_revision",
444
+ "state_revision",
445
+ "membership_set_revision",
446
+ "members_complete",
447
+ "state_fingerprint",
448
+ "membership_set_fingerprint",
449
+ "workspace_deleted_at",
450
+ "workspace_deleted_fact_id",
451
+ "workspace_deleted_fact_revision",
452
+ ]);
453
+ if (!Array.isArray(row.company_key_history))
454
+ invalid(`${path}.company_key_history`, "must be an array");
455
+ const history = row.company_key_history.map((item, index) => canonicalText(item, `${path}.company_key_history[${index}]`));
456
+ assertSortedUnique(history, (item) => item, `${path}.company_key_history`);
457
+ if (typeof row.members_complete !== "boolean")
458
+ invalid(`${path}.members_complete`, "must be a boolean");
459
+ const state = {
460
+ clerk_org_id: canonicalText(row.clerk_org_id, `${path}.clerk_org_id`),
461
+ company_key: canonicalText(row.company_key, `${path}.company_key`),
462
+ company_key_history: history,
463
+ normalized_domain: nullableDomain(row.normalized_domain, `${path}.normalized_domain`),
464
+ billing_stage: nullableEnum(row.billing_stage, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_BILLING_STAGES, `${path}.billing_stage`),
465
+ stage_since: nullableTimestamp(row.stage_since, `${path}.stage_since`),
466
+ loss_kind: nullableEnum(row.loss_kind, ["paid_churn", "trial_non_conversion"], `${path}.loss_kind`),
467
+ winning_fact_id: nullableText(row.winning_fact_id, `${path}.winning_fact_id`),
468
+ winning_fact_revision: nullablePositiveInteger(row.winning_fact_revision, `${path}.winning_fact_revision`),
469
+ organization_revision: positiveInteger(row.organization_revision, `${path}.organization_revision`),
470
+ billing_revision: positiveInteger(row.billing_revision, `${path}.billing_revision`),
471
+ state_revision: positiveInteger(row.state_revision, `${path}.state_revision`),
472
+ membership_set_revision: nonNegativeInteger(row.membership_set_revision, `${path}.membership_set_revision`),
473
+ members_complete: row.members_complete,
474
+ state_fingerprint: fingerprint(row.state_fingerprint, `${path}.state_fingerprint`),
475
+ membership_set_fingerprint: fingerprint(row.membership_set_fingerprint, `${path}.membership_set_fingerprint`),
476
+ workspace_deleted_at: nullableTimestamp(row.workspace_deleted_at, `${path}.workspace_deleted_at`),
477
+ workspace_deleted_fact_id: nullableText(row.workspace_deleted_fact_id, `${path}.workspace_deleted_fact_id`),
478
+ workspace_deleted_fact_revision: nullablePositiveInteger(row.workspace_deleted_fact_revision, `${path}.workspace_deleted_fact_revision`),
479
+ };
480
+ const expectedCompanyKey = state.normalized_domain
481
+ ? `domain:${state.normalized_domain}`
482
+ : `clerk_org:${state.clerk_org_id}`;
483
+ same(state.company_key, expectedCompanyKey, `${path}.company_key`, "must match canonical organization identity");
484
+ assertBillingShape(state, path);
485
+ if ((state.workspace_deleted_at === null) !== (state.workspace_deleted_fact_id === null)
486
+ || (state.workspace_deleted_at === null) !== (state.workspace_deleted_fact_revision === null)) {
487
+ invalid(path, "workspace deletion evidence must be complete or entirely null");
488
+ }
489
+ return state;
490
+ }
491
+ function parseMembershipState(value, path) {
492
+ const row = exactRecord(value, path, [
493
+ "clerk_membership_id",
494
+ "journey_id",
495
+ "clerk_user_id",
496
+ "clerk_org_id",
497
+ "role",
498
+ "is_creator",
499
+ "contributes_to_lifecycle",
500
+ "membership_created_at",
501
+ "membership_revision",
502
+ "state_fingerprint",
503
+ "removed_at",
504
+ "removed_reason",
505
+ ]);
506
+ if (typeof row.is_creator !== "boolean")
507
+ invalid(`${path}.is_creator`, "must be a boolean");
508
+ if (typeof row.contributes_to_lifecycle !== "boolean") {
509
+ invalid(`${path}.contributes_to_lifecycle`, "must be a boolean");
510
+ }
511
+ const state = {
512
+ clerk_membership_id: canonicalText(row.clerk_membership_id, `${path}.clerk_membership_id`),
513
+ journey_id: nullableText(row.journey_id, `${path}.journey_id`),
514
+ clerk_user_id: canonicalText(row.clerk_user_id, `${path}.clerk_user_id`),
515
+ clerk_org_id: canonicalText(row.clerk_org_id, `${path}.clerk_org_id`),
516
+ role: canonicalText(row.role, `${path}.role`),
517
+ is_creator: row.is_creator,
518
+ contributes_to_lifecycle: row.contributes_to_lifecycle,
519
+ membership_created_at: timestamp(row.membership_created_at, `${path}.membership_created_at`),
520
+ membership_revision: positiveInteger(row.membership_revision, `${path}.membership_revision`),
521
+ state_fingerprint: fingerprint(row.state_fingerprint, `${path}.state_fingerprint`),
522
+ removed_at: nullableTimestamp(row.removed_at, `${path}.removed_at`),
523
+ removed_reason: nullableEnum(row.removed_reason, ["member_removed", "user_deleted", "workspace_deleted"], `${path}.removed_reason`),
524
+ };
525
+ if ((state.removed_at === null) !== (state.removed_reason === null)) {
526
+ invalid(path, "removed_at and removed_reason must be complete or entirely null");
527
+ }
528
+ if (state.removed_at && Date.parse(state.removed_at) < Date.parse(state.membership_created_at)) {
529
+ invalid(`${path}.removed_at`, "cannot predate membership_created_at");
530
+ }
531
+ if (state.journey_id !== null) {
532
+ same(state.journey_id, futureSignupJourneyId(state.clerk_user_id), `${path}.journey_id`, "must match the retained Clerk user journey");
533
+ }
534
+ const expectedContribution = state.removed_reason === null
535
+ || state.removed_reason === "workspace_deleted";
536
+ if (state.contributes_to_lifecycle !== expectedContribution) {
537
+ invalid(`${path}.contributes_to_lifecycle`, "does not match terminal membership policy");
538
+ }
539
+ return state;
540
+ }
541
+ function parseSubject(value, path) {
542
+ const row = exactRecord(value, path, [
543
+ "kind",
544
+ "key",
545
+ "billing_stage",
546
+ "stage_since",
547
+ "aggregate_revision",
548
+ "aggregate_fingerprint",
549
+ "winning_clerk_org_id",
550
+ "winning_fact_id",
551
+ "winning_fact_revision",
552
+ "source_state_revision",
553
+ "membership_set_revision",
554
+ "members_complete",
555
+ "projection_action",
556
+ "replacement_company_key",
557
+ "departed_clerk_org_ids",
558
+ "oxygen_product_lifecycle",
559
+ "external_identities",
560
+ "natural_identity",
561
+ "display",
562
+ "represented_clerk_org_ids",
563
+ "represented_company_keys",
564
+ "membership_ids",
565
+ "membership_contributors",
566
+ ]);
567
+ if (!Array.isArray(row.departed_clerk_org_ids))
568
+ invalid(`${path}.departed_clerk_org_ids`, "must be an array");
569
+ if (!Array.isArray(row.external_identities))
570
+ invalid(`${path}.external_identities`, "must be an array");
571
+ if (!Array.isArray(row.represented_clerk_org_ids))
572
+ invalid(`${path}.represented_clerk_org_ids`, "must be an array");
573
+ if (!Array.isArray(row.represented_company_keys))
574
+ invalid(`${path}.represented_company_keys`, "must be an array");
575
+ if (!Array.isArray(row.membership_ids))
576
+ invalid(`${path}.membership_ids`, "must be an array");
577
+ if (!Array.isArray(row.membership_contributors))
578
+ invalid(`${path}.membership_contributors`, "must be an array");
579
+ literal(row.members_complete, true, `${path}.members_complete`);
580
+ const aggregateRevision = positiveInteger(row.aggregate_revision, `${path}.aggregate_revision`);
581
+ const aggregateFingerprint = fingerprint(row.aggregate_fingerprint, `${path}.aggregate_fingerprint`);
582
+ const lifecycleRaw = exactRecord(row.oxygen_product_lifecycle, `${path}.oxygen_product_lifecycle`, [
583
+ "revision",
584
+ "fingerprint",
585
+ ]);
586
+ const lifecycle = {
587
+ revision: positiveInteger(lifecycleRaw.revision, `${path}.oxygen_product_lifecycle.revision`),
588
+ fingerprint: fingerprint(lifecycleRaw.fingerprint, `${path}.oxygen_product_lifecycle.fingerprint`),
589
+ };
590
+ same(lifecycle.revision, aggregateRevision, `${path}.oxygen_product_lifecycle.revision`, "must equal aggregate_revision");
591
+ same(lifecycle.fingerprint, aggregateFingerprint, `${path}.oxygen_product_lifecycle.fingerprint`, "must equal aggregate_fingerprint");
592
+ const identities = row.external_identities.map((item, index) => {
593
+ const identityPath = `${path}.external_identities[${index}]`;
594
+ const identity = exactRecord(item, identityPath, ["provider", "object", "id"]);
595
+ literal(identity.provider, "clerk", `${identityPath}.provider`);
596
+ return {
597
+ provider: "clerk",
598
+ object: enumValue(identity.object, ["user", "organization"], `${identityPath}.object`),
599
+ id: canonicalText(identity.id, `${identityPath}.id`),
600
+ };
601
+ });
602
+ assertSortedUnique(identities, (item) => `${item.object}\u0000${item.id}`, `${path}.external_identities`);
603
+ const naturalRaw = exactRecord(row.natural_identity, `${path}.natural_identity`, ["email", "domain"]);
604
+ const naturalIdentity = {
605
+ email: nullableEmail(naturalRaw.email, `${path}.natural_identity.email`),
606
+ domain: nullableDomain(naturalRaw.domain, `${path}.natural_identity.domain`),
607
+ };
608
+ const displayRaw = exactRecord(row.display, `${path}.display`, [
609
+ "first_name",
610
+ "last_name",
611
+ "company_name",
612
+ ]);
613
+ const display = {
614
+ first_name: nullableDisplayText(displayRaw.first_name, `${path}.display.first_name`),
615
+ last_name: nullableDisplayText(displayRaw.last_name, `${path}.display.last_name`),
616
+ company_name: nullableDisplayText(displayRaw.company_name, `${path}.display.company_name`),
617
+ };
618
+ const stringArray = (raw, field) => {
619
+ const values = raw.map((item, index) => canonicalText(item, `${path}.${field}[${index}]`));
620
+ assertSortedUnique(values, (item) => item, `${path}.${field}`);
621
+ return values;
622
+ };
623
+ const contributors = row.membership_contributors.map((item, index) => parseContributor(item, `${path}.membership_contributors[${index}]`));
624
+ assertSortedUnique(contributors, (item) => `${item.clerk_org_id}\u0000${item.clerk_membership_id}`, `${path}.membership_contributors`);
625
+ const subject = {
626
+ kind: enumValue(row.kind, ["company", "person"], `${path}.kind`),
627
+ key: canonicalText(row.key, `${path}.key`),
628
+ billing_stage: nullableEnum(row.billing_stage, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_BILLING_STAGES, `${path}.billing_stage`),
629
+ stage_since: nullableTimestamp(row.stage_since, `${path}.stage_since`),
630
+ aggregate_revision: aggregateRevision,
631
+ aggregate_fingerprint: aggregateFingerprint,
632
+ winning_clerk_org_id: nullableText(row.winning_clerk_org_id, `${path}.winning_clerk_org_id`),
633
+ winning_fact_id: nullableText(row.winning_fact_id, `${path}.winning_fact_id`),
634
+ winning_fact_revision: nullablePositiveInteger(row.winning_fact_revision, `${path}.winning_fact_revision`),
635
+ source_state_revision: positiveInteger(row.source_state_revision, `${path}.source_state_revision`),
636
+ membership_set_revision: nonNegativeInteger(row.membership_set_revision, `${path}.membership_set_revision`),
637
+ members_complete: true,
638
+ projection_action: enumValue(row.projection_action, ["upsert", "neutralize"], `${path}.projection_action`),
639
+ replacement_company_key: nullableText(row.replacement_company_key, `${path}.replacement_company_key`),
640
+ departed_clerk_org_ids: stringArray(row.departed_clerk_org_ids, "departed_clerk_org_ids"),
641
+ oxygen_product_lifecycle: lifecycle,
642
+ external_identities: identities,
643
+ natural_identity: naturalIdentity,
644
+ display,
645
+ represented_clerk_org_ids: stringArray(row.represented_clerk_org_ids, "represented_clerk_org_ids"),
646
+ represented_company_keys: stringArray(row.represented_company_keys, "represented_company_keys"),
647
+ membership_ids: stringArray(row.membership_ids, "membership_ids"),
648
+ membership_contributors: contributors,
649
+ };
650
+ assertSubjectShape(subject, path);
651
+ return subject;
652
+ }
653
+ function parseContributor(value, path) {
654
+ const row = exactRecord(value, path, [
655
+ "clerk_membership_id",
656
+ "clerk_org_id",
657
+ "role",
658
+ "is_creator",
659
+ "active",
660
+ "removed_reason",
661
+ ]);
662
+ if (typeof row.is_creator !== "boolean")
663
+ invalid(`${path}.is_creator`, "must be a boolean");
664
+ if (typeof row.active !== "boolean")
665
+ invalid(`${path}.active`, "must be a boolean");
666
+ const removedReason = nullableEnum(row.removed_reason, ["member_removed", "user_deleted", "workspace_deleted"], `${path}.removed_reason`);
667
+ if (row.active !== (removedReason === null)) {
668
+ invalid(path, "active and removed_reason must describe the same terminal state");
669
+ }
670
+ return {
671
+ clerk_membership_id: canonicalText(row.clerk_membership_id, `${path}.clerk_membership_id`),
672
+ clerk_org_id: canonicalText(row.clerk_org_id, `${path}.clerk_org_id`),
673
+ role: canonicalText(row.role, `${path}.role`),
674
+ is_creator: row.is_creator,
675
+ active: row.active,
676
+ removed_reason: removedReason,
677
+ };
678
+ }
679
+ function assertSubjectShape(// skipcq: JS-R1005 -- closed discriminated-union validator keeps Person/Company identity rules auditable together.
680
+ subject, path) {
681
+ assertBillingShape(subject, path);
682
+ const userIds = subject.external_identities
683
+ .filter((item) => item.object === "user")
684
+ .map((item) => item.id);
685
+ const organizationIds = subject.external_identities
686
+ .filter((item) => item.object === "organization")
687
+ .map((item) => item.id);
688
+ if (subject.kind === "person") {
689
+ if (subject.projection_action !== "upsert")
690
+ invalid(`${path}.projection_action`, "Person subjects can only be upserted");
691
+ if (userIds.length !== 1 || organizationIds.length !== 0) {
692
+ invalid(`${path}.external_identities`, "a Person requires exactly one Clerk user identity");
693
+ }
694
+ same(subject.key, `clerk_user:${userIds[0]}`, `${path}.key`, "must derive from the exact Clerk user identity");
695
+ if (subject.natural_identity.domain !== null || subject.display.company_name !== null) {
696
+ invalid(path, "Person domain and company_name must be null");
697
+ }
698
+ if (subject.replacement_company_key !== null || subject.departed_clerk_org_ids.length !== 0) {
699
+ invalid(path, "a Person cannot carry Company rekey departure fields");
700
+ }
701
+ if (subject.membership_contributors.length === 0) {
702
+ invalid(`${path}.membership_contributors`, "a Person requires retained membership evidence");
703
+ }
704
+ const contributorMembershipIds = subject.membership_contributors
705
+ .map((item) => item.clerk_membership_id)
706
+ .sort(codeUnitCompare);
707
+ sameJson(subject.membership_ids, contributorMembershipIds, `${path}.membership_ids`, "must match membership contributors");
708
+ const contributorOrgIds = uniqueSorted(subject.membership_contributors.map((item) => item.clerk_org_id));
709
+ sameJson(subject.represented_clerk_org_ids, contributorOrgIds, `${path}.represented_clerk_org_ids`, "must match membership contributors");
710
+ return;
711
+ }
712
+ if (userIds.length !== 0 || subject.natural_identity.email !== null) {
713
+ invalid(path, "Company subjects cannot contain Person identities");
714
+ }
715
+ if (subject.display.first_name !== null || subject.display.last_name !== null) {
716
+ invalid(`${path}.display`, "Company first_name and last_name must be null");
717
+ }
718
+ if (subject.membership_ids.length !== 0 || subject.membership_contributors.length !== 0) {
719
+ invalid(path, "Company membership evidence belongs on Person subjects");
720
+ }
721
+ sameJson(organizationIds, subject.represented_clerk_org_ids, `${path}.external_identities`, "Clerk organization identities must match represented_clerk_org_ids");
722
+ sameJson(subject.represented_company_keys, [subject.key], `${path}.represented_company_keys`, "must contain only the subject key");
723
+ if (subject.projection_action === "neutralize") {
724
+ if (organizationIds.length !== 0) {
725
+ invalid(path, "a neutralized Company cannot assert Clerk organization identities");
726
+ }
727
+ if (subject.replacement_company_key === null || subject.departed_clerk_org_ids.length === 0) {
728
+ invalid(path, "a neutralized Company requires an explicit replacement and departure");
729
+ }
730
+ if (subject.billing_stage !== null)
731
+ invalid(`${path}.billing_stage`, "must be null when neutralized");
732
+ if (subject.key.startsWith("domain:")) {
733
+ same(subject.natural_identity.domain, subject.key.slice("domain:".length), `${path}.natural_identity.domain`, "must preserve the historical domain as lookup-only identity");
734
+ }
735
+ else if (subject.natural_identity.domain !== null) {
736
+ invalid(`${path}.natural_identity.domain`, "must be null for a domainless Company key");
737
+ }
738
+ return;
739
+ }
740
+ if (organizationIds.length === 0) {
741
+ invalid(`${path}.external_identities`, "an upserted Company requires a Clerk organization identity");
742
+ }
743
+ const expectedKey = subject.natural_identity.domain
744
+ ? `domain:${subject.natural_identity.domain}`
745
+ : organizationIds.length === 1
746
+ ? `clerk_org:${organizationIds[0]}`
747
+ : null;
748
+ if (expectedKey === null) {
749
+ invalid(`${path}.external_identities`, "a domainless Company can represent exactly one Clerk organization");
750
+ }
751
+ same(subject.key, expectedKey, `${path}.key`, "must derive from canonical Company identity");
752
+ }
753
+ function assertProjectionClosure(// skipcq: JS-R1005 -- one closure pass verifies cross-linked organizations, memberships, subjects, winners, and creator evidence.
754
+ snapshot) {
755
+ const organizationById = new Map(snapshot.organization_states.map((item) => [item.clerk_org_id, item]));
756
+ const membershipById = new Map(snapshot.membership_states.map((item) => [item.clerk_membership_id, item]));
757
+ if (membershipById.size !== snapshot.membership_states.length) {
758
+ invalid("$.snapshot.membership_states", "Clerk membership IDs must be globally unique");
759
+ }
760
+ if (snapshot.organization_states.some((item) => !item.members_complete)) {
761
+ invalid("$.snapshot.organization_states", "a complete projection requires members_complete=true");
762
+ }
763
+ if (!organizationById.has(snapshot.causal_clerk_org_id)) {
764
+ invalid("$.snapshot.organization_states", "must include causal_clerk_org_id");
765
+ }
766
+ const companies = snapshot.subjects.filter((subject) => subject.kind === "company");
767
+ const current = companies.filter((subject) => subject.projection_action === "upsert"
768
+ && subject.represented_clerk_org_ids.includes(snapshot.causal_clerk_org_id)
769
+ && subject.replacement_company_key === null
770
+ && subject.departed_clerk_org_ids.length === 0);
771
+ if (current.length !== 1) {
772
+ invalid("$.snapshot.subjects", "must contain exactly one current Company for causal_clerk_org_id");
773
+ }
774
+ const currentCompany = current[0];
775
+ if (!currentCompany) {
776
+ invalid("$.snapshot.subjects", "must contain a current Company for causal_clerk_org_id");
777
+ }
778
+ for (const [index, subject] of snapshot.subjects.entries()) {
779
+ const path = `$.snapshot.subjects[${index}]`;
780
+ if (subject.kind === "company") {
781
+ for (const clerkOrgId of subject.represented_clerk_org_ids) {
782
+ const organization = organizationById.get(clerkOrgId);
783
+ if (!organization || organization.company_key !== subject.key) {
784
+ invalid(`${path}.represented_clerk_org_ids`, "must match organization_states Company identity");
785
+ }
786
+ }
787
+ const winner = subject.winning_clerk_org_id
788
+ ? organizationById.get(subject.winning_clerk_org_id)
789
+ : undefined;
790
+ if (subject.winning_clerk_org_id && !winner) {
791
+ invalid(`${path}.winning_clerk_org_id`, "must reference retained organization_states evidence");
792
+ }
793
+ if (winner) {
794
+ if (!subject.represented_clerk_org_ids.includes(winner.clerk_org_id)) {
795
+ invalid(`${path}.winning_clerk_org_id`, "must be represented by this Company");
796
+ }
797
+ same(subject.billing_stage, winner.billing_stage, `${path}.billing_stage`, "must match the winning organization");
798
+ same(subject.stage_since, winner.stage_since, `${path}.stage_since`, "must match the winning organization");
799
+ same(subject.winning_fact_id, winner.winning_fact_id, `${path}.winning_fact_id`, "must match the winning organization");
800
+ same(subject.winning_fact_revision, winner.winning_fact_revision, `${path}.winning_fact_revision`, "must match the winning organization");
801
+ }
802
+ if (subject !== currentCompany) {
803
+ same(subject.replacement_company_key, currentCompany.key, `${path}.replacement_company_key`, "historical Companies must explicitly depart to the current Company");
804
+ sameJson(subject.departed_clerk_org_ids, [snapshot.causal_clerk_org_id], `${path}.departed_clerk_org_ids`, "must contain exactly the causal Clerk organization");
805
+ if (subject.represented_clerk_org_ids.includes(snapshot.causal_clerk_org_id)) {
806
+ invalid(`${path}.represented_clerk_org_ids`, "a departed Company cannot retain the causal Clerk identity");
807
+ }
808
+ }
809
+ continue;
810
+ }
811
+ if (!subject.membership_contributors.some((item) => item.clerk_org_id === snapshot.causal_clerk_org_id)) {
812
+ invalid(`${path}.membership_contributors`, "each affected Person requires causal membership evidence");
813
+ }
814
+ const clerkUserId = subject.key.slice("clerk_user:".length);
815
+ for (const contributor of subject.membership_contributors) {
816
+ const membership = membershipById.get(contributor.clerk_membership_id);
817
+ if (!membership
818
+ || membership.clerk_user_id !== clerkUserId
819
+ || membership.clerk_org_id !== contributor.clerk_org_id
820
+ || membership.role !== contributor.role
821
+ || membership.is_creator !== contributor.is_creator
822
+ || (membership.removed_at === null) !== contributor.active
823
+ || membership.removed_reason !== contributor.removed_reason) {
824
+ invalid(`${path}.membership_contributors`, "must match retained membership_states evidence");
825
+ }
826
+ }
827
+ const contributorCompanyKeys = uniqueSorted(subject.membership_contributors.map((item) => {
828
+ const organization = organizationById.get(item.clerk_org_id);
829
+ if (!organization) {
830
+ invalid(`${path}.membership_contributors`, "must reference retained organization_states evidence");
831
+ }
832
+ return organization.company_key;
833
+ }));
834
+ sameJson(subject.represented_company_keys, contributorCompanyKeys, `${path}.represented_company_keys`, "must match retained organization identity evidence");
835
+ const winner = subject.winning_clerk_org_id
836
+ ? organizationById.get(subject.winning_clerk_org_id)
837
+ : undefined;
838
+ if (subject.winning_clerk_org_id && !winner) {
839
+ invalid(`${path}.winning_clerk_org_id`, "must reference retained organization_states evidence");
840
+ }
841
+ if (winner) {
842
+ if (!subject.membership_contributors.some((item) => item.clerk_org_id === winner.clerk_org_id)) {
843
+ invalid(`${path}.winning_clerk_org_id`, "must be represented by Person membership evidence");
844
+ }
845
+ same(subject.billing_stage, winner.billing_stage, `${path}.billing_stage`, "must match the winning organization");
846
+ same(subject.stage_since, winner.stage_since, `${path}.stage_since`, "must match the winning organization");
847
+ same(subject.winning_fact_id, winner.winning_fact_id, `${path}.winning_fact_id`, "must match the winning organization");
848
+ same(subject.winning_fact_revision, winner.winning_fact_revision, `${path}.winning_fact_revision`, "must match the winning organization");
849
+ }
850
+ }
851
+ }
852
+ function parseShippedActivity(value) {
853
+ const path = "$.activity";
854
+ const row = exactRecord(value, path, [
855
+ "type",
856
+ "source_provider",
857
+ "provider_event_id",
858
+ "occurred_at",
859
+ "summary",
860
+ "subject_keys",
861
+ "metadata",
862
+ ]);
863
+ literal(row.source_provider, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_AUTHORITY, `${path}.source_provider`);
864
+ if (!Array.isArray(row.subject_keys))
865
+ invalid(`${path}.subject_keys`, "must be an array");
866
+ const subjectKeys = row.subject_keys.map((item, index) => canonicalText(item, `${path}.subject_keys[${index}]`));
867
+ assertSortedUnique(subjectKeys, (item) => item, `${path}.subject_keys`);
868
+ const metadataRaw = exactRecord(row.metadata, `${path}.metadata`, [
869
+ "source_fact_id",
870
+ "source_fact_revision",
871
+ "causal_clerk_org_id",
872
+ "loss_kind",
873
+ "termination_kind",
874
+ ]);
875
+ return {
876
+ type: enumValue(row.type, [...FUTURE_SIGNUP_LIFECYCLE_PROJECTION_ACTIVITY_TYPES, "note"], `${path}.type`),
877
+ source_provider: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_AUTHORITY,
878
+ provider_event_id: canonicalText(row.provider_event_id, `${path}.provider_event_id`),
879
+ occurred_at: timestamp(row.occurred_at, `${path}.occurred_at`),
880
+ summary: displayText(row.summary, `${path}.summary`, 500),
881
+ subject_keys: subjectKeys,
882
+ metadata: {
883
+ source_fact_id: canonicalText(metadataRaw.source_fact_id, `${path}.metadata.source_fact_id`),
884
+ source_fact_revision: positiveInteger(metadataRaw.source_fact_revision, `${path}.metadata.source_fact_revision`),
885
+ causal_clerk_org_id: canonicalText(metadataRaw.causal_clerk_org_id, `${path}.metadata.causal_clerk_org_id`),
886
+ loss_kind: nullableEnum(metadataRaw.loss_kind, ["paid_churn", "trial_non_conversion"], `${path}.metadata.loss_kind`),
887
+ termination_kind: nullableEnum(metadataRaw.termination_kind, ["replaced", "duplicate", "unqualified"], `${path}.metadata.termination_kind`),
888
+ },
889
+ };
890
+ }
891
+ function assertShippedActivity(activity, sourceFact, snapshot) {
892
+ const expectedType = sourceFact.fact_type === "trial_started"
893
+ ? "trial_started"
894
+ : sourceFact.fact_type === "paid"
895
+ ? "plan_paid"
896
+ : sourceFact.fact_type === "lost"
897
+ ? "customer_lost"
898
+ : sourceFact.fact_type === "workspace_deleted"
899
+ ? "workspace_deleted"
900
+ : "note";
901
+ same(activity.type, expectedType, "$.activity.type", "does not match source_fact.fact_type");
902
+ same(activity.provider_event_id, futureSignupLifecycleEventId(futureSignupLifecycleProjectionFactId(sourceFact.fact_id), sourceFact.fact_revision), "$.activity.provider_event_id", "must equal the projection event identity");
903
+ same(activity.occurred_at, sourceFact.occurred_at, "$.activity.occurred_at", "must match source_fact.occurred_at");
904
+ same(activity.metadata.source_fact_id, sourceFact.fact_id, "$.activity.metadata.source_fact_id", "must match source_fact.fact_id");
905
+ same(activity.metadata.source_fact_revision, sourceFact.fact_revision, "$.activity.metadata.source_fact_revision", "must match source_fact.fact_revision");
906
+ same(activity.metadata.causal_clerk_org_id, sourceFact.clerk_org_id, "$.activity.metadata.causal_clerk_org_id", "must match source_fact.clerk_org_id");
907
+ same(activity.metadata.loss_kind, sourceFact.loss_kind, "$.activity.metadata.loss_kind", "must match source_fact.loss_kind");
908
+ same(activity.metadata.termination_kind, sourceFact.termination_kind, "$.activity.metadata.termination_kind", "must match source_fact.termination_kind");
909
+ const subjects = new Map(snapshot.subjects.map((item) => [item.key, item]));
910
+ for (const [index, key] of activity.subject_keys.entries()) {
911
+ const subject = subjects.get(key);
912
+ if (!subject)
913
+ invalid(`$.activity.subject_keys[${index}]`, "must reference a snapshot subject");
914
+ if (subject.projection_action === "neutralize") {
915
+ invalid(`$.activity.subject_keys[${index}]`, "cannot reference a neutralized subject");
916
+ }
917
+ }
918
+ if (expectedType !== "note" && activity.subject_keys.length === 0) {
919
+ invalid("$.activity.subject_keys", "customer lifecycle activity requires a participant");
920
+ }
921
+ }
922
+ function normalizeSubjects(snapshot, sourceFact) {
923
+ const organizationById = new Map(snapshot.organization_states.map((item) => [item.clerk_org_id, item]));
924
+ return snapshot.subjects.map((subject) => {
925
+ const aliases = subject.projection_action === "neutralize"
926
+ ? []
927
+ : [
928
+ ...subject.external_identities.map((identity) => ({
929
+ type: identity.object === "user" ? "clerk_user" : "clerk_organization",
930
+ value: identity.id,
931
+ })),
932
+ ...(subject.natural_identity.email
933
+ ? [{ type: "email", value: subject.natural_identity.email }]
934
+ : []),
935
+ ...(subject.natural_identity.domain
936
+ ? [{ type: "domain", value: subject.natural_identity.domain }]
937
+ : []),
938
+ ...(subject.kind === "company"
939
+ && subject.represented_clerk_org_ids.includes(sourceFact.clerk_org_id)
940
+ && sourceFact.stripe_customer_id
941
+ ? [{ type: "stripe_customer", value: sourceFact.stripe_customer_id }]
942
+ : []),
943
+ ].sort(compareAlias);
944
+ const winner = subject.winning_clerk_org_id
945
+ ? organizationById.get(subject.winning_clerk_org_id)
946
+ : undefined;
947
+ return {
948
+ kind: subject.kind,
949
+ key: subject.key,
950
+ aliases,
951
+ lookup_identities: subject.projection_action === "neutralize"
952
+ && subject.natural_identity.domain
953
+ ? [{ type: "domain", value: subject.natural_identity.domain }]
954
+ : [],
955
+ display: subject.display,
956
+ aggregate_revision: subject.aggregate_revision,
957
+ aggregate_fingerprint: subject.aggregate_fingerprint,
958
+ source_state_revision: subject.source_state_revision,
959
+ membership_set_revision: subject.membership_set_revision,
960
+ members_complete: true,
961
+ projection_action: subject.projection_action,
962
+ replacement_company_key: subject.replacement_company_key,
963
+ departed_clerk_org_ids: subject.departed_clerk_org_ids,
964
+ billing_stage: subject.billing_stage,
965
+ stage_since: subject.stage_since,
966
+ loss_kind: subject.billing_stage === "lost" ? winner?.loss_kind ?? null : null,
967
+ winning_clerk_org_id: subject.winning_clerk_org_id,
968
+ winning_fact_id: subject.winning_fact_id,
969
+ winning_fact_revision: subject.winning_fact_revision,
970
+ prebilling_candidate: null,
971
+ membership_contributors: subject.membership_contributors,
972
+ };
973
+ });
974
+ }
975
+ function normalizeActivity(activity, sourceFact, subjects) {
976
+ if (sourceFact.fact_type === "projection_refreshed" || sourceFact.fact_type === "subscription_ended") {
977
+ return {
978
+ emit: false,
979
+ type: null,
980
+ source_provider: null,
981
+ provider_event_id: null,
982
+ occurred_at: null,
983
+ summary: null,
984
+ participants: [],
985
+ };
986
+ }
987
+ const byKey = new Map(subjects.map((subject) => [subject.key, subject]));
988
+ const currentCompany = subjects.find((subject) => subject.kind === "company"
989
+ && subject.projection_action === "upsert"
990
+ && subject.aliases.some((alias) => alias.type === "clerk_organization" && alias.value === sourceFact.clerk_org_id));
991
+ const participants = activity.subject_keys.map((key) => {
992
+ const subject = byKey.get(key);
993
+ if (!subject) {
994
+ invalid("$.activity.subject_keys", "must reference a normalized snapshot subject");
995
+ }
996
+ return {
997
+ kind: subject.kind,
998
+ key,
999
+ role: key === currentCompany?.key ? "primary" : "related",
1000
+ };
1001
+ });
1002
+ if (!participants.some((participant) => participant.role === "primary")) {
1003
+ invalid("$.activity.subject_keys", "customer lifecycle activity must include the causal/current Company");
1004
+ }
1005
+ return {
1006
+ emit: true,
1007
+ type: activity.type,
1008
+ source_provider: FUTURE_SIGNUP_LIFECYCLE_PROJECTION_AUTHORITY,
1009
+ provider_event_id: activity.provider_event_id,
1010
+ occurred_at: activity.occurred_at,
1011
+ summary: activity.summary,
1012
+ participants,
1013
+ };
1014
+ }
1015
+ function assertBillingShape(state, path) {
1016
+ if (state.billing_stage === null) {
1017
+ if (state.stage_since !== null || state.winning_fact_id !== null || state.winning_fact_revision !== null
1018
+ || ("winning_clerk_org_id" in state && state.winning_clerk_org_id !== null)
1019
+ || ("loss_kind" in state && state.loss_kind !== null)) {
1020
+ invalid(path, "a null billing stage requires null winning evidence");
1021
+ }
1022
+ return;
1023
+ }
1024
+ present(state.stage_since, `${path}.stage_since`);
1025
+ present(state.winning_fact_id, `${path}.winning_fact_id`);
1026
+ present(state.winning_fact_revision, `${path}.winning_fact_revision`);
1027
+ if ("winning_clerk_org_id" in state)
1028
+ present(state.winning_clerk_org_id ?? null, `${path}.winning_clerk_org_id`);
1029
+ if ("loss_kind" in state) {
1030
+ if (state.billing_stage === "lost")
1031
+ present(state.loss_kind ?? null, `${path}.loss_kind`);
1032
+ else if (state.loss_kind !== null)
1033
+ invalid(`${path}.loss_kind`, "must be null unless billing_stage is lost");
1034
+ }
1035
+ }
1036
+ function parseStageMapping(value, path) {
1037
+ const row = exactRecord(value, path, FUTURE_SIGNUP_LIFECYCLE_PROJECTION_SEMANTIC_STAGES);
1038
+ const mapping = Object.fromEntries(FUTURE_SIGNUP_LIFECYCLE_PROJECTION_SEMANTIC_STAGES.map((stage) => [
1039
+ stage,
1040
+ stageToken(row[stage], `${path}.${stage}`),
1041
+ ]));
1042
+ if (new Set(Object.values(mapping)).size !== FUTURE_SIGNUP_LIFECYCLE_PROJECTION_SEMANTIC_STAGES.length) {
1043
+ invalid(path, "must map the six semantic stages to six distinct physical tokens");
1044
+ }
1045
+ return mapping;
1046
+ }
1047
+ function exactRecord(value, path, fields) {
1048
+ if (!value || typeof value !== "object" || Array.isArray(value))
1049
+ invalid(path, "must be an object");
1050
+ const row = value;
1051
+ const allowed = new Set(fields);
1052
+ for (const key of Object.keys(row)) {
1053
+ if (!allowed.has(key))
1054
+ invalid(`${path}.${key}`, "is not part of the closed contract");
1055
+ }
1056
+ for (const field of fields) {
1057
+ if (!Object.hasOwn(row, field))
1058
+ invalid(`${path}.${field}`, "is required");
1059
+ }
1060
+ return row;
1061
+ }
1062
+ function canonicalText(value, path, maxLength = 512) {
1063
+ if (typeof value !== "string" || value.length === 0)
1064
+ invalid(path, "must be a non-empty string");
1065
+ if (value !== value.trim())
1066
+ invalid(path, "must not contain surrounding whitespace");
1067
+ if (value.length > maxLength)
1068
+ invalid(path, `must be at most ${maxLength} characters`);
1069
+ if (hasAsciiControlCharacter(value))
1070
+ invalid(path, "must not contain control characters");
1071
+ return value;
1072
+ }
1073
+ function hasAsciiControlCharacter(value) {
1074
+ for (const character of value) {
1075
+ const codePoint = character.codePointAt(0);
1076
+ if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f))
1077
+ return true;
1078
+ }
1079
+ return false;
1080
+ }
1081
+ function nullableText(value, path) {
1082
+ return value === null ? null : canonicalText(value, path);
1083
+ }
1084
+ function displayText(value, path, maxLength = 256) {
1085
+ return canonicalText(value, path, maxLength);
1086
+ }
1087
+ function nullableDisplayText(value, path) {
1088
+ return value === null ? null : displayText(value, path);
1089
+ }
1090
+ function timestamp(value, path) {
1091
+ if (typeof value !== "string")
1092
+ invalid(path, "must be an ISO-8601 UTC string");
1093
+ const parsed = new Date(value);
1094
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) {
1095
+ invalid(path, "must be canonical ISO-8601 UTC with millisecond precision");
1096
+ }
1097
+ return value;
1098
+ }
1099
+ function nullableTimestamp(value, path) {
1100
+ return value === null ? null : timestamp(value, path);
1101
+ }
1102
+ function nullableEmail(value, path) {
1103
+ if (value === null)
1104
+ return null;
1105
+ const email = canonicalText(value, path, 320);
1106
+ if (email !== email.toLowerCase())
1107
+ invalid(path, "must be a lowercase canonical email");
1108
+ const at = email.lastIndexOf("@");
1109
+ if (at < 1 || at !== email.indexOf("@") || at === email.length - 1) {
1110
+ invalid(path, "must be a canonical email");
1111
+ }
1112
+ canonicalDomain(email.slice(at + 1), path);
1113
+ return email;
1114
+ }
1115
+ function nullableDomain(value, path) {
1116
+ return value === null ? null : canonicalDomain(canonicalText(value, path, 253), path);
1117
+ }
1118
+ const CANONICAL_DOMAIN = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
1119
+ function canonicalDomain(value, path) {
1120
+ if (value !== value.toLowerCase() || value.startsWith("www.") || !CANONICAL_DOMAIN.test(value)) {
1121
+ invalid(path, "must use canonical domain_v1 form");
1122
+ }
1123
+ return value;
1124
+ }
1125
+ function positiveInteger(value, path) {
1126
+ if (!Number.isSafeInteger(value) || value < 1)
1127
+ invalid(path, "must be a positive safe integer");
1128
+ return value;
1129
+ }
1130
+ function nonNegativeInteger(value, path) {
1131
+ if (!Number.isSafeInteger(value) || value < 0)
1132
+ invalid(path, "must be a non-negative safe integer");
1133
+ return value;
1134
+ }
1135
+ function nullablePositiveInteger(value, path) {
1136
+ return value === null ? null : positiveInteger(value, path);
1137
+ }
1138
+ function nullableNonNegativeInteger(value, path) {
1139
+ return value === null ? null : nonNegativeInteger(value, path);
1140
+ }
1141
+ function nullableCurrency(value, path) {
1142
+ if (value === null)
1143
+ return null;
1144
+ if (typeof value !== "string" || !/^[a-z]{3}$/.test(value))
1145
+ invalid(path, "must be a lowercase ISO currency code");
1146
+ return value;
1147
+ }
1148
+ function fingerprint(value, path) {
1149
+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
1150
+ invalid(path, "must be a lowercase SHA-256 fingerprint");
1151
+ return value;
1152
+ }
1153
+ function enumValue(value, allowed, path) {
1154
+ if (typeof value !== "string" || !allowed.includes(value)) {
1155
+ invalid(path, `must be one of ${allowed.join(", ")}`);
1156
+ }
1157
+ return value;
1158
+ }
1159
+ function nullableEnum(value, allowed, path) {
1160
+ return value === null ? null : enumValue(value, allowed, path);
1161
+ }
1162
+ function literal(value, expected, path) {
1163
+ if (value !== expected)
1164
+ invalid(path, `must equal ${String(expected)}`);
1165
+ }
1166
+ function same(actual, expected, path, rule) {
1167
+ if (actual !== expected)
1168
+ invalid(path, rule);
1169
+ }
1170
+ function sameJson(actual, expected, path, rule) {
1171
+ if (canonicalJson(actual) !== canonicalJson(expected))
1172
+ invalid(path, rule);
1173
+ }
1174
+ function present(value, path) {
1175
+ if (value === null)
1176
+ invalid(path, "is required for this fact or state");
1177
+ }
1178
+ function allNull(value, fields, path) {
1179
+ const row = value;
1180
+ for (const field of fields)
1181
+ if (row[field] !== null)
1182
+ invalid(`${path}.${field}`, "must be null for this fact or state");
1183
+ }
1184
+ function assertSortedUnique(values, key, path) {
1185
+ let previous = null;
1186
+ for (const value of values) {
1187
+ const current = key(value);
1188
+ if (previous !== null && current <= previous)
1189
+ invalid(path, "must be strictly sorted and unique using code-unit order");
1190
+ previous = current;
1191
+ }
1192
+ }
1193
+ function uniqueSorted(values) {
1194
+ return [...new Set(values)].sort(codeUnitCompare);
1195
+ }
1196
+ function compareAlias(left, right) {
1197
+ return codeUnitCompare(left.type, right.type) || codeUnitCompare(left.value, right.value);
1198
+ }
1199
+ function stageToken(value, path) {
1200
+ if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(value)) {
1201
+ invalid(path, "must be a canonical lowercase CRM stage token");
1202
+ }
1203
+ return value;
1204
+ }
1205
+ function canonicalUuid(value, path) {
1206
+ if (typeof value !== "string"
1207
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) {
1208
+ invalid(path, "must be a canonical lowercase workspace user UUID");
1209
+ }
1210
+ return value;
1211
+ }
1212
+ function sha256Canonical(value) {
1213
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
1214
+ }
1215
+ function canonicalJson(value) {
1216
+ return JSON.stringify(canonicalize(value));
1217
+ }
1218
+ function canonicalize(value) {
1219
+ if (Array.isArray(value))
1220
+ return value.map(canonicalize);
1221
+ if (value && typeof value === "object") {
1222
+ return Object.fromEntries(Object.entries(value)
1223
+ .sort(([left], [right]) => codeUnitCompare(left, right))
1224
+ .map(([key, child]) => [key, canonicalize(child)]));
1225
+ }
1226
+ return value;
1227
+ }
1228
+ function codeUnitCompare(left, right) {
1229
+ return left < right ? -1 : left > right ? 1 : 0;
1230
+ }
1231
+ function invalid(path, rule) {
1232
+ throw new FutureSignupLifecycleProjectionContractError(path, rule);
1233
+ }