@volter/twin-segment 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +154 -0
  2. package/client/segment-mirror.css +45 -0
  3. package/client/segment-mirror.tsx +154 -0
  4. package/dist/client/segment-mirror.bundle.js +342 -0
  5. package/dist/client/segment-mirror.css +45 -0
  6. package/dist/client/segment-mirror.d.ts +34 -0
  7. package/dist/client/segment-mirror.js +80 -0
  8. package/dist/client/segment-mirror.tsx +154 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +34 -0
  11. package/dist/src/index.d.ts +8 -0
  12. package/dist/src/index.js +53 -0
  13. package/dist/src/segment-budget.d.ts +41 -0
  14. package/dist/src/segment-budget.js +112 -0
  15. package/dist/src/segment-capabilities.d.ts +12 -0
  16. package/dist/src/segment-capabilities.gen.d.ts +3 -0
  17. package/dist/src/segment-capabilities.gen.js +22 -0
  18. package/dist/src/segment-capabilities.js +907 -0
  19. package/dist/src/segment-conformance.d.ts +8 -0
  20. package/dist/src/segment-conformance.js +106 -0
  21. package/dist/src/segment-connector.d.ts +76 -0
  22. package/dist/src/segment-connector.js +226 -0
  23. package/dist/src/segment-mirror-ui.d.ts +42 -0
  24. package/dist/src/segment-mirror-ui.js +143 -0
  25. package/dist/src/segment-server.d.ts +25 -0
  26. package/dist/src/segment-server.js +90 -0
  27. package/dist/src/segment-surface.gen.d.ts +48 -0
  28. package/dist/src/segment-surface.gen.js +267 -0
  29. package/dist/src/segment-twin.d.ts +98 -0
  30. package/dist/src/segment-twin.js +543 -0
  31. package/package.json +59 -0
  32. package/src/cli.ts +30 -0
  33. package/src/index.ts +87 -0
  34. package/src/segment-budget.ts +138 -0
  35. package/src/segment-capabilities.gen.ts +25 -0
  36. package/src/segment-capabilities.ts +988 -0
  37. package/src/segment-conformance.ts +123 -0
  38. package/src/segment-connector.ts +233 -0
  39. package/src/segment-journey.uitest.ts +116 -0
  40. package/src/segment-mirror-ui.ts +157 -0
  41. package/src/segment-server.ts +95 -0
  42. package/src/segment-surface.gen.ts +277 -0
  43. package/src/segment-twin.ts +664 -0
@@ -0,0 +1,907 @@
1
+ // Segment capability manifest — the REAL vendor surface as the denominator.
2
+ //
3
+ // ── WHERE THIS DENOMINATOR CAME FROM, AND WHERE IT STOPS ─────────────────────────────────────
4
+ // Segment publishes NO machine-readable spec for its ingestion surface, so the denominator was
5
+ // AUTHORED from evidence at var/line/segment/SURFACE.json (the line's A0 station) and compiled
6
+ // into segment-capabilities.gen.ts. Sixteen operations, each carrying the evidence it was
7
+ // ratified on, in two tiers of strength:
8
+ // • SDK WIRE LITERAL (2): POST /v1/batch — the single URL @segment/analytics-node@2.3.0's
9
+ // Publisher ever posts to — and POST /token on the OAuth authorization server.
10
+ // • DOCS ONLY, NOT VERIFIED OFFLINE (14): the six direct POST /v1/<type> routes, the mobile
11
+ // batch route POST /v1/b, analytics.js's POST /v1/t, and the six literal Pixel Routes
12
+ // GET /v1/pixel/<type>. All grounded in github.com/segmentio/segment-docs, the vendor's own
13
+ // docs SOURCE repository (segment.com answers 403 to non-browser clients).
14
+ // Rulings and the ratified vendor facts: var/line/segment/RULINGS.json + FACTS.json.
15
+ //
16
+ // FIVE THINGS THIS DENOMINATOR EXCLUDES, each ruled rather than silently omitted:
17
+ // 1. The Segment PUBLIC API (api.segmentapis.com) — workspaces, sources, destinations,
18
+ // warehouses, tracking plans — a different product on a different host behind a workspace
19
+ // token. Ruled denominator:public-api-plane-is-out-of-scope; NOT claimed in the injector
20
+ // host map, so a Public API call from a world still leaves for the real vendor.
21
+ // 2. The PROFILE API (profiles.segment.com) — the vendor's only nearby read surface, on a
22
+ // different host, credential and product tier, returning resolved profiles rather than
23
+ // ingested events. Ruled denominator:profile-api-is-the-only-read-back; it is the reason
24
+ // connector pull is a filed gap rather than an unfinished job.
25
+ // 3. cdn.segment.com/v1/projects/{writeKey}/settings — real and first-party, but that host also
26
+ // serves the analytics.js BUNDLE, so routing it into the twin would break loading the real
27
+ // library. Ruled denominator:cdn-settings-plane-named-but-not-claimed; left OUT rather than
28
+ // added-and-unreachable.
29
+ // 4. Inbound webhooks — this vendor has none on the tracking plane (checked: no
30
+ // webhooks.segment.com anywhere in the docs tree, no generic Webhook Source page). Ruled
31
+ // denominator:no-inbound-webhook-surface, so the absence of webhook capabilities is a
32
+ // finding, not an oversight.
33
+ // 5. The OBJECTS APIs — objects.segment.com/v1/set and the four /v0 routes of
34
+ // objects-bulk-api.segmentapis.com. Real, first-party, and on the SAME server-source catalog
35
+ // branch as the two API planes this pack did ratify, behind the SAME source write key and the
36
+ // same HTTP Basic pair; A0 missed them, and A3 filed them. They stay out — the plane is
37
+ // beta on its own page, is warehouse-object loading rather than event ingestion, and no
38
+ // `analytics-<language>` client can reach it, so this article's SDK-anchored method has
39
+ // nothing to verify against. Ruled denominator:object-apis-plane-named-but-not-claimed.
40
+ // Neither host is claimed in the injector map, so those calls still leave for the real
41
+ // vendor: the same named exposure the Public API carries, and the same follow-up.
42
+ //
43
+ // ── A MIRROR IS OWED AND FILED, NOT FABRICATED ───────────────────────────────────────────────
44
+ // Segment fails the "API is the product" test the same way its sibling analytics pack does: the
45
+ // instrumentation half is written in code, but the vendor's core job — wiring sources to
46
+ // destinations, reading the live Source Debugger, editing tracking plans — happens in a browser.
47
+ // ui-scope.json rules needsUi TRUE and the screens are filed as todos below rather than invented.
48
+ // What this pack models today is the INGESTION half, which is genuinely code-first.
49
+ import { mkdtempSync, rmSync } from 'node:fs';
50
+ import { tmpdir } from 'node:os';
51
+ import { join } from 'node:path';
52
+ import { createElement } from 'react';
53
+ import { renderToStaticMarkup } from 'react-dom/server';
54
+ import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
55
+ import { buildSegmentMirrorClient, createSegmentMirrorServer } from "./segment-mirror-ui.js";
56
+ import { DroppedRow, EventRow } from "../client/segment-mirror.js";
57
+ import { GENERATED_CAPABILITIES } from "./segment-capabilities.gen.js";
58
+ import { liveSegmentExecute, pushPendingSegmentActions, syncSegmentFromReal } from "./segment-connector.js";
59
+ import { OPS } from "./segment-surface.gen.js";
60
+ import { dropped, events, groups, handleSegmentTwinRequest, identities, SEGMENT_MAX_BATCH_EVENTS, SEGMENT_MAX_REQUEST_BYTES, SEMANTICS, } from "./segment-twin.js";
61
+ /** Run a verify against a FRESH temp root so it proves the capability from nothing. An omitted
62
+ * root would not error — the kernel falls back to the operator's real ~/.volter state dir, which
63
+ * is gitignored, so the verify would pass while poisoning later runs. */
64
+ async function withRoot(fn) {
65
+ const root = mkdtempSync(join(tmpdir(), 'segment-cap-'));
66
+ const h = (method, path, body, authorization) => handleSegmentTwinRequest({
67
+ method,
68
+ path,
69
+ body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
70
+ authorization,
71
+ root,
72
+ });
73
+ try {
74
+ return await verifyBoundary('segment.withRoot', () => fn(h, root));
75
+ }
76
+ finally {
77
+ rmSync(root, { recursive: true, force: true });
78
+ }
79
+ }
80
+ /** The mirror's browser bundle, built ONCE per process. `Bun.build` has a per-process ceiling
81
+ * (~6), so every UI verify shares this one promise — see ADDING_A_TWIN §9's measurement note. */
82
+ let bundle = null;
83
+ const mirrorBundle = () => (bundle ??= buildSegmentMirrorClient());
84
+ /** Run a verify against a FRESH temp root with the REAL mirror server listening on a real socket,
85
+ * so what is asserted is what a browser would actually be served — not an in-process collector's
86
+ * view of it (the azure `data: [DONE]` class: a structurally invisible wire frame). */
87
+ async function withMirrorRoot(fn) {
88
+ const root = mkdtempSync(join(tmpdir(), 'segment-mirror-cap-'));
89
+ const server = await createSegmentMirrorServer({ root, port: 0 });
90
+ const h = (method, path, body, authorization) => handleSegmentTwinRequest({
91
+ method,
92
+ path,
93
+ body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
94
+ authorization,
95
+ root,
96
+ });
97
+ const fetchOrigin = (path) => fetch(`http://127.0.0.1:${server.port}${path}`);
98
+ try {
99
+ return await verifyBoundary('segment.withMirrorRoot', () => fn(fetchOrigin, h, root));
100
+ }
101
+ finally {
102
+ server.stop();
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ }
106
+ const KEY = 'twin_write_key';
107
+ /** The envelope shape @segment/analytics-node@2's Publisher builds (publisher.ts:243-247). */
108
+ const flush = (messages, extra = {}) => ({
109
+ batch: messages,
110
+ writeKey: KEY,
111
+ sentAt: '2026-08-24T12:00:00.000Z',
112
+ ...extra,
113
+ });
114
+ const ok = (r) => r.status >= 200 && r.status < 300;
115
+ const msg = (r) => String(r.body?.message ?? '');
116
+ const code = (r) => String(r.body?.code ?? '');
117
+ /** Filed-gap helper (the `todo('<id>'…)` literal form scripts/pull-audit.ts greps for). */
118
+ function todo(id, area, dimension, title, tier) {
119
+ return { id, area, title, dimension, expected: 'todo', tier };
120
+ }
121
+ const HAND_CAPABILITIES = [
122
+ // ── batch: the one route every official server SDK sends to ──────────────────────────────
123
+ {
124
+ id: 'segment.api.batch',
125
+ area: 'batch',
126
+ title: 'POST /v1/batch accepts the envelope @segment/analytics-node builds — {batch, writeKey, sentAt} — with a 2xx, and every message folds into twin state with its own type, ids, event/traits and properties',
127
+ dimension: 'api',
128
+ expected: 'done',
129
+ tier: 'core',
130
+ verify: () => withRoot(async (h, root) => {
131
+ const res = await h('POST', '/v1/batch', flush([
132
+ { type: 'identify', userId: 'u_1', traits: { email: 'a@b.test', plan: 'pro' }, messageId: 'm_1', timestamp: '2026-08-24T11:59:00.000Z' },
133
+ { type: 'track', userId: 'u_1', event: 'Order Completed', properties: { revenue: 14.99 }, messageId: 'm_2' },
134
+ ]));
135
+ // The ONLY thing the pinned SDK reads off the response: `response.status >= 200 &&
136
+ // response.status < 300` (src/plugins/segmentio/publisher.ts). A 4xx here fails delivery.
137
+ if (!ok(res))
138
+ return false;
139
+ const feed = events(root);
140
+ if (feed.length !== 2)
141
+ return false;
142
+ const [first, second] = feed;
143
+ if (first?.messageType !== 'identify' || first.userId !== 'u_1' || first.messageId !== 'm_1')
144
+ return false;
145
+ // The caller's own `timestamp` survives, and the envelope's `sentAt` is carried onto each
146
+ // message (src/connections/spec/common.md '## Timestamps' — four distinct timestamps).
147
+ if (first.timestamp !== '2026-08-24T11:59:00.000Z' || first.sentAt !== '2026-08-24T12:00:00.000Z')
148
+ return false;
149
+ if (typeof first.receivedAt !== 'string')
150
+ return false;
151
+ if (second?.messageType !== 'track' || second.event !== 'Order Completed')
152
+ return false;
153
+ if (second.properties.revenue !== 14.99)
154
+ return false;
155
+ // …and the identify folded a projectable identity carrying its traits.
156
+ const who = identities(root).find((i) => i.id === 'u_1');
157
+ return who?.traits?.email === 'a@b.test';
158
+ }),
159
+ },
160
+ {
161
+ id: 'segment.api.batch.per_message_dispatch',
162
+ area: 'batch',
163
+ title: 'a MIXED batch is discriminated per MESSAGE by its own `type`, never by batch[0] — identify/track/group/page/alias in one flush each reach their own semantics, in either order, and none is lost or judged by its neighbour\'s rules',
164
+ dimension: 'api',
165
+ expected: 'done',
166
+ tier: 'core',
167
+ verify: () => withRoot(async (h, root) => {
168
+ // Not a contrived input: `flushAt` defaults to 15 (src/app/analytics-node.ts), so an
169
+ // ordinary process flushes a heterogeneous array. Discriminating on batch[0] would route
170
+ // every later message through the first one's validation — the defect
171
+ // var/line/mixpanel/REVIEW.md F2 records for the sibling analytics pack.
172
+ const forward = await h('POST', '/v1/batch', flush([
173
+ { type: 'page', userId: 'u_2', name: 'Pricing', properties: { path: '/pricing' }, messageId: 'p_1' },
174
+ { type: 'identify', userId: 'u_2', traits: { plan: 'free' }, messageId: 'p_2' },
175
+ { type: 'group', userId: 'u_2', groupId: 'g_1', traits: { name: 'Initech' }, messageId: 'p_3' },
176
+ ]));
177
+ if (!ok(forward))
178
+ return false;
179
+ // A `group` message NOT first still created its group; an `identify` NOT first still
180
+ // created its identity; the `page` kept the page-only fields.
181
+ if (groups(root).find((g) => g.id === 'g_1')?.traits?.name !== 'Initech')
182
+ return false;
183
+ if (identities(root).find((i) => i.id === 'u_2')?.traits?.plan !== 'free')
184
+ return false;
185
+ if (events(root).find((e) => e.messageId === 'p_1')?.name !== 'Pricing')
186
+ return false;
187
+ // …and the reverse order: an identify FIRST must not make the twin judge the rest of the
188
+ // flush by identify's rules (a track has no traits; a page has no event name).
189
+ const reversed = await h('POST', '/v1/batch', flush([
190
+ { type: 'identify', userId: 'u_3', traits: { plan: 'pro' }, messageId: 'r_1' },
191
+ { type: 'track', userId: 'u_3', event: 'Signed Up', messageId: 'r_2' },
192
+ { type: 'alias', userId: 'u_3', previousId: 'anon_3', messageId: 'r_3' },
193
+ ]));
194
+ if (!ok(reversed))
195
+ return false;
196
+ if (!(identities(root).find((i) => i.id === 'u_3')?.previousIds ?? []).includes('anon_3'))
197
+ return false;
198
+ // Six messages across two flushes, every one of them in the feed, in order.
199
+ return events(root).map((e) => e.messageId).join(',') === 'p_1,p_2,p_3,r_1,r_2,r_3';
200
+ }),
201
+ },
202
+ {
203
+ id: 'segment.api.batch.envelope_merge',
204
+ area: 'batch',
205
+ title: 'batch-level `context` and `integrations` are MERGED into each message (the vendor\'s own words), with message-level keys winning — and the envelope\'s writeKey reaches every message',
206
+ dimension: 'api',
207
+ expected: 'done',
208
+ tier: 'common',
209
+ verify: () => withRoot(async (h, root) => {
210
+ // http-api/index.md, the batch field table: context is "The same as Context for other
211
+ // calls, but it will be merged with any context inside each of the items in the batch",
212
+ // and integrations likewise.
213
+ const res = await h('POST', '/v1/batch', flush([
214
+ { type: 'track', userId: 'u_4', event: 'A', messageId: 'e_1', context: { locale: 'en-GB' } },
215
+ { type: 'track', userId: 'u_4', event: 'B', messageId: 'e_2' },
216
+ ], { context: { device: { type: 'phone' }, locale: 'en-US' }, integrations: { All: false, Mixpanel: true } }));
217
+ if (!ok(res))
218
+ return false;
219
+ const a = events(root).find((e) => e.messageId === 'e_1');
220
+ const b = events(root).find((e) => e.messageId === 'e_2');
221
+ const aCtx = a.context;
222
+ const bCtx = b.context;
223
+ // e_1 declared its own locale, so the message wins; the batch-level device still merges in.
224
+ if (aCtx.locale !== 'en-GB' || aCtx.device.type !== 'phone')
225
+ return false;
226
+ // e_2 declared nothing, so it inherits both batch-level keys.
227
+ if (bCtx.locale !== 'en-US' || bCtx.device.type !== 'phone')
228
+ return false;
229
+ if (a.integrations.Mixpanel !== true)
230
+ return false;
231
+ return a.writeKey === KEY && b.writeKey === KEY;
232
+ }),
233
+ },
234
+ {
235
+ id: 'segment.api.batch.unmodeled_type_fails_loudly',
236
+ area: 'batch',
237
+ title: 'a ratified-but-unmodeled message TYPE riding inside a modeled batch (`screen`) is refused BY NAME with nothing stored — it may not inherit /v1/batch\'s success, and a silent 200 storing nothing would be indistinguishable from acceptance',
238
+ dimension: 'api',
239
+ expected: 'done',
240
+ tier: 'core',
241
+ verify: () => withRoot(async (h, root) => {
242
+ // `screen` is a type the UNMODIFIED SDK can put in an envelope: its SegmentEventType union
243
+ // is 'track'|'page'|'identify'|'alias'|'screen' (src/app/types/segment-event.ts) and
244
+ // Analytics.screen() produces one. So this is a real flush shape, not a synthetic probe.
245
+ await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_5', event: 'Before', messageId: 'g_0' }]));
246
+ const before = events(root).length;
247
+ const res = await h('POST', '/v1/batch', flush([
248
+ { type: 'track', userId: 'u_5', event: 'Rides Along', messageId: 'g_1' },
249
+ { type: 'screen', userId: 'u_5', name: 'Home', messageId: 'g_2' },
250
+ ]));
251
+ if (res.status !== 404 || code(res) !== 'twin_gap')
252
+ return false;
253
+ if (!msg(res).includes('[twin gap]') || !msg(res).includes('screen'))
254
+ return false;
255
+ // NOTHING from the refused flush landed — not even the well-formed track beside it.
256
+ if (events(root).length !== before)
257
+ return false;
258
+ // …and the SAME type at its own ratified route earns the same named refusal.
259
+ const direct = await h('POST', '/v1/screen', { userId: 'u_5', name: 'Home', writeKey: KEY });
260
+ return direct.status === 404 && msg(direct).includes('[twin gap]') && msg(direct).includes('/v1/screen');
261
+ }),
262
+ },
263
+ {
264
+ id: 'segment.api.batch.event_cap',
265
+ area: 'batch',
266
+ title: 'the vendor\'s documented batch caps are enforced as ACCEPT-AND-DROP, not as a 4xx: past 2,500 events and past 32KB per batched event the request still answers 200 and the offending messages are simply not folded',
267
+ dimension: 'api',
268
+ expected: 'done',
269
+ tier: 'common',
270
+ verify: () => withRoot(async (h, root) => {
271
+ // "Each batch request can only have up to 2500 events... Segment returns a 200 response
272
+ // but rejects the event when the number of batched events exceeds the limit."
273
+ const many = Array.from({ length: SEGMENT_MAX_BATCH_EVENTS + 2 }, (_, i) => ({
274
+ type: 'track', userId: 'u_6', event: 'Bulk', messageId: `cap_${i}`,
275
+ }));
276
+ const res = await h('POST', '/v1/batch', flush(many));
277
+ if (!ok(res))
278
+ return false; // a 400 here would be wrong: the vendor answers 200
279
+ if (events(root).length !== SEGMENT_MAX_BATCH_EVENTS)
280
+ return false;
281
+ const overflow = dropped(root).filter((d) => d.reason === 'batch_exceeds_2500_events');
282
+ if (overflow.length !== 2 || !overflow.some((d) => d.messageId === `cap_${SEGMENT_MAX_BATCH_EVENTS}`))
283
+ return false;
284
+ // "a limit of 32KB per event in the batch" — the OVERSIZE MEMBER is dropped while its
285
+ // well-formed batch-mate still lands, which is what makes this a per-event cap and not a
286
+ // per-request one.
287
+ const fat = { type: 'track', userId: 'u_6', event: 'Fat', messageId: 'fat_1', properties: { blob: 'x'.repeat(SEGMENT_MAX_REQUEST_BYTES + 1) } };
288
+ const mixed = await h('POST', '/v1/batch', flush([fat, { type: 'track', userId: 'u_6', event: 'Slim', messageId: 'slim_1' }]));
289
+ if (!ok(mixed))
290
+ return false;
291
+ if (events(root).some((e) => e.messageId === 'fat_1'))
292
+ return false;
293
+ if (!events(root).some((e) => e.messageId === 'slim_1'))
294
+ return false;
295
+ return dropped(root).some((d) => d.messageId === 'fat_1' && d.reason === 'event_exceeds_32kb');
296
+ }),
297
+ },
298
+ todo('segment.api.batch.success_body', 'batch', 'api', 'the BODY the vendor returns on an accepted request is undocumented on every first-party page, and no pinned client reads it (analytics-node@2 checks only `response.status >= 200 && < 300`; analytics-python returns the response unparsed on 200). This twin answers {"success": true}, which is a CHOICE, not a grounded claim — no capability asserts it. Settling it needs a real-account probe this station does not have', 'niche'),
299
+ // ── the direct per-type routes ────────────────────────────────────────────────────────────
300
+ {
301
+ id: 'segment.api.track',
302
+ area: 'track',
303
+ title: 'POST /v1/track folds an event with its name and properties, and answers 200 — including when the body states its own `type`, as the vendor\'s own OAuth example does',
304
+ dimension: 'api',
305
+ expected: 'done',
306
+ tier: 'core',
307
+ verify: () => withRoot(async (h, root) => {
308
+ const res = await h('POST', '/v1/track', { userId: '019mr8mf4r', event: 'Item Purchased', properties: { name: 'Leap to Conclusions Mat', revenue: 14.99 }, context: { ip: '24.5.68.47' }, timestamp: '2012-12-02T00:30:12.984Z', writeKey: KEY });
309
+ if (!ok(res))
310
+ return false;
311
+ const [e] = events(root);
312
+ if (e?.messageType !== 'track' || e.event !== 'Item Purchased' || e.userId !== '019mr8mf4r')
313
+ return false;
314
+ if (e.properties.revenue !== 14.99)
315
+ return false;
316
+ if (e.context.ip !== '24.5.68.47')
317
+ return false;
318
+ // The direct route and the batched form must agree: this is the SAME body inside an
319
+ // envelope, and it has to fold identically rather than through a second code path.
320
+ await h('POST', '/v1/batch', flush([{ type: 'track', userId: '019mr8mf4r', event: 'Item Purchased', properties: { revenue: 14.99 }, messageId: 'same_1' }]));
321
+ const batched = events(root).find((x) => x.messageId === 'same_1');
322
+ return batched?.messageType === 'track' && batched.event === 'Item Purchased' && batched.endpoint === '/v1/batch' && e.endpoint === '/v1/track';
323
+ }),
324
+ },
325
+ {
326
+ id: 'segment.api.track.requires_event_name',
327
+ area: 'track',
328
+ title: '"All Track events sent to Segment must have an `event` field" — a nameless Track is ACCEPTED (200, as the vendor answers) and then dropped rather than folded, and the drop is attributable',
329
+ dimension: 'api',
330
+ expected: 'done',
331
+ tier: 'common',
332
+ verify: () => withRoot(async (h, root) => {
333
+ const res = await h('POST', '/v1/track', { userId: 'u_7', properties: { a: 1 }, writeKey: KEY, messageId: 'noname_1' });
334
+ // The vendor's own rule: "Segment returns a 200 response for all API requests except
335
+ // errors caused by large payloads and JSON errors". A 4xx here would be infidelity.
336
+ if (res.status !== 200)
337
+ return false;
338
+ if (events(root).length !== 0)
339
+ return false;
340
+ return dropped(root).some((d) => d.messageId === 'noname_1' && d.reason === 'track_missing_event');
341
+ }),
342
+ },
343
+ {
344
+ id: 'segment.api.identify',
345
+ area: 'identify',
346
+ title: 'POST /v1/identify folds a user with its traits, and a LATER identify MERGES over the earlier one rather than replacing the profile — the deliberate dirty-state verify',
347
+ dimension: 'api',
348
+ expected: 'done',
349
+ tier: 'core',
350
+ verify: () => withRoot(async (h, root) => {
351
+ // '## Identify': "Segment recommends calling Identify a single time when the user's
352
+ // account is first created, and only identifying again later when their traits change" —
353
+ // so a later call names only what changed and must not erase the rest.
354
+ await h('POST', '/v1/identify', { userId: 'pgibbons', traits: { email: 'pgibbons@example.com', name: 'Peter Gibbons', industry: 'Technology' }, writeKey: KEY, messageId: 'id_1' });
355
+ await h('POST', '/v1/identify', { userId: 'pgibbons', traits: { industry: 'Software' }, writeKey: KEY, messageId: 'id_2' });
356
+ // …and the same value written back at a later instant must not vanish into the kernel's
357
+ // content+millisecond dedupe (the `rev` ordinal in segment-twin.ts).
358
+ await h('POST', '/v1/identify', { userId: 'pgibbons', traits: { industry: 'Technology' }, writeKey: KEY, messageId: 'id_3' });
359
+ const who = identities(root).find((i) => i.id === 'pgibbons');
360
+ if (!who)
361
+ return false;
362
+ const traits = who.traits;
363
+ if (traits.industry !== 'Technology')
364
+ return false;
365
+ if (traits.email !== 'pgibbons@example.com' || traits.name !== 'Peter Gibbons')
366
+ return false;
367
+ if (Number(who.rev) !== 3)
368
+ return false;
369
+ return events(root).length === 3;
370
+ }),
371
+ },
372
+ {
373
+ id: 'segment.api.page',
374
+ area: 'page',
375
+ title: 'POST /v1/page folds a page view keeping the vendor\'s page-only fields (name, category, properties) — and a page is not a track: it carries no `event`',
376
+ dimension: 'api',
377
+ expected: 'done',
378
+ tier: 'core',
379
+ verify: () => withRoot(async (h, root) => {
380
+ const res = await h('POST', '/v1/page', { userId: '019mr8mf4r', name: 'Tracking HTTP API', category: 'Docs', properties: { path: '/docs', url: 'https://example.test/docs' }, timestamp: '2012-12-02T00:31:29.738Z', writeKey: KEY, messageId: 'pg_1' });
381
+ if (!ok(res))
382
+ return false;
383
+ const p = events(root).find((e) => e.messageId === 'pg_1');
384
+ if (p?.messageType !== 'page' || p.name !== 'Tracking HTTP API' || p.category !== 'Docs')
385
+ return false;
386
+ if (p.properties.path !== '/docs')
387
+ return false;
388
+ if (p.event !== null)
389
+ return false;
390
+ // A page with no identifier at all is the documented accept-and-drop, not a fold.
391
+ await h('POST', '/v1/page', { name: 'Anonymous', writeKey: KEY, messageId: 'pg_2' });
392
+ return dropped(root).some((d) => d.messageId === 'pg_2' && d.reason === 'no_user_anon_id');
393
+ }),
394
+ },
395
+ {
396
+ id: 'segment.api.group',
397
+ area: 'group',
398
+ title: 'POST /v1/group folds a group with merged traits and its member users, and a group call with no `groupId` has nothing to group — accepted (200) and dropped',
399
+ dimension: 'api',
400
+ expected: 'done',
401
+ tier: 'core',
402
+ verify: () => withRoot(async (h, root) => {
403
+ const res = await h('POST', '/v1/group', { userId: '019mr8mf4r', groupId: '8e9df332ac', traits: { name: 'Initech', industry: 'Technology', employees: 420 }, writeKey: KEY, messageId: 'gr_1' });
404
+ if (!ok(res))
405
+ return false;
406
+ const g = groups(root).find((x) => x.id === '8e9df332ac');
407
+ if (g?.traits?.employees !== 420)
408
+ return false;
409
+ if (!(g?.members ?? []).includes('019mr8mf4r'))
410
+ return false;
411
+ // A second member, and a trait update that must MERGE rather than replace.
412
+ await h('POST', '/v1/group', { userId: 'second_user', groupId: '8e9df332ac', traits: { employees: 500 }, writeKey: KEY, messageId: 'gr_2' });
413
+ const after = groups(root).find((x) => x.id === '8e9df332ac');
414
+ const traits = after.traits;
415
+ if (traits.employees !== 500 || traits.name !== 'Initech')
416
+ return false;
417
+ if (after.members.length !== 2)
418
+ return false;
419
+ const bad = await h('POST', '/v1/group', { userId: 'u_8', traits: { name: 'Nowhere' }, writeKey: KEY, messageId: 'gr_3' });
420
+ return bad.status === 200 && dropped(root).some((d) => d.messageId === 'gr_3' && d.reason === 'group_missing_group_id');
421
+ }),
422
+ },
423
+ {
424
+ id: 'segment.api.alias',
425
+ area: 'alias',
426
+ title: 'POST /v1/alias associates a `previousId` with a `userId`: the prior identity\'s traits carry forward, the link is projectable, and an alias with no previousId is accepted-and-dropped',
427
+ dimension: 'api',
428
+ expected: 'done',
429
+ tier: 'common',
430
+ verify: () => withRoot(async (h, root) => {
431
+ // The dirty-state half: the anonymous user is identified FIRST, so aliasing has real
432
+ // prior state to carry rather than an empty subject.
433
+ await h('POST', '/v1/identify', { anonymousId: '39239-239239', traits: { plan: 'trial' }, writeKey: KEY, messageId: 'al_0' });
434
+ const res = await h('POST', '/v1/alias', { previousId: '39239-239239', userId: '019mr8mf4r', timestamp: '2012-12-02T00:31:29.738Z', writeKey: KEY, messageId: 'al_1' });
435
+ if (!ok(res))
436
+ return false;
437
+ const who = identities(root).find((i) => i.id === '019mr8mf4r');
438
+ if (!(who?.previousIds ?? []).includes('39239-239239'))
439
+ return false;
440
+ if (who?.traits?.plan !== 'trial')
441
+ return false;
442
+ // A second alias accumulates rather than replacing the first.
443
+ await h('POST', '/v1/alias', { previousId: 'anon_zzz', userId: '019mr8mf4r', writeKey: KEY, messageId: 'al_2' });
444
+ if ((identities(root).find((i) => i.id === '019mr8mf4r')?.previousIds ?? []).length !== 2)
445
+ return false;
446
+ const bad = await h('POST', '/v1/alias', { userId: 'u_9', writeKey: KEY, messageId: 'al_3' });
447
+ return bad.status === 200 && dropped(root).some((d) => d.messageId === 'al_3' && d.reason === 'alias_missing_previous_id');
448
+ }),
449
+ },
450
+ // ── auth: three documented schemes ────────────────────────────────────────────────────────
451
+ {
452
+ id: 'segment.api.auth.write_key_in_body',
453
+ area: 'auth',
454
+ title: 'the DEFAULT scheme — writeKey in the JSON body with NO Authorization header at all — is accepted, and the key that lands on each message is the envelope\'s. This is what the pinned SDK sends; a twin demanding a header could not be reached by it',
455
+ dimension: 'api',
456
+ expected: 'done',
457
+ tier: 'core',
458
+ verify: () => withRoot(async (h, root) => {
459
+ // "The authentication writeKey should be sent as part of the body of the request... For
460
+ // this auth type, you do not need to set any authentication header." And publisher.ts
461
+ // sets only Content-Type and User-Agent unless oauthSettings are configured.
462
+ const res = await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_a', event: 'No Header', messageId: 'auth_1' }]));
463
+ if (!ok(res))
464
+ return false;
465
+ const e = events(root).find((x) => x.messageId === 'auth_1');
466
+ return e?.writeKey === KEY && e.authScheme === 'body';
467
+ }),
468
+ },
469
+ {
470
+ id: 'segment.api.auth.basic',
471
+ area: 'auth',
472
+ title: 'HTTP Basic with the write key as the USERNAME and an empty password resolves the key from the header — the analytics-node v1 wire shape (`auth: { username: writeKey }`), which sends no writeKey in the body at all',
473
+ dimension: 'api',
474
+ expected: 'done',
475
+ tier: 'common',
476
+ verify: () => withRoot(async (h, root) => {
477
+ // "taking a Segment source Write Key, 'abc123', as the username, adding a colon, and then
478
+ // the password field is left empty. After base64 encoding 'abc123:' becomes 'YWJjMTIzOg=='".
479
+ // The literal below is that exact documented pair — a fixture the vendor itself printed.
480
+ const res = await h('POST', '/v1/batch', { batch: [{ type: 'track', userId: 'u_b', event: 'Basic', messageId: 'auth_2' }] }, 'Basic YWJjMTIzOg==');
481
+ if (!ok(res))
482
+ return false;
483
+ const e = events(root).find((x) => x.messageId === 'auth_2');
484
+ if (e?.writeKey !== 'abc123' || e.authScheme !== 'basic')
485
+ return false;
486
+ // The base64 carries UTF-8 BYTES: a write key above U+00FF must survive, which `atob`
487
+ // alone cannot do (it yields one JS char per byte) — the mixpanel F1 encoding class.
488
+ const uni = 'ключ_日本';
489
+ const header = `Basic ${Buffer.from(`${uni}:`, 'utf8').toString('base64')}`;
490
+ await h('POST', '/v1/batch', { batch: [{ type: 'track', userId: 'u_b', event: 'Unicode Key', messageId: 'auth_3' }] }, header);
491
+ return events(root).find((x) => x.messageId === 'auth_3')?.writeKey === uni;
492
+ }),
493
+ },
494
+ {
495
+ id: 'segment.api.auth.oauth_bearer_keeps_body_key',
496
+ area: 'auth',
497
+ title: 'under OAuth the Bearer token is a DIFFERENT credential from the write key: the header does not become the write key, and the payload\'s writeKey is still what identifies the source',
498
+ dimension: 'api',
499
+ expected: 'done',
500
+ tier: 'common',
501
+ verify: () => withRoot(async (h, root) => {
502
+ // '#### OAuth': "Include the access token in the Authorization header as a Bearer token
503
+ // along with your project's write key in the payload of the request." publisher.ts does
504
+ // exactly that: `Authorization: Bearer ${token.access_token}` PLUS writeKey in the body.
505
+ const res = await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_c', event: 'OAuth', messageId: 'auth_4' }]), 'Bearer an_access_token_not_a_write_key');
506
+ if (!ok(res))
507
+ return false;
508
+ const e = events(root).find((x) => x.messageId === 'auth_4');
509
+ // The bug this pins: treating the Bearer value as the credential would store the access
510
+ // token as the write key and lose the source identity entirely.
511
+ return e?.writeKey === KEY && e.authScheme === 'oauth';
512
+ }),
513
+ },
514
+ todo('segment.api.auth.rejects_unknown_write_key', 'auth', 'api', 'what the vendor answers for a MISSING or INVALID write key is documented nowhere first-party — the HTTP API page lists only oversize payloads and invalid JSON as 400 causes, and names no auth failure. This twin therefore accepts the request and records writeKey:null rather than inventing a refusal status or code', 'common'),
515
+ todo('segment.api.oauth_token', 'oauth', 'api', 'POST /token on oauth2.segment.io / oauth2.eu1.segmentapis.com — the client-credentials exchange the SDK\'s TokenManager drives (RS256 JWT client assertion, grant_type=client_credentials, scope tracking_api:write). Modeling it means real RS256 verification against a registered public key plus the 400/401/415/429 branches TokenManager treats as unrecoverable; not built in v1, and the route answers the loud gap', 'niche'),
516
+ // ── common fields the Spec defines for every call ─────────────────────────────────────────
517
+ {
518
+ id: 'segment.api.common.requires_an_identifier',
519
+ area: 'common',
520
+ title: '"The HTTP API requires that each payload has a userId and/or anonymousId" — a message with neither is ACCEPTED with 200 and dropped as `no_user_anon_id` (the vendor\'s own error name), and an anonymousId ALONE is sufficient',
521
+ dimension: 'api',
522
+ expected: 'done',
523
+ tier: 'core',
524
+ verify: () => withRoot(async (h, root) => {
525
+ const none = await h('POST', '/v1/batch', flush([{ type: 'track', event: 'Homeless', messageId: 'c_1' }]));
526
+ if (none.status !== 200)
527
+ return false;
528
+ if (events(root).length !== 0)
529
+ return false;
530
+ if (!dropped(root).some((d) => d.messageId === 'c_1' && d.reason === 'no_user_anon_id'))
531
+ return false;
532
+ // …and the other half of "and/or": anonymousId alone is a legitimate identifier.
533
+ const anon = await h('POST', '/v1/batch', flush([{ type: 'track', anonymousId: '507f191e810c19729de860ea', event: 'Anonymous', messageId: 'c_2' }]));
534
+ if (!ok(anon))
535
+ return false;
536
+ const e = events(root).find((x) => x.messageId === 'c_2');
537
+ return e?.anonymousId === '507f191e810c19729de860ea' && e.userId === null;
538
+ }),
539
+ },
540
+ {
541
+ id: 'segment.api.common.message_id_dedupe',
542
+ area: 'common',
543
+ title: '"Segment deduplicates events using the messageId field" — a replayed messageId is accepted (200) and folded EXACTLY once, across separate requests and within one batch; a message that carries none gets one minted server-side, as the vendor does',
544
+ dimension: 'api',
545
+ expected: 'done',
546
+ tier: 'core',
547
+ verify: () => withRoot(async (h, root) => {
548
+ // This is the retry path of every official SDK: `maxRetries` defaults to 3
549
+ // (src/app/analytics-node.ts) and a retried flush re-sends the same messageIds.
550
+ await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_d', event: 'Once', messageId: 'dupe_1' }]));
551
+ const replay = await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_d', event: 'Once Again', messageId: 'dupe_1' }]));
552
+ if (!ok(replay))
553
+ return false; // a retry is accepted, not refused
554
+ if (events(root).filter((e) => e.messageId === 'dupe_1').length !== 1)
555
+ return false;
556
+ if (events(root).find((e) => e.messageId === 'dupe_1').event !== 'Once')
557
+ return false;
558
+ // …and within ONE flush, which a per-request-only check would miss.
559
+ await h('POST', '/v1/batch', flush([
560
+ { type: 'track', userId: 'u_d', event: 'Inner A', messageId: 'dupe_2' },
561
+ { type: 'track', userId: 'u_d', event: 'Inner B', messageId: 'dupe_2' },
562
+ ]));
563
+ if (events(root).filter((e) => e.messageId === 'dupe_2').length !== 1)
564
+ return false;
565
+ // A message with NO messageId is still folded, with one minted for it — the vendor adds
566
+ // it server-side ("automatically added to all payloads coming into Segment"). Two such
567
+ // messages must not collide into one row.
568
+ await h('POST', '/v1/batch', flush([
569
+ { type: 'track', userId: 'u_d', event: 'Mintable' },
570
+ { type: 'track', userId: 'u_d', event: 'Mintable' },
571
+ ]));
572
+ const minted = events(root).filter((e) => e.event === 'Mintable');
573
+ return minted.length === 2 && minted[0].messageId !== minted[1].messageId && typeof minted[0].messageId === 'string';
574
+ }),
575
+ },
576
+ todo('segment.api.common.message_id_length', 'common', 'api', '"ensure all events have unique messageId values with fewer than 100 characters" — what the vendor does with a longer one (truncate, drop, ignore) is not stated on any first-party page, so this twin neither enforces nor rejects it rather than guessing a behaviour', 'niche'),
577
+ todo('segment.api.common.timestamp_skew_correction', 'common', 'api', 'Segment computes `timestamp = receivedAt - (sentAt - originalTimestamp)` server-side (src/connections/spec/common.md \'## Timestamps\'), and rewrites the caller\'s `timestamp` into `originalTimestamp`. This twin stores all of timestamp/sentAt/receivedAt faithfully but does NOT yet perform that arithmetic or mint originalTimestamp', 'common'),
578
+ todo('segment.api.common.context_direct_ip', 'common', 'api', '"When sending a HTTP call from a user\'s device, you can collect the IP address by setting context.direct to true" (\'## Collecting IP Address\'), i.e. the server fills context.ip from the connection. A local twin has no meaningful client IP to fill, so the flag is stored and otherwise ignored', 'niche'),
579
+ todo('segment.api.common.integrations_routing', 'common', 'api', 'the `integrations` object turns individual destinations on and off ("\'All\': false says that no destination should be enabled unless otherwise specified"), and Segment defaults it to {All: true, Salesforce: false}. This twin stores the object verbatim and merges batch-level over message-level, but models no destination fan-out, so the flags change nothing locally', 'common'),
580
+ todo('segment.api.common.event_property_cap', 'common', 'api', '"Events ingested by Segment have a limit of 10,000 properties per individual event received... Segment will not persist properties beyond this limit, and will drop any corresponding values" (src/connections/rate-limits.md, updated January 25, 2024). Not enforced by this twin', 'niche'),
581
+ // ── errors and limits ─────────────────────────────────────────────────────────────────────
582
+ {
583
+ id: 'segment.api.errors.envelope_and_400s',
584
+ area: 'errors',
585
+ title: 'the only two documented 400 causes — invalid JSON and an oversize payload — answer 400 with the vendor\'s {code, message} envelope, the shape analytics-python parses off every non-200; everything else the vendor accepts still answers 200',
586
+ dimension: 'api',
587
+ expected: 'done',
588
+ tier: 'core',
589
+ verify: () => withRoot(async (h, root) => {
590
+ // "If you send an event with invalid JSON, Segment returns a 400 Bad Request error."
591
+ const badJson = await h('POST', '/v1/batch', '{"batch": [');
592
+ if (badJson.status !== 400)
593
+ return false;
594
+ const body = badJson.body;
595
+ // The SHAPE is grounded: analytics-python does
596
+ // `raise APIError(res.status_code, payload["code"], payload["message"])`.
597
+ if (typeof body.code !== 'string' || typeof body.message !== 'string')
598
+ return false;
599
+ if (badJson.headers?.['content-type'] !== 'application/json')
600
+ return false;
601
+ // "There is a maximum of 32KB per normal API request... Segment's API responds with
602
+ // 400 Bad Request if these limits are exceeded."
603
+ const fat = await h('POST', '/v1/track', { userId: 'u_e', event: 'Fat', properties: { blob: 'x'.repeat(SEGMENT_MAX_REQUEST_BYTES) }, writeKey: KEY });
604
+ if (fat.status !== 400 || typeof fat.body.code !== 'string')
605
+ return false;
606
+ // …and the SAME payload under /v1/batch's own 500KB cap is accepted, so the cap is really
607
+ // per-route and not one global number.
608
+ const inBatch = await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_e', event: 'Fat But Batched', messageId: 'fat_ok', properties: { blob: 'x'.repeat(20 * 1024) } }]));
609
+ if (!ok(inBatch))
610
+ return false;
611
+ if (!events(root).some((e) => e.messageId === 'fat_ok'))
612
+ return false;
613
+ // A 500KB+ batch does cross its own cap.
614
+ const huge = await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_e', event: 'Huge', properties: { blob: 'x'.repeat(600 * 1024) } }]));
615
+ return huge.status === 400;
616
+ }),
617
+ },
618
+ todo('segment.api.errors.code_vocabulary', 'errors', 'api', 'Segment publishes NO vocabulary of error `code` values. The only literal on a first-party page is `no_user_anon_id`; the shape {code, message} is grounded in analytics-python\'s parser but the strings this twin emits for invalid JSON / oversize payloads are descriptive placeholders, not vendor strings, and no capability asserts them. Settling this needs a real-account probe', 'common'),
619
+ todo('segment.api.limits.rate_limit_429', 'limits', 'api', '"Requests that exceed acceptable limits may be rejected with HTTP Status Code 429. When Segment rejects the requests, the response header contains Retry-After and X-RateLimit-Reset headers" — the SDK\'s Publisher reads x-ratelimit-reset and waits (publisher.ts:283-296). This twin never throttles and never emits a 429, so that whole SDK branch is unexercised; arming a deterministic 429 through a twin-only control route is the way in', 'common'),
620
+ todo('segment.api.limits.content_type_required', 'limits', 'api', '"To send data to Segment\'s HTTP API, a content-type header must be set to \'application/json\'" — what the vendor does when it is absent or wrong is not stated, so this twin parses the body regardless rather than inventing a refusal', 'niche'),
621
+ todo('segment.api.limits.gzipped_request_body', 'limits', 'api', 'Segment ACCEPTS a gzip-compressed ingestion body: the first-party analytics-python client sets `Content-Encoding: gzip` and posts the deflated bytes to /v1/batch (segment/analytics/request.py), exposed as the documented `gzip` Client argument ("`True` to compress data with gzip before sending, `False` by default" — segment-docs .../server/python/index.md). This twin reads the body as text and never decodes the encoding, so a gzip=True client gets a 400 `invalid_json` where the real vendor accepts. Verified against the booted server, A3. Off by default in every first-party client and absent from @segment/analytics-node@2 entirely, so it is filed rather than built', 'common'),
622
+ // ── regional ──────────────────────────────────────────────────────────────────────────────
623
+ todo('segment.api.regional.eu_endpoint', 'regional', 'api', 'EU workspaces send to events.eu1.segmentapis.com instead of api.segment.io ("If you are located in the EU and use the https://api.segment.io/v1/ endpoint, you might not see any errors, but your events will not appear in the Segment app"). Both hosts are routed to this twin by the injector, but the twin does not model the region as STATE — an EU-destined event is indistinguishable from a US one once it lands', 'common'),
624
+ // ── the routes ratified but not modeled ───────────────────────────────────────────────────
625
+ {
626
+ id: 'segment.api.fail_loudly',
627
+ area: 'conformance',
628
+ title: 'DELIBERATE DEVIATION (the real Segment answers 200 to these): every RATIFIED but unmodeled operation answers a LOUD vendor-shaped 404 naming the op, the deviation and its grounding — the mobile /v1/b, analytics.js\'s /v1/t, /v1/screen, the six Pixel Routes and the OAuth token exchange — and an endpoint outside the ratified surface answers a different, equally loud refusal',
629
+ dimension: 'api',
630
+ expected: 'done',
631
+ tier: 'core',
632
+ verify: () => withRoot(async (h, root) => {
633
+ // A deliberate deviation from the vendor's blanket 200, and it is the one deviation the
634
+ // twins bar REQUIRES: a 200 that stores nothing is indistinguishable from success.
635
+ // EVERY unmodeled op, not a sample of them: a list that stopped at the interesting ones
636
+ // would leave four ratified pixel routes claimed by a capability nothing exercised.
637
+ const unmodeled = [
638
+ ['POST', '/v1/b', 'mobile_batch'],
639
+ ['POST', '/v1/t', 'browser_track'],
640
+ ['POST', '/v1/screen', 'screen'],
641
+ ['GET', '/v1/pixel/track', 'pixel_track'],
642
+ ['GET', '/v1/pixel/identify', 'pixel_identify'],
643
+ ['GET', '/v1/pixel/page', 'pixel_page'],
644
+ ['GET', '/v1/pixel/screen', 'pixel_screen'],
645
+ ['GET', '/v1/pixel/group', 'pixel_group'],
646
+ ['GET', '/v1/pixel/alias', 'pixel_alias'],
647
+ ['POST', '/token', 'oauth_token'],
648
+ ];
649
+ // …and that list IS the ratified surface minus what SEMANTICS models — checked, not
650
+ // assumed, so a seventeenth ratified op cannot slip in unexercised.
651
+ if (unmodeled.length !== OPS.length - Object.keys(SEMANTICS).length)
652
+ return false;
653
+ for (const [method, path, id] of unmodeled) {
654
+ const res = await h(method, path, method === 'GET' ? undefined : { userId: 'u', writeKey: KEY });
655
+ if (res.status !== 404 || code(res) !== 'twin_gap')
656
+ return false;
657
+ if (!msg(res).includes('[twin gap]') || !msg(res).includes(id) || !msg(res).includes(path))
658
+ return false;
659
+ // The gap must carry the grounding, so a reader can tell a ratified gap from a typo…
660
+ if (!msg(res).includes('Grounding:'))
661
+ return false;
662
+ // …and it must NAME the deviation, because the reader of this 404 is exactly the person
663
+ // who would otherwise conclude that the real Segment 404s here.
664
+ if (!msg(res).includes('DEVIATION: the real Segment answers 200'))
665
+ return false;
666
+ }
667
+ // A path the surface never ratified is refused too — but as an UNKNOWN endpoint, not as a
668
+ // twin gap, so the two cases stay distinguishable on the wire.
669
+ const unknown = await h('POST', '/v1/pixel/nonsense', { userId: 'u' });
670
+ if (unknown.status !== 404 || code(unknown) !== 'unknown_endpoint')
671
+ return false;
672
+ // A modeled path with the WRONG method is not a modeled call either.
673
+ const wrongMethod = await h('GET', '/v1/batch');
674
+ if (wrongMethod.status !== 404 || code(wrongMethod) !== 'unknown_endpoint')
675
+ return false;
676
+ // …and none of that wrote anything.
677
+ return events(root).length === 0 && dropped(root).length === 0;
678
+ }),
679
+ },
680
+ // ── connector ─────────────────────────────────────────────────────────────────────────────
681
+ {
682
+ id: 'segment.connector.push',
683
+ area: 'connector',
684
+ title: 'push replays locally-ingested flushes onto the vendor\'s real POST /v1/batch through the INJECTED execute, rebuilding the SDK\'s own envelope (every message of the flush, `type` restored from the kernel-safe `messageType`), confirms an action only on a 2xx — and the flush SURVIVES its own confirm: every message, identity and group is still projectable afterwards',
685
+ dimension: 'connector',
686
+ expected: 'done',
687
+ tier: 'core',
688
+ verify: () => withRoot(async (h, root) => {
689
+ await h('POST', '/v1/batch', flush([
690
+ { type: 'identify', userId: 'u_push', traits: { plan: 'pro' }, messageId: 'push_1' },
691
+ { type: 'track', userId: 'u_push', event: 'Pushed Event', properties: { n: 1 }, messageId: 'push_2' },
692
+ ]));
693
+ const sent = [];
694
+ // A fake execute — the real network exists only inside liveSegmentExecute (D4/D5).
695
+ const okPush = await pushPendingSegmentActions(async (req) => { sent.push(req); return { status: 200, data: { success: true } }; }, { root });
696
+ if (okPush.pushed !== 1 || okPush.failed !== 0)
697
+ return false;
698
+ if (sent.length !== 1 || sent[0].method !== 'POST' || sent[0].path !== '/v1/batch')
699
+ return false;
700
+ const envelope = sent[0].body;
701
+ // THE WHOLE flush, not just its first message — reading action.fields alone would push
702
+ // one message and confirm the batch.
703
+ if (!Array.isArray(envelope.batch) || envelope.batch.length !== 2)
704
+ return false;
705
+ // `type` restored: the kernel's META set drops a field named `type`, so the twin stores
706
+ // `messageType` and the replay has to map it back or the vendor gets typeless messages.
707
+ if (envelope.batch[0].type !== 'identify' || envelope.batch[1].type !== 'track')
708
+ return false;
709
+ if (envelope.batch[1].event !== 'Pushed Event')
710
+ return false;
711
+ if (envelope.batch[0].traits.plan !== 'pro')
712
+ return false;
713
+ if (envelope.writeKey !== KEY)
714
+ return false;
715
+ // THE FLUSH SURVIVES ITS OWN CONFIRM. `projectResources` suppresses a confirmed action's
716
+ // projection and rebuilds that state from the observed events the confirm wrote
717
+ // (control-plane/src/actions.ts), so a confirm carrying only `action.fields` deletes every
718
+ // message after batch[0] — and the identity the flush folded — the moment it is pushed.
719
+ // Read AFTER the push, which is the only order that can see it.
720
+ if (events(root).map((e) => e.messageId).join(',') !== 'push_1,push_2')
721
+ return false;
722
+ if (identities(root).find((i) => i.id === 'u_push')?.traits?.plan !== 'pro')
723
+ return false;
724
+ // Confirmed, so it is no longer pending: a second push must send NOTHING.
725
+ const again = await pushPendingSegmentActions(async (req) => { sent.push(req); return { status: 200, data: null }; }, { root });
726
+ if (again.pushed !== 0 || sent.length !== 1)
727
+ return false;
728
+ // A NON-2xx is a refusal: the action stays pending rather than being silently confirmed.
729
+ await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_push', event: 'Refused', messageId: 'push_3' }]));
730
+ const refused = await pushPendingSegmentActions(async () => ({ status: 400, data: { code: 'invalid_json', message: 'no' } }), { root });
731
+ if (refused.pushed !== 0 || refused.failed !== 1)
732
+ return false;
733
+ const retry = await pushPendingSegmentActions(async () => ({ status: 200, data: null }), { root });
734
+ if (retry.pushed !== 1)
735
+ return false;
736
+ // …and the LIVE execute — the only thing that ever reaches api.segment.io — stamps the
737
+ // write key where this vendor's default auth actually lives: INSIDE the envelope, with no
738
+ // Authorization header. Driven here through its own injected fetchImpl, so no network is
739
+ // touched. The S4 scaffold's `Bearer <SEGMENT_WRITE_KEY>` guess would authenticate nothing.
740
+ const seen = [];
741
+ const live = liveSegmentExecute({
742
+ apiKey: 'live_write_key',
743
+ budgetOptions: { path: join(root, 'budget.json') },
744
+ now: () => new Date('2026-08-24T12:34:56.000Z'),
745
+ fetchImpl: (async (url, init) => {
746
+ seen.push({ url: String(url), init: init ?? {} });
747
+ return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'content-type': 'application/json' } });
748
+ }),
749
+ });
750
+ const answer = await live({ method: 'POST', path: '/v1/batch', body: { batch: [{ type: 'track', userId: 'u', event: 'Unkeyed' }] } });
751
+ if (answer.status !== 200)
752
+ return false;
753
+ const wire = JSON.parse(String(seen[0].init.body));
754
+ if (wire.writeKey !== 'live_write_key' || wire.sentAt !== '2026-08-24T12:34:56.000Z')
755
+ return false;
756
+ if (seen[0].init.headers?.authorization !== undefined)
757
+ return false;
758
+ return seen[0].init.headers?.['content-type'] === 'application/json' && seen[0].url === 'https://api.segment.io/v1/batch';
759
+ }),
760
+ },
761
+ {
762
+ id: 'segment.connector.pull_is_impossible_not_empty',
763
+ area: 'connector',
764
+ title: 'syncSegmentFromReal folds NOTHING and says so — every one of the sixteen ratified ops is a WRITE, so there is no read-back to mirror; the pull must not fabricate an empty account over real observed state, and must not disturb what is already there',
765
+ dimension: 'connector',
766
+ expected: 'done',
767
+ tier: 'common',
768
+ verify: () => withRoot(async (h, root) => {
769
+ await h('POST', '/v1/batch', flush([{ type: 'track', userId: 'u_p', event: 'Local', messageId: 'pull_1' }]));
770
+ const before = events(root).length;
771
+ // The injected execute must never be CALLED: a v1 pull that reached the vendor would be
772
+ // spending a live allowance to learn nothing.
773
+ let calls = 0;
774
+ const result = await syncSegmentFromReal(async () => { calls += 1; return { status: 200, data: null }; }, { root });
775
+ if (result.pulled !== 0 || calls !== 0)
776
+ return false;
777
+ // ADDING_A_TWIN §6: a connector that maps "no read path" to "an empty account" hands
778
+ // syncPull an empty account to fold OVER real observed state. Nothing may vanish.
779
+ if (events(root).length !== before)
780
+ return false;
781
+ return events(root).some((e) => e.messageId === 'pull_1');
782
+ }),
783
+ },
784
+ todo('segment.connector.pull', 'connector', 'api', 'pull real-account state: Segment\'s tracking plane exposes NO read-back operation — its own answer to "did my event land" is the browser Source Debugger, a live websocket view, not an API. The nearest read surface is the Profile API on profiles.segment.com behind an Engage access token (HTTP Basic username) in a different product tier, and it returns resolved profiles, never the raw ingested events this twin folds. v1 therefore mirrors nothing rather than faking an empty account', 'common'),
785
+ // ── surface the Spec declares that this twin stores but does not INTERPRET ────────────────
786
+ todo('segment.api.identify.reserved_traits', 'identify', 'api', "the Spec reserves seventeen identify traits with defined types and meanings (src/connections/spec/identify.md: address, age, avatar, birthday, company, createdAt, description, email, firstName, gender, id, lastName, name, phone, title, username, website), and Segment DERIVES from them — \"If you only pass a first and last name, Segment automatically fills in the full name for you.\" This twin stores the traits object verbatim and performs no derivation and no type checking", 'common'),
787
+ todo('segment.api.group.reserved_traits', 'group', 'api', 'the Spec reserves twelve group traits (src/connections/spec/group.md: address, avatar, createdAt, description, email, employees, id, industry, name, phone, website, plan). This twin merges the traits object verbatim and neither validates their types nor treats any of them specially', 'niche'),
788
+ todo('segment.api.pixel.always_200_gif', 'pixel', 'api', "the Pixel API's defining contract: \"Each endpoint *always* responds with a 200 <empty-gif>, even if an error occurs\", with the payload in a base64 `?data=` blob OR plain query parameters (the base64 \"is optional, however it prevents special character interpretation\"). Modeling it means a GET decoder, real GIF BYTES on the wire and a response that can never be a JSON error — including never this twin's own loud gap. Today all six pixel routes answer the gap instead", 'niche'),
789
+ todo('segment.api.track.spec_event_vocabularies', 'track', 'api', "the Segment Spec's third component — the industry event vocabularies Segment maps to destination features (E-Commerce, Mobile, Video, B2B SaaS, AI Copilot) and the cloud-source specs (Email, Live Chat, A/B Testing), each with reserved event names and property shapes. Real vendor contract, entirely uninterpreted here: this twin accepts any `event` string with any properties", 'niche'),
790
+ todo('segment.api.regional.custom_domain', 'regional', 'api', "Segment-Managed Custom Domain moves the whole tracking surface onto a customer subdomain — \"modify the endpoint from Segment's default domain (https://api.segment.io/v1/pixel/track) to your custom domain (https://api.mysubdomain.mydomain.com/v1/pixel/track)\" (src/connections/sources/custom-domain.md), with server sources using `host` and mobile sources `apiHost`. The twin serves the paths on whatever host it is pointed at, but the injector host map claims only the two Segment-owned tracking hosts, so a custom-domain SDK's traffic would not be intercepted", 'niche'),
791
+ todo('segment.api.common.version_field', 'common', 'api', "`version` is one of the ten common fields on every call (src/connections/spec/common.md's structure block shows `\"version\": 2`). This twin neither reads it nor stamps one, so a caller pinning a payload version gets no different behaviour", 'niche'),
792
+ // ── the mirror this vendor is owed ────────────────────────────────────────────────────────
793
+ // THE SOURCE DEBUGGER. The screen the vendor tells you to open when an event returns 200 and
794
+ // never arrives — and the reason it cannot be an API capability is that this API is WRITE-ONLY:
795
+ // all sixteen ratified operations are ingests and none of them reads a message back. So the
796
+ // mirror reads the twin's OWN store door (`GET /twin/store/{events,dropped}`), the kernel's
797
+ // read-only named-projection door.
798
+ //
799
+ // ONE capability, not two. A §9 review was right that a second id over the store door would be
800
+ // DENOMINATOR PADDING: §6 names a twin-only route as scaffolding that must stay out of the
801
+ // manifest, and the door, its 404 and its determinism all predate this screen and are already
802
+ // graded by the R9 replay and R5c. So the door assertions belong HERE, where they are not a
803
+ // coverage claim but the proof that the screen's data path is the twin's real one — read over a
804
+ // REAL SOCKET, because a verify that only looks in-process is structurally unable to see what a
805
+ // browser is served (the azure `data: [DONE]` class).
806
+ {
807
+ id: 'segment.ui.debugger',
808
+ area: 'mirror',
809
+ title: 'The Source Debugger: the live accepted/dropped stream read over the RUNNING mirror\'s own store door and rendered by the mirror\'s real components — an event sent through the tracking API appears with its type, label, subject and properties, and a message the vendor would silently drop appears with its REASON',
810
+ dimension: 'ui',
811
+ expected: 'done',
812
+ tier: 'core',
813
+ verify: async () => await withMirrorRoot(async (fetchOrigin, h, root) => {
814
+ // (1) the BUILT bundle carries the debugger's static literal markers. Template-literal
815
+ // class names would be erased by the minifier, which is why PILL_CLASS is a table.
816
+ const js = await mirrorBundle();
817
+ if (!['Source Debugger', 'event-row', 'dropped-row', 'pill pill-track', 'pill pill-dropped', 'drop-reason'].every((m) => js.includes(m)))
818
+ return false;
819
+ // (2) seed through the twin's OWN write path — the vendor's real route, not a hand-built
820
+ // log. One message that is ACCEPTED, one the vendor silently drops (no userId and no
821
+ // anonymousId is the vendor's own documented `no_user_anon_id`).
822
+ const sent = await h('POST', '/v1/batch', flush([
823
+ { type: 'track', userId: 'u_debug', event: 'Order Completed', properties: { revenue: 14.99, currency: 'USD' }, messageId: 'm_ok' },
824
+ { type: 'track', event: 'Orphan Event', messageId: 'm_drop' },
825
+ ]));
826
+ if (!ok(sent))
827
+ return false;
828
+ // (3) read them back THE WAY THE BROWSER DOES — over the wire, off the running mirror's
829
+ // store door, on the same origin the ingest route was served on. The bytes must equal
830
+ // the in-process projection: the door is the projection, not a second copy.
831
+ const accepted = (await (await fetchOrigin('/twin/store/events')).json());
832
+ const refused = (await (await fetchOrigin('/twin/store/dropped')).json());
833
+ if (JSON.stringify(accepted) !== JSON.stringify(events(root)))
834
+ return false;
835
+ if (JSON.stringify(refused) !== JSON.stringify(dropped(root)))
836
+ return false;
837
+ if (accepted.length !== 1 || refused.length !== 1)
838
+ return false;
839
+ if (accepted[0].event !== 'Order Completed' || refused[0].messageId !== 'm_drop')
840
+ return false;
841
+ // ...and the SHELL comes off that same origin, so screen and data cannot drift onto two
842
+ // ports — which is the only thing that makes "the browser sees this" true.
843
+ const shell = await fetchOrigin('/');
844
+ // the shell loads the bundle relative to its <base> (the hosted mirror mounts it under a path), and it is served
845
+ if (shell.status !== 200 || !(await shell.text()).includes('src="assets/app.js"') || (await fetchOrigin('/assets/app.js')).status !== 200)
846
+ return false;
847
+ // (4) render the mirror's OWN exported components over those fetched rows and assert the
848
+ // seeded values survive into the emitted markup. This is the claim about the SHIPPED
849
+ // component, not a lookalike.
850
+ const acceptedMarkup = renderToStaticMarkup(createElement(EventRow, { row: accepted[0] }));
851
+ const droppedMarkup = renderToStaticMarkup(createElement(DroppedRow, { row: refused[0] }));
852
+ return (acceptedMarkup.includes('event-row') &&
853
+ acceptedMarkup.includes('pill pill-track') &&
854
+ acceptedMarkup.includes('Order Completed') &&
855
+ acceptedMarkup.includes('u_debug') &&
856
+ acceptedMarkup.includes('revenue') && acceptedMarkup.includes('14.99') &&
857
+ acceptedMarkup.includes('currency') && acceptedMarkup.includes('USD') &&
858
+ droppedMarkup.includes('dropped-row') &&
859
+ droppedMarkup.includes('pill pill-dropped') &&
860
+ droppedMarkup.includes(String(refused[0].reason)) &&
861
+ droppedMarkup.includes('drop-reason') &&
862
+ // ...and the two rows are not the SAME markup with a different string in it: each
863
+ // carries the landmarks of its own stream and NEITHER of the other's. The negative half
864
+ // was half-vacuous before (§9) — a `DroppedRow` that rendered nothing satisfied it —
865
+ // so both directions now assert a landmark that must be PRESENT as well as one absent.
866
+ !acceptedMarkup.includes('pill pill-dropped') && !acceptedMarkup.includes('drop-reason') &&
867
+ !droppedMarkup.includes('pill pill-track') && !droppedMarkup.includes('u_debug'));
868
+ }),
869
+ },
870
+ todo('segment.ui.connections', 'mirror', 'ui', 'the Connections screen — sources wired to destinations, which is the vendor\'s core browser job and the thing its Public API also manages. Unmodeled: this pack twins the ingestion plane only', 'common'),
871
+ todo('segment.ui.profiles', 'mirror', 'ui', 'a user/group explorer over the identity and group projections identify/group calls fold', 'common'),
872
+ ];
873
+ const HAND_IDS = new Set(HAND_CAPABILITIES.map((c) => c.id));
874
+ // Generated inventory minus any op the hand file supersedes (each earned done replaces its
875
+ // generated todo — matched by capability id).
876
+ const generated = GENERATED_CAPABILITIES.filter((c) => !HAND_IDS.has(c.id));
877
+ export const SEGMENT_CAPABILITIES = [...HAND_CAPABILITIES, ...generated];
878
+ /** Committed area census (TWIN-87/F1), enumerated TOP-DOWN from the vendor's own documentation
879
+ * nav rather than from this manifest. The nine op areas are the ones SURFACE.json ratified
880
+ * (batch, track, identify, page, screen, group, alias, pixel, oauth); `auth`, `common`, `errors`,
881
+ * `limits` and `regional` are the H2 sections of the vendor's HTTP Tracking API page that carry
882
+ * behaviour rather than routes ('### Authentication', the Spec's common fields, '## Errors',
883
+ * '## Rate limits' + '## Max request size', '## Regional configuration'); `conformance`,
884
+ * `connector` and `mirror` are this pack's structural areas. A regeneration that DROPS an area
885
+ * reddens the census test instead of silently shrinking the denominator. */
886
+ export const SEGMENT_AREAS = [
887
+ 'alias',
888
+ 'auth',
889
+ 'batch',
890
+ 'common',
891
+ 'conformance',
892
+ 'connector',
893
+ 'errors',
894
+ 'group',
895
+ 'identify',
896
+ 'limits',
897
+ 'mirror',
898
+ 'oauth',
899
+ 'page',
900
+ 'pixel',
901
+ 'regional',
902
+ 'screen',
903
+ 'track',
904
+ ];
905
+ export function segmentCapabilities() {
906
+ return checkCapabilities('segment', SEGMENT_CAPABILITIES);
907
+ }