Skip to content

Commit d87d7ad

Browse files
Korayemcodex
andcommitted
fix(transfer): preserve archive provenance
Record bank-row JSON encoding in transfer manifests so decoded scalar strings and serialized objects restore without ambiguous parsing. Preserve archived document and observation timestamps during replay. Co-Authored-By: OpenAI GPT-5 Codex medium <noreply@openai.com>
1 parent b5504da commit d87d7ad

5 files changed

Lines changed: 155 additions & 38 deletions

File tree

hindsight-api-slim/hindsight_api/admin/cli.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,14 @@ async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str,
360360
# export_bank resolves table names via fq_table (the _current_schema
361361
# contextvar); set it so the raw connection targets the right schema.
362362
_current_schema.set(schema)
363-
data = await export_bank(conn, bank_id, include_history=include_history)
363+
# _admin_connect registers JSON codecs, so row dumps already contain
364+
# decoded Python values (including JSON scalar strings).
365+
data = await export_bank(
366+
conn,
367+
bank_id,
368+
include_history=include_history,
369+
bank_rows_json_encoding="decoded",
370+
)
364371
finally:
365372
await conn.close()
366373

hindsight-api-slim/hindsight_api/engine/transfer/export.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..schema import fq_table
2424
from .schema import (
2525
SCHEMA_VERSION,
26+
BankRowsJSONEncoding,
2627
TransferCausalRelation,
2728
TransferChunk,
2829
TransferDocument,
@@ -272,7 +273,13 @@ async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
272273
return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS and k != "id"} for row in rows]
273274

274275

275-
async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False) -> bytes:
276+
async def export_bank(
277+
conn: Any,
278+
bank_id: str,
279+
*,
280+
include_history: bool = False,
281+
bank_rows_json_encoding: BankRowsJSONEncoding = "serialized",
282+
) -> bytes:
276283
"""Export an entire bank into a portable ZIP archive (no embeddings).
277284
278285
Produces a superset of the documents archive: the logical
@@ -327,6 +334,7 @@ async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False)
327334
directive_count=len(bank_rows.get("directives", [])),
328335
webhook_count=len(bank_rows.get("webhooks", [])),
329336
includes_history=include_history,
337+
bank_rows_json_encoding=bank_rows_json_encoding,
330338
)
331339
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
332340

hindsight-api-slim/hindsight_api/engine/transfer/importer.py

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from ..schema import fq_table
3131
from .schema import (
3232
SCHEMA_VERSION,
33+
BankRowsJSONEncoding,
3334
TransferDocument,
3435
TransferFact,
3536
TransferManifest,
@@ -275,7 +276,18 @@ def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
275276
return ParsedBankArchive(manifest=manifest, bank_rows=bank_rows, history_rows=history_rows)
276277

277278

278-
async def _restore_rows(conn: Any, table: str, rows: list[dict]) -> int:
279+
def _resolve_bank_rows_json_encoding(manifest: TransferManifest) -> BankRowsJSONEncoding:
280+
"""Resolve row JSON provenance, including the released v1 archive contract."""
281+
return manifest.bank_rows_json_encoding or "decoded"
282+
283+
284+
async def _restore_rows(
285+
conn: Any,
286+
table: str,
287+
rows: list[dict],
288+
*,
289+
bank_rows_json_encoding: BankRowsJSONEncoding = "decoded",
290+
) -> int:
279291
"""Insert verbatim rows into a bank-scoped table, coercing JSON-encoded values
280292
back to the column's type (timestamps, uuids, jsonb). ``ON CONFLICT DO NOTHING``
281293
keeps an import idempotent and safe to re-run against a partially-filled target."""
@@ -302,14 +314,10 @@ async def _restore_rows(conn: Any, table: str, rows: list[dict]) -> int:
302314
value = row[col]
303315
if data_type in ("jsonb", "json"):
304316
# asyncpg has no JSON codec on these raw connections; pass JSON
305-
# text and cast. A string may be serialized JSON from a raw export
306-
# or an already-decoded JSON scalar from a codec-enabled export.
307-
if isinstance(value, str):
308-
try:
309-
json.loads(value)
310-
except json.JSONDecodeError:
311-
value = json.dumps(value)
312-
elif value is not None:
317+
# text and cast. Provenance is required because a decoded JSON
318+
# scalar containing JSON text is indistinguishable from a raw
319+
# serialized object after the outer archive JSON is parsed.
320+
if value is not None and (bank_rows_json_encoding == "decoded" or not isinstance(value, str)):
313321
value = json.dumps(value)
314322
values.append(value)
315323
placeholders.append(f"${position}::jsonb")
@@ -360,6 +368,7 @@ async def import_bank(
360368
if ops is None:
361369
ops = backend.ops
362370
parsed = parse_bank_archive(archive_bytes)
371+
bank_rows_json_encoding = _resolve_bank_rows_json_encoding(parsed.manifest)
363372
source_bank_id = parsed.manifest.source_bank_id
364373
bank_id = target_bank_id or source_bank_id
365374

@@ -382,7 +391,12 @@ async def import_bank(
382391
f"(it is not a merge). Delete the bank first, or pass a different target bank id."
383392
)
384393
# Bank row first — children (documents, mental_models, …) FK to it.
385-
await _restore_rows(conn, "banks", parsed.bank_rows.get("banks", []))
394+
await _restore_rows(
395+
conn,
396+
"banks",
397+
parsed.bank_rows.get("banks", []),
398+
bank_rows_json_encoding=bank_rows_json_encoding,
399+
)
386400
# Ensure the bank's per-bank vector indexes exist (no-op for global-index
387401
# extensions); idempotent and keeps the restored banks row (ON CONFLICT DO NOTHING).
388402
await bank_utils.get_or_create_bank_profile(backend, bank_id)
@@ -407,17 +421,38 @@ async def import_bank(
407421
)
408422
async with acquire_with_retry(backend) as conn:
409423
result.mental_models_imported = await _restore_rows(
410-
conn, "mental_models", parsed.bank_rows.get("mental_models", [])
424+
conn,
425+
"mental_models",
426+
parsed.bank_rows.get("mental_models", []),
427+
bank_rows_json_encoding=bank_rows_json_encoding,
411428
)
412429
# Restored after mental_models so the (mental_model_id, bank_id) FK resolves.
413430
result.mental_model_history_imported = await _restore_rows(
414-
conn, "mental_model_history", parsed.bank_rows.get("mental_model_history", [])
431+
conn,
432+
"mental_model_history",
433+
parsed.bank_rows.get("mental_model_history", []),
434+
bank_rows_json_encoding=bank_rows_json_encoding,
435+
)
436+
result.directives_imported = await _restore_rows(
437+
conn,
438+
"directives",
439+
parsed.bank_rows.get("directives", []),
440+
bank_rows_json_encoding=bank_rows_json_encoding,
441+
)
442+
result.webhooks_imported = await _restore_rows(
443+
conn,
444+
"webhooks",
445+
parsed.bank_rows.get("webhooks", []),
446+
bank_rows_json_encoding=bank_rows_json_encoding,
415447
)
416-
result.directives_imported = await _restore_rows(conn, "directives", parsed.bank_rows.get("directives", []))
417-
result.webhooks_imported = await _restore_rows(conn, "webhooks", parsed.bank_rows.get("webhooks", []))
418448
if include_history:
419449
for table in _HISTORY_TABLES:
420-
result.history_rows_imported += await _restore_rows(conn, table, parsed.history_rows.get(table, []))
450+
result.history_rows_imported += await _restore_rows(
451+
conn,
452+
table,
453+
parsed.history_rows.get(table, []),
454+
bank_rows_json_encoding=bank_rows_json_encoding,
455+
)
421456

422457
logger.info(
423458
"[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), "
@@ -524,6 +559,15 @@ async def _import_one_document(
524559
document.tags,
525560
ops=ops,
526561
)
562+
if document.created_at is not None:
563+
# Transfer archives carry source provenance. Apply it here,
564+
# without changing normal retain/upsert timestamp semantics.
565+
await conn.execute(
566+
f"UPDATE {fq_table('documents')} SET created_at = $1 WHERE id = $2 AND bank_id = $3",
567+
document.created_at,
568+
target_id,
569+
bank_id,
570+
)
527571

528572
chunk_id_map: dict[int, str] = {}
529573
if chunk_meta:
@@ -643,11 +687,19 @@ async def _import_observations(
643687

644688
all_source_ids: set[uuid.UUID] = set()
645689
for (obs, sources), obs_unit_id in zip(resolved, obs_unit_ids):
690+
observation_uuid = uuid.UUID(obs_unit_id)
691+
if obs.event_date is not None:
692+
# insert_facts_batch derives event_date for normal writes;
693+
# transfer restores the source value carried by the archive.
694+
await conn.execute(
695+
f"UPDATE {fq_table('memory_units')} SET event_date = $1 WHERE id = $2 AND bank_id = $3",
696+
obs.event_date,
697+
observation_uuid,
698+
bank_id,
699+
)
646700
source_uuids = [uuid.UUID(s) for s in sources]
647701
all_source_ids.update(source_uuids)
648-
await _link_observation_sources(
649-
conn, ops, bank_id, uuid.UUID(obs_unit_id), source_uuids, obs.proof_count
650-
)
702+
await _link_observation_sources(conn, ops, bank_id, observation_uuid, source_uuids, obs.proof_count)
651703

652704
# Mark source facts consolidated so the target consolidator skips them.
653705
if all_source_ids:

hindsight-api-slim/hindsight_api/engine/transfer/schema.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
SCHEMA_VERSION = 1
2424

2525
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
26+
BankRowsJSONEncoding = Literal["decoded", "serialized"]
2627

2728

2829
class TransferCausalRelation(BaseModel):
@@ -136,3 +137,7 @@ class TransferManifest(BaseModel):
136137
webhook_count: int = 0
137138
# True when --include-history carried audit_log / llm_requests.
138139
includes_history: bool = False
140+
# How JSON/JSONB values in bank/history row files were represented by the
141+
# producing connection. Absent on legacy v1 archives; import treats those as
142+
# decoded because the released producer was the codec-enabled admin CLI.
143+
bank_rows_json_encoding: BankRowsJSONEncoding | None = None

hindsight-api-slim/tests/test_document_transfer.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
132142
async 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

Comments
 (0)