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 string
decrypt (singular) has the same gap.
Why it's easy to get bitten
- Nothing warns.
BulkDecryptedData is Array<DecryptionResult<JsPlaintext | null>> (packages/stack/src/types.ts:424), and JsPlaintext includes string, 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-encryption or the JSDoc says the raw path skips reconstruction.
- Untested either way. Nothing in
packages/stack/__tests__/ asserts a date column's type through bulkDecrypt, 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, column
It'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 / bulkDecryptModels via MappedDecryptOperation — 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.
bulkDecrypt returns DecryptionResult<T> = { data } | { error } (types.ts:426-434). Map only the success arm, leave error entries and nulls untouched.
- Return type.
JsPlaintext deliberately excludes Date (v3.ts:136-137), so the typed client's decrypt / bulkDecrypt signatures need widening to JsPlaintext | Date. That's the only API-surface change.
- Unregistered tables. A payload whose
i.t isn'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 doing new Date(str) themselves keep working. That's what keeps breakage risk low.
- Both entries — mirror it in
wasm-inline.ts so the two don't diverge.
- Round-trip tests asserting
instanceof Date through decrypt, bulkDecrypt, decryptModel, and bulkDecryptModels, 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.
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
decryptandbulkDecryptare still bare passthroughs, soreconstructDatePathsnever runs on the raw path:reconstructDatePathshas no raw-path caller anywhere — onlyclient-v3.ts:298(the model path), the two DynamoDB operations, and theadapter-kitre-export. The JSDoc this issue objects to is verbatim atclient-v3.ts:192-195.Where the code moved (#829)
packages/stack/src/encryption/v3.tsis now a 6-line re-export barrel. Every reference below should be re-read againstclient-v3.ts:TypedEncryptionClient.decryptJSDocEncryptionClient<S>v3.ts:96-98client-v3.ts:192-195v3.ts:125(// Parity passthroughs)v3.ts:150-176(rowReconstructor)client-v3.ts:~298v3.ts:270-309(MappedDecryptOperation)client-v3.ts:~466-470v3.ts:311-312(raw delegations)client-v3.ts:481,:486v3.ts:136-137(JsPlaintextexcludesDate)v3.tsv3.ts:138-139(idempotence note)v3.tsTypedEncryptionClientnow 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', …)integration/wasm/v2-decrypt-compat.integration.test.ts:380with the framing at
:532-536: "The boundary, pinned rather than asserted away: date reconstruction is a property of the MODEL path… A single-valuedecrypthas 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-195JSDoc (the payload does carry column identity, so the stated reason is wrong even if the behaviour is intended) and theskills/stash-encryptionBulk 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:
decrypt(singular) has the same gap.Why it's easy to get bitten
BulkDecryptedDataisArray<DecryptionResult<JsPlaintext | null>>(packages/stack/src/types.ts:424), andJsPlaintextincludesstring, so the compiler is satisfied either way..getTime(), or an offset-bearing serialization.skills/stash-encryptionor the JSDoc says the raw path skips reconstruction.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):That is correct about static types — TypeScript can't know at compile time which column a runtime
Encryptedcame from. But it has been carried over into the runtime behaviour, where it isn't true: every payload carries its column identity. protect-ffi'sEncryptedScalarandEncryptedSteVecboth declare (lib/index.d.cts:111-140):It's the same field the Supabase example surfaces as
ciphertextIdentifier: { t: "users", c: "email" }. Sodecrypt/bulkDecrypthave everything needed to look upcast_asfor(i.t, i.c)and rebuild theDate— 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-176decryptModel/bulkDecryptModelsviaMappedDecryptOperation—v3.ts:270-309v3.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) → isDateLikemap alongside the existing per-table one, built from the sameDATE_LIKE_CASTSsource, and apply it indecrypt/bulkDecryptby reading each payload'si.t/i.c.Details worth getting right:
bulkDecryptreturnsDecryptionResult<T>={ data } | { error }(types.ts:426-434). Map only the success arm, leave error entries andnulls untouched.JsPlaintextdeliberately excludesDate(v3.ts:136-137), so the typed client'sdecrypt/bulkDecryptsignatures need widening toJsPlaintext | Date. That's the only API-surface change.i.tisn't in the client's schemas should pass through unchanged rather than fail — a raw decrypt of a foreign payload is legitimate.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.wasm-inline.tsso the two don't diverge.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
decryptJSDoc so it doesn't imply the payload lacks column identity, and state theDate-vs-string consequence inskills/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.