From bbe98a763b82aad7e982e679fd772bf19295eef3 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 16:53:50 -0400 Subject: [PATCH 01/11] fix(act): revive dates on read instead of re-validating the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading an event ran a throwing z.parse() over its data whenever the schema mentioned a date. All that is needed there is to turn stored ISO strings back into Dates — the payload was already validated on write — and the extra validation rejected payloads the framework itself wrote. A sensitive() field is moved into the pii sidecar on write, so data structurally cannot hold it, and a schema that requires it fails. That made an aggregate carrying both a sensitive field and a date unreadable and unwritable: query, load, and every later command threw. Three changes. The sensitive keys are optional in the rebuilt schema, since they are never in data. Reviving is a safeParse that falls back to the stored value, so a payload written against an older declaration still reads. The sidecar gets its half of the same rebuild, so a disclosed sensitive(z.date()) is a Date rather than a string beside a plain sibling that is one. The rebuild also descends through default, prefault, catch, readonly, nonoptional and tuple, which previously left a date inside them unrevived. Cost is unchanged (0.0%, -2.7%, -0.5% across a one-date, a nested and an array payload): safeParse costs what parse did, and only the sensitive keys gained a wrapper. Reviving the sidecar runs only when one is present. Closes #1594 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../act-sqlite/test/read-schema-dates.spec.ts | 255 ++++++++++++++++++ .../test/test-read-schema-dates.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-a.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-a.db-wal | Bin 0 -> 173072 bytes libs/act-sqlite/test/test-rsd-b.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-b.db-wal | Bin 0 -> 148352 bytes libs/act-sqlite/test/test-rsd-c.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-c.db-wal | Bin 0 -> 148352 bytes libs/act-sqlite/test/test-rsd-d.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-d.db-wal | Bin 0 -> 148352 bytes libs/act-sqlite/test/test-rsd-e.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-e.db-wal | Bin 0 -> 148352 bytes libs/act-sqlite/test/test-rsd-f.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-f.db-wal | Bin 0 -> 148352 bytes .../all-packages-stability.spec.ts.snap | 87 ++++-- libs/act/src/builders/event-builder.ts | 87 ++++-- 16 files changed, 393 insertions(+), 36 deletions(-) create mode 100644 libs/act-sqlite/test/read-schema-dates.spec.ts create mode 100644 libs/act-sqlite/test/test-read-schema-dates.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-a.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-a.db-wal create mode 100644 libs/act-sqlite/test/test-rsd-b.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-b.db-wal create mode 100644 libs/act-sqlite/test/test-rsd-c.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-c.db-wal create mode 100644 libs/act-sqlite/test/test-rsd-d.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-d.db-wal create mode 100644 libs/act-sqlite/test/test-rsd-e.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-e.db-wal create mode 100644 libs/act-sqlite/test/test-rsd-f.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-f.db-wal diff --git a/libs/act-sqlite/test/read-schema-dates.spec.ts b/libs/act-sqlite/test/read-schema-dates.spec.ts new file mode 100644 index 000000000..759916e97 --- /dev/null +++ b/libs/act-sqlite/test/read-schema-dates.spec.ts @@ -0,0 +1,255 @@ +/** + * Reading converts, it never validates (#1594). + * + * On a serializing adapter deliberately: InMemory holds the original objects + * and never round-trips a date through its ISO form, so it cannot see any of + * this. + */ +import { unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { act, cache, dispose, sensitive, state, store } from "@rotorsoft/act"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; +import { SqliteStore } from "../src/index.js"; + +const actor = { id: "reader", name: "Reader" }; +const paths: string[] = []; + +async function open_store(name: string) { + const path = join(import.meta.dirname, `test-rsd-${name}.db`); + paths.push(path); + try { + unlinkSync(path); + } catch {} + store(new SqliteStore({ url: `file:${path}` })); + await store().seed(); + await cache().clear(); +} + +afterEach(async () => { + await dispose()(); + for (const p of paths.splice(0)) { + try { + unlinkSync(p); + } catch {} + } +}); + +describe("read schema converts dates without validating (#1594)", () => { + it("a required sensitive field lives in `pii`, and reading still works", async () => { + await open_store("a"); + const Happened = z.object({ at: z.date(), email: sensitive(z.string()) }); + const S = state({ A: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "a1", actor }, + { + at: new Date("2020-01-01"), + email: "u@example.com", + } + ); + + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + email: unknown; + }; + expect(data.at).toBeInstanceOf(Date); + expect(data.email).toBe("[REDACTED]"); + + // load works, so the stream still accepts commands + const snap = await app.load(S, "a1"); + expect(snap.state.n).toBe(1); + await expect( + app.do( + "Do", + { stream: "a1", actor }, + { + at: new Date("2020-06-01"), + email: "v@example.com", + } + ) + ).resolves.toBeDefined(); + }); + + it("a disclosed sensitive date reaches the reducer as a Date, like its plain sibling", async () => { + await open_store("b"); + const seen: Record = {}; + const Happened = z.object({ at: z.date(), born: sensitive(z.date()) }); + const S = state({ B: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ + Happened: ({ data }, s) => { + seen.at = data.at instanceof Date ? "Date" : typeof data.at; + seen.born = data.born instanceof Date ? "Date" : typeof data.born; + return { n: s.n + 1 }; + }, + }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .discloses(() => true) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "b1", actor }, + { + at: new Date("2020-01-01"), + born: new Date("1990-02-03"), + } + ); + await cache().clear(); // fold cold, from the store + seen.at = seen.born = ""; + await app.load(S, { stream: "b1", actor }); + expect(seen).toEqual({ at: "Date", born: "Date" }); + }); + + it("a redacted sensitive date keeps its sentinel instead of becoming a date", async () => { + await open_store("c"); + const Happened = z.object({ at: z.date(), born: sensitive(z.date()) }); + const S = state({ C: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "c1", actor }, + { + at: new Date("2020-01-01"), + born: new Date("1990-02-03"), + } + ); + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + born: unknown; + }; + expect(data.at).toBeInstanceOf(Date); + expect(data.born).toBe("[REDACTED]"); + }); + + it("revives a date through every wrapper the rebuild knows", async () => { + await open_store("d"); + const Happened = z.object({ + a: z.date(), + b: z.date().default(() => new Date(0)), + t: z.tuple([z.date(), z.string()]), + r: z.date().readonly(), + c: z.date().catch(() => new Date(0)), + n: z.date().optional().nonoptional(), + p: z.date().prefault(() => new Date(0)), + u: z.union([z.date(), z.string()]), + l: z.array(z.date()), + m: z.record(z.string(), z.date()), + o: z.object({ deep: z.date() }), + x: z.date().nullable(), + }); + const S = state({ D: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + const when = new Date("2022-01-01"); + await app.do( + "Do", + { stream: "d1", actor }, + { + a: new Date("2020-01-01"), + b: new Date("2021-01-01"), + t: [when, "x"], + r: when, + c: when, + n: when, + p: when, + u: when, + l: [when], + m: { k: when }, + o: { deep: when }, + x: null, + } + ); + const d = (await app.query_array({}))[0]!.data as Record; + for (const key of ["a", "b", "r", "c", "n", "p", "u"]) + expect(d[key], key).toBeInstanceOf(Date); + expect(d.t[0]).toBeInstanceOf(Date); + expect(d.t[1]).toBe("x"); + expect(d.l[0]).toBeInstanceOf(Date); + expect(d.m.k).toBeInstanceOf(Date); + expect(d.o.deep).toBeInstanceOf(Date); + expect(d.x).toBeNull(); + }); + + it("hands back what is stored when the payload predates the declaration", async () => { + await open_store("f"); + const Happened = z.object({ at: z.date(), label: z.string() }); + const S = state({ F: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + // written before `label` was added to the declaration + await store().commit( + "f1", + [{ name: "Happened", data: { at: new Date("2020-01-01") } } as never], + { correlation: "c", causation: {} }, + -1 + ); + + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + label: unknown; + }; + // reading does not throw, and the stored value comes back as stored + expect(data.label).toBeUndefined(); + expect(typeof data.at).toBe("string"); + }); + + it("leaves an ISO-shaped z.string() a string (#1556 stays fixed)", async () => { + await open_store("e"); + const Happened = z.object({ at: z.date(), created_at: z.string() }); + const S = state({ E: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "e1", actor }, + { + at: new Date("2020-01-01"), + created_at: "2020-05-05T00:00:00.000Z", + } + ); + const d = (await app.query_array({}))[0]!.data as { + at: unknown; + created_at: unknown; + }; + expect(d.at).toBeInstanceOf(Date); + expect(typeof d.created_at).toBe("string"); + }); +}); diff --git a/libs/act-sqlite/test/test-read-schema-dates.db-shm b/libs/act-sqlite/test/test-read-schema-dates.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..cd2bf1c00275920d67936b7746c2d0fcc1c06341 GIT binary patch literal 32768 zcmeI*yG=tu5Czb&0fYJfFBS3UO9i$EsIi(O05KsVgKpBJx(Fg3J1vw@0275+Y zk8M4_`{ood9(-Ixu9b8;5$k8EucveQHXdF--HxB%ZyxU-9zRD{FR$mHA9sU)UY~CD zQ^WN7HBsg3zm~G2o6bx+v+3l|bEWr6pD%r(^nU7#<^59X%jx{~{QELftd?umO3(96 zX62lmk?*Sr5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjZ3DbPtfCg!7`wnnU^trt777yEHI%?FT0 zpqfB8j;dt~CQw&kCXVYqfOG=Y1ZLx;TDD*Ubp?8HTK55@6R0Mz5Tk0@f(g_W$Qwfy zfi?w}BX9Us1X>ixo1hheHU;wjb48#{fsNQ|b8u4=h|SohK%h~9-A3m&Hi3u%1Om+p g95g$*!3pI5-BSbz5FkK+009C72oNAZfI!;<-^Mp9w*UYD literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-a.db-shm b/libs/act-sqlite/test/test-rsd-a.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..9c800754ef573eed7174ef82ee1d2d9a922f25f0 GIT binary patch literal 32768 zcmeI*O-@r$6ae7UQcA0({D@i*to*7Z?1B&`3|N4PGpDiw1}?$5OJL4+nBXq--sYBc z;=qKLyySi-xjnD1@11+kw}8{h!&&55ORE#Hewy-nTED0F!?+rM|9O4)t^4cp{OjYz z&6nGqACv!lK9!oqr}X(}Au+(dyieXE*ZtT1dENAgAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&Ucs7AfnlZ5w z!!$KwHBE(h9dBbh-aT7&g$T4+pc{K}*yaN|8-d0Img0RJHCA0=0&N!P#WarFd_ZR- z(3n6!KEz35)fFbtW`RNM$Hz7w(AfwyCXgqF3IcTsjN%~jEM7sNK7l+5S`esHAkRM+ z1nLynh*$Bs&enBZy})K{MgDIOL7+Z?H}$owV**8BLV`fG0z1`qu5kjp2nY}$K!5-N Q0t5&UAV7csf$9bR0^~U*{r~^~ literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-a.db-wal b/libs/act-sqlite/test/test-rsd-a.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..84278929d4964faf5cd499d06f7f42b2d871baaf GIT binary patch literal 173072 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 literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-b.db-wal b/libs/act-sqlite/test/test-rsd-b.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..7e2abf7c562d3443cf7b778cb7b872c921ab6638 GIT binary patch literal 148352 zcmeI*du&_P9l&wlJnT4+x&<^7LfLI#G*RQ$PMVTbeJrJJ$eK89>Wrpi7(Z@=!Ld`@ ziBdXNHNaTeBsA7;(_oumNP7rXFu|lf5E5*RhlIM04H!dg`~gCOJp^=xv32M8-s?va zHz9&j?C(_`_i^scIUn0ee9!Ov&SeLz(-zk&=UXgGEpj>Z-^Z^x)EH~-JZiaXsq(L< z#4aU&|95}#o7mUSd+n`VJNu()O&v`n$AW3KL!4bvVfn18in0~rvr3+0Eb{!B+vc%i z-zzMuCl56^PFgCLxGYN!IGz^YSP(z}0R#|0009ILKmY**&US$vl~(7fRmw;@7>a4B zy)p65F_6;sPH6G)%+0FHhrB(0uj=pF((hGglx>Lz$Fw$aX6tmHVskb&DmRa1uBlCG z@pMZ6T2XKXeV^J=s~Sr*qW1ax-d^vJx?`wspl4`A-R>PxdxrgkeLivOfY;}5%a%^1 zlUi_0^?P^wRo|fa9PaPW9i7yYsc0gez2NK;vckpAjs(-eV#mg`;>W_GN@*=p?9}mS zG;=6d&0$~P&S7th-j+7isCBE+$ktUAR_EqM#S)E2v?)V8IFU|dwgWm~z@vY!6i>Ap zE2&m)y{K%g=T0%gt8e1(=UO z%oeeb`3U5De5)BR5WV;Aq{@26{l<%)1T8;n$2q1s}0tg_000IagP*ws3@dDpC z_m0G~PrlhVPrQIFPbibOFmF3^y}a$YR^Q`aWade^HzTq3yc?#@9}MByugFcegD8Z<>fyRFYxjHBI2hbfB*srAb-A+3y4h?1Q0*~0R#|0009ILD0_kZ zHk-3kblG>#>atHKwcuDF8qxccYl`;So8{*3wBN94Mz1~b0`YVzpif#bi;wvTc9-IO z1oAz8jTtY{Yd^8M`taIm;swh77cl?`Ab=6|g^$~i7 zoZ*Dn9}A}SW5&2|;}K|e*{#l=CdHEJ|4;3WMGM9~8;@wfW31E?FQ83DBU1rADk8vq z1mqVmQYsAl`gRU`Tl5!7n>wi_Q_)1c)qI_;s<1jYHx_%H8BZsV{$0*3XpRN2%iqRk zyugV4tslR!?8v4DxpS%f!h!$-2q1s}0tg_000IagfB*s|EFk;-Yt8!t|9kdm+lG6; z7hxPh2|waA4FLoYKmY**5I_I{1Q0*~ft*0WID$7{fBv8EC+R#y;CpWB}o zQLn1kYkI)>+yV8fy3pu(RV^04o;{+6uop&*t7>aUoz>a9qF5|zuG#@lZoQsy1o^`l z7)L!*2o%H% zyzun>7yV#^^O%emP#h;M;)4YN1Q0*~0R#|0009ILKmY**N=(42R5ysk0;fc?3gQK7 zuT(C0<(XHWlJNqH<9&j4|DF>aIY1d^U zFmtB$a4Z-dGd3EGbbx)nt=`@0tWxTrPc=%m81vh=so5Fkw|aM7-X@X(wvuCI9tE%z%7BB41mDQ5TL{hKwtZR~Yki3KB9VGAIc=QbA9hC3!8_fIyo;xmi z;L>$p+d+PTv*Di*>VN#03mjA%Y|hQ1H$FAHH$IU}YO!Eiiv)scqkDKs z@m~4-()qpfv0z-At7~4p@(S;eH`}?pQFW_5zO8ZwzYS_`>OAqIi5C!$KuDkWFxSz| zJcYyy$QR~}@QZ?1ju|mwL{hZ6>{e$_Q?a=#jhA@9W31Hfw-GNuya4e6XDnVozQzg3+!}QovtP&I;(3xuOokH z(T;psD8JiYZnqLIAO}6?W&f8d-?m`+K(Zd({~STVxg6#F?$reTvQ5 z*r?n*mbs>0Fa2vp!4>pm&iP1Mb3@{)4{^IMRHdj(~2JpCpDQyr`V}yEM7pq$6sp33wW3P z@sR_|d=114l=Q!#G!6j-5I_I{1Q0*~0R#|000C2=Lavzi1rp02J9fe0im05tP;s1V z5g#lFAbNOn&pGj%EnzG&lSP(aV@Sz_BRL9&Fh=n z-R*9d+ao^wZuk1k$7;9Ry{Gxc{mtgI93u8Wlnl8d376IpicU_3yPDgY!@-GEFr7Ij zt`#=dWGI3ty)_|<=wS`=T#wiiWfIBe_4}Kn5wS}|7eutA*lnCJq;KCS7Ez~lE_au! z!|iW(ukY+w-{D@};py75zd4jh%Bpy}yG5njUG9#Vl@%=-c_fAROH4vn2W zM~@eH$KrUW)EZ8E5I_I{1Q0*~0R#|0009ILD02b3QrT$NGd2i`YN=t~7dZ0oRPRkE z2KMOj0`FNI@0Ga~%|`$M1Q0*~0R#|0009ILK%gWAswrDnbcNeP!Js?r z?&w?_3U!Wl2RpQINb5Y++1;UC*4Z`c>gw$7cCC%Hce=XTySrTN9Ube|YNMBhLY`5p zQr)0fET=@H>cwX^Uf@p;p0oY@6R&z?yg-HHIg8_c@r4Be1Q0*~0R#|0009ILKmY** UK1l+XR1PTf-9M7LIMdP2?@-gF(M^EyV6uk z<6a-T-CYl851-E?$6`92h`~w9gLE$Mc1JJ$*R!Yho8HCs{ncqad_3OHy$%2P`}C;q zI7+XdzoLBnlTz)NNM|ygsdV!D>5^wk?v^}Taxdk%ay_4NKb>*T_b-(1%51Sz-sky# z*UEKrjr`p4{pnH=AV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5*ByFe%HnCM0?ZGBisTQAmQGqz&q z->WJ>pbY{OvDbz+^gy5ifyvl!psJDt+8{6$2W@CW4+I(zn2Ey%swzpK4Fa<m1$G)eEe|TIB!s5CrNI s*r;!06B8%`LlOk471*wJa)T4tML>W60RjXF5FkK+009C72vjfd1xI)%$p8QV literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-c.db-wal b/libs/act-sqlite/test/test-rsd-c.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..f37f3b374051c7210ba77245d77a9e2d2562302f GIT binary patch literal 148352 zcmeI*du&_P9l&wlJnT3xw?e~20dX59C0^pzPMS8YC@fIZW=)(nb)bPt=GwjigJZY0 zleHAa(sp8Knx=sSe{34s(Eb?WIgK$f4F+PMJ(v*E1be_HhD2c-Y+6C(F}ibn@AWHj z6CxUS_C-|{W$VOegFMGv*0D<8 z>uhW1Zf$d&u+_JFZS6O^ek;DQAbj|J?e5-QWh$#h5_)ET zLcDVfXY~EEdNNwR*>LGZU^o;|L&KMh2Gp{$JxOgw?-OTw4v#1fcSnbEbS8IAeNIni zGsf4tk}DYd)ShP5T;efxBp3>83rwgxCq~AGC#KXLfhl!(GBiFC6sL{_f}y^A=}b1Q zYcpynuqUJj$HnL5=xE{SoSx3aQ_1`V7nYC}u5@-x%W9R5&FGbnMMagedaTl^`{ME3 zp+Yq$gCn~p13gAt`c$*l9z^+5I_I{1Q0*~0R#|00D9wjqE30tg_000IagfB*srAdnX*$uDrh4^KYQJ3aO468Qxh^Jg!fZ=mS#qO1A* z0u601n)wC93JU@VAbeeRm8{;~FzCE^9_MGcbq306EGL1Wvk`FH`b z%7Op_2q1s}0tg_000IcquE1EM$o1c$=;a;!G84YhYj3aAy`M{#U+CPIn;zl?n2$ir z7O|Z92;_TwyA?0+;KuK+8|!Yc5id}?zlvx%0tg_000IagfB*srAb>zk36#VO4BfbK z&kfGUCYOj8a1;sU@)j0t=dM?@z1V6#zkt2XRTM8EHdqiq009ILKmY**5I_KdPgmf8 z-QgbSQ0D#R-S(NJwl8ySDrTA_dV(r+XE8;HP^qtMePoUt>WQu zH}L|Igc%rN7}uG`q2WtL17@NFwWnD%mv~Ga35EjO0u$=aiIK74i79nQU`idH42_Qj z#i?V&3oJ8UK)%O!Sn&b_nX?;S`sz*B5HIlQ{vzV1BY*$`2q1s}0tg_000IagaGC^4 z;ssWH!+)ap`9B|6B3_`WsBSWE;fnJMI9hKji5C!?EC?Wg00IagfB*srAW-uHha3*~ zpy;w6T+n5oP3ziBI36?llbb5`*<0n7?zF$~qOx9l;suh~OxT#TU;!WV5$vhP`3U5D z{3a`2;Nf$}`+oeV_%!hXHUEnk00a;~009ILKmY**5I_Kd+7Kv-7pQ-x{my&7KXh`5 zc!At)1CvMQEnIQDfV25%S-e2=(bi~fz|%?u5I_I{1Q0*~fl`4Zdz&2Y&Q9ePS5^~) zjE&yPjQP!3GR)ZASv1br?Ar_lL|qCP^elrx$V z`x9E$IA)IfHXi|x*J*bTcPh4A|9@tGB3?4?*?dI9esiUncme%DJa!;#L`8&|kAVCF zW=e(0;K;7YK#%c4=~L(QbS9okdaT!3Z=KzJaYv=snfY|`8{g&Jg4S37r~GYf#S26p zeQL`+H(s7@lRH<*FDwWkfB*srAbr!VX;il|rB z>vbdGeDQ#KRb6iMysB0TV9y^hLfA_q##Plb&|-IQTT>~PwNUM_zp&oQID+C~42&Zn z?;zs{vKi(njF*kp`Gm$1w8%fRR=mL8C$^vUP|K?+#u3!YKOeLc0R#|0009ILKmY** z5I~^D1!UiUt94)Cw)ST)IRBlYZ!?ad#-9~59{~gqKmY**5I_I{1Q0-=Rs>4o1v2+O z_P~eFU7e8e0*dQ|O?eVA23Pee1|K}JJ{;%sE~J19AH5J0R#|0009Kf1cB>X9qx6V%Dg)_?Yc|^X3ex7 zO=$5MbEC~n2RIVk8rY*QD5Z`ERkLJ|Ilq0MnxA2QYhd@KeIgm4hrELhcW0+^iz}CQ z(F8NUIZF~QnmdcKEgE}r8M+ovx0p%IrlWdU3a{m6U`*@T^g-23uc!vcLu#-%N1)f= zXU-n3s^;Tcxv;NLR!^r>X`{{yu1VfO@(z-Bki3KY;-@L^pnQ+tX5|-n=kDg|y+1$y zDe?=P3IBvp2Lup6009ILKmY**5I_I{1U_+rl6Zj|297-Oy=!h6Tq0hey{J|)=3vF~ z0&Pum72^fOAr=G>KmY**5U6^Ad9}^qzF73eXBPIxr_yOXp=I@0Sj(E-!|j!O<%>%f z_s%D@q`p|!yn4mi-F&fe0Xg00IagfB*srAbqLa4} zRva(T?s(=D@dDx?3jzorfB*srd{lw?zIKOum*{{$cd0SJu_Ip=D(<$I z+aBTtpNfXJj^ zO+%v3>B(#+oJnf?GS{YZzlw}qJw{|!pO~z~W5!b@wHaOAqIQS##lzw5u|~VQw?onM ze-z9s%EHE~eZilEq9XElpZNpj8J^t@cK5mtWv2XcMs19ejg>BF6e_x;vBR^oUevKy znKGsg*`J7K^>9YtKdUFBdZu)$3pT6VBUR2INTi_%F&tJHH~^1 zU+YS)VC*ZJha?`e@>mp4L!u5(hQ>#N;?%J~FjRQznk@ zFSxKo?jfsic1+7^rE`lEt~{exJ{C>uGL259Q%_sGfP9a?%8D1b=*h@G&-=;ze&PkH z`d?5QhX4WyAb?(nr*9_&Kp0hY}p<4Z`bzi)02AaP?whN+T7Le>-Twme(@3V z`8MZ1*7|(DE4yww)MZV}Az}|i$*4D$^6CSTRAO#Zva7Evs?BD!Z0?x2R@7RPp$MY% z)|4n>gf+->{bEa$Nu|3sAL@$7#4ZtC5Yy9Qw|T;Zv3-+RM4dKxeM8;>U#Q==d2nF! zfN$+U|N1KrbwyHXSrz}LO`_8MUf)1@rNskVFCSd%zQFOPzV18rop1d@#tSH}cWvT> z1px#QKmY**5I_I{1Q0*~0R*Z|z}~RB$r<&FX614NSoZ}Uc;VTrUJgF^Wg}kTZJX=u zYHK*{K>z^+5I_I{1Q0*~0R#|0pvDE9N_~gZ$k-qx8l{GHUto6k^ViQbynlfaFYunt z^6DKmY**5I_I{1Q0*~0R*a2ps{|n5ieku&9m+c=o`}ahrO=<821H^+g!)1 zvhg$u0R#|0009ILKmY**5I_KdniXiNKhJ4pO%Mo8;xj)V!3WQbJ~{iBA)WjJHT(3S z;Rqmr00IagfB*srAbAsBd6!eIznCy-6F; zqY-`Z@eqDM!~^&M5#*2|5tG6U8jw>0v@1=u zH174W+uikm_VD#Ia?GXEiC8#Jc_E$iwUej!&h^#p<>2o5{{G_e=Hu+)W%F(L$KR(% zeaB&X{Tzw%@o!4CV=SHVbSBct?Ip059zALlEe0iVe z8?BY=_KMC`Sp4LuNOKwv8N8>p%zfi?*A;-C#} z=z%~30(oPoAW)~kAo3<&L7+x~ya`$ms8b;CKNke*6j+SqII44WQ&%sr6f2Sc+d~kj tPhhRSkxfjX2ngAe0t5&UAV7cs0RjXF5Fk*!z!!l6D=Pp1 literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-d.db-wal b/libs/act-sqlite/test/test-rsd-d.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..a8984ed5f66ea6a48328b8233c000a3512f27ed7 GIT binary patch literal 148352 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 PpR(BfgObuxXXXC^@JUJc literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-e.db-shm b/libs/act-sqlite/test/test-rsd-e.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..882b343bb4dcc54c89c840b47c0caceea1a65a92 GIT binary patch literal 32768 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 s0^|BdHZg%BFd;#pT7j)OV literal 0 HcmV?d00001 diff --git a/libs/act-sqlite/test/test-rsd-f.db-wal b/libs/act-sqlite/test/test-rsd-f.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..74c4c6f79835bc4240666ad7ac4e344832fdeb10 GIT binary patch literal 148352 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 l0tg_000IagfB*srAb unknown) | undefined; + /** The same, for the \`pii\` sidecar's half of the declaration. */ + readonly parse_pii: ((data: unknown) => unknown) | undefined; }; /** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ @@ -10977,15 +10979,23 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Rebuild a schema for reading: dates coerce from their stored string, and - * objects keep keys they don't declare. + * Rebuild a schema for reading: dates coerce from their stored string, the + * sensitive keys are optional, and objects keep keys they don't declare. + * + * Reading revives dates, it does not re-validate — the payload was validated + * on the way in. The sensitive keys are optional because the write path moved + * them into the \`pii\` sidecar, so \`data\` structurally cannot hold them. * * A construct this doesn't recognise is returned untouched, so an unfamiliar * schema still parses — it just won't coerce dates buried inside one. That * fallthrough is what keeps this small: it describes the shapes worth * rebuilding, not every shape that exists. */ -function to_read_schema(schema: unknown, found: { date: boolean }): unknown { +function to_read_schema( + schema: unknown, + found: { date: boolean }, + sensitive?: readonly string[] +): unknown { const def = def_of(schema); if (!def) return schema; switch (def.type) { @@ -10996,8 +11006,10 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { const shape = def.shape as Record | undefined; if (!shape) return schema; const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) - next[key] = to_read_schema(inner, found) as z.ZodType; + for (const [key, inner] of Object.entries(shape)) { + const rebuilt = to_read_schema(inner, found) as z.ZodType; + next[key] = sensitive?.includes(key) ? rebuilt.optional() : rebuilt; + } return z.looseObject(next); } case "array": @@ -11010,10 +11022,22 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { case "union": return z.union( (def.options as unknown[]).map( - (o) => to_read_schema(o, found) as z.ZodType + (o) => to_read_schema(o, found, sensitive) as z.ZodType ) as never ); + case "tuple": + return z.tuple( + (def.items as unknown[]).map( + (i) => to_read_schema(i, found) as z.ZodType + ) as never + ); + case "readonly": + case "nonoptional": + return to_read_schema(def.innerType, found); case "optional": + case "default": + case "prefault": + case "catch": return (to_read_schema(def.innerType, found) as z.ZodType).optional(); case "nullable": return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); @@ -11035,12 +11059,19 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { */ export function event_tags(schema: z.ZodType): EventTags { const sensitive: string[] = []; + const pii_shape: Record = {}; + const found = { date: false }; const collect = (node: unknown): void => { const shape = def_of(node)?.shape as Record | undefined; if (shape) { for (const key of Object.keys(shape)) - if (is_pii(shape[key])) sensitive.push(key); + if (is_pii(shape[key])) { + sensitive.push(key); + pii_shape[key] ??= ( + to_read_schema(shape[key], found) as z.ZodType + ).optional(); + } return; } const options = (node as { options?: unknown }).options; @@ -11048,13 +11079,23 @@ export function event_tags(schema: z.ZodType): EventTags { }; collect(schema); - const found = { date: false }; - const read_schema = to_read_schema(schema, found); + const unique = [...new Set(sensitive)]; + const read_schema = to_read_schema(schema, found, unique) as z.ZodType; + // The sidecar carries the split-out fields alone, so it gets their half of + // the same rebuild — without it a disclosed \`sensitive(z.date())\` arrives as + // a string beside a plain sibling that is a Date. + const pii_schema = z.looseObject(pii_shape); + // Reviving must never reject: a stored payload can disagree with the current + // declaration (a field added since it was written), and dropping the read is + // worse than handing back what is stored. + const revive = (schema: z.ZodType) => (data: unknown) => { + const revived = schema.safeParse(data); + return revived.success ? revived.data : data; + }; return { - sensitive: [...new Set(sensitive)], - parse: found.date - ? (data: unknown) => (read_schema as z.ZodType).parse(data) - : undefined, + sensitive: unique, + parse: found.date ? revive(read_schema) : undefined, + parse_pii: found.date && unique.length ? revive(pii_schema) : undefined, }; } @@ -11086,7 +11127,7 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse } = tags; + const { sensitive, parse, parse_pii } = tags; if (!parse && sensitive.length === 0) return undefined; const gate: EventGate = @@ -11098,10 +11139,20 @@ export function make_event_reader( if (!parse) return gate; - // Type before disclosing: the gate copies, so parsing afterwards would - // leave the consumer's value a string. - return ((event, actor) => - gate({ ...event, data: parse(event.data) } as never, actor)) as EventGate; + // Revive before disclosing: the gate copies, so reviving afterwards would + // leave the consumer's value a string — and it substitutes REDACTED and + // SHREDDED, which are not dates. + return ((event, actor) => { + const pii = (event as { pii?: unknown }).pii; + return gate( + { + ...event, + data: parse(event.data), + ...(parse_pii && pii != null ? { pii: parse_pii(pii) } : {}), + } as never, + actor + ); + }) as EventGate; } /** diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 04c46f8d5..3b723e534 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -50,6 +50,8 @@ export type EventTags = { * schema declares no dates. */ readonly parse: ((data: unknown) => unknown) | undefined; + /** The same, for the `pii` sidecar's half of the declaration. */ + readonly parse_pii: ((data: unknown) => unknown) | undefined; }; /** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ @@ -58,15 +60,23 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Rebuild a schema for reading: dates coerce from their stored string, and - * objects keep keys they don't declare. + * Rebuild a schema for reading: dates coerce from their stored string, the + * sensitive keys are optional, and objects keep keys they don't declare. + * + * Reading revives dates, it does not re-validate — the payload was validated + * on the way in. The sensitive keys are optional because the write path moved + * them into the `pii` sidecar, so `data` structurally cannot hold them. * * A construct this doesn't recognise is returned untouched, so an unfamiliar * schema still parses — it just won't coerce dates buried inside one. That * fallthrough is what keeps this small: it describes the shapes worth * rebuilding, not every shape that exists. */ -function to_read_schema(schema: unknown, found: { date: boolean }): unknown { +function to_read_schema( + schema: unknown, + found: { date: boolean }, + sensitive?: readonly string[] +): unknown { const def = def_of(schema); if (!def) return schema; switch (def.type) { @@ -77,8 +87,10 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { const shape = def.shape as Record | undefined; if (!shape) return schema; const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) - next[key] = to_read_schema(inner, found) as z.ZodType; + for (const [key, inner] of Object.entries(shape)) { + const rebuilt = to_read_schema(inner, found) as z.ZodType; + next[key] = sensitive?.includes(key) ? rebuilt.optional() : rebuilt; + } return z.looseObject(next); } case "array": @@ -91,10 +103,22 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { case "union": return z.union( (def.options as unknown[]).map( - (o) => to_read_schema(o, found) as z.ZodType + (o) => to_read_schema(o, found, sensitive) as z.ZodType + ) as never + ); + case "tuple": + return z.tuple( + (def.items as unknown[]).map( + (i) => to_read_schema(i, found) as z.ZodType ) as never ); + case "readonly": + case "nonoptional": + return to_read_schema(def.innerType, found); case "optional": + case "default": + case "prefault": + case "catch": return (to_read_schema(def.innerType, found) as z.ZodType).optional(); case "nullable": return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); @@ -116,12 +140,19 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { */ export function event_tags(schema: z.ZodType): EventTags { const sensitive: string[] = []; + const pii_shape: Record = {}; + const found = { date: false }; const collect = (node: unknown): void => { const shape = def_of(node)?.shape as Record | undefined; if (shape) { for (const key of Object.keys(shape)) - if (is_pii(shape[key])) sensitive.push(key); + if (is_pii(shape[key])) { + sensitive.push(key); + pii_shape[key] ??= ( + to_read_schema(shape[key], found) as z.ZodType + ).optional(); + } return; } const options = (node as { options?: unknown }).options; @@ -129,13 +160,23 @@ export function event_tags(schema: z.ZodType): EventTags { }; collect(schema); - const found = { date: false }; - const read_schema = to_read_schema(schema, found); + const unique = [...new Set(sensitive)]; + const read_schema = to_read_schema(schema, found, unique) as z.ZodType; + // The sidecar carries the split-out fields alone, so it gets their half of + // the same rebuild — without it a disclosed `sensitive(z.date())` arrives as + // a string beside a plain sibling that is a Date. + const pii_schema = z.looseObject(pii_shape); + // Reviving must never reject: a stored payload can disagree with the current + // declaration (a field added since it was written), and dropping the read is + // worse than handing back what is stored. + const revive = (schema: z.ZodType) => (data: unknown) => { + const revived = schema.safeParse(data); + return revived.success ? revived.data : data; + }; return { - sensitive: [...new Set(sensitive)], - parse: found.date - ? (data: unknown) => (read_schema as z.ZodType).parse(data) - : undefined, + sensitive: unique, + parse: found.date ? revive(read_schema) : undefined, + parse_pii: found.date && unique.length ? revive(pii_schema) : undefined, }; } @@ -167,7 +208,7 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse } = tags; + const { sensitive, parse, parse_pii } = tags; if (!parse && sensitive.length === 0) return undefined; const gate: EventGate = @@ -179,10 +220,20 @@ export function make_event_reader( if (!parse) return gate; - // Type before disclosing: the gate copies, so parsing afterwards would - // leave the consumer's value a string. - return ((event, actor) => - gate({ ...event, data: parse(event.data) } as never, actor)) as EventGate; + // Revive before disclosing: the gate copies, so reviving afterwards would + // leave the consumer's value a string — and it substitutes REDACTED and + // SHREDDED, which are not dates. + return ((event, actor) => { + const pii = (event as { pii?: unknown }).pii; + return gate( + { + ...event, + data: parse(event.data), + ...(parse_pii && pii != null ? { pii: parse_pii(pii) } : {}), + } as never, + actor + ); + }) as EventGate; } /** From dd4fb1186d1bfdcfa9a1822a1d27231f5fc72cfb Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:07:39 -0400 Subject: [PATCH 02/11] fix(act): leave the sensitive keys out of the read schema entirely They were optional, which says they might be there. They are not: the write path moves them into the pii sidecar, so data does not hold them and there is nothing to describe. Anything that does turn up under one of those names rides through the loose object untouched. Also 3.8% cheaper on an event with three sensitive fields, since the schema has three fewer keys to consider. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- libs/act-sqlite/test/test-rsd-a.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-a.db-wal | Bin 173072 -> 173072 bytes libs/act-sqlite/test/test-rsd-b.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-b.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-c.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-c.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-d.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-d.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-e.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-e.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-f.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-f.db-wal | Bin 148352 -> 148352 bytes .../all-packages-stability.spec.ts.snap | 12 +++++++----- libs/act/src/builders/event-builder.ts | 12 +++++++----- 14 files changed, 14 insertions(+), 10 deletions(-) diff --git a/libs/act-sqlite/test/test-rsd-a.db-shm b/libs/act-sqlite/test/test-rsd-a.db-shm index 9c800754ef573eed7174ef82ee1d2d9a922f25f0..0da2963012f6e551439fe1f1ad67c7cefa5d3261 100644 GIT binary patch delta 64 vcmZo@U}|V!njj&OwEo;Nx&O0yJ6jKgGkf^)XS@-t{FmRi0gu$iga!2gi*g+` delta 64 vcmZo@U}|V!njj(ZLVhPFgD+pfWy%GGQxh}aw;z#$+A6V2N7vg+2BRoA#S2MRC=h_V2UVu8u= zd}h3p-W{-abAthg0Nk8Av%=0rOUupzS+IG#!%Kd+S(yuzrKb9eHE?ZiG+;+)2w{o8 z=E4-b1FYeB0+NP~EnkCJVtT%zX{c4+d4JZc$)~{@#2b+`yb7(j|9L+DUNj9iUfd7& zU1-S%*5JMnNrMkZfBh+AlR0P_-iwy^t$2RV6s)2AAW|r;j)*?NGvN`(=7Nhzp~SV@ z*TyWZVjG$%p@=N((7N}Ne30cR1N0-k$Iki7}Jg(8|Ef%D__3z{q_1zDtZsaKoG<&MPQ}mLzQda0%J4Iwk(e zwmqd~+c_R13o~;r&#u1|^lrPtXJp}0fmLOb&PMOpZo$li6b+GS?=S70y;x#*dK&$cmr+cyLw3ooBDdA5Lh(8TR0l97dvG~ZyK#IWJ__6Nnt z!tbu!)X1IvGI9HlW~M9rE~e&|rj~|=mb%I2rfIq+X@(}cmZoOrx|T^PX{HvbhN)?0 h$!v_QyuiZcKPU+UF+38Eom-#8JNv)fa)BvK0szpiyW9W( delta 1192 zcmbPmf@{JFE(`N|wk8JMM-mJS0t_H9IdEM%(>xCso2enoERLS?1qv}Di}&?UoOaUb zq0{6AALTcOzvLHyX*l5Nq%mXm-kqBR1ULi)VWNz+rC+QY&(7xB94NpfAj$$XiUlU8 ze@r2bPg-N!<^}@}0k}EwC$4jF9f-aTvS9OehnM_tvkv)eUy{_kERbt+qX9cYgT9tU zK)&_$DFrQo(fIKQs+XUzc7q z_|I<)*5JMnNyEwgGdUgCd@n`Quyfw$69xPCTmow-KZq1c3n%OiDq4DX=jMWoNTKwi zN_gAdY$I+oW178Z{*rLy4&S`sBT@kETyT8LbH>k`fC0@cAj$&_Q65-`YCb8>(7n5$ zWBUa`MkF1(SRZF>>zK~I{e>d3@Y4B}EIRp9hPAbe=R=U&x0^dLBKcMz zqVD#Y6PL5moGIgWBFlef>jAKi^dw{*iidKpHT2!AL(`FPz9zzC&P^_`j_FOvI<9P8 zW25l1OaV=Y$K=$`vz**UU>(O70d>F=jPU*1&Z4W+9JU|WgzS%PJDqMswMA=ezj25$ zi=QLaILX4a!X$0F*CobGZUZY*V=H3=J!1ol>H97*D!~malh~Y-yCCBG_79hk4KwMn z6nC4yVdi#@$H>C(CZ?oMtdRHJuJ9RIctgRJRXn8cxqLV#*-a8(;ocrasiIySKf;7}=`# zk$lVMhF&EqEewRc;WAjAAlvG`FbITN66Jx_vT}$(1bKR6w6SJgL1EaLW iv@|wGR$gG?@*k9hffybMoIB-T6!7^nEEkxIu!!+LFn@KFOh4;PS5L@9{`&OjuA40HR4A3jhEB delta 64 vcmZo@U}|V!njj(JEwtsD;j?cNoqvxdbV}wZDGA!GRoEaQj7Mr?!h(7LK%W`6 diff --git a/libs/act-sqlite/test/test-rsd-b.db-wal b/libs/act-sqlite/test/test-rsd-b.db-wal index 7e2abf7c562d3443cf7b778cb7b872c921ab6638..0f48344eec09e6534cd11d9ceb39b53613b8827f 100644 GIT binary patch delta 991 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJbzLUXjXWHPY-Zx(gjmmM|>D8z^?KDASPZfN(e z-IEu5l;0Tsl3xI(!Qk1R?)hSG^EL+va0m#(M18(}PQKv1Du!!wpa7GAC=1Xi7MNUT z>!CnT=RLxk8w@xE;N~@kS&K4c83cmZXF5J_U4Gd>y0Z|@cfbziHDNz#4 zt-AM5?e+_Tj7U0;%J7S~i(7MSf1!vhyp*-RfBMPhU)zk3MXMt0gv$P9Wq|@`ySWo1 zl5dZ^{pfda;hYa>&UE@|uc?xdA_&%zo`kF;<55RO*@f43jlgzOKmOMbQnb9Q-c zzj25$fuEx^xyUfpGS7Uv!zIQ{ZbK^rGb;m2Jp*I&>02%_>c9=-($4A4@RqgQ{^1g` zVe2;6wUIleCZuR+yri=J_?axm z?E%8b!p4g{O&qKBBe!QLGhO9(PEAa;Ff=pLO*Jx3)HN|pO4GHlv@q8-HZU2y(eAdb%IXxoJ zKTlrpQGR3iOMU^EhN=CR?522^=xq)V;1Cdmi89E)UA3uNYzf!qKmjHJQ5K+4EHJq# z$|W*~-peI#ZZO~wfSc3gzFW@q`iTmV1)H}!yyScZ@YWn7yZ4cHMHI{Y)- z?*4XH18aDmfTSUF$-+yjRXlspG)xqplz-*qTTieC@kS&KTdvQmH)Fhi8BN2Qt#jD9 zZ*$3jHMlQC(%{bd&7O6NNfVle3QLhVyOTV@U=8I5kwPhw^Ye-~f>Z4_7hFUNrGHn} z>DVlpn}TM{+m|;!{K;Q9W%GiMNCD(F`E!@SO$B9OKr;)7@&H4W2Nt6JGm7f$0`>Q9 zzaYqnq~qr44RZ5s#4l`rp@=NZ70xL2=-i`I+l-J!1)tjnZge+r0=a#=xf3IjZw)7@ zY*aJr4nT9}yX8j{7M&>g3D%LGgsdYb=cUioUwfs|beOHXvF`MbwrH@9=}pKw7WYrP z6eTr%E1Hg-yDseK>`J{2)^U6hPzOB0xC&m~UoYs#yZyi>WPjw#9=;$og(-9UjYEtH z{2ZyqNttD3$rjTcE-_|u8(5hdTNxYZ85>$m-*Smj2X5G*IE6bBmlnO*{^1g`VfSM% z{_o8FA+(+2F|zQ~@5S!T-+ZICD||*4el}aY$*%L+mhBeIOi0mi`0j>t54KgLY!47d z7FIlVOZMjTu!8Lw%1l@Joh^-1%}p)RbS+FREp<&&j7)Vcj4Um5jf{=WOj6U#ladV6 g*ce%Pftm6@DC&V2?)Yn4guErbJu_V1V9X={09L4+v;Y7A diff --git a/libs/act-sqlite/test/test-rsd-c.db-shm b/libs/act-sqlite/test/test-rsd-c.db-shm index 3b8b335cb12932da9732e32edf443832a9a79525..71f500945bdb6036ae88c85c3f4d051715682a0b 100644 GIT binary patch delta 64 wcmZo@U}|V!njj&ebDAYJjrZ9DIpq&kcbtpnuRH&JwP>ZpQ#?`|6Bg710ESQ<@c;k- delta 64 vcmZo@U}|V!njj$&TqUFUoacqcv9Fn&0g>}VR2dfkT9M237LU}%ga!2gHUb*` diff --git a/libs/act-sqlite/test/test-rsd-c.db-wal b/libs/act-sqlite/test/test-rsd-c.db-wal index f37f3b374051c7210ba77245d77a9e2d2562302f..acb5690492a7fc57f4a1eb207f2d96db381a23c7 100644 GIT binary patch delta 992 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJbr${(ukI2X2y(T)V`ZH-7Sa zo{ErHow>!M#ha2VeXXWu8xBGcqn;Q+-1>p9E zov^Z(Qr-0p;7zB@R0-IU;YJ!*(Db&9s|RfSwNHr7@$0`pgaCi z{P)}s|F3SpAjpWMBZh5HJG)NGf$c98k%ilzhSaaQq&#Vx5wd8Eh+&hsL~Rnt?c2?r z7?FINuxXLq+}>v=(46@(G-c*T(P!ZB+@79b+ssPm$ z*SFs|#F)U(QJP$oXqsZ4Jl)|EV6hS_DP#kEhF=Xi`PyyNiIpQk7HAK0$&8CiJ!d!9W!3uiTLw_s*MiUv2m-3Hz> z#jCal2qOz`ja|G;lk1D*_6%jFEBr1=mKH`yrYVNH$tkI3x+Vt6X1YmfM#j3vDdvVr nmX>J-Nft(IjI6xCO!*%a_dpDHeJaaood?f&)0Q_FGYJ3yMy8<% delta 993 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJcrzGiX;M9vSn7x3fes~eiwy5BpVD;(h394NpfAj$$XiUlTT zr=Rh1{>C%xn;Q%`1mNZCvzC9)UGHPe9VJ!F-OsuR03@ng)v+^N1?WeW73t;*CfejElus?l#n>qiGQ6HvFM| z>2?KJgZn}x4O?aWbvE6~OhePae4KCks)ZK+!5YdBB85`Yt|PXqYQl;(7hFUNrMoXW z-}3)#NkB8^@n_52FOxMSHZS;y6hJWtJ%l%MKgkCMG_!yx4=_Y|U?Dn*@2s-?H;ake zF9aZ~eau2|3$eC?X4|GAF6C>b!fm%?Md^S01C*vA-|$KyKe|?!<`X+i#1x z(qeYYA3<|wm2vaIdFkz@U>)g6$U4?0PJTH}chP+`9n-7~uK#$y_BmL`^d@8-28lPO zH|9&9MAN|~=KVqN$<^&(9mf{|b-)vh-Jzs6vRfJpw;$Mq><^#)RxuA2vzcwbafmU2 zpCi>cDL<#oGH<%WCB{r{11nQwD`NvaVZX~er0JTNrX=YmCYc)QT38sTB&H@BnHyPH gurado0(0emP}~DC-1UkwRlyoBc%CnBFlG_}0J<5P%m4rY diff --git a/libs/act-sqlite/test/test-rsd-d.db-shm b/libs/act-sqlite/test/test-rsd-d.db-shm index 5e6ff6a1820a8e411205bf63e98560a6e3fa4f35..93fcc6e1fcd6f5a36ad48c2316e1409fb54f348f 100644 GIT binary patch delta 64 vcmZo@U}|V!njj(J*?4AR?dsl>)~6Nwqj$_dDASa{WcEe27?0G(ga!2gjm8|s delta 64 wcmZo@U}|V!njj%zA*ONl4P#T#LO-4j*S2l5I^X)u=+Hx{mw2Q$CM>820EGV@UjP6A diff --git a/libs/act-sqlite/test/test-rsd-d.db-wal b/libs/act-sqlite/test/test-rsd-d.db-wal index a8984ed5f66ea6a48328b8233c000a3512f27ed7..ed088c93b6acdccb1a5446390a3ce3908d77f0ee 100644 GIT binary patch delta 996 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJc)rxp97cg#PS!g0>;?u0l$pb#Un_`=wlZzVso z%O@}RD8DiMCBFbngUtG;4t9V4eA*l!z#$+A6Mg^4&E-|zu@_vM0|l4_L|K4FvB2b7 z#iQQ+*mfa(bAthg0Nk8i85cZ${1U1GS+IG#!%Kd+S#Li-o>O+_<}R+yjRx!p4V`DQ z8nW};Ux76|Pe9U;%FumeeeDhhG!3C?X_i$_Hl77*5N|}%Fu^PS;FezN>1Y}XY%3r3 zsh>{(Yj9tPq~XfT+(7sNgpqT|kd4M6x0}D~_LtYH(^?Hrl zF9CiNYwzw5}NM!p9MP%WdIva9gV>g}KW`ry%`QXOyRFgSwAh&NfcVa~HZ8C3N zuv75KDQM30ELtRWK+bwESVwvivW{Mf=6(OX!{(#u_^9&hhb^1UeXx${O~^Wq?R+yu zDUh=gO~<1p6D0$`9F+&_IKBv|1D;@ZY)uV3b^WQ}_5+)c{Sg$wv}j^p_Vn#H4ly?K zbCf0*WmJ@wSWZvA#F(jKXk}n#WnigiU}_u%3_xb&6fS#WQrYd3zm>LsxP)w;@2vBT z3oifOu$|*Eval3W+r3%Ez4D<+_n&`G+bx)xkYeIP-MJH5uJKjd1B8)< zD?8YPx9{NEvpqwZ=?cG#p@oHsfu&`#u1T74ny!hVd77?8lDVO-WlE}1nnhYtnt7T9 g8zU<(Fk}7)ML!V3Lu2BZM$ePIt814x7&8d~02PO-ssI20 delta 996 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJaZ{dhK9+qTWB<#XVc@`(@hfkKSP;*US6-dS~I zLd@g^ALTcOzvLHyX_#Jpn62x4+3L*!0vrN@Fi~aOHm~pDOs!m-0|l4_L|K4FvB2b7 z7Ok7o6eMr4xxs)#0B%m6r_j4-lX5|j1)H}!yyS1SAasCU;l;h@QxZra^sf!F)TXb|J6^@kS&Kmy5gD*FL>!fTn@@T4nmC zmoH+$8r&BmX?V+^UEX&q)*MYk?9UhP91cJ80c$8fh!jd|96cuNtozu#x!@vFD2WR6 zzG)Iy2t_kSV8i{63=aF9n-_dU3ZS-=d{vc8{`&v}npr@U2NR=Ta9hu#WU3WF4AIXDwNMuu=g{$Bq0CabhRyo`7{sZ$j2l+8TaK zc(>doG#$(Nx6kJ}cjOLO$MHo#9qn5J z-?i)6wsSm27PhRpF2T5R#kB1TpOJ-c$S*(NqQU%Qy9F~7QcSQu4Sa6S)%an1fH1PK z#Vr9f{j|C2+cT7zuJF4g8k(l0ni*K?ni?7;>6#c@rs*acCMM}x070T@TAGo0k_8(h dD=#o({s%=r5W_>`s)m?FP!r>umz}2Hf4F{qj^?#_mk&-*0tzuAi+|R!dN$|A zn%k2Xe3ai9{*qq+reX8#_zUX;veY&Q2yh4p!bH^`?knMo*r>y`IZ%K}K$Hb&6bnr5 z&+Oa#g70qpy}7}FLjZ2hL!-DI%Rfz80J327c88bzaI?fy{nlt`E3D(%+-Sg#(6Hpw zZi5`{NAJKIo+lt_`2SW^Bo=TN0c&tyh@@frL4HN8SyHpmGz3q3VKIH8#c!~N@`FgBB-9x%f z-F`uk5lP2OzpEMCvzJF~f1!vheB+s&OAk$h{q z^K^1u<^2vcXZo+&a;kQz;z6*E^dw{*qEF8AX=*+CjHV-M`s){7U5^ffbxdzU))BGH zN+o*LTU|6AjrVmttDna|2J1M!2&e;|U>0a`Dz17}sk;5ZCS-rq9a?EDV_PY>{l+22 zW`2&+n-n@#WaF{lg_>^Hxi5 zsXCdW#I>E{F|u%MXLi}b$?tw`SNM!9T(Lmts_Z7tKie&snUG>a-Ld4WP`~Vz?E%8b z!tefOX4T%@Yq>o`ndu6@i;=N~xv_<1imr))p`oscaZ<8wVw!=eZc2(-qJ?RSk$H-l hAsZtrFEC^N2Sq;+!$afdvW*by4SoM+sw z)^v;qYj~c3q`|rOpSSp%^NeU3PADjzQ zITftIeIb&DE7zxG*mo6AN7HaazQg+Pem@qlhVp|*q4cSxpz~b(E$Ph#7m-3~{f74o z7u{I37tI*vgpVhs+4w3qFZhTQK)ck6u5N73|SlKgOvRfpw%OA?sL^9Mr1kbz=dVj@2eRZ-20wvIneVdK0pa>*qH) zo84T~gr;NFp=1^r^V}4$j^m4fI^YRL$nM*Uinm-z+Yf9)_Qy>TF5kpyF{`)VIK f7+HCN8S_6V`hgf88sSXeOw9ED_AGBOW)c7Z5|d+X diff --git a/libs/act-sqlite/test/test-rsd-f.db-shm b/libs/act-sqlite/test/test-rsd-f.db-shm index 5b76dc394b49da0f577bd83fb045a71b7769dd1a..29df4cd41362a107cf3c13e82e9e645325e928bf 100644 GIT binary patch delta 64 wcmZo@U}|V!njj&Ouz#t|x*1iz@td1e)P5y%&%c~dcxKazC_GXd6Bg710F-DQ;Q#;t delta 64 ucmZo@U}|V!njj%@$j22y(+*F;%X!&y? zp~(wA%5MyR$u9uY;QpmD#Iigsb8~0*0V(S~KT`);uz9<~OMbXnPrjX-Fxi=-k!y3K0Xss&Ui03b zTU*!c1#5VofTZD``p*@|E_^tTrs3tL!=@554|aexh&Li>IB(E8b?I)cb!Zw|l{cSF zf3fv2ScCgQBn@|W@Q6C4?R<)+;jhKIC5P9pQUYrzKZq1c_tlT@vypFlxw+sXQYd+G zwG{<_ekF}&j6tv0v7@{6-8V1zh!jBY)La7!8ecU51DaVtlm{51Jg^X5Z!S2c{mXfa z?H2?Yk#y{OIPJ;p*9OzKzfeRL)_#`rAvO8^*=U#Om9Ngp{$zv zLQ{?X0-6pb$9=6ga<3l(>o~p$r~{s06khXBjVt`OVf%qi$o}A&dO>K{>&<1`ZyaL$ z!mnazWngAyV5w(dY7qqtGG^p7t#Fj_+-upi-t8YQA)6o@Sb6Z^M$zlrIUXYmU*Wd+ zx#Ip+;q3~ak%hO2N4mVcqFT7!f|&^^>{riAJ@9m&V(In(VPs*a&1L&8I!sO6o}tWi zh2O<2#mLCmGQ~_c$=Je3*Tm2;RoBwY)KJ&JG%?lGz$`H}Ejf{mk(C#i75{@G9Ejom Sw^_PB!MAG0y5$YVOacJY2ZXBt delta 982 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJcZw`HEDhy*^}#e8on_vyk6Oc3%_{6t~h%V7a({NCn?e~>Rizi?W;*CfeCYG2iR#g3Z3Qa?>=N;xG zZHIWU2KR+X8mbI4FFW5_eh^IqXG+tldZSJMz#7UAB83t!%`i2=;$Mhy-9ZSomlxeJr zd4m=dOP+XGyPrA{2i9?X5l{y_!MFu+T+Ykr>)C!_6S6;~5=*uRY3>Q$e&Z117k(83 zD^p`DV*@>76N4yVkTD~t>A3n3<5m{%_o^nV&vs?Asn7j4Yh4+O45gUA=UBhBDI?eizd; zlVsC0i)3AcwA3_R69YpdT?+$a6J0|~W3wcq | undefined => /** * Rebuild a schema for reading: dates coerce from their stored string, the - * sensitive keys are optional, and objects keep keys they don't declare. + * sensitive keys are left out, and objects keep keys they don't declare. * * Reading revives dates, it does not re-validate — the payload was validated - * on the way in. The sensitive keys are optional because the write path moved - * them into the \`pii\` sidecar, so \`data\` structurally cannot hold them. + * on the way in. The sensitive keys are left out rather than made optional + * because the write path moved them into the \`pii\` sidecar: \`data\` does not + * hold them, so there is nothing to describe. Anything that does turn up under + * one of those names rides through the loose object untouched. * * A construct this doesn't recognise is returned untouched, so an unfamiliar * schema still parses — it just won't coerce dates buried inside one. That @@ -11007,8 +11009,8 @@ function to_read_schema( if (!shape) return schema; const next: Record = {}; for (const [key, inner] of Object.entries(shape)) { - const rebuilt = to_read_schema(inner, found) as z.ZodType; - next[key] = sensitive?.includes(key) ? rebuilt.optional() : rebuilt; + if (sensitive?.includes(key)) continue; + next[key] = to_read_schema(inner, found) as z.ZodType; } return z.looseObject(next); } diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 3b723e534..40cb19555 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -61,11 +61,13 @@ const def_of = (schema: unknown): Record | undefined => /** * Rebuild a schema for reading: dates coerce from their stored string, the - * sensitive keys are optional, and objects keep keys they don't declare. + * sensitive keys are left out, and objects keep keys they don't declare. * * Reading revives dates, it does not re-validate — the payload was validated - * on the way in. The sensitive keys are optional because the write path moved - * them into the `pii` sidecar, so `data` structurally cannot hold them. + * on the way in. The sensitive keys are left out rather than made optional + * because the write path moved them into the `pii` sidecar: `data` does not + * hold them, so there is nothing to describe. Anything that does turn up under + * one of those names rides through the loose object untouched. * * A construct this doesn't recognise is returned untouched, so an unfamiliar * schema still parses — it just won't coerce dates buried inside one. That @@ -88,8 +90,8 @@ function to_read_schema( if (!shape) return schema; const next: Record = {}; for (const [key, inner] of Object.entries(shape)) { - const rebuilt = to_read_schema(inner, found) as z.ZodType; - next[key] = sensitive?.includes(key) ? rebuilt.optional() : rebuilt; + if (sensitive?.includes(key)) continue; + next[key] = to_read_schema(inner, found) as z.ZodType; } return z.looseObject(next); } From de169813225a5d1c0c464d387d854f098276f699 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:13:15 -0400 Subject: [PATCH 03/11] fix(act): describe only the date fields when reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copied schema listed every field, so reading re-checked data that was already checked on save. Now it names the date paths and nothing else; everything else rides through the loose object untouched. This is what makes the read tolerant rather than a list of exceptions. A field that is not a date is not described, so a sensitive field sitting in the pii sidecar, a field added to the declaration after an event was written, and a field since removed are all simply not this schema's business. Dates are optional for the same reason. Costs 0.07us per event read against a 545us cold load on Postgres (act-pg/PERFORMANCE.md:1430) — about 0.01% of a read. The earlier version of this branch kept every field declared because that measured faster in isolation, which was the wrong thing to optimize: the same PERFORMANCE.md table records a faster hand-rolled walk being rejected for the same reason. Also keeps null in a nullable date, which coercion would have turned into the epoch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../act-sqlite/test/read-schema-dates.spec.ts | 33 ++++- libs/act-sqlite/test/test-rsd-a.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-a.db-wal | Bin 173072 -> 173072 bytes libs/act-sqlite/test/test-rsd-b.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-b.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-c.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-c.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-d.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-d.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-e.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-e.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-f.db-shm | Bin 32768 -> 32768 bytes libs/act-sqlite/test/test-rsd-f.db-wal | Bin 148352 -> 148352 bytes libs/act-sqlite/test/test-rsd-g.db-shm | Bin 0 -> 32768 bytes libs/act-sqlite/test/test-rsd-g.db-wal | Bin 0 -> 148352 bytes .../all-packages-stability.spec.ts.snap | 138 +++++++++--------- libs/act/src/builders/event-builder.ts | 136 +++++++++-------- 17 files changed, 175 insertions(+), 132 deletions(-) create mode 100644 libs/act-sqlite/test/test-rsd-g.db-shm create mode 100644 libs/act-sqlite/test/test-rsd-g.db-wal diff --git a/libs/act-sqlite/test/read-schema-dates.spec.ts b/libs/act-sqlite/test/read-schema-dates.spec.ts index 759916e97..daccc847f 100644 --- a/libs/act-sqlite/test/read-schema-dates.spec.ts +++ b/libs/act-sqlite/test/read-schema-dates.spec.ts @@ -147,6 +147,7 @@ describe("read schema converts dates without validating (#1594)", () => { a: z.date(), b: z.date().default(() => new Date(0)), t: z.tuple([z.date(), z.string()]), + td: z.tuple([z.string(), z.string()]), r: z.date().readonly(), c: z.date().catch(() => new Date(0)), n: z.date().optional().nonoptional(), @@ -174,6 +175,7 @@ describe("read schema converts dates without validating (#1594)", () => { a: new Date("2020-01-01"), b: new Date("2021-01-01"), t: [when, "x"], + td: ["no", "dates"], r: when, c: when, n: when, @@ -190,13 +192,14 @@ describe("read schema converts dates without validating (#1594)", () => { expect(d[key], key).toBeInstanceOf(Date); expect(d.t[0]).toBeInstanceOf(Date); expect(d.t[1]).toBe("x"); + expect(d.td).toEqual(["no", "dates"]); expect(d.l[0]).toBeInstanceOf(Date); expect(d.m.k).toBeInstanceOf(Date); expect(d.o.deep).toBeInstanceOf(Date); expect(d.x).toBeNull(); }); - it("hands back what is stored when the payload predates the declaration", async () => { + it("revives dates in a payload that predates the declaration", async () => { await open_store("f"); const Happened = z.object({ at: z.date(), label: z.string() }); const S = state({ F: z.object({ n: z.number() }) }) @@ -220,9 +223,33 @@ describe("read schema converts dates without validating (#1594)", () => { at: unknown; label: unknown; }; - // reading does not throw, and the stored value comes back as stored + // reading does not throw; the missing field is simply absent, and the + // date still revives because the schema only ever described the date expect(data.label).toBeUndefined(); - expect(typeof data.at).toBe("string"); + expect(data.at).toBeInstanceOf(Date); + }); + + it("hands back what is stored when a date field holds something else", async () => { + await open_store("g"); + const Happened = z.object({ at: z.date() }); + const S = state({ G: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await store().commit( + "g1", + [{ name: "Happened", data: { at: "not a date" } } as never], + { correlation: "c", causation: {} }, + -1 + ); + + const data = (await app.query_array({}))[0]!.data as { at: unknown }; + expect(data.at).toBe("not a date"); }); it("leaves an ISO-shaped z.string() a string (#1556 stays fixed)", async () => { diff --git a/libs/act-sqlite/test/test-rsd-a.db-shm b/libs/act-sqlite/test/test-rsd-a.db-shm index 0da2963012f6e551439fe1f1ad67c7cefa5d3261..dd8ba62f2bf97eca599ff334e211b27b93a1cd1d 100644 GIT binary patch delta 64 wcmZo@U}|V!njj%zw^^3yI=k@ROjuA40D<2g4*&oF delta 64 vcmZo@U}|V!njj&OwEo;Nx&O0yJ6jKgGkf^)XS@-t{FmRi0gu$iga!2gi*g+` diff --git a/libs/act-sqlite/test/test-rsd-a.db-wal b/libs/act-sqlite/test/test-rsd-a.db-wal index 794a8d67b24eb695f715f064489b9fe0d4544b16..3642ab21e9bb19c8ad271d4fcc4ece980c217394 100644 GIT binary patch delta 1187 zcmbPmf@{JFE(`N|wk8JMM-mJS0t_IK91?qc>CP8F=D38H`HH+a02E?G78jU!dR;jW zd*I{+ALTcOzvLHyX;`#Gl0&?R?aJl=0S*B{nCSXM^*I-F(m!!+4isP#5M==x#R8M7 z-?lic!0LeI<^}@}0k}DHk1{z#b1|BMEZDr=;Uz!Ztlv#WOKR^a{^8o(XuyupFm0}} zM9Eu@D_{-J6Oc3<%e|emEaiIu=qE0M~_IQ34zC}Y6ExlQ=52qWh%79`;d*86b{U0jM#!Qag6yBWZge$(+`ir1i4n=SG4D=A z`@T3IhUU!AIomfiESjJW){&lstmAd%fwWnZ$^+4KAX_8om>P*Y&*+xw1#Q9;Fqu%G8pO-RhZ!ku-=TlC6PGiYU zWy%GGQxh}aw;z#$+A6V2N7vg+2BRoA#S2MRC=h_V2UVu8u= zd}h3p-W{-abAthg0Nk8Av%=0rOUupzS+IG#!%Kd+S(yuzrKb9eHE?ZiG+;+)2w{o8 z=E4-b1FYeB0+NP~EnkCJVtT%zX{c4+d4JZc$)~{@#2b+`yb7(j|9L+DUNj9iUfd7& zU1-S%*5JMnNrMkZfBh+AlR0P_-iwy^t$2RV6s)2AAW|r;j)*?NGvN`(=7Nhzp~SV@ z*TyWZVjG$%p@=N((7N}Ne30cR1N0-k$Iki7}hgz|6|PQqRE1eEPmij7o5$oFmRFD2A3KZ2xcx*{C`t{>ipI zrDfYW9wQ4gb1u)WzZCRtyTWH=;ZlKBWs}ZE@7Qj^%!CvPk!kNQ?VY_?Vtarvvhdvb zMz1^1y6)Ycq0H3I2XbbaNm5oiiZhK3r)L{6r4#1|&Xc10w{I)-ZEr9}wr8=LGRMM# ziz~O!a7Gr^db{%AXWN**?HhuTg_qBnJX=6LXyW!0$;iS-ns2aAV%YF|`-5U+;dfVV zYUIv-nYjH&Gt(7*7gKXfQ%gfbOWkC1(==U^G(!_zOH(s*UCX4DG*gRI!_+jhWHv@t dUSPTMAC!857#;)1&aF@4o&8^Kxxf@A0RZ(Jy3GIp diff --git a/libs/act-sqlite/test/test-rsd-b.db-shm b/libs/act-sqlite/test/test-rsd-b.db-shm index e50b586b4124a308819b27fa66f66c7be081c37b..82fb3554d3f5afa0e2f1010a00050951d8de071f 100644 GIT binary patch delta 64 wcmZo@U}|V!njj$&dH=wQ{?`@n=WpC!Iu!!+LFn@KFOh4;PS5L@9{`&OjuA40HR4A3jhEB diff --git a/libs/act-sqlite/test/test-rsd-b.db-wal b/libs/act-sqlite/test/test-rsd-b.db-wal index 0f48344eec09e6534cd11d9ceb39b53613b8827f..9db974c30e74474fd88a354a6456eb3c13a72a2d 100644 GIT binary patch delta 991 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJdCH|{U;@i{5tzHG}Tk?#ZsBJuA0nbAthg0Nk9ub#`CU3a6%mEZDr=;Uz!ZETwQA1Gm<{g^Gmi={W?HiS#v#T8 zeh#D3v??PbgN*48ml(4-4UMc!O!bTmO{Z_U#Ha%|O6U9irzXZ%rMG{$glyD3r!P8J zZ@pZ)o#QdG@G}>$7hzT%jN27HBMY-x=%|PO436J!!OVmd3A2s|xMwb&=Da;X7+Lrl z`*q`ny*J&qXDBmW;de1ivrICuG&ImnOEOQ^HAzlN*0o4VHrBOFF)_0=FfcVqHA!J( eWaS0s$^W332V%JER~)z>`M%D8z^?KDASPZfN(e z-IEu5l;0Tsl3xI(!Qk1R?)hSG^EL+va0m#(M18(}PQKv1Du!!wpa7GAC=1Xi7MNUT z>!CnT=RLxk8w@xE;N~@kS&K4c83cm|a(E$g|tcofwgPd*tm$ zzk>_sd_Z%i(@%R%m4p;Qu#WU3WE~;T!UEGjv1_C0_{S)-f5Mvu$zUDRn~-&ExbdQ1 zFP6^~O^4LwT@h}t5-x*v9A5;~0Z%YnmI;M$yb}1k{lF$KPcDPv3HhQ3q}mmv&BXhPSNc_79hkjas+4z9qST zr}TD?$H>CZcj^^slKGb}BD@xBA~m;7pTN|x^2I6c(|DvdCM>820BM*Uod5s; delta 64 wcmZo@U}|V!njj&ebDAYJjrZ9DIpq&kcbtpnuRH&JwP>ZpQ#?`|6Bg710ESQ<@c;k- diff --git a/libs/act-sqlite/test/test-rsd-c.db-wal b/libs/act-sqlite/test/test-rsd-c.db-wal index acb5690492a7fc57f4a1eb207f2d96db381a23c7..54b61c3a5cc3daa5c729e4cb3c46400e8a4d0d00 100644 GIT binary patch delta 990 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJawycTOBHMdP~lowFHuvbtOD8z^?o)czN%(=BN zX!3%O@*Bfn@(aK;X#euvcqXTSadUtGhkzhV)SCaD<%#P;v$-}03NQ(XvH*=@fyu2o zUTeO5&xO3r4F((naB~Eeetm7`nR*;#!RGA_FZtnSdC%YQx9hZJCD-Og19pUl`@(Um zoky8^!5W??AZa+dKDpK|!J!;Y!;c;LVm`AUc7QdAHzH|Jk)JOV$|TK(rhzGZmzj<0 z{Lf$w?hBDLbTZUe-DWxQ7EQx8qZe1pEv=fs8p;nMh0?t~LGPI+s}^o9xQG-=s#ncd z8)w{Djb_X)*N)w()v4<@FZhTQK$l}zT@*Dx?F0;HW&u$iV2JX-LiGQP5X0ifZ2{Xa z2r?q+c=`PM+hy*>?b}}{A`9zVw3WW@m^yWv5wd8RRZvFxuhW4bw{JIhVnp)oh9!2y(T)V`ZH-7Sa zwH zRCj#?Yj~c3q~YAG$L$LauTDYJaO?T54IxkddV)2GHzH{e`nc}SukVT4Xc|O=125c? z+U5h+;Jy$^gEDt`i~WVLE;J2)Kd*Riw12~9u!i!3NTI}&zDsoNJ;86A3oass(&d+P zj_$hq@e7(U#apY6p66WmXY+!ONCCvZpfJ1Sg2iKCKr;)7@&H4W2Nt5oKZ^gJ`{Do9 z?H2?Yk#xkc?P+J%Njb3ng(9+W`_qv6HJ6kpZ8Jg^jS(?y5|^k=0=a#=xf3IjZxc2x zvYXrc>;#%KKZd5v{3!Yi9G=_LlaO_koLTRvzAMrhO-IzX2l@{`Ex!%6V|o*^j%N9- z`I%>@^`hyB3}ZiW#YDIptmF71pbmI~DGQZ%d12r?b^C!$$o}YCrMxOYb;b4VHx4l- z@N<+V7bTjem?uwnxWt&vX<%k$V5w(dVm5utB}N^%QFa+>aqZJ|l(v7kglyE4mHKz*=jqA)2evDGMiyTGo@Wow!dXq*Etr{*BEd~>w}JOe@v7|s!pOo~ zV;Aqz_&v9587xnYu}Wtu^fg%KMg dD=#oh{s%=o5W^jx%5qxg!86{p)~6Nwqj$_dDASa{WcEe27?0G(ga!2gjm8|s diff --git a/libs/act-sqlite/test/test-rsd-d.db-wal b/libs/act-sqlite/test/test-rsd-d.db-wal index ed088c93b6acdccb1a5446390a3ce3908d77f0ee..b6a20c70d5575369e7418561063ddd27ceb7d278 100644 GIT binary patch delta 1042 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJc*cHBFkS)?4jH08|!0vrN@FwrTW>?g0>az2!6bD#i|fG7*lC>EI9 zzx_Os>Q~|tHa8e>2*Aym`&#Ayl4<6GAPY8ccX-JUH|y4N+x}>7&fQ#_8x7bI8q!w2 zjDNa&!E>;N=Ltv}ME0EjADs2E6HP<2L<7ULkDf(f4dRVR8Y*W+v0Qi4&p^{~n0;fw z&1)auf;G4=MAA?#v^eJLz2ov|8m4bxntx2In+L3+{2)>&?G9;~J5kS8d2_){r z(pegrdBGLUn3ChNf3^Lq88mhaV_8=YhZAQqVUuGnKQrvf08szrv=1z=AzCCl% zxov~cwyS8){3;h}nR0W(FR+gEBxD_?Co?Vgn(J$z>0k*I`=6D1>IhiJ^d@8-y4mN% zWe@Uoq3I~Ed2!;1F8dp>j^m4fI^YRLeN)7o=#_@AwjbC8^oJBIEcjQHB(caEzvl&| zVBRwfyk|Bi3LN6)YB6NwW)S3%ZBv|n=mKN0KuL;{RkTuGzLJhoN@7WB@pi+Dj9QEw zMy2VAX@(Z%(~~bTW-A#QS(%vX85x;H0Zn5DVt79JzB9Va^I&V#_79hkLvc&ue@CCJ zc#rKIkCBD@7BvNmOQ!DFuJ9RISa0t9Fa6iJrM6ozGa<#>lfN-1df#p^+8!W`EIg&N zG{kNbQ_}VfWu`0qE{UdQW=0l9X}ac#iAlO9DVCPHmS(0Vx@O6#W|pQ#mS)Ko#%zqN eyufVyALwvUf`NzKtJnLt9Tkz@yu87fNdN$8R=hm` delta 1025 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJc)rxp97cg#PS!g0>;?u0l$pb#Un_`=wlZzVso z%O@}RD8DiMCBFbngUtG;4t9V4eA*l!z#$+A6Mg^4&E-|zu@_vM0|l4_L|K4FvB2b7 z#iQQ+*mfa(bAthg0Nk8i85cZ${1U1GS+IG#!%Kd+S#Li-o>O+_<}R+yjRx!p4V`DQ z8nW};Ux76|Pe9U;%FumeeeDhhG!3C?X_i$_Hl77*5N|}%Fu^PS;FezN>1Y}XY%3r3 zsh>{(Yj9tPq~XfT+(7sNgpqT|kd4M6x0}D~_LtYH(^?Hrl zF9CiNYwzw5}NM!p9MP%WdIva9gV>g}KW`ry%`QXOyRFgSwAh&NfcVa~HZ8C3N zuv75KDQM30ELtRWK+bwESVwvivW{Mf=6(OX!{(#u_^9&hhb^1UeXx${O~^Wq?R+yu zDUh=gO~<1p6D0$`9F+&_IKBv|1D;@ZY)uV3b^WQ}_5+)M{*Z!&MNkCOqKSFg(|JKD znD+q#?}N>W0@rxCT4Wiy83Z|GTPvrpyTDky-Si@(8Y4$(a#2P_X^G|Z=AHCZuTkP~_5fjI;mQs+;q5!P_H55kX1c=fVrXGu zVqj^RtZR~HoTh7HXr88Pkz{VDYnhU2lxC5ZlxCi0!N$nS3(U3ufer_y4|q^bJk#iT NvUhdu@&;oj0RZv?v8@0A diff --git a/libs/act-sqlite/test/test-rsd-e.db-shm b/libs/act-sqlite/test/test-rsd-e.db-shm index d206da2b7c244992c0ddf39d861ea406218f1740..3f692d6e8d57341850158a80e9177e292736876f 100644 GIT binary patch delta 64 vcmZo@U}|V!njj%@ukiiC8BEvR^FlIRGgh{pp5kmCp8d`}0gu$iga!2goJ1W_ delta 64 wcmZo@U}|V!njj$|lYIGR+ut{G-ItxF-+#D%z4U^2@2BP~y}={3F=0VH05V7;BLDyZ diff --git a/libs/act-sqlite/test/test-rsd-e.db-wal b/libs/act-sqlite/test/test-rsd-e.db-wal index 090d0bd446dc8273466a353086e604c7fda791c6..126da8aed419166f149739ea8419d97d0ca0cc70 100644 GIT binary patch delta 994 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJbyA(^fjE89*>1%x-w-FKr8D8z^?{w&>Luc~jT z{p1B7S50I7mCQjae?vv`)=y2+h&9;T9F^HX%TPSI*{A9n>#Tg`Bq%* z!nyrE-&xR{d2_LH=eu-aQ?QQoBxD`a4W>@KXS#SknvT1XUH6}h`KN<*Om9Ng(Z5~1 zUT@bnPBb0&Tcs>&ujXtA>o~p$r~{s0e)W75$=k(hxc$H;WPhw#7E{R*|8VB^8;2O1 z`8kYA(~B~aa?PhFUt-KwGBmO>G1W6NF^&QTA2V|Lw%IYa@o~t8FWWy{LN;ywLxut^ zImV*x9FLKO?It;hFs`W(+OF^!S@@XgyWeNl3At>yU}i#!hr%~C%)y&W&ukA6MizE0 zvVP*SCUWxj3}vP({4Ob`1_mZ3hNimaMrM|}CW%QFx|V6i7P^Khrb$LgNoE#ispf2q eth~T%`5zSdKnxFyh3^aRxnF0RvAn^UNdN%WE|z5g delta 994 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJc>mz}2Hf4F{qj^?#_mk&-*0tzuAi+|R!dN$|A zn%k2Xe3ai9{*qq+reX8#_zUX;veY&Q2yh4p!bH^`?knMo*r>y`IZ%K}K$Hb&6bnr5 z&+Oa#g70qpy}7}FLjZ2hL!-DI%Rfz80J327c88bzaI?fy{nlt`E3D(%+-Sg#(6Hpw zZi5`{NAJKIo+lt_`2SW^Bo=TN0c&tyh@@frL4HN8SyHpmGz3q3VKIH8#c!~N@`FgBB-9x%f z-F`uk5lP2OzpEMCvzJF~f1!vheB+s&OAk$h{q z^K^1u<^2vcXZo+&a;kQz;z6*E^dw{*qEF8AX=*+CjHV-M`s){7U5^ffbxdzU))BGH zN+o*LTU|6AjrVmttDna|2J1M!2&e;|U>0a`Dz17}sk;5ZCS-rq9a?EDV_PY>{l+22 zW`2&+1er;Fyj4WKSK?3L{S!pOq! z{$^&?-rQ@sJwuu43crhyv4y#@g=LDaiGiV^u8DC{vTkCUfvIjvidmwCX^N3~ikTrB fBP%a3TmA<{J`lsh;^yUK8M!xq+m<&NGYJ3ywq4T0 diff --git a/libs/act-sqlite/test/test-rsd-f.db-shm b/libs/act-sqlite/test/test-rsd-f.db-shm index 29df4cd41362a107cf3c13e82e9e645325e928bf..f288b1577613cfccdd16f5beb1b84bda5b89cdee 100644 GIT binary patch delta 64 wcmZo@U}|V!njj&u%&Tejz2z5O>=b`KnI1ny$SVCgo1fd{SUgf26Bg710GWXt_W%F@ delta 64 wcmZo@U}|V!njj&Ouz#t|x*1iz@td1e)P5y%&%c~dcxKazC_GXd6Bg710F-DQ;Q#;t diff --git a/libs/act-sqlite/test/test-rsd-f.db-wal b/libs/act-sqlite/test/test-rsd-f.db-wal index c57d9bd2207c1db56a888b367619aabed1b0bd85..16dc7ac7151d23252283b9a5ea2cc0a3c339f995 100644 GIT binary patch delta 980 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJbvia(!BkDns6^MOiWOH6bxP>2y({9WmWulz6c zy(TaCD8DiMCBFbnLuiNO>QtktoSOp#I0OV?qC4-2d5b@fHs;zKD8M8j$^tZs1t!;9 zHMOyPrI*;|1_KTOxH%nX`Nd;DZu<|iVDol|m;7+EM9SZZ3+(4S$hEoAfE}Ts@Q%p! zZjno=U=7a`kTiIwHa_k~Crs^+<|2>q-SMbq#*+02|@@s$-=L-|3ZP%>d@TiWi!nYp>(B2p+>8FS8U zP`J;GW=zRn%T(9>bHz6=_=prhNs_lOf2dQl0tPg*fG7_zM0sE#T6^@u>g#_!BDP-; zWJJ<2U29tXi9IXjx4%$C7QUgvY=5G2j_o!hWYO8b4{{~zWOjnwzTMo35y`h#u9@?l zaBPo8bEdzE^TutbOn!iMq$eTkcyGn0*r}Xdho(c|d(HJ#8*?v#bxdzU*6~AXTl}(~ zGy^mpx64%+ehKS$fpr{T1k?dfFctUuT#RomPG%!vO%(GA3K->+`+TIO0#!LbLV|R#9 delta 980 zcmZo@=WJ-_v@ow{YhuuSB*DNSzyJdAo10YBekF7N%-mMAXKRNoP>2y(+*F;%X!&y? zp~(wA%5MyR$u9uY;QpmD#Iigsb8~0*0V(S~KT`);uz9<~OMbXnPrjX-Fxi=-k!y3K0Xss&Ui03b zTU*!c1#5VofTZD``p*@|E_^tTrs3tL!=@554|aexh&Li>IB(E8b?I)cb!Zw|l{cSF zf3fv2ScCgQBn@|W@Q6C4?R<)+;jhKIC5P9pQUYrzKZq1c_tlT@vypFlxw+sXQYd+G zwG{<_ekF}&j6tv0v7@{6-8V1zh!jBY)La7!8ecU51DaVtlm{51Jg^X5Z!S2c{mXfa z?H2?Yk#y{OIPJ;p*9OzKzfeRL)_#`rAvO8^*=U#Om9Ngp{$zv zLQ{?X0-6pb$9=6ga<3l(>o~p$r~{s06khXBjVt`OVf%qi$o}A&dO>K{>&<1`ZyaL$ z#;;^xW@TWhXJBd(1q?7|US3fx+-||lgcS0t=cOKax=*omdw?*qu+!$UeHR_3CT`DAX1c=f zVwPfLWNevYrkiAJVWewfXqc*NX=ZAuYhap~YHDDXn3|TH$i~RZ3(ScBLD3DwaNpZ3 Q-JjrFHDlfK24f}x00bt4KmY&$ diff --git a/libs/act-sqlite/test/test-rsd-g.db-shm b/libs/act-sqlite/test/test-rsd-g.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..c3cf1dfcae3e467513bbdb0c65700883b44159a0 GIT binary patch literal 32768 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 oU)(SufB*srAb unknown) | undefined; - /** The same, for the \`pii\` sidecar's half of the declaration. */ + /** The same for the \`pii\` sidecar, when a sensitive field is a date. */ readonly parse_pii: ((data: unknown) => unknown) | undefined; }; @@ -10979,72 +10979,82 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Rebuild a schema for reading: dates coerce from their stored string, the - * sensitive keys are left out, and objects keep keys they don't declare. + * Build a schema that describes an event's date fields and nothing else, or + * \`undefined\` when it declares no dates. * - * Reading revives dates, it does not re-validate — the payload was validated - * on the way in. The sensitive keys are left out rather than made optional - * because the write path moved them into the \`pii\` sidecar: \`data\` does not - * hold them, so there is nothing to describe. Anything that does turn up under - * one of those names rides through the loose object untouched. + * JSON has no date type, so a stored \`Date\` comes back as text and something + * has to turn it back. That is the entire job. The payload was validated when + * it was committed, so every other field is already known to be good and is + * left out — it rides through the loose object untouched rather than being + * checked a second time. * - * A construct this doesn't recognise is returned untouched, so an unfamiliar - * schema still parses — it just won't coerce dates buried inside one. That - * fallthrough is what keeps this small: it describes the shapes worth - * rebuilding, not every shape that exists. + * Leaving them out is also what stops a read from rejecting what the framework + * itself wrote. A \`sensitive(...)\` field is moved into the \`pii\` sidecar on the + * way in, so \`data\` does not hold it; an event written against an older + * declaration predates whatever was added since. Neither is a date, so neither + * is this schema's business. The dates themselves are optional for the same + * reason: absent is not wrong. + * + * Zod still does the walking, so nesting, arrays, records, unions and the + * wrappers are handled by the engine rather than by a traversal of our own + * that would drift as Zod grows constructs. A construct this doesn't + * recognise contributes no date, which is the documented fallthrough. */ -function to_read_schema( +function to_date_schema( schema: unknown, - found: { date: boolean }, sensitive?: readonly string[] -): unknown { +): z.ZodType | undefined { const def = def_of(schema); - if (!def) return schema; + if (!def) return undefined; + const inner = () => to_date_schema(def.innerType); switch (def.type) { case "date": - found.date = true; return z.coerce.date(); case "object": { const shape = def.shape as Record | undefined; - if (!shape) return schema; - const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) { + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { if (sensitive?.includes(key)) continue; - next[key] = to_read_schema(inner, found) as z.ZodType; + const dates = to_date_schema(field); + if (dates) dated[key] = dates.optional(); } - return z.looseObject(next); - } - case "array": - return z.array(to_read_schema(def.element, found) as z.ZodType); - case "record": - return z.record( - z.string(), - to_read_schema(def.valueType, found) as z.ZodType - ); - case "union": - return z.union( - (def.options as unknown[]).map( - (o) => to_read_schema(o, found, sensitive) as z.ZodType - ) as never - ); - case "tuple": - return z.tuple( - (def.items as unknown[]).map( - (i) => to_read_schema(i, found) as z.ZodType - ) as never + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = to_date_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => to_date_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = to_date_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + const options = (def.options as unknown[]).map((o) => + to_date_schema(o, sensitive) ); - case "readonly": - case "nonoptional": - return to_read_schema(def.innerType, found); + return options.some(Boolean) + ? z.union(options.map((o) => o ?? z.unknown()) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); case "optional": + case "nonoptional": + case "readonly": case "default": case "prefault": case "catch": - return (to_read_schema(def.innerType, found) as z.ZodType).optional(); - case "nullable": - return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); + return inner(); default: - return schema; + return undefined; } } @@ -11061,8 +11071,7 @@ function to_read_schema( */ export function event_tags(schema: z.ZodType): EventTags { const sensitive: string[] = []; - const pii_shape: Record = {}; - const found = { date: false }; + const pii_dates: Record = {}; const collect = (node: unknown): void => { const shape = def_of(node)?.shape as Record | undefined; @@ -11070,9 +11079,8 @@ export function event_tags(schema: z.ZodType): EventTags { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { sensitive.push(key); - pii_shape[key] ??= ( - to_read_schema(shape[key], found) as z.ZodType - ).optional(); + const dates = to_date_schema(shape[key]); + if (dates) pii_dates[key] ??= dates.optional(); } return; } @@ -11082,22 +11090,22 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const read_schema = to_read_schema(schema, found, unique) as z.ZodType; - // The sidecar carries the split-out fields alone, so it gets their half of - // the same rebuild — without it a disclosed \`sensitive(z.date())\` arrives as - // a string beside a plain sibling that is a Date. - const pii_schema = z.looseObject(pii_shape); - // Reviving must never reject: a stored payload can disagree with the current - // declaration (a field added since it was written), and dropping the read is - // worse than handing back what is stored. + const data_schema = to_date_schema(schema, unique); + // The sidecar holds the split-out fields alone, so a date among them needs + // its own pass — without it a disclosed \`sensitive(z.date())\` arrives as + // text beside a plain sibling that is a Date. + const has_pii_date = Object.keys(pii_dates).length > 0; + // Reviving must never reject. A stored payload can disagree with the current + // declaration in ways this schema deliberately does not describe, and handing + // back what is stored beats refusing to read it. const revive = (schema: z.ZodType) => (data: unknown) => { const revived = schema.safeParse(data); return revived.success ? revived.data : data; }; return { sensitive: unique, - parse: found.date ? revive(read_schema) : undefined, - parse_pii: found.date && unique.length ? revive(pii_schema) : undefined, + parse: data_schema && revive(data_schema), + parse_pii: has_pii_date ? revive(z.looseObject(pii_dates)) : undefined, }; } diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 40cb19555..31a292fd2 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -46,11 +46,11 @@ export type EventTags = { /** Keys marked `sensitive(...)`, top level (and across union variants). */ readonly sensitive: readonly string[]; /** - * Types a stored payload against the declaration, or `undefined` when the - * schema declares no dates. + * Revives the dates in a stored payload, or `undefined` when the schema + * declares none. */ readonly parse: ((data: unknown) => unknown) | undefined; - /** The same, for the `pii` sidecar's half of the declaration. */ + /** The same for the `pii` sidecar, when a sensitive field is a date. */ readonly parse_pii: ((data: unknown) => unknown) | undefined; }; @@ -60,72 +60,82 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Rebuild a schema for reading: dates coerce from their stored string, the - * sensitive keys are left out, and objects keep keys they don't declare. + * Build a schema that describes an event's date fields and nothing else, or + * `undefined` when it declares no dates. * - * Reading revives dates, it does not re-validate — the payload was validated - * on the way in. The sensitive keys are left out rather than made optional - * because the write path moved them into the `pii` sidecar: `data` does not - * hold them, so there is nothing to describe. Anything that does turn up under - * one of those names rides through the loose object untouched. + * JSON has no date type, so a stored `Date` comes back as text and something + * has to turn it back. That is the entire job. The payload was validated when + * it was committed, so every other field is already known to be good and is + * left out — it rides through the loose object untouched rather than being + * checked a second time. * - * A construct this doesn't recognise is returned untouched, so an unfamiliar - * schema still parses — it just won't coerce dates buried inside one. That - * fallthrough is what keeps this small: it describes the shapes worth - * rebuilding, not every shape that exists. + * Leaving them out is also what stops a read from rejecting what the framework + * itself wrote. A `sensitive(...)` field is moved into the `pii` sidecar on the + * way in, so `data` does not hold it; an event written against an older + * declaration predates whatever was added since. Neither is a date, so neither + * is this schema's business. The dates themselves are optional for the same + * reason: absent is not wrong. + * + * Zod still does the walking, so nesting, arrays, records, unions and the + * wrappers are handled by the engine rather than by a traversal of our own + * that would drift as Zod grows constructs. A construct this doesn't + * recognise contributes no date, which is the documented fallthrough. */ -function to_read_schema( +function to_date_schema( schema: unknown, - found: { date: boolean }, sensitive?: readonly string[] -): unknown { +): z.ZodType | undefined { const def = def_of(schema); - if (!def) return schema; + if (!def) return undefined; + const inner = () => to_date_schema(def.innerType); switch (def.type) { case "date": - found.date = true; return z.coerce.date(); case "object": { const shape = def.shape as Record | undefined; - if (!shape) return schema; - const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) { + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { if (sensitive?.includes(key)) continue; - next[key] = to_read_schema(inner, found) as z.ZodType; + const dates = to_date_schema(field); + if (dates) dated[key] = dates.optional(); } - return z.looseObject(next); + return Object.keys(dated).length ? z.looseObject(dated) : undefined; } - case "array": - return z.array(to_read_schema(def.element, found) as z.ZodType); - case "record": - return z.record( - z.string(), - to_read_schema(def.valueType, found) as z.ZodType - ); - case "union": - return z.union( - (def.options as unknown[]).map( - (o) => to_read_schema(o, found, sensitive) as z.ZodType - ) as never - ); - case "tuple": - return z.tuple( - (def.items as unknown[]).map( - (i) => to_read_schema(i, found) as z.ZodType - ) as never + case "array": { + const element = to_date_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => to_date_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = to_date_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + const options = (def.options as unknown[]).map((o) => + to_date_schema(o, sensitive) ); - case "readonly": - case "nonoptional": - return to_read_schema(def.innerType, found); + return options.some(Boolean) + ? z.union(options.map((o) => o ?? z.unknown()) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); case "optional": + case "nonoptional": + case "readonly": case "default": case "prefault": case "catch": - return (to_read_schema(def.innerType, found) as z.ZodType).optional(); - case "nullable": - return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); + return inner(); default: - return schema; + return undefined; } } @@ -142,8 +152,7 @@ function to_read_schema( */ export function event_tags(schema: z.ZodType): EventTags { const sensitive: string[] = []; - const pii_shape: Record = {}; - const found = { date: false }; + const pii_dates: Record = {}; const collect = (node: unknown): void => { const shape = def_of(node)?.shape as Record | undefined; @@ -151,9 +160,8 @@ export function event_tags(schema: z.ZodType): EventTags { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { sensitive.push(key); - pii_shape[key] ??= ( - to_read_schema(shape[key], found) as z.ZodType - ).optional(); + const dates = to_date_schema(shape[key]); + if (dates) pii_dates[key] ??= dates.optional(); } return; } @@ -163,22 +171,22 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const read_schema = to_read_schema(schema, found, unique) as z.ZodType; - // The sidecar carries the split-out fields alone, so it gets their half of - // the same rebuild — without it a disclosed `sensitive(z.date())` arrives as - // a string beside a plain sibling that is a Date. - const pii_schema = z.looseObject(pii_shape); - // Reviving must never reject: a stored payload can disagree with the current - // declaration (a field added since it was written), and dropping the read is - // worse than handing back what is stored. + const data_schema = to_date_schema(schema, unique); + // The sidecar holds the split-out fields alone, so a date among them needs + // its own pass — without it a disclosed `sensitive(z.date())` arrives as + // text beside a plain sibling that is a Date. + const has_pii_date = Object.keys(pii_dates).length > 0; + // Reviving must never reject. A stored payload can disagree with the current + // declaration in ways this schema deliberately does not describe, and handing + // back what is stored beats refusing to read it. const revive = (schema: z.ZodType) => (data: unknown) => { const revived = schema.safeParse(data); return revived.success ? revived.data : data; }; return { sensitive: unique, - parse: found.date ? revive(read_schema) : undefined, - parse_pii: found.date && unique.length ? revive(pii_schema) : undefined, + parse: data_schema && revive(data_schema), + parse_pii: has_pii_date ? revive(z.looseObject(pii_dates)) : undefined, }; } From d953e0fab859a19a1033b051a7b7294fac1c3b98 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:16:40 -0400 Subject: [PATCH 04/11] refactor(act): name the read-side schemas after what they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit date_reviver_schema builds the schema, and each event carries a date_reviver for its data and a pii_date_reviver for its sidecar. They were called to_date_schema and parse/parse_pii, which described the mechanism rather than the job and read like a second validation pass — the thing this branch removed. Also stops the tests leaving sqlite WAL and SHM files behind: cleanup removed the .db and not its two siblings, so they were committed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .gitignore | 1 + .../test/test-read-schema-dates.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-a.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-a.db-wal | Bin 173072 -> 0 bytes libs/act-sqlite/test/test-rsd-b.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-b.db-wal | Bin 148352 -> 0 bytes libs/act-sqlite/test/test-rsd-c.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-c.db-wal | Bin 148352 -> 0 bytes libs/act-sqlite/test/test-rsd-d.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-d.db-wal | Bin 148352 -> 0 bytes libs/act-sqlite/test/test-rsd-e.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-e.db-wal | Bin 148352 -> 0 bytes libs/act-sqlite/test/test-rsd-f.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-f.db-wal | Bin 148352 -> 0 bytes libs/act-sqlite/test/test-rsd-g.db-shm | Bin 32768 -> 0 bytes libs/act-sqlite/test/test-rsd-g.db-wal | Bin 148352 -> 0 bytes .../all-packages-stability.spec.ts.snap | 49 ++++++++++-------- libs/act/src/builders/event-builder.ts | 49 ++++++++++-------- libs/act/test/schema-dates.spec.ts | 20 +++---- 19 files changed, 68 insertions(+), 51 deletions(-) delete mode 100644 libs/act-sqlite/test/test-read-schema-dates.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-a.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-a.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-b.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-b.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-c.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-c.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-d.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-d.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-e.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-e.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-f.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-f.db-wal delete mode 100644 libs/act-sqlite/test/test-rsd-g.db-shm delete mode 100644 libs/act-sqlite/test/test-rsd-g.db-wal diff --git a/.gitignore b/.gitignore index bb60fab3a..163ff3971 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ coverage/ local.db* test-store.db* test-autoclose.db* +test-rsd-*.db* tck-store.db* .DS_Store *.tsbuildinfo diff --git a/libs/act-sqlite/test/test-read-schema-dates.db-shm b/libs/act-sqlite/test/test-read-schema-dates.db-shm deleted file mode 100644 index cd2bf1c00275920d67936b7746c2d0fcc1c06341..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*yG=tu5Czb&0fYJfFBS3UO9i$EsIi(O05KsVgKpBJx(Fg3J1vw@0275+Y zk8M4_`{ood9(-Ixu9b8;5$k8EucveQHXdF--HxB%ZyxU-9zRD{FR$mHA9sU)UY~CD zQ^WN7HBsg3zm~G2o6bx+v+3l|bEWr6pD%r(^nU7#<^59X%jx{~{QELftd?umO3(96 zX62lmk?*Sr5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjZ3DbPtfCg!7`wnnU^trt777yEHI%?FT0 zpqfB8j;dt~CQw&kCXVYqfOG=Y1ZLx;TDD*Ubp?8HTK55@6R0Mz5Tk0@f(g_W$Qwfy zfi?w}BX9Us1X>ixo1hheHU;wjb48#{fsNQ|b8u4=h|SohK%h~9-A3m&Hi3u%1Om+p g95g$*!3pI5-BSbz5FkK+009C72oNAZfI!;<-^Mp9w*UYD diff --git a/libs/act-sqlite/test/test-rsd-a.db-shm b/libs/act-sqlite/test/test-rsd-a.db-shm deleted file mode 100644 index dd8ba62f2bf97eca599ff334e211b27b93a1cd1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI*J#G_06ae7opV$s@j3I%T5EDp9NFwD5IR!0ME|R09$q8IhP*G4IYWh?hfevP( zF>;dv$t$fi-;>5~z21E@?>oTr?EW%xJWZ<;F}+B6n%31~-u*e;`Evd7)3>|NzkV;i zf1TX?xZR)q=kux5L!710KPypg|4V7_=%&?6tDjc>zESd^?;ESGFo8A;^y4s2+I&D~ zBhZ+@Al}AlW7QQV&}M;Ayo(QQKA^J^XiOka3>5_G6d1=*EZeY6bSI?Ofvo4iFF^K!5-N R0t5&UAV7cs0Rq(v`~{O~EBF8a diff --git a/libs/act-sqlite/test/test-rsd-a.db-wal b/libs/act-sqlite/test/test-rsd-a.db-wal deleted file mode 100644 index 3642ab21e9bb19c8ad271d4fcc4ece980c217394..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 173072 zcmeI*eQX=$9l&v)lh|>Z#4VtitW>*(X{~8e?4&JCN1=wAl9D)W>Ojkj%*DQf!7r(O z39YIQ(iJh#{s4qFZG%YzT8WJ@1{;h&K>V|@4QZ$h1{>_9+aDkp6HwR+W3cD(-T5Vn zn@~Xu_V<(~=ey@F_k8T6@jbui_dDx)*Iq~W-%oWo<~!sPk6!WA{>NTDa_9O>CI(M= z^+~ZwDc?T-wx=JsuGPJH%jSWkp{v`oxwK}ez2a!6)3HufMO&x%*C>y%7J2;C$M(Kz zU!9KS6F0Yck2o6IyB+PBrX}dI|rS&dxqktWofT&=x2a28RBK5l)c!~`ydgZSZW`_%3=MBnFA8r{LnG0l{)jkqFdT_?6jSPu@BLf4ay%TyapUh^8C!F3wUU0Re6Plq_+n3g>?~98|GW0~XL&uWI z!miR~jzs!5kA#<+ed$uItM*%+^e=O|eCKv3j$|gG@3h3VaU)xJ95Vr9LGx>ac&Rm8 zFE{x0EbNi?V8#`~pqIqi4=HP_}!<)na~u z#FK}qsWO6 zKmY**5I_I{1Q0*~0R#~EEDKb`3w-OY-cyY?nxC5`Ucgn>A(@}xnClTVwcT8d7Z9r~ z2q1s}0tg_000IagfI$5U3^s{e{~d}xb%0-H!Z%0lUDby73(4|JgZuKKpLhZ45r}FL zbE!ul-{ZUNc!6J~UrHTF{3l1eK>hwIqUQ)8fB*srAbvQ|Bnk8PqT_#k>TUhqEaK5s~Gp!c$3%J_6W$^;y0Sf{MAbUhewUcP?^iFmFgzSq3*-B1)g`f*-8>ds zLcG9e$_k7yjh&WpG<4oT*h+MuE^Se*C7DqBBhm1N@UXgRxPLG-yiL6*yiE;_M2Gq# z;?P0j1?CtpAm8J=i}3pcmeT{1px#QKmY**5I_I{1nOR3x7+Po zDTeGOU2c=7$&~y1@HsiN{&Xze{zH>XO{hV1Zr=a;&))utq0(j(aV>@2p z;M?l$iBrD1uT5^8FF&y$fB*srAb80tg_000IagfB*srAb>zgpkf|@wr=glHy_;DGRr)I*3#h)Sd$$z==}S@BrL}n#;q;~LWfAqN zdaZ5-oX;Fkuc~v6o>$ds0qn&+W(a#_#JH;Zd*``)8x~iKWi4HHELd7^WgbEKGzR7o zkav)I1V)}Zg~=(ibv~te1oPy7vv$0|1I~*Fp4fP^!aRa{`Qw9rB7gt_2q1s}0tg_0 z00Icqxquw|Z?*3WJpS6Hzy85xga2S2L7l%U=sp4nAbNMyakW6nF+>glMGcTntLK>z^+5I_I{1U?G_*R;BQXD(DGeTA~?G7*?v zX+56Ol4r+yA%{2pKPB(J9R4cur8X1bJk@6gY z-N7!adbp}u&u{g{fl^yNm&@kNYo2ya@(z-Bki3KB9UMy@r@VvmJ${>=U*Ph8{r0lK zSD(3<`~shaKSH<$1Q0*~0R#|0009ILKmY**K6QbLc!9&IM<2T3?pv445--qRc2zRw z;F#kD+L|Y-#tVpDEC?Wg00IagQ1b$lYMa}4t{9EaPaloX=5l&UGxS7EGpym^_UfbZ z<*my{=TllnpJ`}bz2w61aJV?QyH*XTp~!k!!S8IfR60+5YvKjOGcan_JuD4$Q>T!4 z0r`VDCH$h|C&!MMup%k^-5!@Ow6I$3O6x~F7PMAccDsoeAYOoYf#Vi0Am8J++wlUg z?|O3kcW=LLGw}j7|3?@dL;wK<5I_I{1Q0*~0R#|0KnhgE3uJ$HW8ktg9(`|?cmZ!2 zoxFu`%<%&4?x&6tFCccZAbSb;+I~Ie(3!k@d6+BPcYgefB*srAbLT_rLW- z{+{G4@dES9dOA<6A9K8b*KHg%UO?<-K>z^+5I~@&1vYzKzV3xea@x>-*+BmMsss75 zQTedFeC#J)Ku&rt%lI zWgUuM{7=C;qik%hx~Bb?P}W5L?z6t3Ji@=F(d9d{LrG6Poq088%cg24G#izj(%j(R z)F7^LnX=6+8?qynH1t?r-!ZOd;(EUFVWV~KXz09wusUVuQhAA8;zC_H6Vj!tNXR#{u5bmp{PeP2AM%QQOG4n1!10`find^=vCYy4o}YZtmt zCtjeY{{*FT2q1s}0tg_000IagfB*sr*aA+uV&50|{htpVh@aFmUluP^yo(&-g9QNu z5I_I{1Q0*~0R#|00D<}xn7mSHaV%(FG_+S)d#)C|P#YW5GkRior)G4n>Ff#g1iAx3 z@evIK))YRL2LgdhJNNAFvrQ05d&akqtyndFb!S&+TpQ18Mq!^gSKMBc zK?$Pu`mAVT1~|xLgW{2Blg)Lm+1;5;h)p8SAfe~PX6t}q^YI?Bh-+Ha9a!Dn8;JG< z*93dktXR3cC$Q?$-JN<`OQyuZWQ=3jzorfB*srAb-+?>fBi)>_Bu4+01vfB*srAbUA>v-DNnE3|mLTkKoi-8VYg7YN36y?6C$m(tXxI2<2|PPK}E^X>Zr&P5NO^Z3c` z7tDBp7aZOXyf2*aj&lJBAbtho zMq1DJ@&C-c0>=IKlgKfbPA6jFIOTSh6)083d}^_#48BYD3CWn3j%ct v0`&>3)i<(<2^4`o2?Es$Y*stD!3k_5AV7cs0RjXF5FkK+009C7su%bMUj;2C diff --git a/libs/act-sqlite/test/test-rsd-b.db-wal b/libs/act-sqlite/test/test-rsd-b.db-wal deleted file mode 100644 index 9db974c30e74474fd88a354a6456eb3c13a72a2d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 148352 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 PL&|*D5A1bytxf+2O2`6N diff --git a/libs/act-sqlite/test/test-rsd-c.db-shm b/libs/act-sqlite/test/test-rsd-c.db-shm deleted file mode 100644 index cc2c4e835fade84e3414507a46422aa31df4a6b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 diff --git a/libs/act-sqlite/test/test-rsd-d.db-shm b/libs/act-sqlite/test/test-rsd-d.db-shm deleted file mode 100644 index ee28ba8f368d6cb79c82206bda7625125327f9ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 diff --git a/libs/act-sqlite/test/test-rsd-e.db-shm b/libs/act-sqlite/test/test-rsd-e.db-shm deleted file mode 100644 index 3f692d6e8d57341850158a80e9177e292736876f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 Qm3_)=*AFTxD;sP74}>ZH$^ZZW diff --git a/libs/act-sqlite/test/test-rsd-f.db-shm b/libs/act-sqlite/test/test-rsd-f.db-shm deleted file mode 100644 index f288b1577613cfccdd16f5beb1b84bda5b89cdee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 diff --git a/libs/act-sqlite/test/test-rsd-g.db-shm b/libs/act-sqlite/test/test-rsd-g.db-shm deleted file mode 100644 index c3cf1dfcae3e467513bbdb0c65700883b44159a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 oU)(SufB*srAb unknown) | undefined; - /** The same for the \`pii\` sidecar, when a sensitive field is a date. */ - readonly parse_pii: ((data: unknown) => unknown) | undefined; + readonly date_reviver: ((data: unknown) => unknown) | undefined; + /** + * Revives the dates in a stored \`pii\` sidecar, or \`undefined\` when no + * sensitive field is a date. + */ + readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; /** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ @@ -11000,13 +11003,13 @@ const def_of = (schema: unknown): Record | undefined => * that would drift as Zod grows constructs. A construct this doesn't * recognise contributes no date, which is the documented fallthrough. */ -function to_date_schema( +function date_reviver_schema( schema: unknown, sensitive?: readonly string[] ): z.ZodType | undefined { const def = def_of(schema); if (!def) return undefined; - const inner = () => to_date_schema(def.innerType); + const inner = () => date_reviver_schema(def.innerType); switch (def.type) { case "date": return z.coerce.date(); @@ -11016,28 +11019,28 @@ function to_date_schema( const dated: Record = {}; for (const [key, field] of Object.entries(shape)) { if (sensitive?.includes(key)) continue; - const dates = to_date_schema(field); + const dates = date_reviver_schema(field); if (dates) dated[key] = dates.optional(); } return Object.keys(dated).length ? z.looseObject(dated) : undefined; } case "array": { - const element = to_date_schema(def.element); + const element = date_reviver_schema(def.element); return element && z.array(element); } case "tuple": { - const items = (def.items as unknown[]).map((i) => to_date_schema(i)); + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); return items.some(Boolean) ? z.tuple(items.map((i) => i ?? z.unknown()) as never) : undefined; } case "record": { - const value = to_date_schema(def.valueType); + const value = date_reviver_schema(def.valueType); return value && z.record(z.string(), value); } case "union": { const options = (def.options as unknown[]).map((o) => - to_date_schema(o, sensitive) + date_reviver_schema(o, sensitive) ); return options.some(Boolean) ? z.union(options.map((o) => o ?? z.unknown()) as never) @@ -11079,7 +11082,7 @@ export function event_tags(schema: z.ZodType): EventTags { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { sensitive.push(key); - const dates = to_date_schema(shape[key]); + const dates = date_reviver_schema(shape[key]); if (dates) pii_dates[key] ??= dates.optional(); } return; @@ -11090,7 +11093,7 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const data_schema = to_date_schema(schema, unique); + const data_reviver_schema = date_reviver_schema(schema, unique); // The sidecar holds the split-out fields alone, so a date among them needs // its own pass — without it a disclosed \`sensitive(z.date())\` arrives as // text beside a plain sibling that is a Date. @@ -11104,8 +11107,10 @@ export function event_tags(schema: z.ZodType): EventTags { }; return { sensitive: unique, - parse: data_schema && revive(data_schema), - parse_pii: has_pii_date ? revive(z.looseObject(pii_dates)) : undefined, + date_reviver: data_reviver_schema && revive(data_reviver_schema), + pii_date_reviver: has_pii_date + ? revive(z.looseObject(pii_dates)) + : undefined, }; } @@ -11137,8 +11142,8 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse, parse_pii } = tags; - if (!parse && sensitive.length === 0) return undefined; + const { sensitive, date_reviver, pii_date_reviver } = tags; + if (!date_reviver && sensitive.length === 0) return undefined; const gate: EventGate = sensitive.length === 0 @@ -11147,7 +11152,7 @@ export function make_event_reader( ? (((event) => pii_strip(event as never, sensitive)) as EventGate) : make_gate(sensitive, predicate as never); - if (!parse) return gate; + if (!date_reviver) return gate; // Revive before disclosing: the gate copies, so reviving afterwards would // leave the consumer's value a string — and it substitutes REDACTED and @@ -11157,8 +11162,10 @@ export function make_event_reader( return gate( { ...event, - data: parse(event.data), - ...(parse_pii && pii != null ? { pii: parse_pii(pii) } : {}), + data: date_reviver(event.data), + ...(pii_date_reviver && pii != null + ? { pii: pii_date_reviver(pii) } + : {}), } as never, actor ); diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 31a292fd2..727cb43ea 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -46,12 +46,15 @@ export type EventTags = { /** Keys marked `sensitive(...)`, top level (and across union variants). */ readonly sensitive: readonly string[]; /** - * Revives the dates in a stored payload, or `undefined` when the schema - * declares none. + * Revives the dates in a stored `data` payload, or `undefined` when the + * schema declares none. */ - readonly parse: ((data: unknown) => unknown) | undefined; - /** The same for the `pii` sidecar, when a sensitive field is a date. */ - readonly parse_pii: ((data: unknown) => unknown) | undefined; + readonly date_reviver: ((data: unknown) => unknown) | undefined; + /** + * Revives the dates in a stored `pii` sidecar, or `undefined` when no + * sensitive field is a date. + */ + readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; /** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ @@ -81,13 +84,13 @@ const def_of = (schema: unknown): Record | undefined => * that would drift as Zod grows constructs. A construct this doesn't * recognise contributes no date, which is the documented fallthrough. */ -function to_date_schema( +function date_reviver_schema( schema: unknown, sensitive?: readonly string[] ): z.ZodType | undefined { const def = def_of(schema); if (!def) return undefined; - const inner = () => to_date_schema(def.innerType); + const inner = () => date_reviver_schema(def.innerType); switch (def.type) { case "date": return z.coerce.date(); @@ -97,28 +100,28 @@ function to_date_schema( const dated: Record = {}; for (const [key, field] of Object.entries(shape)) { if (sensitive?.includes(key)) continue; - const dates = to_date_schema(field); + const dates = date_reviver_schema(field); if (dates) dated[key] = dates.optional(); } return Object.keys(dated).length ? z.looseObject(dated) : undefined; } case "array": { - const element = to_date_schema(def.element); + const element = date_reviver_schema(def.element); return element && z.array(element); } case "tuple": { - const items = (def.items as unknown[]).map((i) => to_date_schema(i)); + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); return items.some(Boolean) ? z.tuple(items.map((i) => i ?? z.unknown()) as never) : undefined; } case "record": { - const value = to_date_schema(def.valueType); + const value = date_reviver_schema(def.valueType); return value && z.record(z.string(), value); } case "union": { const options = (def.options as unknown[]).map((o) => - to_date_schema(o, sensitive) + date_reviver_schema(o, sensitive) ); return options.some(Boolean) ? z.union(options.map((o) => o ?? z.unknown()) as never) @@ -160,7 +163,7 @@ export function event_tags(schema: z.ZodType): EventTags { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { sensitive.push(key); - const dates = to_date_schema(shape[key]); + const dates = date_reviver_schema(shape[key]); if (dates) pii_dates[key] ??= dates.optional(); } return; @@ -171,7 +174,7 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const data_schema = to_date_schema(schema, unique); + const data_reviver_schema = date_reviver_schema(schema, unique); // The sidecar holds the split-out fields alone, so a date among them needs // its own pass — without it a disclosed `sensitive(z.date())` arrives as // text beside a plain sibling that is a Date. @@ -185,8 +188,10 @@ export function event_tags(schema: z.ZodType): EventTags { }; return { sensitive: unique, - parse: data_schema && revive(data_schema), - parse_pii: has_pii_date ? revive(z.looseObject(pii_dates)) : undefined, + date_reviver: data_reviver_schema && revive(data_reviver_schema), + pii_date_reviver: has_pii_date + ? revive(z.looseObject(pii_dates)) + : undefined, }; } @@ -218,8 +223,8 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse, parse_pii } = tags; - if (!parse && sensitive.length === 0) return undefined; + const { sensitive, date_reviver, pii_date_reviver } = tags; + if (!date_reviver && sensitive.length === 0) return undefined; const gate: EventGate = sensitive.length === 0 @@ -228,7 +233,7 @@ export function make_event_reader( ? (((event) => pii_strip(event as never, sensitive)) as EventGate) : make_gate(sensitive, predicate as never); - if (!parse) return gate; + if (!date_reviver) return gate; // Revive before disclosing: the gate copies, so reviving afterwards would // leave the consumer's value a string — and it substitutes REDACTED and @@ -238,8 +243,10 @@ export function make_event_reader( return gate( { ...event, - data: parse(event.data), - ...(parse_pii && pii != null ? { pii: parse_pii(pii) } : {}), + data: date_reviver(event.data), + ...(pii_date_reviver && pii != null + ? { pii: pii_date_reviver(pii) } + : {}), } as never, actor ); diff --git a/libs/act/test/schema-dates.spec.ts b/libs/act/test/schema-dates.spec.ts index cbf8362b4..c93eed843 100644 --- a/libs/act/test/schema-dates.spec.ts +++ b/libs/act/test/schema-dates.spec.ts @@ -65,11 +65,11 @@ describe("schema-driven date revival (#1556)", () => { }) ); expect(tags.sensitive).toEqual([]); - expect(tags.parse).toBeTypeOf("function"); + expect(tags.date_reviver).toBeTypeOf("function"); // Zod does the work, so nesting, arrays and records all type correctly — // the shapes a hand-rolled path walk would have had to re-implement. - const out = tags.parse!({ + const out = tags.date_reviver!({ at: "2026-01-01T00:00:00.000Z", label: "2026-06-06T00:00:00.000Z", nested: { born: "1990-02-03T00:00:00.000Z" }, @@ -85,14 +85,16 @@ describe("schema-driven date revival (#1556)", () => { }); it("builds no reader when a schema declares no dates", () => { - expect(event_tags(z.object({ a: z.string() })).parse).toBeUndefined(); + expect( + event_tags(z.object({ a: z.string() })).date_reviver + ).toBeUndefined(); }); it("keeps keys the schema does not declare", () => { // Event stores hold payloads written against older schemas. A strict Zod // object would silently drop them; losing committed data on read would be // worse than the mistyping this fixes. - const read = event_tags(z.object({ at: z.date() })).parse!; + const read = event_tags(z.object({ at: z.date() })).date_reviver!; const out = read({ at: "2026-01-01T00:00:00.000Z", legacy: "written before this field was removed", @@ -110,7 +112,7 @@ describe("schema-driven date revival (#1556)", () => { z.object({ n: z.number() }), ]), }) - ).parse!; + ).date_reviver!; const out = read({ maybe: "2026-01-01T00:00:00.000Z", orNull: null, @@ -128,7 +130,7 @@ describe("schema-driven date revival (#1556)", () => { z.object({ at: z.date(), blob: z.map(z.string(), z.string()) }) ); const m = new Map([["k", "v"]]); - const out = tags.parse!({ + const out = tags.date_reviver!({ at: "2026-01-01T00:00:00.000Z", blob: m, }) as Record; @@ -140,19 +142,19 @@ describe("schema-driven date revival (#1556)", () => { // Guards the recursion: a malformed or foreign node reached through a // child slot must pass through, not crash the build. const foreign = { not: "a zod schema" } as unknown as z.ZodType; - expect(event_tags(foreign).parse).toBeUndefined(); + expect(event_tags(foreign).date_reviver).toBeUndefined(); expect(event_tags(foreign).sensitive).toEqual([]); // An object def with no shape takes the same path. const shapeless = { _zod: { def: { type: "object" } }, } as unknown as z.ZodType; - expect(event_tags(shapeless).parse).toBeUndefined(); + expect(event_tags(shapeless).date_reviver).toBeUndefined(); }); it("leaves an already-typed value alone", () => { // InMemory holds references, so the value can already be a `Date`. - const read = event_tags(z.object({ at: z.date() })).parse!; + const read = event_tags(z.object({ at: z.date() })).date_reviver!; const already = new Date("2020-01-01T00:00:00.000Z"); const out = read({ at: already }) as { at: Date }; expect(out.at.getTime()).toBe(already.getTime()); From a3bed5f6e1363514135a0c39fb35614fe159a936 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:25:11 -0400 Subject: [PATCH 05/11] refactor(act): give the reviver schema one job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It took a list of sensitive keys and skipped them, which is not what a date reviver is for. That was left over from when the schema copied every field and a sensitive key would have been required and missing. Now that it names only the dates, a sensitive key reaches it only if it is a date, and every date is already optional — so the skip decided nothing. Dropping the parameter leaves the function doing what it is called: given a declared schema, say where the dates are. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 49 +++++++++---------- libs/act/src/builders/event-builder.ts | 43 ++++++++-------- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index d1d7cbe09..a33e74371 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -10982,31 +10982,29 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Build a schema that describes an event's date fields and nothing else, or - * \`undefined\` when it declares no dates. + * Build the schema that revives an event's dates, or \`undefined\` when it + * declares none. * * JSON has no date type, so a stored \`Date\` comes back as text and something - * has to turn it back. That is the entire job. The payload was validated when - * it was committed, so every other field is already known to be good and is - * left out — it rides through the loose object untouched rather than being - * checked a second time. - * - * Leaving them out is also what stops a read from rejecting what the framework - * itself wrote. A \`sensitive(...)\` field is moved into the \`pii\` sidecar on the - * way in, so \`data\` does not hold it; an event written against an older - * declaration predates whatever was added since. Neither is a date, so neither - * is this schema's business. The dates themselves are optional for the same - * reason: absent is not wrong. - * - * Zod still does the walking, so nesting, arrays, records, unions and the - * wrappers are handled by the engine rather than by a traversal of our own - * that would drift as Zod grows constructs. A construct this doesn't - * recognise contributes no date, which is the documented fallthrough. - */ -function date_reviver_schema( - schema: unknown, - sensitive?: readonly string[] -): z.ZodType | undefined { + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar + * rather than in \`data\`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +function date_reviver_schema(schema: unknown): z.ZodType | undefined { const def = def_of(schema); if (!def) return undefined; const inner = () => date_reviver_schema(def.innerType); @@ -11018,7 +11016,6 @@ function date_reviver_schema( if (!shape) return undefined; const dated: Record = {}; for (const [key, field] of Object.entries(shape)) { - if (sensitive?.includes(key)) continue; const dates = date_reviver_schema(field); if (dates) dated[key] = dates.optional(); } @@ -11040,7 +11037,7 @@ function date_reviver_schema( } case "union": { const options = (def.options as unknown[]).map((o) => - date_reviver_schema(o, sensitive) + date_reviver_schema(o) ); return options.some(Boolean) ? z.union(options.map((o) => o ?? z.unknown()) as never) @@ -11093,7 +11090,7 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const data_reviver_schema = date_reviver_schema(schema, unique); + const data_reviver_schema = date_reviver_schema(schema); // The sidecar holds the split-out fields alone, so a date among them needs // its own pass — without it a disclosed \`sensitive(z.date())\` arrives as // text beside a plain sibling that is a Date. diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 727cb43ea..8387d0b80 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -63,31 +63,29 @@ const def_of = (schema: unknown): Record | undefined => (schema as { def?: Record }).def; /** - * Build a schema that describes an event's date fields and nothing else, or - * `undefined` when it declares no dates. + * Build the schema that revives an event's dates, or `undefined` when it + * declares none. * * JSON has no date type, so a stored `Date` comes back as text and something - * has to turn it back. That is the entire job. The payload was validated when - * it was committed, so every other field is already known to be good and is - * left out — it rides through the loose object untouched rather than being - * checked a second time. + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. * - * Leaving them out is also what stops a read from rejecting what the framework - * itself wrote. A `sensitive(...)` field is moved into the `pii` sidecar on the - * way in, so `data` does not hold it; an event written against an older - * declaration predates whatever was added since. Neither is a date, so neither - * is this schema's business. The dates themselves are optional for the same - * reason: absent is not wrong. + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A `sensitive(...)` field lives in the `pii` sidecar + * rather than in `data`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. * - * Zod still does the walking, so nesting, arrays, records, unions and the - * wrappers are handled by the engine rather than by a traversal of our own - * that would drift as Zod grows constructs. A construct this doesn't - * recognise contributes no date, which is the documented fallthrough. + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. */ -function date_reviver_schema( - schema: unknown, - sensitive?: readonly string[] -): z.ZodType | undefined { +function date_reviver_schema(schema: unknown): z.ZodType | undefined { const def = def_of(schema); if (!def) return undefined; const inner = () => date_reviver_schema(def.innerType); @@ -99,7 +97,6 @@ function date_reviver_schema( if (!shape) return undefined; const dated: Record = {}; for (const [key, field] of Object.entries(shape)) { - if (sensitive?.includes(key)) continue; const dates = date_reviver_schema(field); if (dates) dated[key] = dates.optional(); } @@ -121,7 +118,7 @@ function date_reviver_schema( } case "union": { const options = (def.options as unknown[]).map((o) => - date_reviver_schema(o, sensitive) + date_reviver_schema(o) ); return options.some(Boolean) ? z.union(options.map((o) => o ?? z.unknown()) as never) @@ -174,7 +171,7 @@ export function event_tags(schema: z.ZodType): EventTags { collect(schema); const unique = [...new Set(sensitive)]; - const data_reviver_schema = date_reviver_schema(schema, unique); + const data_reviver_schema = date_reviver_schema(schema); // The sidecar holds the split-out fields alone, so a date among them needs // its own pass — without it a disclosed `sensitive(z.date())` arrives as // text beside a plain sibling that is a Date. From 1a065550397618849cf0b9488074108c9e78b543 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:32:27 -0400 Subject: [PATCH 06/11] perf(act): revive the pii sidecar only for readers allowed to see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was revived whenever a sidecar was present, before the gate decided anything, so the values were converted and then thrown away on every denied read. Two of those are unconditional: a strip reader drops pii outright, and query/query_array carry no actor so they always default-deny — which is the common read surface. The gate still owns the decision. The guard checks only for an actor and a disclosure predicate, and never calls the predicate, so a caller's code still runs exactly once. Output is unchanged on every path: a denied read still reads REDACTED, a disclosed one still gets a Date. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 13 ++++++++++--- libs/act/src/builders/event-builder.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index a33e74371..faec4c7e8 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -11151,6 +11151,15 @@ export function make_event_reader( if (!date_reviver) return gate; + // The sidecar is only worth reviving for a reader that can be shown it. + // A \`strip\` reader drops \`pii\` outright, and a redacting one discloses only + // to an actor a predicate approves — so no actor and no predicate means the + // values are on their way to REDACTED whatever they hold. The predicate + // itself stays uncalled here: the gate owns that decision and calling it + // twice would run a caller's code twice. + const revive_pii = + disclosure === "redact" && predicate ? pii_date_reviver : undefined; + // Revive before disclosing: the gate copies, so reviving afterwards would // leave the consumer's value a string — and it substitutes REDACTED and // SHREDDED, which are not dates. @@ -11160,9 +11169,7 @@ export function make_event_reader( { ...event, data: date_reviver(event.data), - ...(pii_date_reviver && pii != null - ? { pii: pii_date_reviver(pii) } - : {}), + ...(revive_pii && actor && pii != null ? { pii: revive_pii(pii) } : {}), } as never, actor ); diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 8387d0b80..c02597327 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -232,6 +232,15 @@ export function make_event_reader( if (!date_reviver) return gate; + // The sidecar is only worth reviving for a reader that can be shown it. + // A `strip` reader drops `pii` outright, and a redacting one discloses only + // to an actor a predicate approves — so no actor and no predicate means the + // values are on their way to REDACTED whatever they hold. The predicate + // itself stays uncalled here: the gate owns that decision and calling it + // twice would run a caller's code twice. + const revive_pii = + disclosure === "redact" && predicate ? pii_date_reviver : undefined; + // Revive before disclosing: the gate copies, so reviving afterwards would // leave the consumer's value a string — and it substitutes REDACTED and // SHREDDED, which are not dates. @@ -241,9 +250,7 @@ export function make_event_reader( { ...event, data: date_reviver(event.data), - ...(pii_date_reviver && pii != null - ? { pii: pii_date_reviver(pii) } - : {}), + ...(revive_pii && actor && pii != null ? { pii: revive_pii(pii) } : {}), } as never, actor ); From 902275e77911bda9eb04a940804ca563274f0ece Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:39:03 -0400 Subject: [PATCH 07/11] refactor(act): move date reviving to its own schema utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working out where an event's dates are is a Zod question, not an event one — the code takes a declared schema and returns the schema that revives its dates, and knows nothing about events, states or PII. It sat inside the event builder because that is where it was first needed. internal/date-reviver.ts now owns it, beside the other schema helpers, reached through the internal barrel. The event builder composes it: one reviver for an event's data, another for the sensitive fields in its pii sidecar. Also fixes a union bug found while reviewing. Reducing a variant to its date paths leaves it matching almost any payload, so Zod picked the first option and the variant that declared the date was never tried — a union event's dates were not revived at all, and a sibling's payload could be read under the wrong variant's rules. Variants keep their fields, so a discriminator can still reject a payload that is not its own. Four union cases added to schema-dates.spec.ts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 377 ++++++++++++++---- libs/act/src/builders/event-builder.ts | 87 +--- libs/act/src/internal/date-reviver.ts | 142 +++++++ libs/act/src/internal/index.ts | 1 + libs/act/test/schema-dates.spec.ts | 51 +++ 5 files changed, 492 insertions(+), 166 deletions(-) create mode 100644 libs/act/src/internal/date-reviver.ts diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index faec4c7e8..800e7d3db 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -9148,6 +9148,150 @@ function write_line(writer: WriteStream, line: string): Promise { }); } +// === date-reviver.ts === +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into \`Date\`s, driven by the declared schema. + * + * JSON has no date type, so a \`Date\` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or \`undefined\` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` + * and another for the sensitive fields held in its \`pii\` sidecar. + * + * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * \`z.string()\` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. @internal */ +export const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields: a discriminator can only reject a sibling's payload if it is still + * there to be checked. Every key is optional, so the variant still matches + * when a \`sensitive(...)\` field sits in the \`pii\` sidecar or when a stored + * payload predates a field the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or \`undefined\` when it + * declares none. + * + * JSON has no date type, so a stored \`Date\` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar + * rather than in \`data\`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} + // === defer-config.ts === /** * @module defer-config @@ -10935,7 +11079,9 @@ export class NonRetryableError extends Error { * target a projection already serves. Both checks skip dynamic resolvers, * because a \`.to(fn)\` target is unknowable until an event arrives. * - **resolution** — each event's schema is read once into {@link EventTags}: - * which fields are sensitive, and how to type the stored payload. + * which fields are sensitive, and how to revive the dates in a stored + * payload. Working out where the dates are is a Zod concern, so it lives in + * \`internal/date-reviver.ts\`; this module composes what that returns. * - **composition** — the per-surface readers, each a single {@link EventGate} * that types the payload and applies disclosure in one call. * @@ -10949,6 +11095,7 @@ export class NonRetryableError extends Error { */ import { z } from "zod"; +import { date_reviver_schema, def_of } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, @@ -10976,88 +11123,6 @@ export type EventTags = { readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; -/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ -const def_of = (schema: unknown): Record | undefined => - (schema as { _zod?: { def?: Record } })._zod?.def ?? - (schema as { def?: Record }).def; - -/** - * Build the schema that revives an event's dates, or \`undefined\` when it - * declares none. - * - * JSON has no date type, so a stored \`Date\` comes back as text and something - * has to turn it back. That is this function's only job, and the schema it - * returns says only where the dates are: every other field is left out and - * rides through the loose object untouched. The payload was validated when it - * was committed, so re-checking it on the way out would be work already done. - * - * Naming only the dates is also what makes a read tolerant, without needing a - * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar - * rather than in \`data\`; an event written against an older declaration - * predates whatever was added since; a field dropped from the declaration is - * still in the store. None of those are dates, so none of them are described - * here, and a payload carrying any of them still reads. The dates themselves - * are optional for the same reason — absent is not wrong. - * - * Zod does the walking, so nesting, arrays, records, unions and the wrappers - * are handled by the engine rather than by a traversal of our own that would - * drift as Zod grows constructs. A construct this doesn't recognise - * contributes no date, which is the documented fallthrough. - */ -function date_reviver_schema(schema: unknown): z.ZodType | undefined { - const def = def_of(schema); - if (!def) return undefined; - const inner = () => date_reviver_schema(def.innerType); - switch (def.type) { - case "date": - return z.coerce.date(); - case "object": { - const shape = def.shape as Record | undefined; - if (!shape) return undefined; - const dated: Record = {}; - for (const [key, field] of Object.entries(shape)) { - const dates = date_reviver_schema(field); - if (dates) dated[key] = dates.optional(); - } - return Object.keys(dated).length ? z.looseObject(dated) : undefined; - } - case "array": { - const element = date_reviver_schema(def.element); - return element && z.array(element); - } - case "tuple": { - const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); - return items.some(Boolean) - ? z.tuple(items.map((i) => i ?? z.unknown()) as never) - : undefined; - } - case "record": { - const value = date_reviver_schema(def.valueType); - return value && z.record(z.string(), value); - } - case "union": { - const options = (def.options as unknown[]).map((o) => - date_reviver_schema(o) - ); - return options.some(Boolean) - ? z.union(options.map((o) => o ?? z.unknown()) as never) - : undefined; - } - case "nullable": - // Keep the null: coercing it would hand back the epoch. - return inner()?.nullable(); - case "optional": - case "nonoptional": - case "readonly": - case "default": - case "prefault": - case "catch": - return inner(); - default: - return undefined; - } -} - /** * Resolve an event's schema in a single pass. * @@ -13908,6 +13973,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema, def_of } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, @@ -28426,6 +28492,150 @@ export function close_correlation( }); } +// === date-reviver.ts === +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into \`Date\`s, driven by the declared schema. + * + * JSON has no date type, so a \`Date\` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or \`undefined\` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` + * and another for the sensitive fields held in its \`pii\` sidecar. + * + * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * \`z.string()\` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. @internal */ +export const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields: a discriminator can only reject a sibling's payload if it is still + * there to be checked. Every key is optional, so the variant still matches + * when a \`sensitive(...)\` field sits in the \`pii\` sidecar or when a stored + * payload predates a field the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or \`undefined\` when it + * declares none. + * + * JSON has no date type, so a stored \`Date\` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar + * rather than in \`data\`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} + // === defer-config.ts === /** * @module defer-config @@ -32745,6 +32955,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema, def_of } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index c02597327..a82cb54e7 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -16,7 +16,9 @@ * target a projection already serves. Both checks skip dynamic resolvers, * because a `.to(fn)` target is unknowable until an event arrives. * - **resolution** — each event's schema is read once into {@link EventTags}: - * which fields are sensitive, and how to type the stored payload. + * which fields are sensitive, and how to revive the dates in a stored + * payload. Working out where the dates are is a Zod concern, so it lives in + * `internal/date-reviver.ts`; this module composes what that returns. * - **composition** — the per-surface readers, each a single {@link EventGate} * that types the payload and applies disclosure in one call. * @@ -30,6 +32,7 @@ */ import { z } from "zod"; +import { date_reviver_schema, def_of } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, @@ -57,88 +60,6 @@ export type EventTags = { readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; -/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ -const def_of = (schema: unknown): Record | undefined => - (schema as { _zod?: { def?: Record } })._zod?.def ?? - (schema as { def?: Record }).def; - -/** - * Build the schema that revives an event's dates, or `undefined` when it - * declares none. - * - * JSON has no date type, so a stored `Date` comes back as text and something - * has to turn it back. That is this function's only job, and the schema it - * returns says only where the dates are: every other field is left out and - * rides through the loose object untouched. The payload was validated when it - * was committed, so re-checking it on the way out would be work already done. - * - * Naming only the dates is also what makes a read tolerant, without needing a - * rule per exception. A `sensitive(...)` field lives in the `pii` sidecar - * rather than in `data`; an event written against an older declaration - * predates whatever was added since; a field dropped from the declaration is - * still in the store. None of those are dates, so none of them are described - * here, and a payload carrying any of them still reads. The dates themselves - * are optional for the same reason — absent is not wrong. - * - * Zod does the walking, so nesting, arrays, records, unions and the wrappers - * are handled by the engine rather than by a traversal of our own that would - * drift as Zod grows constructs. A construct this doesn't recognise - * contributes no date, which is the documented fallthrough. - */ -function date_reviver_schema(schema: unknown): z.ZodType | undefined { - const def = def_of(schema); - if (!def) return undefined; - const inner = () => date_reviver_schema(def.innerType); - switch (def.type) { - case "date": - return z.coerce.date(); - case "object": { - const shape = def.shape as Record | undefined; - if (!shape) return undefined; - const dated: Record = {}; - for (const [key, field] of Object.entries(shape)) { - const dates = date_reviver_schema(field); - if (dates) dated[key] = dates.optional(); - } - return Object.keys(dated).length ? z.looseObject(dated) : undefined; - } - case "array": { - const element = date_reviver_schema(def.element); - return element && z.array(element); - } - case "tuple": { - const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); - return items.some(Boolean) - ? z.tuple(items.map((i) => i ?? z.unknown()) as never) - : undefined; - } - case "record": { - const value = date_reviver_schema(def.valueType); - return value && z.record(z.string(), value); - } - case "union": { - const options = (def.options as unknown[]).map((o) => - date_reviver_schema(o) - ); - return options.some(Boolean) - ? z.union(options.map((o) => o ?? z.unknown()) as never) - : undefined; - } - case "nullable": - // Keep the null: coercing it would hand back the epoch. - return inner()?.nullable(); - case "optional": - case "nonoptional": - case "readonly": - case "default": - case "prefault": - case "catch": - return inner(); - default: - return undefined; - } -} - /** * Resolve an event's schema in a single pass. * diff --git a/libs/act/src/internal/date-reviver.ts b/libs/act/src/internal/date-reviver.ts new file mode 100644 index 000000000..359eb60d5 --- /dev/null +++ b/libs/act/src/internal/date-reviver.ts @@ -0,0 +1,142 @@ +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into `Date`s, driven by the declared schema. + * + * JSON has no date type, so a `Date` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or `undefined` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: `event_tags` asks for one reviver for an event's `data` + * and another for the sensitive fields held in its `pii` sidecar. + * + * The shape-based {@link dateReviver} in `utils.ts` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * `z.string()` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. @internal */ +export const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields: a discriminator can only reject a sibling's payload if it is still + * there to be checked. Every key is optional, so the variant still matches + * when a `sensitive(...)` field sits in the `pii` sidecar or when a stored + * payload predates a field the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or `undefined` when it + * declares none. + * + * JSON has no date type, so a stored `Date` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A `sensitive(...)` field lives in the `pii` sidecar + * rather than in `data`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} diff --git a/libs/act/src/internal/index.ts b/libs/act/src/internal/index.ts index 8c46e9fd2..2669afd0b 100644 --- a/libs/act/src/internal/index.ts +++ b/libs/act/src/internal/index.ts @@ -70,6 +70,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema, def_of } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, diff --git a/libs/act/test/schema-dates.spec.ts b/libs/act/test/schema-dates.spec.ts index c93eed843..43393dd6e 100644 --- a/libs/act/test/schema-dates.spec.ts +++ b/libs/act/test/schema-dates.spec.ts @@ -274,4 +274,55 @@ describe("schema-driven date revival (#1556)", () => { expect(seen).toEqual({ at: "Date", label: "string" }); await dispose(); }); + + it("revives dates in the variant a union payload actually matches", () => { + // The variants share a key: `at` is a string in one and a date in the + // other. Reducing a variant to its date paths would make every variant + // match every payload, so the first one wins and the wrong rule applies. + const U = z.union([ + z.object({ k: z.literal("b"), at: z.string() }), + z.object({ k: z.literal("a"), at: z.date() }), + ]); + const revive = event_tags(U).date_reviver!; + expect(revive({ k: "a", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + k: "a", + at: new Date("2020-01-01T00:00:00.000Z"), + }); + expect(revive({ k: "b", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + k: "b", + at: "2020-01-01T00:00:00.000Z", + }); + }); + + it("revives a union's date when a variant without one is declared first", () => { + const U = z.union([ + z.object({ k: z.literal("b"), n: z.number() }), + z.object({ k: z.literal("a"), at: z.date() }), + ]); + const out = event_tags(U).date_reviver!({ + k: "a", + at: "2020-01-01T00:00:00.000Z", + }) as { at: unknown }; + expect(out.at).toBeInstanceOf(Date); + }); + + it("builds nothing for a union with no dates in any variant", () => { + const U = z.union([ + z.object({ k: z.literal("a") }), + z.object({ k: z.literal("b"), n: z.number() }), + ]); + expect(event_tags(U).date_reviver).toBeUndefined(); + }); + + it("still revives a union variant when a field it declares is missing", () => { + const U = z.union([ + z.object({ k: z.literal("a"), at: z.date(), added_later: z.string() }), + z.object({ k: z.literal("b") }), + ]); + const out = event_tags(U).date_reviver!({ + k: "a", + at: "2020-01-01T00:00:00.000Z", + }) as { at: unknown }; + expect(out.at).toBeInstanceOf(Date); + }); }); From ab0a130debf6f0e62c0a776a6e5bc9b4c87fde18 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:47:31 -0400 Subject: [PATCH 08/11] test(act): pin why a union variant keeps its fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A union can be told apart by the type of an ordinary field rather than by a literal discriminator. Narrowing a variant to its dates, or relaxing its other fields, makes the first variant match everything — so the one that declared the date is never tried and a sibling's payload is read under the wrong rules. Measured both ways before writing this down: with the fields kept, each variant's payload reads correctly; with them relaxed, the dated variant comes back as text. The test is the case that would otherwise invite someone to narrow it back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 30 ++++++++++++++----- libs/act/src/internal/date-reviver.ts | 15 +++++++--- libs/act/test/schema-dates.spec.ts | 20 +++++++++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index 800e7d3db..155530a6d 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -9184,10 +9184,17 @@ export const def_of = (schema: unknown): Record | undefined => * Rebuild one union variant so it still recognises its own payloads. * * Same date coercion as everywhere else, but the variant keeps its other - * fields: a discriminator can only reject a sibling's payload if it is still - * there to be checked. Every key is optional, so the variant still matches - * when a \`sensitive(...)\` field sits in the \`pii\` sidecar or when a stored - * payload predates a field the declaration has since gained. + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a \`sensitive(...)\` + * field sits in the \`pii\` sidecar or when a stored payload predates a field + * the declaration has since gained. * * Reports whether this variant declared a date, so a union with none anywhere * builds nothing at all. @@ -28528,10 +28535,17 @@ export const def_of = (schema: unknown): Record | undefined => * Rebuild one union variant so it still recognises its own payloads. * * Same date coercion as everywhere else, but the variant keeps its other - * fields: a discriminator can only reject a sibling's payload if it is still - * there to be checked. Every key is optional, so the variant still matches - * when a \`sensitive(...)\` field sits in the \`pii\` sidecar or when a stored - * payload predates a field the declaration has since gained. + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a \`sensitive(...)\` + * field sits in the \`pii\` sidecar or when a stored payload predates a field + * the declaration has since gained. * * Reports whether this variant declared a date, so a union with none anywhere * builds nothing at all. diff --git a/libs/act/src/internal/date-reviver.ts b/libs/act/src/internal/date-reviver.ts index 359eb60d5..41f978e07 100644 --- a/libs/act/src/internal/date-reviver.ts +++ b/libs/act/src/internal/date-reviver.ts @@ -33,10 +33,17 @@ export const def_of = (schema: unknown): Record | undefined => * Rebuild one union variant so it still recognises its own payloads. * * Same date coercion as everywhere else, but the variant keeps its other - * fields: a discriminator can only reject a sibling's payload if it is still - * there to be checked. Every key is optional, so the variant still matches - * when a `sensitive(...)` field sits in the `pii` sidecar or when a stored - * payload predates a field the declaration has since gained. + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a `sensitive(...)` + * field sits in the `pii` sidecar or when a stored payload predates a field + * the declaration has since gained. * * Reports whether this variant declared a date, so a union with none anywhere * builds nothing at all. diff --git a/libs/act/test/schema-dates.spec.ts b/libs/act/test/schema-dates.spec.ts index 43393dd6e..6c494845c 100644 --- a/libs/act/test/schema-dates.spec.ts +++ b/libs/act/test/schema-dates.spec.ts @@ -325,4 +325,24 @@ describe("schema-driven date revival (#1556)", () => { }) as { at: unknown }; expect(out.at).toBeInstanceOf(Date); }); + + it("revives a union told apart by field type, not a discriminator", () => { + // No literal to discriminate on: the variants differ only in the TYPE of + // `v`, and `at` is a date in one and a string in the other. This is why a + // variant keeps its fields — narrow them and the first variant matches + // everything, so the one that declared the date is never tried. + const U = z.union([ + z.object({ v: z.number(), at: z.string() }), + z.object({ v: z.string(), at: z.date() }), + ]); + const revive = event_tags(U).date_reviver!; + expect(revive({ v: "x", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + v: "x", + at: new Date("2020-01-01T00:00:00.000Z"), + }); + expect(revive({ v: 7, at: "2020-01-01T00:00:00.000Z" })).toEqual({ + v: 7, + at: "2020-01-01T00:00:00.000Z", + }); + }); }); From 3d0225bc3764dba5e801143ebe160690762866c5 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:51:05 -0400 Subject: [PATCH 09/11] refactor(act): let the reviver module export one function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit def_of was exported so the event builder could reach an event's shape while collecting sensitive keys — a Zod internals accessor leaking out of a module whose job is to return one schema. The builder doesn't need it. It reads `.shape` directly, the same way pii_fields in sensitive.ts already does for the same walk. def_of is private again and date_reviver_schema is the whole interface: how a Zod schema is taken apart stays inside the module that takes it apart. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 22 ++++++++++--------- libs/act/src/builders/event-builder.ts | 4 ++-- libs/act/src/internal/date-reviver.ts | 7 +++--- libs/act/src/internal/index.ts | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index 155530a6d..39798622d 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -9164,7 +9164,8 @@ function write_line(writer: WriteStream, line: string): Promise { * * Sits beside the other schema utilities rather than inside the event builder, * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` - * and another for the sensitive fields held in its \`pii\` sidecar. + * and another for the sensitive fields held in its \`pii\` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. * * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this * replaced — it revived anything ISO-8601-looking, including fields declared @@ -9175,8 +9176,8 @@ function write_line(writer: WriteStream, line: string): Promise { import { z } from "zod"; -/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. @internal */ -export const def_of = (schema: unknown): Record | undefined => +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ +const def_of = (schema: unknown): Record | undefined => (schema as { _zod?: { def?: Record } })._zod?.def ?? (schema as { def?: Record }).def; @@ -11102,7 +11103,7 @@ export class NonRetryableError extends Error { */ import { z } from "zod"; -import { date_reviver_schema, def_of } from "../internal/index.js"; +import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, @@ -11146,7 +11147,7 @@ export function event_tags(schema: z.ZodType): EventTags { const pii_dates: Record = {}; const collect = (node: unknown): void => { - const shape = def_of(node)?.shape as Record | undefined; + const shape = (node as { shape?: Record }).shape; if (shape) { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { @@ -13980,7 +13981,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; -export { date_reviver_schema, def_of } from "./date-reviver.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, @@ -28515,7 +28516,8 @@ export function close_correlation( * * Sits beside the other schema utilities rather than inside the event builder, * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` - * and another for the sensitive fields held in its \`pii\` sidecar. + * and another for the sensitive fields held in its \`pii\` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. * * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this * replaced — it revived anything ISO-8601-looking, including fields declared @@ -28526,8 +28528,8 @@ export function close_correlation( import { z } from "zod"; -/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. @internal */ -export const def_of = (schema: unknown): Record | undefined => +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ +const def_of = (schema: unknown): Record | undefined => (schema as { _zod?: { def?: Record } })._zod?.def ?? (schema as { def?: Record }).def; @@ -32969,7 +32971,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; -export { date_reviver_schema, def_of } from "./date-reviver.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index a82cb54e7..240439cba 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -32,7 +32,7 @@ */ import { z } from "zod"; -import { date_reviver_schema, def_of } from "../internal/index.js"; +import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, @@ -76,7 +76,7 @@ export function event_tags(schema: z.ZodType): EventTags { const pii_dates: Record = {}; const collect = (node: unknown): void => { - const shape = def_of(node)?.shape as Record | undefined; + const shape = (node as { shape?: Record }).shape; if (shape) { for (const key of Object.keys(shape)) if (is_pii(shape[key])) { diff --git a/libs/act/src/internal/date-reviver.ts b/libs/act/src/internal/date-reviver.ts index 41f978e07..0e898ccaa 100644 --- a/libs/act/src/internal/date-reviver.ts +++ b/libs/act/src/internal/date-reviver.ts @@ -13,7 +13,8 @@ * * Sits beside the other schema utilities rather than inside the event builder, * which composes it: `event_tags` asks for one reviver for an event's `data` - * and another for the sensitive fields held in its `pii` sidecar. + * and another for the sensitive fields held in its `pii` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. * * The shape-based {@link dateReviver} in `utils.ts` is the predecessor this * replaced — it revived anything ISO-8601-looking, including fields declared @@ -24,8 +25,8 @@ import { z } from "zod"; -/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. @internal */ -export const def_of = (schema: unknown): Record | undefined => +/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ +const def_of = (schema: unknown): Record | undefined => (schema as { _zod?: { def?: Record } })._zod?.def ?? (schema as { def?: Record }).def; diff --git a/libs/act/src/internal/index.ts b/libs/act/src/internal/index.ts index 2669afd0b..88c9c6cc1 100644 --- a/libs/act/src/internal/index.ts +++ b/libs/act/src/internal/index.ts @@ -70,7 +70,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; -export { date_reviver_schema, def_of } from "./date-reviver.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, From c187d5670071e949f90d836c171b23047db3c6da Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 17:54:07 -0400 Subject: [PATCH 10/11] refactor(act): let sensitive.ts answer which fields are sensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event builder walked an event's shape itself to find sensitive keys — top-level keys plus union options, the same traversal pii_fields already does — because it needed each key's schema and pii_fields returns only names. pii_schemas now returns the schemas, and pii_fields is its keys, so there is one walk instead of two. The builder asks sensitive.ts which fields are sensitive and date-reviver.ts where the dates are, then composes the two answers. It no longer touches .shape, .options or _zod at all. Same shape as the date change: the module that knows how to take a schema apart is the one that does it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../all-packages-stability.spec.ts.snap | 206 +++++++++++------- libs/act/src/builders/event-builder.ts | 41 ++-- libs/act/src/internal/sensitive.ts | 55 +++-- 3 files changed, 172 insertions(+), 130 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index 39798622d..b26595f1a 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -11107,8 +11107,9 @@ import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, - is_pii, make_gate, + pii_fields, + pii_schemas, pii_split, pii_strip, } from "../internal/sensitive.js"; @@ -11143,31 +11144,18 @@ export type EventTags = { * @internal */ export function event_tags(schema: z.ZodType): EventTags { - const sensitive: string[] = []; + // Which fields are sensitive is \`sensitive.ts\`'s question and where the + // dates are is \`date-reviver.ts\`'s; this composes the two answers. The + // sidecar holds the split-out fields alone, so a date among them needs its + // own reviver — without it a disclosed \`sensitive(z.date())\` arrives as text + // beside a plain sibling that is a Date. const pii_dates: Record = {}; + for (const [key, field] of Object.entries(pii_schemas(schema))) { + const dates = date_reviver_schema(field); + if (dates) pii_dates[key] = dates.optional(); + } - const collect = (node: unknown): void => { - const shape = (node as { shape?: Record }).shape; - if (shape) { - for (const key of Object.keys(shape)) - if (is_pii(shape[key])) { - sensitive.push(key); - const dates = date_reviver_schema(shape[key]); - if (dates) pii_dates[key] ??= dates.optional(); - } - return; - } - const options = (node as { options?: unknown }).options; - if (Array.isArray(options)) for (const option of options) collect(option); - }; - collect(schema); - - const unique = [...new Set(sensitive)]; const data_reviver_schema = date_reviver_schema(schema); - // The sidecar holds the split-out fields alone, so a date among them needs - // its own pass — without it a disclosed \`sensitive(z.date())\` arrives as - // text beside a plain sibling that is a Date. - const has_pii_date = Object.keys(pii_dates).length > 0; // Reviving must never reject. A stored payload can disagree with the current // declaration in ways this schema deliberately does not describe, and handing // back what is stored beats refusing to read it. @@ -11175,12 +11163,11 @@ export function event_tags(schema: z.ZodType): EventTags { const revived = schema.safeParse(data); return revived.success ? revived.data : data; }; + const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: unique, + sensitive: pii_fields(schema), date_reviver: data_reviver_schema && revive(data_reviver_schema), - pii_date_reviver: has_pii_date - ? revive(z.looseObject(pii_dates)) - : undefined, + pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, }; } @@ -18330,42 +18317,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** @@ -36702,42 +36706,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** @@ -41934,42 +41955,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 240439cba..4b9851eba 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -36,8 +36,9 @@ import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, - is_pii, make_gate, + pii_fields, + pii_schemas, pii_split, pii_strip, } from "../internal/sensitive.js"; @@ -72,31 +73,18 @@ export type EventTags = { * @internal */ export function event_tags(schema: z.ZodType): EventTags { - const sensitive: string[] = []; + // Which fields are sensitive is `sensitive.ts`'s question and where the + // dates are is `date-reviver.ts`'s; this composes the two answers. The + // sidecar holds the split-out fields alone, so a date among them needs its + // own reviver — without it a disclosed `sensitive(z.date())` arrives as text + // beside a plain sibling that is a Date. const pii_dates: Record = {}; + for (const [key, field] of Object.entries(pii_schemas(schema))) { + const dates = date_reviver_schema(field); + if (dates) pii_dates[key] = dates.optional(); + } - const collect = (node: unknown): void => { - const shape = (node as { shape?: Record }).shape; - if (shape) { - for (const key of Object.keys(shape)) - if (is_pii(shape[key])) { - sensitive.push(key); - const dates = date_reviver_schema(shape[key]); - if (dates) pii_dates[key] ??= dates.optional(); - } - return; - } - const options = (node as { options?: unknown }).options; - if (Array.isArray(options)) for (const option of options) collect(option); - }; - collect(schema); - - const unique = [...new Set(sensitive)]; const data_reviver_schema = date_reviver_schema(schema); - // The sidecar holds the split-out fields alone, so a date among them needs - // its own pass — without it a disclosed `sensitive(z.date())` arrives as - // text beside a plain sibling that is a Date. - const has_pii_date = Object.keys(pii_dates).length > 0; // Reviving must never reject. A stored payload can disagree with the current // declaration in ways this schema deliberately does not describe, and handing // back what is stored beats refusing to read it. @@ -104,12 +92,11 @@ export function event_tags(schema: z.ZodType): EventTags { const revived = schema.safeParse(data); return revived.success ? revived.data : data; }; + const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: unique, + sensitive: pii_fields(schema), date_reviver: data_reviver_schema && revive(data_reviver_schema), - pii_date_reviver: has_pii_date - ? revive(z.looseObject(pii_dates)) - : undefined, + pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, }; } diff --git a/libs/act/src/internal/sensitive.ts b/libs/act/src/internal/sensitive.ts index 431e77f93..5c564b107 100644 --- a/libs/act/src/internal/sensitive.ts +++ b/libs/act/src/internal/sensitive.ts @@ -116,42 +116,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a `z.object({...})` and returns the keys whose + * Walks the top-level shape of a `z.object({...})` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * `sensitive(...)`. Returns an empty array for non-object schemas or events + * `sensitive(...)`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * `z.object` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's `sensitive_fields(event_name)` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the `pii` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading `.shape` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's `sensitive_fields(event_name)` lookup, + * and public through `types/schemas.ts`, where act-http's OpenAPI emitter uses + * it to mark request-body properties `writeOnly`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** From bf33b7dbd7a64304229bdedd88cac1b2db0c1cff Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Sun, 30 Aug 2026 18:15:54 -0400 Subject: [PATCH 11/11] refactor(act): walk an event's sensitive fields once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit event_tags called pii_schemas for the sidecar's date revivers and then pii_fields for the names, which walks the same shape a second time — the duplication the previous commit set out to remove, reintroduced in the same function. One call, keys taken from the result. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Vfpx1bDy3t57oHBynvNPF --- .../test/__snapshots__/all-packages-stability.spec.ts.snap | 6 +++--- libs/act/src/builders/event-builder.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index b26595f1a..d55b17f49 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -11108,7 +11108,6 @@ import { type EventGate, IDENTITY_GATE, make_gate, - pii_fields, pii_schemas, pii_split, pii_strip, @@ -11149,8 +11148,9 @@ export function event_tags(schema: z.ZodType): EventTags { // sidecar holds the split-out fields alone, so a date among them needs its // own reviver — without it a disclosed \`sensitive(z.date())\` arrives as text // beside a plain sibling that is a Date. + const pii = pii_schemas(schema); const pii_dates: Record = {}; - for (const [key, field] of Object.entries(pii_schemas(schema))) { + for (const [key, field] of Object.entries(pii)) { const dates = date_reviver_schema(field); if (dates) pii_dates[key] = dates.optional(); } @@ -11165,7 +11165,7 @@ export function event_tags(schema: z.ZodType): EventTags { }; const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: pii_fields(schema), + sensitive: Object.keys(pii), date_reviver: data_reviver_schema && revive(data_reviver_schema), pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, }; diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 4b9851eba..c8f749afe 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -37,7 +37,6 @@ import { type EventGate, IDENTITY_GATE, make_gate, - pii_fields, pii_schemas, pii_split, pii_strip, @@ -78,8 +77,9 @@ export function event_tags(schema: z.ZodType): EventTags { // sidecar holds the split-out fields alone, so a date among them needs its // own reviver — without it a disclosed `sensitive(z.date())` arrives as text // beside a plain sibling that is a Date. + const pii = pii_schemas(schema); const pii_dates: Record = {}; - for (const [key, field] of Object.entries(pii_schemas(schema))) { + for (const [key, field] of Object.entries(pii)) { const dates = date_reviver_schema(field); if (dates) pii_dates[key] = dates.optional(); } @@ -94,7 +94,7 @@ export function event_tags(schema: z.ZodType): EventTags { }; const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: pii_fields(schema), + sensitive: Object.keys(pii), date_reviver: data_reviver_schema && revive(data_reviver_schema), pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, };