Found while expanding v2 read coverage for #829. Characterised by a passing test on that branch, not fixed.
Symptom
Reading a stored EQL v2 item whose date-like column was declared under a v2 grouped field returns the decrypted value as a string rather than a Date. The declared column type is types.Date(...) / types.Timestamp(...), so the TypeScript type says Date and the runtime hands back a string.
Nothing throws and no data is lost — the value decrypts correctly. It is simply not reconstructed.
Mechanism
Two halves of the read path disagree about which path a grouped column lives at.
-
A v2 grouped column registered its build key on the bare leaf, so a field inside a group was stored as <group>.<leaf>__source while the schema knew it only as <leaf>. makeColumnMatcher's bare-leaf fallback (packages/stack/src/dynamodb/helpers.ts, v2 reads only) exists precisely so those attributes still rebuild:
if (paths.has(dotted)) return dotted
if (allowBareLeaf && prefix && paths.has(leaf)) return leaf
The envelope is therefore rebuilt at the nested position (details.birthday).
-
rowReconstructor (packages/stack/src/encryption/client-v3.ts) derives its date paths from the declared property names:
const dateProperties = Object.entries(propToDb).filter(...).map(([property]) => property)
return (row) => reconstructDatePaths(row, dateProperties)
So it asks reconstructDatePaths for a top-level birthday, the value sits at details.birthday, Object.hasOwn(source, leaf) is false, and the path is skipped.
reconstructDatePaths is genuinely path-aware — a column declared at a dotted path reconstructs correctly. The gap is only that the v2 bare-leaf fallback resolves to a path that the reconstruction step is not told about.
Scope
Narrow. Requires all of: DynamoDB adapter, { storedEqlVersion: 2 }, a v2 grouped field, and a date-like cast_as. v3 reads are unaffected (v3 registers full dotted paths and never uses the fallback). Non-date grouped columns are unaffected, since only date-like casts need per-row reconstruction.
Workaround available today
Declare the dotted path, which makes the exact-path match fire and reconstruction follow it:
const orders = encryptedTable('orders', {
'details.birthday': types.Date('details_birthday'),
})
This is covered by a passing test and is the recommended fix for callers in the meantime.
Where it is pinned
packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts, in the block:
a v2 grouped date column decrypts but is not reconstructed
It is labelled CHARACTERISATION, not endorsement. It asserts the current (wrong) behaviour so the gap is visible rather than silent.
When this is fixed, that test must be inverted — it currently encodes the defect as expected behaviour, and nothing in CI will flag that a green test is describing a bug.
Suggested fix direction
Have the read path reconstruct at the position the envelope was actually rebuilt at, rather than at the declared path — i.e. thread the matched path from makeColumnMatcher through to the reconstruction step for the v2 bare-leaf case, instead of recomputing date paths independently from the table's declared keys.
Found while expanding v2 read coverage for #829. Characterised by a passing test on that branch, not fixed.
Symptom
Reading a stored EQL v2 item whose date-like column was declared under a v2 grouped field returns the decrypted value as a string rather than a
Date. The declared column type istypes.Date(...)/types.Timestamp(...), so the TypeScript type saysDateand the runtime hands back a string.Nothing throws and no data is lost — the value decrypts correctly. It is simply not reconstructed.
Mechanism
Two halves of the read path disagree about which path a grouped column lives at.
A v2 grouped column registered its build key on the bare leaf, so a field inside a group was stored as
<group>.<leaf>__sourcewhile the schema knew it only as<leaf>.makeColumnMatcher's bare-leaf fallback (packages/stack/src/dynamodb/helpers.ts, v2 reads only) exists precisely so those attributes still rebuild:The envelope is therefore rebuilt at the nested position (
details.birthday).rowReconstructor(packages/stack/src/encryption/client-v3.ts) derives its date paths from the declared property names:So it asks
reconstructDatePathsfor a top-levelbirthday, the value sits atdetails.birthday,Object.hasOwn(source, leaf)is false, and the path is skipped.reconstructDatePathsis genuinely path-aware — a column declared at a dotted path reconstructs correctly. The gap is only that the v2 bare-leaf fallback resolves to a path that the reconstruction step is not told about.Scope
Narrow. Requires all of: DynamoDB adapter,
{ storedEqlVersion: 2 }, a v2 grouped field, and a date-likecast_as. v3 reads are unaffected (v3 registers full dotted paths and never uses the fallback). Non-date grouped columns are unaffected, since only date-like casts need per-row reconstruction.Workaround available today
Declare the dotted path, which makes the exact-path match fire and reconstruction follow it:
This is covered by a passing test and is the recommended fix for callers in the meantime.
Where it is pinned
packages/stack/__tests__/dynamodb/v2-type-coverage.test.ts, in the block:It is labelled CHARACTERISATION, not endorsement. It asserts the current (wrong) behaviour so the gap is visible rather than silent.
When this is fixed, that test must be inverted — it currently encodes the defect as expected behaviour, and nothing in CI will flag that a green test is describing a bug.
Suggested fix direction
Have the read path reconstruct at the position the envelope was actually rebuilt at, rather than at the declared path — i.e. thread the matched path from
makeColumnMatcherthrough to the reconstruction step for the v2 bare-leaf case, instead of recomputing date paths independently from the table's declared keys.