Repository navigation
decrypt / bulkDecrypt hand back date columns as ISO strings while the model helpers return Date — silently, and not for the stated reason #779
Description
Correction (2026-07-30) — line references and one claim are stale
The defect below is still live on main (c8b1325a), but #829 moved the code and one of the report's claims has since been overtaken. Read this first; the original report follows unchanged.
The defect is unchanged
decrypt and bulkDecrypt are still bare passthroughs, so reconstructDatePaths never runs on the raw path:
packages/stack/src/encryption/client-v3.ts:481 decrypt: (encrypted) => client.decrypt(encrypted),
packages/stack/src/encryption/client-v3.ts:486 bulkDecrypt: (payloads) => client.bulkDecrypt(payloads),
reconstructDatePaths has no raw-path caller anywhere — only client-v3.ts:298 (the model path), the two DynamoDB operations, and the adapter-kit re-export. The JSDoc this issue objects to is verbatim at client-v3.ts:192-195.
Where the code moved (#829)
packages/stack/src/encryption/v3.ts is now a 6-line re-export barrel. Every reference below should be re-read against client-v3.ts:
| This issue cites | Now at |
|---|---|
TypedEncryptionClient.decrypt JSDoc |
type retired by #829 — it is EncryptionClient<S> |
v3.ts:96-98 |
client-v3.ts:192-195 |
v3.ts:125 (// Parity passthroughs) |
comment did not survive the move |
v3.ts:150-176 (rowReconstructor) |
client-v3.ts:~298 |
v3.ts:270-309 (MappedDecryptOperation) |
client-v3.ts:~466-470 |
v3.ts:311-312 (raw delegations) |
client-v3.ts:481, :486 |
v3.ts:136-137 (JsPlaintext excludes Date) |
moved out of v3.ts |
v3.ts:138-139 (idempotence note) |
moved out of v3.ts |
TypedEncryptionClient now survives only in CHANGELOGs; no source definition remains.
Retracted: "Untested either way"
That bullet is no longer true. The behaviour was deliberately pinned on 2026-07-29 by d5391279:
packages/stack/integration/shared/v2-decrypt-compat.integration.test.ts:542—it('returns a date-like scalar as its stored string, not a Date', …)- wasm equivalent at
integration/wasm/v2-decrypt-compat.integration.test.ts:380
with the framing at :532-536: "The boundary, pinned rather than asserted away: date reconstruction is a property of the MODEL path… A single-value decrypt has no table, so it returns the stored string — the same contract v3 has."
Those comments choose this issue's own "Alternative, if the boundary is intended" branch — fix the documentation, not the behaviour. Unless that decision is revisited, the remaining work here is documentation: correct the client-v3.ts:192-195 JSDoc (the payload does carry column identity, so the stated reason is wrong even if the behaviour is intended) and the skills/stash-encryption Bulk Operations section.
Tracking
#816 explicitly scopes the fix out — "Relates to #779*; do not widen decrypt or bulkDecrypt plaintext input types here"* — and is the only open issue referencing this one. When #816 and #778 close, this issue is referenced only from closed work.
Found while documenting the bulk surface for #754 (skills PR #777). Not a ciphertext or crypto issue — encryption and decryption are correct. This is JS-side type reconstruction.
The behaviour
The same stored value comes back as two different JavaScript types depending on which decrypt method you call:
const events = encryptedTable('events', { createdAt: types.TimestampOrd('created_at') })
// model path → Date
const a = await client.bulkDecryptModels(rows, events)
a.data[0].createdAt instanceof Date // true
// raw path → ISO string
const b = await client.bulkDecrypt(payloads)
b.data[0].data instanceof Date // false — it's a stringdecrypt (singular) has the same gap.
Why it's easy to get bitten
- Nothing warns.
BulkDecryptedDataisArray<DecryptionResult<JsPlaintext | null>>(packages/stack/src/types.ts:424), andJsPlaintextincludesstring, so the compiler is satisfied either way. - It half-works. Comparing two ISO-8601 strings lexicographically gives the right answer, so ordering code passes review and passes tests. It breaks later on arithmetic,
.getTime(), or an offset-bearing serialization. - Undocumented. Nothing in
skills/stash-encryptionor the JSDoc says the raw path skips reconstruction. - Untested either way. Nothing in
packages/stack/__tests__/asserts a date column's type throughbulkDecrypt, so the current behaviour isn't a pinned contract.
The stated reason doesn't hold at runtime
TypedEncryptionClient.decrypt's JSDoc (packages/stack/src/encryption/v3.ts:96-98):
Decrypt a single value. Cannot be strongly typed — a lone ciphertext carries no column identity — so it resolves to the FFI plaintext union unchanged.
That is correct about static types — TypeScript can't know at compile time which column a runtime Encrypted came from. But it has been carried over into the runtime behaviour, where it isn't true: every payload carries its column identity. protect-ffi's EncryptedScalar and EncryptedSteVec both declare (lib/index.d.cts:111-140):
i: { t: string; c: string } // table, columnIt's the same field the Supabase example surfaces as ciphertextIdentifier: { t: "users", c: "email" }. So decrypt / bulkDecrypt have everything needed to look up cast_as for (i.t, i.c) and rebuild the Date — they just don't.
Where the split actually lives
Reconstruction is implemented once per table and keyed by JS property name, which is a model-shaped key:
rowReconstructor—packages/stack/src/encryption/v3.ts:150-176- applied by
decryptModel/bulkDecryptModelsviaMappedDecryptOperation—v3.ts:270-309 - the raw methods are one-line delegations with no wrapping —
v3.ts:311-312, under the comment// Parity passthroughs — not v3-strengthened, delegated as-is.(v3.ts:125)
The WASM entry draws the identical boundary: datePropertyPaths (packages/stack/src/wasm-inline.ts:597-608) is also keyed by JS property name and also feeds the model path only.
So the model/raw split is consistent and evidently deliberate as a boundary. What seems wrong is (a) the justification given for it, which doesn't survive contact with the payload shape, and (b) that the consequence is silent and undocumented.
Suggested fix
Add a (table, column) → isDateLike map alongside the existing per-table one, built from the same DATE_LIKE_CASTS source, and apply it in decrypt / bulkDecrypt by reading each payload's i.t / i.c.
Details worth getting right:
- Position stability and the per-item error union.
bulkDecryptreturnsDecryptionResult<T>={ data } | { error }(types.ts:426-434). Map only the success arm, leave error entries andnulls untouched. - Return type.
JsPlaintextdeliberately excludesDate(v3.ts:136-137), so the typed client'sdecrypt/bulkDecryptsignatures need widening toJsPlaintext | Date. That's the only API-surface change. - Unregistered tables. A payload whose
i.tisn't in the client's schemas should pass through unchanged rather than fail — a raw decrypt of a foreign payload is legitimate. - Idempotence.
new Date(aDate)is a no-op, which the existing reconstructor already relies on (v3.ts:138-139), so callers currently doingnew Date(str)themselves keep working. That's what keeps breakage risk low. - Both entries — mirror it in
wasm-inline.tsso the two don't diverge. - Round-trip tests asserting
instanceof Datethroughdecrypt,bulkDecrypt,decryptModel, andbulkDecryptModels, since none exist today.
Alternative, if the boundary is intended
If raw-path reconstruction is deliberately out of scope, then the fix is documentation rather than code: correct the decrypt JSDoc so it doesn't imply the payload lacks column identity, and state the Date-vs-string consequence in skills/stash-encryption's Bulk Operations section with a pointer to the model helpers. Happy either way — flagging it so it's a decision rather than an accident.
Activity
coderdan commentedon Jul 24, 2026
A third instance landed with #780
#780 (now merged into
remove-v2at972a8a85) adds table-less overloads ofdecryptModel/bulkDecryptModels—packages/stack/src/encryption/v3.ts:120-137. Their JSDoc states the same characteristic this issue is about:So there are now three paths that skip reconstruction —
decrypt,bulkDecrypt, and table-lessdecryptModel/bulkDecryptModels— against two that perform it. Worth folding into the same fix rather than treating separately.But this one splits in two, and only half of it is a gap.
The new overload exists for legacy EQL v2 models whose table "is not, and cannot be, a member of
S". For a genuinely unregistered table the stated reason is sound: the client has no schema for it, so there is nocast_asto look up, and passing the value through unchanged is correct. That is a real constraint, not an oversight.The gap is the other half: the table-less call is also reachable for a table that is registered. Nothing forces a caller to pass the table, and the JSDoc's own advice ("Prefer the two-arg form whenever the table IS registered") concedes it happens. In that case the payload's
i.t/i.cresolves the schema perfectly well — same lookup this issue proposes fordecrypt/bulkDecrypt. A caller who omits the table gets ISO strings from a column the client knows is a timestamp.That makes the suggested fix cheaper than it first looked, since one
(table, column) → isDateLikemap serves all three paths:i.t/i.c, whether or not the caller passed the table.The "unregistered tables pass through" item already in the fix sketch above is exactly the v2-compat case #780 needs, so the two requirements are compatible rather than in tension.
If the resolution goes the documentation route instead, note that the
decryptJSDoc atv3.ts:96-98and this new overload's JSDoc now give the same reason ("carries no column identity" / "there is nocast_asto reconstruct from"), and it is accurate for only one of them.tobyhede commentedon Jul 30, 2026
The upstream option was investigated and rejected — recording why
#840 takes this issue's documentation branch. Before settling there, the obvious third option was tested properly, because it looked strictly better than either branch on offer:
That is the version where the split stops existing rather than being documented, and this repo has already executed the same move once: the JS-side bigint fixup was deleted at
66a0e026when protect-ffi 0.28 started returning native bigints.packages/stack/src/types.ts:89-90even anticipates it — "When the upstream FFIJsPlaintextincludesDateandbigint, both extra arms can collapse back intoJsPlaintext."It does not hold up. Three independent reasons, any one of which is sufficient:
1.
cast_as: 'date'is a calendar date, and JavaScript has no calendar-date type.packages/stack/src/schema/internal.ts:47-53— "dateis calendar-date only (time-of-day truncated to midnight), whiletimestamppreserves the full date+time", shipped to customers atskills/stash-encryption/SKILL.md:247. Converting'2026-07-30'into aDatemanufactures a time-of-day and a zone the stored value does not have:getDate()returns the previous day anywhere west of Greenwich, andJSON.stringifyemits a fabricatedT00:00:00.000Z.The bigint precedent inverts here, which is what makes this decisive rather than merely awkward. bigint was adopted because a JS
numbercannot hold an i64 — it removed information loss. A JSDatecannot hold a calendar day, so this would introduce it. Today the lossy form is opt-in (decryptModel) and the faithful string stays reachable (decrypt); move the conversion into the FFI and no consumer, in any language binding, can recover the stored calendar day. "ReturnDatefortimestamponly" avoids this entirely and is defensible on its own merits — but it delivers none of the promised cleanup.2. The JS-side reconstruction is descriptor-driven, not ciphertext-driven, so it could not be deleted regardless. The SDK repairs from the caller's current v3 table descriptor, applied whatever the payload contains. An FFI can only key off what was stored. Three tested cases where only the descriptor works:
Date—packages/stack-supabase/__tests__/supabase-v3-builder.test.ts:1136integration/shared/v2-decrypt-compat.integration.test.ts:309, reconstruction is "driven by the v3 table'scast_asand applied REGARDLESS of the payload's wire version"So the FFI change would be additive — a second parallel mechanism — not a replacement.
reconstructDatePaths/reconstructDateValue/DATE_LIKE_CASTSare also published via@cipherstash/stack/adapter-kit, so removing them is a major bump independently of any of this.3. Upstream already stringifies dates on purpose.
%Y-%m-%dandwriting rfc3339 datetime to string should never failsit in protect-ffi's wasm string pool as far back as 0.27.0, and the RustPlaintextenum carries aDatevariant the published TS union deliberately does not expose. Confirmed unchanged at 0.31.0 (latest; this repo pins 0.30.0):JsPlaintextis byte-identical, anddecrypt/decryptBulk/decryptBulkFallibleall still resolve to it. This was a decision, not an oversight — "just add it upstream" is a request to reverse a standing choice.Two further objections worth having on the record:
reconstructDateValuedeliberately returns the raw value rather than an Invalid Date (the "wasm-inline has no model helpers: encryptModel/decryptModel (and their bulk forms) are Node-only #742 review"), shipped as a minor bump with a user-visible caveat.packages/prisma-next/src/execution/envelope-date.ts:53-71throws on the same input. Three incompatible policies exist today, and each adapter can choose only because the FFI hands back a string.One correction to the report above, which is otherwise accurate. The framing that decrypt could "look up
cast_asfor(i.t, i.c)" is right about the information being available but wrong about the mechanism:DecryptOptionsis{ ciphertext, lockContext?, unverifiedContext? }— no table, no column, no cast. Decrypt does not consult the encrypt config. What actually happens is thatcast_asselects the RustPlaintextvariant at encrypt time and decrypt's output follows the stored variant (protect-ffi/lib/bigintWire.d.ts:20-22: "The decrypt direction needs no decoding"). The conclusion that the SDK could reconstruct fromi: { t, c }is unaffected — the client holds the config and the payload carries the identity, which is the whole point of this issue — but the bigint behaviour is not evidence that decrypt does a cast lookup.#840 therefore pins the boundary at the type layer as well as the runtime one, so that if
JsPlaintextever does gain aDatearm upstream, the type test fails and forces the decision instead of the JSDoc going quietly stale.