@@ -128,12 +128,23 @@ def test_export_jsonb_coercion_preserves_decoded_scalar_string():
128128 assert _as_jsonb ('{"scope": "combined"}' ) == {"scope" : "combined" }
129129
130130
131+ def test_legacy_bank_archive_defaults_to_decoded_json_rows ():
132+ """Released v1 bank archives came from the codec-enabled admin CLI."""
133+ from hindsight_api .engine .transfer .importer import _resolve_bank_rows_json_encoding
134+
135+ manifest = TransferManifest (source_bank_id = "legacy" , archive_type = "bank" )
136+
137+ assert manifest .bank_rows_json_encoding is None
138+ assert _resolve_bank_rows_json_encoding (manifest ) == "decoded"
139+
140+
131141@pytest .mark .asyncio
132142async def test_restore_rows_normalizes_jsonb_strings (memory ):
133- """JSONB restore accepts decoded scalars without double-encoding JSON text ."""
143+ """JSONB restore follows archive provenance instead of guessing from strings ."""
134144 from hindsight_api .engine .transfer .importer import _restore_rows
135145
136- request_id = uuid .uuid4 ()
146+ decoded_request_id = uuid .uuid4 ()
147+ serialized_request_id = uuid .uuid4 ()
137148 backend = await memory ._get_backend ()
138149 async with acquire_with_retry (backend ) as conn :
139150 try :
@@ -142,27 +153,48 @@ async def test_restore_rows_normalizes_jsonb_strings(memory):
142153 "llm_requests" ,
143154 [
144155 {
145- "id" : str (request_id ),
156+ "id" : str (decoded_request_id ),
146157 "status" : "completed" ,
147158 "input" : "I am an already-decoded scalar" ,
148- "output" : json . dumps ( "I am already-serialized JSON text" ) ,
159+ "output" : '{"answer":"JSON-looking decoded scalar"}' ,
149160 "llm_info" : {"shape" : "decoded-object" },
150- "metadata" : json .dumps ({"shape" : "serialized-object" }),
151161 }
152162 ],
163+ bank_rows_json_encoding = "decoded" ,
153164 )
154- row = await conn .fetchrow (
155- f"SELECT input::text, output::text, llm_info::text, metadata::text "
156- f"FROM { fq_table ('llm_requests' )} WHERE id = $1" ,
157- request_id ,
165+ await _restore_rows (
166+ conn ,
167+ "llm_requests" ,
168+ [
169+ {
170+ "id" : str (serialized_request_id ),
171+ "status" : "completed" ,
172+ "input" : json .dumps ("serialized scalar" ),
173+ "output" : json .dumps ({"answer" : "serialized object" }),
174+ }
175+ ],
176+ bank_rows_json_encoding = "serialized" ,
177+ )
178+ decoded_row = await conn .fetchrow (
179+ f"SELECT input::text, output::text, llm_info::text FROM { fq_table ('llm_requests' )} WHERE id = $1" ,
180+ decoded_request_id ,
158181 )
159- assert row is not None
160- assert json .loads (row ["input" ]) == "I am an already-decoded scalar"
161- assert json .loads (row ["output" ]) == "I am already-serialized JSON text"
162- assert json .loads (row ["llm_info" ]) == {"shape" : "decoded-object" }
163- assert json .loads (row ["metadata" ]) == {"shape" : "serialized-object" }
182+ serialized_row = await conn .fetchrow (
183+ f"SELECT input::text, output::text FROM { fq_table ('llm_requests' )} WHERE id = $1" ,
184+ serialized_request_id ,
185+ )
186+ assert decoded_row is not None
187+ assert json .loads (decoded_row ["input" ]) == "I am an already-decoded scalar"
188+ assert json .loads (decoded_row ["output" ]) == '{"answer":"JSON-looking decoded scalar"}'
189+ assert json .loads (decoded_row ["llm_info" ]) == {"shape" : "decoded-object" }
190+ assert serialized_row is not None
191+ assert json .loads (serialized_row ["input" ]) == "serialized scalar"
192+ assert json .loads (serialized_row ["output" ]) == {"answer" : "serialized object" }
164193 finally :
165- await conn .execute (f"DELETE FROM { fq_table ('llm_requests' )} WHERE id = $1" , request_id )
194+ await conn .execute (
195+ f"DELETE FROM { fq_table ('llm_requests' )} WHERE id = ANY($1)" ,
196+ [decoded_request_id , serialized_request_id ],
197+ )
166198
167199
168200@pytest .mark .asyncio
@@ -197,6 +229,7 @@ async def test_export_bank_contents(memory, request_context):
197229 webhooks = json .loads (zf .read ("webhooks.json" ))
198230
199231 assert manifest .archive_type == "bank"
232+ assert manifest .bank_rows_json_encoding == "serialized"
200233 assert manifest .document_count == 1
201234 assert manifest .webhook_count == 1
202235 assert "mental_models.json" in names and "directives.json" in names
@@ -236,7 +269,7 @@ async def _bank_content_snapshot(memory, bank_id):
236269 f"SELECT name, disposition, mission, config FROM { fq_table ('banks' )} WHERE bank_id = $1" , bank_id
237270 )
238271 docs = await conn .fetch (
239- f"SELECT id, original_text, tags FROM { fq_table ('documents' )} WHERE bank_id = $1" , bank_id
272+ f"SELECT id, original_text, tags, created_at FROM { fq_table ('documents' )} WHERE bank_id = $1" , bank_id
240273 )
241274 facts = await conn .fetch (
242275 f"SELECT text, fact_type, context FROM { fq_table ('memory_units' )} "
@@ -268,7 +301,9 @@ async def _bank_content_snapshot(memory, bank_id):
268301 )
269302 return {
270303 "bank" : (bank ["name" ], _as_json (bank ["disposition" ]), bank ["mission" ], _as_json (bank ["config" ])),
271- "documents" : sorted ((d ["id" ], d ["original_text" ], tuple (sorted (d ["tags" ] or []))) for d in docs ),
304+ "documents" : sorted (
305+ (d ["id" ], d ["original_text" ], tuple (sorted (d ["tags" ] or [])), d ["created_at" ]) for d in docs
306+ ),
272307 "facts" : sorted ((f ["text" ], f ["fact_type" ], f ["context" ]) for f in facts ),
273308 "observations" : sorted ((o ["text" ], o ["proof_count" ]) for o in obs ),
274309 "entities" : sorted (e ["canonical_name" ].lower () for e in ents ),
@@ -678,9 +713,17 @@ async def test_export_import_observations(memory, request_context):
678713
679714 # Create a real observation over those source facts.
680715 backend = await memory ._get_backend ()
716+ archived_event_date = datetime (2001 , 2 , 3 , 4 , 5 , 6 , tzinfo = timezone .utc )
681717 async with acquire_with_retry (backend ) as conn :
682718 async with conn .transaction ():
683719 await _create_observation_directly (conn , memory , src , source_ids , "Alice and Bob are colleagues." )
720+ await conn .execute (
721+ f"UPDATE { fq_table ('memory_units' )} SET event_date = $1 "
722+ f"WHERE bank_id = $2 AND fact_type = 'observation' AND text = $3" ,
723+ archived_event_date ,
724+ src ,
725+ "Alice and Bob are colleagues." ,
726+ )
684727
685728 # Export WITHOUT observations -> none in the archive (the bank may also
686729 # contain auto-consolidation observations; the flag is what gates them).
@@ -695,6 +738,7 @@ async def test_export_import_observations(memory, request_context):
695738 assert parsed .manifest .observation_count == len (parsed .observations ) >= 1
696739 mine = next ((o for o in parsed .observations if o .text == "Alice and Bob are colleagues." ), None )
697740 assert mine is not None
741+ assert mine .event_date == archived_event_date
698742 assert len (mine .sources ) == 2 # both sources resolved within the export
699743 assert "embedding" not in archive .decode ("utf-8" , errors = "ignore" )
700744
@@ -708,12 +752,13 @@ async def test_export_import_observations(memory, request_context):
708752 # and those source facts are marked consolidated.
709753 async with acquire_with_retry (backend ) as conn :
710754 obs_row = await conn .fetchrow (
711- f"SELECT source_memory_ids FROM { fq_table ('memory_units' )} "
755+ f"SELECT source_memory_ids, event_date FROM { fq_table ('memory_units' )} "
712756 f"WHERE bank_id = $1 AND fact_type = 'observation' AND text = $2" ,
713757 dst ,
714758 "Alice and Bob are colleagues." ,
715759 )
716760 assert obs_row is not None
761+ assert obs_row ["event_date" ] == archived_event_date
717762 dst_sources = list (obs_row ["source_memory_ids" ] or [])
718763 assert len (dst_sources ) == 2
719764 consolidated = await conn .fetchval (
0 commit comments