Harden V3 transition replay across timezones
This commit is contained in:
parent
0b72b3cbce
commit
a03c6bc2f5
7 changed files with 513 additions and 20 deletions
|
|
@ -264,6 +264,13 @@ worker receipt, and publishes a mode-`0600` file once. An identical rerun is a
|
||||||
no-op; drift or output aliasing is refused. Already-applied legacy approvals
|
no-op; drift or output aliasing is refused. Already-applied legacy approvals
|
||||||
whose exact approved row was never retained remain explicitly unavailable.
|
whose exact approved row was never retained remain explicitly unavailable.
|
||||||
|
|
||||||
|
The rollback boundary is this transition capability only. Stop review/apply
|
||||||
|
workers, revoke and drop
|
||||||
|
`kb_stage.export_applied_proposal_replay_rows(uuid)`, then deploy the prior
|
||||||
|
revision and rerun its `kb_apply_prereqs.sql` before restarting workers. Keep
|
||||||
|
the nullable `approved_proposal_snapshot` column and any captured rows as inert
|
||||||
|
evidence; dropping them is neither required nor safe for rollback.
|
||||||
|
|
||||||
## Genesis Plus Strict Ledger: Working Deterministic Slice
|
## Genesis Plus Strict Ledger: Working Deterministic Slice
|
||||||
|
|
||||||
`ops/run_local_genesis_ledger_rebuild.py` now executes the first exact
|
`ops/run_local_genesis_ledger_rebuild.py` now executes the first exact
|
||||||
|
|
|
||||||
|
|
@ -720,6 +720,7 @@ def _validate_entry_material(
|
||||||
raise ReconstructionError("invalid_replay_receipt", "replay receipt is missing apply or row material")
|
raise ReconstructionError("invalid_replay_receipt", "replay receipt is missing apply or row material")
|
||||||
if proposal_type == "revise_strategy":
|
if proposal_type == "revise_strategy":
|
||||||
_validate_revise_strategy_receipt_rows(proposal, canonical_rows)
|
_validate_revise_strategy_receipt_rows(proposal, canonical_rows)
|
||||||
|
legacy_receipt = False
|
||||||
try:
|
try:
|
||||||
rebuilt = replay_receipt.build_replay_receipt(
|
rebuilt = replay_receipt.build_replay_receipt(
|
||||||
proposal,
|
proposal,
|
||||||
|
|
@ -728,23 +729,34 @@ def _validate_entry_material(
|
||||||
apply_sql_source=apply_metadata.get("source"),
|
apply_sql_source=apply_metadata.get("source"),
|
||||||
exported_at_utc=receipt.get("exported_at_utc"),
|
exported_at_utc=receipt.get("exported_at_utc"),
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
except (KeyError, TypeError, ValueError):
|
||||||
raise ReconstructionError(
|
legacy_receipt = True
|
||||||
"invalid_replay_receipt", "replay receipt failed strict payload and exact-row validation"
|
try:
|
||||||
) from exc
|
rebuilt = replay_exporter.validate_worker_replay_receipt(
|
||||||
|
receipt,
|
||||||
|
expected_proposal_id=material["applied_proposal"].get("id"),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError, replay_exporter.ExportError) as exc:
|
||||||
|
raise ReconstructionError(
|
||||||
|
"invalid_replay_receipt", "replay receipt failed strict payload and exact-row validation"
|
||||||
|
) from exc
|
||||||
|
|
||||||
if receipt.get("hashes") != rebuilt["hashes"]:
|
if not legacy_receipt and receipt.get("hashes") != rebuilt.get("hashes"):
|
||||||
raise ReconstructionError("replay_receipt_hash_mismatch", "replay receipt hashes are not recoverable")
|
raise ReconstructionError("replay_receipt_hash_mismatch", "replay receipt hashes are not recoverable")
|
||||||
if receipt.get("expected_row_counts") != rebuilt["expected_row_counts"]:
|
if receipt.get("expected_row_counts") != rebuilt.get("expected_row_counts"):
|
||||||
raise ReconstructionError("replay_receipt_count_mismatch", "replay receipt expected counts are invalid")
|
raise ReconstructionError("replay_receipt_count_mismatch", "replay receipt expected counts are invalid")
|
||||||
if receipt.get("actual_row_counts") != rebuilt["actual_row_counts"]:
|
if receipt.get("actual_row_counts") != rebuilt.get("actual_row_counts"):
|
||||||
raise ReconstructionError("replay_receipt_count_mismatch", "replay receipt actual counts are invalid")
|
raise ReconstructionError("replay_receipt_count_mismatch", "replay receipt actual counts are invalid")
|
||||||
actual_replay_hash = rebuilt["hashes"]["replay_material_sha256"]
|
receipt_hashes = receipt.get("hashes")
|
||||||
|
if not isinstance(receipt_hashes, dict):
|
||||||
|
raise ReconstructionError("replay_receipt_hash_mismatch", "replay receipt hashes are not recoverable")
|
||||||
|
actual_replay_hash = receipt_hashes.get("replay_material_sha256")
|
||||||
if actual_replay_hash != expected_replay_hash:
|
if actual_replay_hash != expected_replay_hash:
|
||||||
raise ReconstructionError(
|
raise ReconstructionError(
|
||||||
"replay_material_hash_mismatch", "ledger replay-material pin does not match the private receipt"
|
"replay_material_hash_mismatch", "ledger replay-material pin does not match the private receipt"
|
||||||
)
|
)
|
||||||
receipt = {**receipt, "canonical_rows": rebuilt["canonical_rows"]}
|
receipt = rebuilt
|
||||||
|
proposal = receipt["proposal"]
|
||||||
|
|
||||||
approved = material["approved_proposal"]
|
approved = material["approved_proposal"]
|
||||||
approval = material["approval_snapshot"]
|
approval = material["approval_snapshot"]
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,15 @@ function, and atomically publishes deterministic genesis-ledger input.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -92,6 +94,11 @@ RECEIPT_PROPOSAL_FIELDS = (
|
||||||
"applied_by_agent_id",
|
"applied_by_agent_id",
|
||||||
"applied_at",
|
"applied_at",
|
||||||
)
|
)
|
||||||
|
RECEIPT_PROPOSAL_FIELD_SET = frozenset(RECEIPT_PROPOSAL_FIELDS)
|
||||||
|
LEGACY_RECEIPT_TIMESTAMP_FIELDS = frozenset({"captured_at", "created_at", "updated_at", "reviewed_at", "applied_at"})
|
||||||
|
LEGACY_POSTGRES_UTC_TIMESTAMP = re.compile(
|
||||||
|
r"^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,6})?\+00$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExportError(RuntimeError):
|
class ExportError(RuntimeError):
|
||||||
|
|
@ -106,6 +113,10 @@ def _same_json(left: Any, right: Any) -> bool:
|
||||||
return _canonical_json(left) == _canonical_json(right)
|
return _canonical_json(left) == _canonical_json(right)
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_json(value: Any) -> str:
|
||||||
|
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
for key, value in pairs:
|
for key, value in pairs:
|
||||||
|
|
@ -187,6 +198,17 @@ def _canonical_timestamp(value: Any, *, label: str, nullable: bool = False) -> s
|
||||||
raise ExportError(f"{label} must be a timezone-aware timestamp") from exc
|
raise ExportError(f"{label} must be a timezone-aware timestamp") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_legacy_postgres_timestamp(value: Any, *, label: str) -> str:
|
||||||
|
if not isinstance(value, str) or not LEGACY_POSTGRES_UTC_TIMESTAMP.fullmatch(value):
|
||||||
|
raise ExportError(f"{label} must use the exact legacy PostgreSQL UTC timestamp format")
|
||||||
|
timestamp_format = "%Y-%m-%d %H:%M:%S.%f%z" if "." in value else "%Y-%m-%d %H:%M:%S%z"
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(value + ":00", timestamp_format)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExportError(f"{label} must be a valid legacy PostgreSQL UTC timestamp") from exc
|
||||||
|
return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_proposal(value: Any, *, label: str) -> dict[str, Any]:
|
def _normalize_proposal(value: Any, *, label: str) -> dict[str, Any]:
|
||||||
row = dict(_require_exact_fields(value, PROPOSAL_FIELDS, label=label))
|
row = dict(_require_exact_fields(value, PROPOSAL_FIELDS, label=label))
|
||||||
for field in PROPOSAL_UUID_FIELDS:
|
for field in PROPOSAL_UUID_FIELDS:
|
||||||
|
|
@ -236,6 +258,186 @@ def _normalize_approval(value: Any, *, label: str) -> dict[str, Any]:
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_receipt_proposal(value: Any, *, label: str) -> dict[str, Any]:
|
||||||
|
row = dict(_require_exact_fields(value, RECEIPT_PROPOSAL_FIELD_SET, label=label))
|
||||||
|
for field in RECEIPT_PROPOSAL_FIELD_SET & PROPOSAL_UUID_FIELDS:
|
||||||
|
row[field] = _canonical_uuid(
|
||||||
|
row[field],
|
||||||
|
label=f"{label}.{field}",
|
||||||
|
nullable=field != "id",
|
||||||
|
)
|
||||||
|
for field in RECEIPT_PROPOSAL_FIELD_SET & PROPOSAL_TIMESTAMP_FIELDS:
|
||||||
|
row[field] = _canonical_timestamp(
|
||||||
|
row[field],
|
||||||
|
label=f"{label}.{field}",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_legacy_receipt_proposal(value: Any, *, label: str) -> dict[str, Any]:
|
||||||
|
row = dict(_require_exact_fields(value, RECEIPT_PROPOSAL_FIELD_SET, label=label))
|
||||||
|
for field in RECEIPT_PROPOSAL_FIELD_SET & PROPOSAL_UUID_FIELDS:
|
||||||
|
row[field] = _canonical_uuid(
|
||||||
|
row[field],
|
||||||
|
label=f"{label}.{field}",
|
||||||
|
nullable=field != "id",
|
||||||
|
)
|
||||||
|
for field in RECEIPT_PROPOSAL_FIELD_SET & PROPOSAL_TIMESTAMP_FIELDS:
|
||||||
|
row[field] = _canonical_legacy_postgres_timestamp(
|
||||||
|
row[field],
|
||||||
|
label=f"{label}.{field}",
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_legacy_receipt_rows(value: Any) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ExportError("worker replay receipt canonical rows must be an object")
|
||||||
|
normalized: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for table, rows in value.items():
|
||||||
|
if not isinstance(table, str) or not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
||||||
|
raise ExportError("worker replay receipt canonical rows must map table names to row arrays")
|
||||||
|
normalized_rows: list[dict[str, Any]] = []
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
normalized_row: dict[str, Any] = {}
|
||||||
|
for field, field_value in row.items():
|
||||||
|
path = f"replay_receipt.canonical_rows.{table}[{index}].{field}"
|
||||||
|
if field in LEGACY_RECEIPT_TIMESTAMP_FIELDS and field_value is not None:
|
||||||
|
normalized_row[field] = _canonical_legacy_postgres_timestamp(field_value, label=path)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
normalized_row[field] = replay_receipt.normalize_typed_value(
|
||||||
|
field,
|
||||||
|
field_value,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExportError("worker replay receipt contains an invalid typed row value") from exc
|
||||||
|
normalized_rows.append(normalized_row)
|
||||||
|
normalized[table] = normalized_rows
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _has_legacy_postgres_timestamp(proposal: dict[str, Any], rows: Any) -> bool:
|
||||||
|
values = [proposal.get("reviewed_at"), proposal.get("applied_at")]
|
||||||
|
if isinstance(rows, dict):
|
||||||
|
values.extend(
|
||||||
|
row.get(field)
|
||||||
|
for table_rows in rows.values()
|
||||||
|
if isinstance(table_rows, list)
|
||||||
|
for row in table_rows
|
||||||
|
if isinstance(row, dict)
|
||||||
|
for field in LEGACY_RECEIPT_TIMESTAMP_FIELDS
|
||||||
|
)
|
||||||
|
return any(isinstance(value, str) and LEGACY_POSTGRES_UTC_TIMESTAMP.fullmatch(value) for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_legacy_worker_replay_receipt(
|
||||||
|
receipt: dict[str, Any],
|
||||||
|
*,
|
||||||
|
expected_proposal_id: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
proposal = receipt.get("proposal")
|
||||||
|
rows = receipt.get("canonical_rows")
|
||||||
|
apply_metadata = receipt.get("apply_engine")
|
||||||
|
if not isinstance(proposal, dict) or not isinstance(rows, dict) or not isinstance(apply_metadata, dict):
|
||||||
|
raise ExportError("worker replay receipt is missing proposal, rows, or apply metadata")
|
||||||
|
if not _has_legacy_postgres_timestamp(proposal, rows):
|
||||||
|
raise ExportError("worker replay receipt is not a supported legacy PostgreSQL rendering")
|
||||||
|
|
||||||
|
normalized_proposal = _normalize_legacy_receipt_proposal(proposal, label="replay_receipt.proposal")
|
||||||
|
if any(
|
||||||
|
proposal[field] is not None and proposal[field] != normalized_proposal[field]
|
||||||
|
for field in RECEIPT_PROPOSAL_FIELD_SET & PROPOSAL_UUID_FIELDS
|
||||||
|
):
|
||||||
|
raise ExportError("legacy worker replay receipt proposal UUIDs are not canonical")
|
||||||
|
if expected_proposal_id is not None and normalized_proposal["id"] != _canonical_uuid(
|
||||||
|
expected_proposal_id,
|
||||||
|
label="expected_proposal_id",
|
||||||
|
):
|
||||||
|
raise ExportError("worker replay receipt proposal id does not match")
|
||||||
|
normalized_rows = _normalize_legacy_receipt_rows(rows)
|
||||||
|
if any(
|
||||||
|
row.get(field) is not None and row.get(field) != normalized_rows[table][index].get(field)
|
||||||
|
for table, table_rows in rows.items()
|
||||||
|
for index, row in enumerate(table_rows)
|
||||||
|
for field in row
|
||||||
|
if field not in LEGACY_RECEIPT_TIMESTAMP_FIELDS
|
||||||
|
):
|
||||||
|
raise ExportError("legacy worker replay receipt row UUIDs are not canonical")
|
||||||
|
exported_at_utc = _canonical_timestamp(
|
||||||
|
receipt.get("exported_at_utc"),
|
||||||
|
label="replay_receipt.exported_at_utc",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
normalized_receipt = replay_receipt.build_replay_receipt(
|
||||||
|
normalized_proposal,
|
||||||
|
normalized_rows,
|
||||||
|
apply_sql_sha256=apply_metadata.get("apply_sql_sha256"),
|
||||||
|
apply_sql_source=apply_metadata.get("source"),
|
||||||
|
exported_at_utc=exported_at_utc,
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise ExportError("legacy worker replay receipt failed strict payload and row validation") from exc
|
||||||
|
|
||||||
|
expected_counts = normalized_receipt["expected_row_counts"]
|
||||||
|
if any(not isinstance(rows.get(table), list) for table in expected_counts):
|
||||||
|
raise ExportError("legacy worker replay receipt is missing an expected row array")
|
||||||
|
raw_rows = {table: sorted(rows.get(table, []), key=_canonical_json) for table in expected_counts}
|
||||||
|
actual_counts = {table: len(table_rows) for table, table_rows in raw_rows.items()}
|
||||||
|
raw_apply_metadata = {
|
||||||
|
"apply_sql_sha256": apply_metadata.get("apply_sql_sha256"),
|
||||||
|
"source": apply_metadata.get("source"),
|
||||||
|
}
|
||||||
|
replay_material = {
|
||||||
|
"receipt_contract_version": replay_receipt.RECEIPT_CONTRACT_VERSION,
|
||||||
|
"apply_engine": raw_apply_metadata,
|
||||||
|
"proposal": proposal,
|
||||||
|
"canonical_rows": raw_rows,
|
||||||
|
}
|
||||||
|
raw_apply_payload = proposal.get("payload", {}).get("apply_payload")
|
||||||
|
expected_receipt = {
|
||||||
|
**replay_material,
|
||||||
|
"artifact": "kb_apply_replay_receipt",
|
||||||
|
"exported_at_utc": receipt.get("exported_at_utc"),
|
||||||
|
"expected_row_counts": expected_counts,
|
||||||
|
"actual_row_counts": actual_counts,
|
||||||
|
"checks": normalized_receipt["checks"],
|
||||||
|
"hashes": {
|
||||||
|
"apply_payload_sha256": _sha256_json(raw_apply_payload),
|
||||||
|
"proposal_payload_sha256": _sha256_json(proposal.get("payload")),
|
||||||
|
"canonical_rows_sha256": _sha256_json(raw_rows),
|
||||||
|
"row_sha256": {table: [_sha256_json(row) for row in table_rows] for table, table_rows in raw_rows.items()},
|
||||||
|
"table_sha256": {table: _sha256_json(table_rows) for table, table_rows in raw_rows.items()},
|
||||||
|
"replay_material_sha256": _sha256_json(replay_material),
|
||||||
|
},
|
||||||
|
"replay_ready": True,
|
||||||
|
"privacy": "private receipt; may contain claim bodies and source excerpts; do not commit",
|
||||||
|
}
|
||||||
|
if not _same_json(receipt, expected_receipt):
|
||||||
|
raise ExportError("legacy worker replay receipt hashes or contract fields are inconsistent")
|
||||||
|
return normalized_receipt
|
||||||
|
|
||||||
|
|
||||||
|
def validate_worker_replay_receipt(
|
||||||
|
receipt: dict[str, Any],
|
||||||
|
*,
|
||||||
|
expected_proposal_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate a current receipt or one exact legacy PostgreSQL timestamp rendering."""
|
||||||
|
try:
|
||||||
|
return replay_receipt.validate_replay_receipt(
|
||||||
|
receipt,
|
||||||
|
expected_proposal_id=expected_proposal_id,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return _validate_legacy_worker_replay_receipt(
|
||||||
|
receipt,
|
||||||
|
expected_proposal_id=expected_proposal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: str) -> datetime:
|
def _parse_timestamp(value: str) -> datetime:
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
@ -298,14 +500,18 @@ def validate_material(
|
||||||
if not isinstance(receipt, dict):
|
if not isinstance(receipt, dict):
|
||||||
raise ExportError("replay_receipt must be an object")
|
raise ExportError("replay_receipt must be an object")
|
||||||
try:
|
try:
|
||||||
replay_receipt.validate_replay_receipt(receipt, expected_proposal_id=proposal_id)
|
normalized_receipt = validate_worker_replay_receipt(receipt, expected_proposal_id=proposal_id)
|
||||||
except ValueError as exc:
|
except (ExportError, ValueError) as exc:
|
||||||
raise ExportError("worker replay receipt failed its exact row-level contract") from exc
|
raise ExportError("worker replay receipt failed its exact row-level contract") from exc
|
||||||
receipt_proposal = receipt.get("proposal")
|
receipt_proposal = receipt.get("proposal")
|
||||||
if not isinstance(receipt_proposal, dict):
|
if not isinstance(receipt_proposal, dict):
|
||||||
raise ExportError("worker replay receipt has no proposal envelope")
|
raise ExportError("worker replay receipt has no proposal envelope")
|
||||||
|
normalized_receipt_proposal = _normalize_receipt_proposal(
|
||||||
|
normalized_receipt.get("proposal"),
|
||||||
|
label="replay_receipt.proposal",
|
||||||
|
)
|
||||||
applied_envelope = {field: applied[field] for field in RECEIPT_PROPOSAL_FIELDS}
|
applied_envelope = {field: applied[field] for field in RECEIPT_PROPOSAL_FIELDS}
|
||||||
if not _same_json(receipt_proposal, applied_envelope):
|
if not _same_json(normalized_receipt_proposal, applied_envelope):
|
||||||
raise ExportError("worker replay receipt does not match the exact applied proposal row")
|
raise ExportError("worker replay receipt does not match the exact applied proposal row")
|
||||||
|
|
||||||
created_at = _parse_timestamp(approved["created_at"])
|
created_at = _parse_timestamp(approved["created_at"])
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,7 @@ create or replace function kb_stage.approve_strict_proposal(
|
||||||
language plpgsql
|
language plpgsql
|
||||||
security definer
|
security definer
|
||||||
set search_path = pg_catalog, pg_temp
|
set search_path = pg_catalog, pg_temp
|
||||||
|
set timezone = 'UTC'
|
||||||
as $function$
|
as $function$
|
||||||
declare
|
declare
|
||||||
v_row kb_stage.kb_proposals%rowtype;
|
v_row kb_stage.kb_proposals%rowtype;
|
||||||
|
|
@ -734,6 +735,7 @@ create or replace function kb_stage.assert_approved_proposal(
|
||||||
language plpgsql
|
language plpgsql
|
||||||
security definer
|
security definer
|
||||||
set search_path = pg_catalog, pg_temp
|
set search_path = pg_catalog, pg_temp
|
||||||
|
set timezone = 'UTC'
|
||||||
as $function$
|
as $function$
|
||||||
begin
|
begin
|
||||||
perform 1
|
perform 1
|
||||||
|
|
@ -779,6 +781,7 @@ create or replace function kb_stage.finish_approved_proposal(
|
||||||
language plpgsql
|
language plpgsql
|
||||||
security definer
|
security definer
|
||||||
set search_path = pg_catalog, pg_temp
|
set search_path = pg_catalog, pg_temp
|
||||||
|
set timezone = 'UTC'
|
||||||
as $function$
|
as $function$
|
||||||
declare
|
declare
|
||||||
v_row kb_stage.kb_proposals%rowtype;
|
v_row kb_stage.kb_proposals%rowtype;
|
||||||
|
|
@ -862,6 +865,7 @@ language plpgsql
|
||||||
stable
|
stable
|
||||||
security definer
|
security definer
|
||||||
set search_path = pg_catalog, pg_temp
|
set search_path = pg_catalog, pg_temp
|
||||||
|
set timezone = 'UTC'
|
||||||
as $function$
|
as $function$
|
||||||
declare
|
declare
|
||||||
v_rows jsonb;
|
v_rows jsonb;
|
||||||
|
|
@ -1144,8 +1148,11 @@ begin
|
||||||
and (
|
and (
|
||||||
owner_role.rolname <> 'kb_gate_owner'
|
owner_role.rolname <> 'kb_gate_owner'
|
||||||
or not procedure.prosecdef
|
or not procedure.prosecdef
|
||||||
or pg_catalog.array_to_string(procedure.proconfig, ',') is distinct from
|
or pg_catalog.cardinality(procedure.proconfig) <> 2
|
||||||
'search_path=pg_catalog, pg_temp'
|
or not procedure.proconfig @> array[
|
||||||
|
'search_path=pg_catalog, pg_temp'::text,
|
||||||
|
'TimeZone=UTC'::text
|
||||||
|
]
|
||||||
or (
|
or (
|
||||||
procedure.proname = 'export_applied_proposal_replay_rows'
|
procedure.proname = 'export_applied_proposal_replay_rows'
|
||||||
and (
|
and (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
|
@ -104,6 +105,41 @@ def _material() -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rehash_worker_receipt(material: dict) -> dict:
|
||||||
|
receipt = material["replay_receipt"]
|
||||||
|
|
||||||
|
def sha256_json(value) -> str:
|
||||||
|
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
rows = receipt["canonical_rows"]
|
||||||
|
replay_material = {
|
||||||
|
"receipt_contract_version": receipt["receipt_contract_version"],
|
||||||
|
"apply_engine": receipt["apply_engine"],
|
||||||
|
"proposal": receipt["proposal"],
|
||||||
|
"canonical_rows": rows,
|
||||||
|
}
|
||||||
|
receipt["hashes"] = {
|
||||||
|
"apply_payload_sha256": sha256_json(receipt["proposal"]["payload"]["apply_payload"]),
|
||||||
|
"proposal_payload_sha256": sha256_json(receipt["proposal"]["payload"]),
|
||||||
|
"canonical_rows_sha256": sha256_json(rows),
|
||||||
|
"row_sha256": {table: [sha256_json(row) for row in table_rows] for table, table_rows in rows.items()},
|
||||||
|
"table_sha256": {table: sha256_json(table_rows) for table, table_rows in rows.items()},
|
||||||
|
"replay_material_sha256": sha256_json(replay_material),
|
||||||
|
}
|
||||||
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_space_timestamp_material() -> dict:
|
||||||
|
material = copy.deepcopy(_material())
|
||||||
|
receipt = material["replay_receipt"]
|
||||||
|
receipt["proposal"]["reviewed_at"] = "2026-07-18 10:01:00+00"
|
||||||
|
receipt["proposal"]["applied_at"] = "2026-07-18 10:02:00+00"
|
||||||
|
receipt["canonical_rows"]["claim_edges"][0]["created_at"] = "2026-07-18 10:01:30+00"
|
||||||
|
receipt["exported_at_utc"] = "2026-07-18T10:03:00+00:00"
|
||||||
|
return _rehash_worker_receipt(material)
|
||||||
|
|
||||||
|
|
||||||
def test_material_matches_genesis_replay_row_contract() -> None:
|
def test_material_matches_genesis_replay_row_contract() -> None:
|
||||||
material = exporter.validate_material(
|
material = exporter.validate_material(
|
||||||
_material(),
|
_material(),
|
||||||
|
|
@ -123,6 +159,78 @@ def test_material_matches_genesis_replay_row_contract() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_postgres_worker_receipt_exports_and_reconstructs_without_rehashing(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
material = _legacy_space_timestamp_material()
|
||||||
|
legacy_hash = material["replay_receipt"]["hashes"]["replay_material_sha256"]
|
||||||
|
|
||||||
|
validated = exporter.validate_material(
|
||||||
|
material,
|
||||||
|
expected_proposal_id=PROPOSAL_ID,
|
||||||
|
expected_sequence=1,
|
||||||
|
)
|
||||||
|
assert validated["replay_receipt"]["proposal"]["reviewed_at"] == "2026-07-18 10:01:00+00"
|
||||||
|
assert validated["replay_receipt"]["hashes"]["replay_material_sha256"] == legacy_hash
|
||||||
|
|
||||||
|
material_path = tmp_path / "legacy-transition.json"
|
||||||
|
material_path.write_text(json.dumps(validated), encoding="utf-8")
|
||||||
|
entry = genesis_rebuild._validate_entry_material(
|
||||||
|
validated,
|
||||||
|
expected_sequence=1,
|
||||||
|
expected_replay_hash=legacy_hash,
|
||||||
|
material_path=material_path,
|
||||||
|
material_sha256=hashlib.sha256(material_path.read_bytes()).hexdigest(),
|
||||||
|
)
|
||||||
|
assert entry.replay_material_sha256 == legacy_hash
|
||||||
|
assert entry.receipt["proposal"]["reviewed_at"] == "2026-07-18T10:01:00.000000Z"
|
||||||
|
assert entry.receipt["canonical_rows"]["claim_edges"][0]["created_at"] == ("2026-07-18T10:01:30.000000Z")
|
||||||
|
|
||||||
|
tampered = copy.deepcopy(material)
|
||||||
|
tampered["replay_receipt"]["hashes"]["replay_material_sha256"] = "0" * 64
|
||||||
|
with pytest.raises(exporter.ExportError, match="worker replay receipt"):
|
||||||
|
exporter.validate_material(tampered, expected_proposal_id=PROPOSAL_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"separator",
|
||||||
|
["X", "_", "\U0001f642"],
|
||||||
|
ids=["x", "underscore", "emoji"],
|
||||||
|
)
|
||||||
|
def test_current_material_rejects_noncanonical_timestamp_separators(separator: str) -> None:
|
||||||
|
material = _material()
|
||||||
|
invalid = material["approved_proposal"]["reviewed_at"].replace("T", separator, 1)
|
||||||
|
material["approved_proposal"]["reviewed_at"] = invalid
|
||||||
|
material["applied_proposal"]["reviewed_at"] = invalid
|
||||||
|
material["approval_snapshot"]["reviewed_at"] = invalid
|
||||||
|
|
||||||
|
with pytest.raises(exporter.ExportError, match="timezone-aware timestamp"):
|
||||||
|
exporter.validate_material(material, expected_proposal_id=PROPOSAL_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"separator",
|
||||||
|
["X", "_", "\U0001f642"],
|
||||||
|
ids=["x", "underscore", "emoji"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("target", ["proposal", "canonical_row"])
|
||||||
|
def test_rehashed_legacy_receipt_rejects_hybrid_timestamp_separators(
|
||||||
|
separator: str,
|
||||||
|
target: str,
|
||||||
|
) -> None:
|
||||||
|
material = _legacy_space_timestamp_material()
|
||||||
|
receipt = material["replay_receipt"]
|
||||||
|
if target == "proposal":
|
||||||
|
receipt["proposal"]["applied_at"] = receipt["proposal"]["applied_at"].replace(" ", separator, 1)
|
||||||
|
else:
|
||||||
|
row = receipt["canonical_rows"]["claim_edges"][0]
|
||||||
|
row["created_at"] = row["created_at"].replace(" ", separator, 1)
|
||||||
|
_rehash_worker_receipt(material)
|
||||||
|
|
||||||
|
with pytest.raises(exporter.ExportError, match="worker replay receipt"):
|
||||||
|
exporter.validate_material(material, expected_proposal_id=PROPOSAL_ID)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("mutation", "message"),
|
("mutation", "message"),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -18,6 +19,7 @@ DATABASE = "teleo_clone"
|
||||||
LEGACY_APPROVED_ID = "aaaaaaaa-0000-4000-8000-000000000001"
|
LEGACY_APPROVED_ID = "aaaaaaaa-0000-4000-8000-000000000001"
|
||||||
LEGACY_APPLIED_ID = "aaaaaaaa-0000-4000-8000-000000000002"
|
LEGACY_APPLIED_ID = "aaaaaaaa-0000-4000-8000-000000000002"
|
||||||
FRESH_PROPOSAL_ID = "aaaaaaaa-0000-4000-8000-000000000003"
|
FRESH_PROPOSAL_ID = "aaaaaaaa-0000-4000-8000-000000000003"
|
||||||
|
TIMEZONE_PROPOSAL_ID = "aaaaaaaa-0000-4000-8000-000000000004"
|
||||||
REVIEWER_ID = "11111111-1111-1111-1111-111111111111"
|
REVIEWER_ID = "11111111-1111-1111-1111-111111111111"
|
||||||
STALE_REVIEWER_ID = "22222222-2222-2222-2222-222222222222"
|
STALE_REVIEWER_ID = "22222222-2222-2222-2222-222222222222"
|
||||||
LEGACY_REVIEWER_HANDLE = "m3ta"
|
LEGACY_REVIEWER_HANDLE = "m3ta"
|
||||||
|
|
@ -556,6 +558,111 @@ order by 1;
|
||||||
assert exported[-3:] == ["applied_proposal", "approval_snapshot", "approved_proposal"]
|
assert exported[-3:] == ["applied_proposal", "approval_snapshot", "approved_proposal"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_functions_are_timezone_independent_across_connections(
|
||||||
|
migrated_postgres: str,
|
||||||
|
) -> None:
|
||||||
|
payload = '{"apply_payload":{"from_claim":"aaaaaaaa-1000-4000-8000-000000000004"}}'
|
||||||
|
_psql(
|
||||||
|
migrated_postgres,
|
||||||
|
f"""
|
||||||
|
insert into kb_stage.kb_proposals (id, proposal_type, status, payload, updated_at)
|
||||||
|
values ('{TIMEZONE_PROPOSAL_ID}'::uuid, 'add_edge', 'pending_review', '{payload}'::jsonb,
|
||||||
|
'2026-07-18T12:00:00Z');
|
||||||
|
set timezone = 'America/Los_Angeles';
|
||||||
|
set session authorization kb_review;
|
||||||
|
select kb_stage.approve_strict_proposal(
|
||||||
|
'{TIMEZONE_PROPOSAL_ID}'::uuid, 'add_edge', '{payload}'::jsonb, '{CANONICAL_REVIEWER_HANDLE}',
|
||||||
|
'Cross-timezone exact approval snapshot.'
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
asserted_and_finished = _psql(
|
||||||
|
migrated_postgres,
|
||||||
|
f"""
|
||||||
|
set timezone = 'Asia/Tokyo';
|
||||||
|
set session authorization kb_apply;
|
||||||
|
select kb_stage.assert_approved_proposal(
|
||||||
|
proposal.id, proposal.proposal_type, proposal.payload, proposal.reviewed_by_handle,
|
||||||
|
proposal.reviewed_by_agent_id, proposal.reviewed_at, proposal.review_note
|
||||||
|
)
|
||||||
|
from kb_stage.kb_proposals proposal where proposal.id = '{TIMEZONE_PROPOSAL_ID}'::uuid;
|
||||||
|
select kb_stage.finish_approved_proposal(
|
||||||
|
proposal.id, proposal.proposal_type, proposal.payload, proposal.reviewed_by_handle,
|
||||||
|
proposal.reviewed_by_agent_id, proposal.reviewed_at, proposal.review_note, 'kb-apply'
|
||||||
|
)
|
||||||
|
from kb_stage.kb_proposals proposal where proposal.id = '{TIMEZONE_PROPOSAL_ID}'::uuid;
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert '"status": "applied"' in asserted_and_finished.stdout
|
||||||
|
|
||||||
|
exported = json.loads(
|
||||||
|
_psql(
|
||||||
|
migrated_postgres,
|
||||||
|
f"""
|
||||||
|
set timezone = 'Pacific/Auckland';
|
||||||
|
set session authorization kb_apply;
|
||||||
|
select kb_stage.export_applied_proposal_replay_rows('{TIMEZONE_PROPOSAL_ID}'::uuid)::text;
|
||||||
|
""",
|
||||||
|
).stdout.splitlines()[-1]
|
||||||
|
)
|
||||||
|
approved = exported["approved_proposal"]
|
||||||
|
applied = exported["applied_proposal"]
|
||||||
|
approval = exported["approval_snapshot"]
|
||||||
|
assert approved["reviewed_at"].endswith("+00:00")
|
||||||
|
assert applied["reviewed_at"] == approved["reviewed_at"]
|
||||||
|
assert approval["reviewed_at"] == approved["reviewed_at"]
|
||||||
|
assert applied["status"] == "applied"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transition_export_rollback_preserves_evidence_and_reinstalls_cleanly() -> None:
|
||||||
|
with _postgres_clone() as container:
|
||||||
|
_apply_migration(container)
|
||||||
|
before = _psql(
|
||||||
|
container,
|
||||||
|
f"""
|
||||||
|
select approved_proposal_snapshot::text
|
||||||
|
from kb_stage.kb_proposal_approvals
|
||||||
|
where proposal_id = '{LEGACY_APPROVED_ID}'::uuid;
|
||||||
|
""",
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
_psql(
|
||||||
|
container,
|
||||||
|
"""
|
||||||
|
revoke all on function kb_stage.export_applied_proposal_replay_rows(uuid)
|
||||||
|
from public, kb_gate_owner, kb_apply, kb_review;
|
||||||
|
drop function kb_stage.export_applied_proposal_replay_rows(uuid);
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
rolled_back = _psql(
|
||||||
|
container,
|
||||||
|
f"""
|
||||||
|
select pg_catalog.to_regprocedure(
|
||||||
|
'kb_stage.export_applied_proposal_replay_rows(uuid)'
|
||||||
|
) is null;
|
||||||
|
select approved_proposal_snapshot::text
|
||||||
|
from kb_stage.kb_proposal_approvals
|
||||||
|
where proposal_id = '{LEGACY_APPROVED_ID}'::uuid;
|
||||||
|
""",
|
||||||
|
).stdout.splitlines()
|
||||||
|
assert rolled_back == ["t", before]
|
||||||
|
|
||||||
|
_apply_migration(container)
|
||||||
|
restored = _psql(
|
||||||
|
container,
|
||||||
|
f"""
|
||||||
|
select pg_catalog.to_regprocedure(
|
||||||
|
'kb_stage.export_applied_proposal_replay_rows(uuid)'
|
||||||
|
) is not null;
|
||||||
|
select approved_proposal_snapshot::text
|
||||||
|
from kb_stage.kb_proposal_approvals
|
||||||
|
where proposal_id = '{LEGACY_APPROVED_ID}'::uuid;
|
||||||
|
""",
|
||||||
|
).stdout.splitlines()
|
||||||
|
assert restored == ["t", before]
|
||||||
|
|
||||||
|
|
||||||
def _catalog_snapshot(container: str) -> str:
|
def _catalog_snapshot(container: str) -> str:
|
||||||
return _psql(
|
return _psql(
|
||||||
container,
|
container,
|
||||||
|
|
@ -689,7 +796,14 @@ select protected_role.rolname || '|' || protected_schema.nspname || '|' ||
|
||||||
migrated_postgres,
|
migrated_postgres,
|
||||||
"""
|
"""
|
||||||
select procedure.proname || '(' || pg_catalog.oidvectortypes(procedure.proargtypes) || ')|' ||
|
select procedure.proname || '(' || pg_catalog.oidvectortypes(procedure.proargtypes) || ')|' ||
|
||||||
owner_role.rolname || '|' || procedure.prosecdef || '|' || procedure.proconfig::text
|
owner_role.rolname || '|' || procedure.prosecdef || '|' ||
|
||||||
|
(
|
||||||
|
pg_catalog.cardinality(procedure.proconfig) = 2
|
||||||
|
and procedure.proconfig @> array[
|
||||||
|
'search_path=pg_catalog, pg_temp'::text,
|
||||||
|
'TimeZone=UTC'::text
|
||||||
|
]
|
||||||
|
)
|
||||||
from pg_catalog.pg_proc procedure
|
from pg_catalog.pg_proc procedure
|
||||||
join pg_catalog.pg_namespace namespace on namespace.oid = procedure.pronamespace
|
join pg_catalog.pg_namespace namespace on namespace.oid = procedure.pronamespace
|
||||||
join pg_catalog.pg_roles owner_role on owner_role.oid = procedure.proowner
|
join pg_catalog.pg_roles owner_role on owner_role.oid = procedure.proowner
|
||||||
|
|
@ -702,12 +816,12 @@ select procedure.proname || '(' || pg_catalog.oidvectortypes(procedure.proargtyp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert function_rows == {
|
assert function_rows == {
|
||||||
'approve_strict_proposal(uuid, text, jsonb, text, text)|kb_gate_owner|true|{"search_path=pg_catalog, pg_temp"}',
|
"approve_strict_proposal(uuid, text, jsonb, text, text)|kb_gate_owner|true|true",
|
||||||
"assert_approved_proposal(uuid, text, jsonb, text, uuid, timestamp with time zone, text)|"
|
"assert_approved_proposal(uuid, text, jsonb, text, uuid, timestamp with time zone, text)|"
|
||||||
'kb_gate_owner|true|{"search_path=pg_catalog, pg_temp"}',
|
"kb_gate_owner|true|true",
|
||||||
"finish_approved_proposal(uuid, text, jsonb, text, uuid, timestamp with time zone, text, text)|"
|
"finish_approved_proposal(uuid, text, jsonb, text, uuid, timestamp with time zone, text, text)|"
|
||||||
'kb_gate_owner|true|{"search_path=pg_catalog, pg_temp"}',
|
"kb_gate_owner|true|true",
|
||||||
'export_applied_proposal_replay_rows(uuid)|kb_gate_owner|true|{"search_path=pg_catalog, pg_temp"}',
|
"export_applied_proposal_replay_rows(uuid)|kb_gate_owner|true|true",
|
||||||
}
|
}
|
||||||
assert (
|
assert (
|
||||||
_psql(
|
_psql(
|
||||||
|
|
|
||||||
|
|
@ -1816,6 +1816,45 @@ def test_live_genesis_plus_ledger_command_rebuilds_exactly_and_proves_cleanup(
|
||||||
rebuild.build_applied_timestamp_normalization_sql(entry),
|
rebuild.build_applied_timestamp_normalization_sql(entry),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
worker_args = argparse.Namespace(
|
||||||
|
container=source_container,
|
||||||
|
role="kb_apply",
|
||||||
|
host="127.0.0.1",
|
||||||
|
db=database,
|
||||||
|
proposal_id=entry.applied_proposal["id"],
|
||||||
|
receipt_out=str(tmp_path / "worker-replay-receipt.json"),
|
||||||
|
receipt_dir=None,
|
||||||
|
)
|
||||||
|
worker_proposal = rebuild.apply_engine.load_proposal(worker_args, "")
|
||||||
|
assert "T" in worker_proposal["reviewed_at"]
|
||||||
|
assert worker_proposal["reviewed_at"].endswith("Z")
|
||||||
|
_, worker_receipt = rebuild.apply_engine.capture_replay_receipt(
|
||||||
|
worker_args,
|
||||||
|
worker_proposal,
|
||||||
|
"",
|
||||||
|
apply_sql=entry.current_apply_sql,
|
||||||
|
)
|
||||||
|
transition_rows = rebuild.replay_exporter.fetch_transition_rows(
|
||||||
|
worker_args,
|
||||||
|
"",
|
||||||
|
entry.applied_proposal["id"],
|
||||||
|
)
|
||||||
|
exported_material = rebuild.replay_exporter.validate_material(
|
||||||
|
{
|
||||||
|
"artifact": rebuild.replay_exporter.MATERIAL_ARTIFACT,
|
||||||
|
"contract_version": rebuild.replay_exporter.MATERIAL_CONTRACT_VERSION,
|
||||||
|
"sequence": 1,
|
||||||
|
"approved_proposal": transition_rows["approved_proposal"],
|
||||||
|
"approval_snapshot": transition_rows["approval_snapshot"],
|
||||||
|
"applied_proposal": transition_rows["applied_proposal"],
|
||||||
|
"replay_receipt": worker_receipt,
|
||||||
|
},
|
||||||
|
expected_proposal_id=entry.applied_proposal["id"],
|
||||||
|
expected_sequence=1,
|
||||||
|
)
|
||||||
|
assert exported_material["replay_receipt"]["hashes"] == worker_receipt["hashes"]
|
||||||
|
assert stat.S_IMODE(Path(worker_args.receipt_out).stat().st_mode) == 0o600
|
||||||
|
|
||||||
final_manifest = tmp_path / "final-manifest.jsonl"
|
final_manifest = tmp_path / "final-manifest.jsonl"
|
||||||
capture_manifest(source_container, database, final_manifest)
|
capture_manifest(source_container, database, final_manifest)
|
||||||
completed, receipt, output = run_genesis_ledger_command(
|
completed, receipt, output = run_genesis_ledger_command(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue