From c3e3ce5efcb1fb112ec3f0e9c3655c62400abfd1 Mon Sep 17 00:00:00 2001 From: Manvendra Date: Thu, 9 Jul 2026 22:11:36 +0530 Subject: [PATCH 1/5] fix(ingest): align pii to server mappers (OR guardrail block) ingest.mjs set pii = piiCategories.length>0 while both server mappers (otlp.ts, poller.ts) OR in guardrailStatus==='block'. Same source span normalized to different pii values by path; poller comment claimed parity. Single-value the contract. --- ingest.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingest.mjs b/ingest.mjs index 39a0179..a92419d 100644 --- a/ingest.mjs +++ b/ingest.mjs @@ -33,7 +33,7 @@ const events = sorted.map((r) => { cacheHit: r.cacheHit === true, fallbackUsed: r.fallbackUsed === true, guardrail: r.guardrailStatus || null, // 'pass' | 'flag' | 'block' - pii: piiCategories.length > 0, + pii: piiCategories.length > 0 || r.guardrailStatus === 'block', // match server mappers (otlp.ts/poller.ts) piiCategories, }; }); From bcad4d543b9b602ab9456b9d88e4c564f093243f Mon Sep 17 00:00:00 2001 From: Manvendra Date: Thu, 9 Jul 2026 22:11:43 +0530 Subject: [PATCH 2/5] docs(schema): document t as absolute epoch ms on the live server path Schema said t is 'ms from first event'; only ingest.mjs relativizes. OTLP/poller emit absolute epoch ms (no per-trace t0 in a streaming ring buffer). Documented both paths; live consumers use arrival time, not t. --- shared/schema.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/schema.ts b/shared/schema.ts index 21de098..79c03b8 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -10,7 +10,11 @@ export type Guardrail = 'pass' | 'flag' | 'block' | null; export interface TraceEvent { /** stable id (span id) — optional until the OTLP/poller path fills it (M1/M2 drill-down). */ id?: string; - /** ms from the first event in the trace. */ + /** + * File/replay path: ms from the first event in the trace (`ingest.mjs` relativizes to t0). + * Live server path (OTLP/poller): absolute epoch ms — there is no single trace t0 in a + * streaming ring buffer; live consumers position comets by arrival time, not `t`. + */ t: number; model: string | null; provider: string | null; From 843110daf2f5b0afbaf5321d46165543d7607d91 Mon Sep 17 00:00:00 2001 From: Manvendra Date: Thu, 9 Jul 2026 22:11:52 +0530 Subject: [PATCH 3/5] fix(server): cap OTLP POST body at 8MB (413) readBody concatenated the whole request with no byte limit; the only network-facing write path could grow memory unbounded on a hostile POST. Cap at 8MB, destroy the socket, respond 413. --- server/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index dbb42a2..7c1a07f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,10 +17,18 @@ import { startPoller } from './poller'; const PORT = Number(process.env.PORT ?? 4319); const hub = new Hub(Number(process.env.BUFFER ?? 2000)); +const MAX_BODY = 8 * 1024 * 1024; // 8 MB cap on the OTLP POST body — the only network-facing write path +class BodyTooLarge extends Error {} + function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let b = ''; - req.on('data', (c) => (b += c)); + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > MAX_BODY) { req.destroy(); reject(new BodyTooLarge('request body too large')); return; } + b += c; + }); req.on('end', () => resolve(b)); req.on('error', reject); }); @@ -47,7 +55,7 @@ const server = createServer(async (req, res) => { res.end('{}'); if (events.length) console.log(`[otlp] +${events.length} span(s) buffer=${hub.size} clients=${hub.clientCount}`); } catch (e) { - res.writeHead(400, { 'content-type': 'application/json' }); + res.writeHead(e instanceof BodyTooLarge ? 413 : 400, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: String(e) })); } return; From 9dacf6f6ad679c593c279517ec44ea76a0efb4ff Mon Sep 17 00:00:00 2001 From: Manvendra Date: Thu, 9 Jul 2026 22:12:00 +0530 Subject: [PATCH 4/5] docs(readme): fix phantom 'npm run serve' -> serve:spike --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 27d9ce1..d6453d0 100644 --- a/README.md +++ b/README.md @@ -135,4 +135,4 @@ develop the renderer. - ffmpeg isn't required; without it you get `.webm` (plays everywhere). With it, `record.mjs` also emits `.mp4` + `.gif`. ## Requirements -node ≥ 20 · python 3 (for `npm run serve`) · a WebGPU-capable Chrome/Edge. See [`CLAUDE.md`](./CLAUDE.md). +node ≥ 20 · python 3 (for `npm run serve:spike`) · a WebGPU-capable Chrome/Edge. See [`CLAUDE.md`](./CLAUDE.md). From 8105e717ecb5a10618e05ed071d5b02169277d46 Mon Sep 17 00:00:00 2001 From: Manvendra Date: Thu, 9 Jul 2026 22:12:00 +0530 Subject: [PATCH 5/5] docs(proof): pick math mirrors river-sim.wgsl (M4 moved motion authority) --- PROOF.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROOF.md b/PROOF.md index b47bb4d..30e9a7e 100644 --- a/PROOF.md +++ b/PROOF.md @@ -131,7 +131,7 @@ to the trailing window; (4) leaked snapshot timers on unmount → removed. All p ## 5. M2 — drill-down (click a comet → its real span, verified against `/traces/:id`) Clicking a comet resolves it to the correct span and shows that span's **real attributes**. The pick is -pure CPU math (`app/src/gpu/motion.ts`) that mirrors the shader's closed-form motion (`shaders/river.wgsl`) +pure CPU math (`app/src/gpu/motion.ts`) that mirrors the shader's closed-form motion (`shaders/river-sim.wgsl`; M4 moved the motion authority here from `river.wgsl`, which is now the draw shader) and inverts a screen click back to the nearest comet head; the server now retains each span's raw attributes and serves them at `GET /traces/:id` (the SSE stream stays lean — attributes are fetched on demand, not streamed).