Skip to content

Commit f06a470

Browse files
Pigbibicodex
andauthored
feat: archive private recovery evidence immutably (#427)
Co-authored-by: Codex <noreply@openai.com>
1 parent c98fb3e commit f06a470

3 files changed

Lines changed: 153 additions & 1 deletion

File tree

docs/ibkr_reconciliation_baseline_enrollment.zh-CN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ AIAuditBridge 的完整 `reconciliation_baseline` 输出。它会同时核验:
3232
券商、复核双审绑定,并以原子比较并设置方式转换状态;任一失败都保持
3333
`RECONCILE_ONLY`
3434

35+
来源发布器还可在显式给出
36+
`gs://.../reconciliation-recovery/ibkr/source/...` 时,把完整候选与双审回执写入
37+
私有证据包。该包不发送给 QRS;写入固定使用 GCS `if_generation_match=0`,所以已存在
38+
对象会失败而不会被覆盖、读取或删除。验证器只能在专用私有存储中读取它。
39+
3540
## 私有验证器(暂不写状态)
3641

3742
`scripts/verify_reconciliation_recovery.py` 是恢复链路的第二层。它使用一枚不同于

scripts/publish_reconciliation_recovery_source.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535

3636
RECONCILIATION_RECOVERY_SYNC_TOKEN_ENV = "RECONCILIATION_RECOVERY_SYNC_TOKEN"
37+
RECONCILIATION_RECOVERY_PRIVATE_EVIDENCE_SCHEMA_VERSION = "ibkr_reconciliation_recovery_private_evidence.v1"
3738
_SHA256_LENGTH = 64
3839

3940

@@ -165,6 +166,87 @@ def build_recovery_source_snapshot(
165166
).to_dict()
166167

167168

169+
def _parse_private_evidence_uri(value: str) -> tuple[str, str]:
170+
"""Allow only an explicit, immutable IBKR source-artifact object path."""
171+
172+
normalized = str(value or "").strip()
173+
if not normalized.startswith("gs://"):
174+
raise ValueError("private_evidence_uri must use gs://")
175+
remainder = normalized.removeprefix("gs://")
176+
bucket_name, separator, object_name = remainder.partition("/")
177+
required_prefix = "reconciliation-recovery/ibkr/source/"
178+
if not bucket_name or not separator or not object_name.startswith(required_prefix) or object_name.endswith("/"):
179+
raise ValueError("private_evidence_uri must use the IBKR recovery source prefix")
180+
return bucket_name, object_name
181+
182+
183+
def build_private_evidence_package(
184+
*,
185+
snapshot: Mapping[str, object],
186+
candidate_payload: Mapping[str, Any],
187+
dual_review_payload: Mapping[str, Any],
188+
) -> dict[str, object]:
189+
"""Package private inputs for the later verifier, never for QRS ingress."""
190+
191+
candidate = extract_baseline_candidate(candidate_payload)
192+
extract_bound_dual_review(dual_review_payload, candidate=candidate)
193+
recoveries = snapshot.get("recoveries")
194+
if not isinstance(recoveries, list) or len(recoveries) != 1 or not isinstance(recoveries[0], Mapping):
195+
raise ValueError("recovery source snapshot must contain exactly one recovery")
196+
recovery_id = str(recoveries[0].get("recovery_id") or "").strip()
197+
if not recovery_id:
198+
raise ValueError("recovery source snapshot is missing recovery_id")
199+
return {
200+
"schema_version": RECONCILIATION_RECOVERY_PRIVATE_EVIDENCE_SCHEMA_VERSION,
201+
"recovery_id": recovery_id,
202+
"candidate_sha256": candidate.candidate_sha256,
203+
"source_snapshot": dict(snapshot),
204+
"baseline_candidate": dict(candidate_payload),
205+
"dual_review": dict(dual_review_payload),
206+
}
207+
208+
209+
def archive_private_evidence_package(
210+
package: Mapping[str, object],
211+
*,
212+
private_evidence_uri: str,
213+
storage_client_factory: Any | None = None,
214+
) -> dict[str, str]:
215+
"""Create one immutable private package with a GCS generation precondition.
216+
217+
The publisher role has create-only access to this prefix. A pre-existing
218+
artifact therefore fails instead of being replaced, and this helper never
219+
lists, reads, deletes, or rewrites an object.
220+
"""
221+
222+
bucket_name, object_name = _parse_private_evidence_uri(private_evidence_uri)
223+
if package.get("schema_version") != RECONCILIATION_RECOVERY_PRIVATE_EVIDENCE_SCHEMA_VERSION:
224+
raise ValueError("private evidence package has an unsupported schema_version")
225+
payload = json.dumps(dict(package), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
226+
if storage_client_factory is None:
227+
try:
228+
from google.cloud import storage
229+
except ImportError as exc: # pragma: no cover - production dependency is installed in runtime images.
230+
raise RuntimeError("google-cloud-storage is required to archive private recovery evidence") from exc
231+
client = storage.Client()
232+
else:
233+
client = storage_client_factory()
234+
blob = client.bucket(bucket_name).blob(object_name)
235+
try:
236+
blob.upload_from_string(
237+
payload,
238+
content_type="application/json",
239+
if_generation_match=0,
240+
)
241+
except Exception as exc:
242+
raise RuntimeError("private recovery evidence archive was not created") from exc
243+
return {
244+
"uri": f"gs://{bucket_name}/{object_name}",
245+
"schema_version": RECONCILIATION_RECOVERY_PRIVATE_EVIDENCE_SCHEMA_VERSION,
246+
"candidate_sha256": str(package["candidate_sha256"]),
247+
}
248+
249+
168250
def publish_recovery_source_snapshot(
169251
snapshot: Mapping[str, object],
170252
*,
@@ -252,6 +334,10 @@ def main(argv: list[str] | None = None) -> int:
252334
"--publish-url",
253335
help="Explicit QRS /api/internal/sync-reconciliation-recovery-source HTTPS URL; omitted means no network call",
254336
)
337+
parser.add_argument(
338+
"--archive-gcs-uri",
339+
help="Explicit private gs://.../reconciliation-recovery/ibkr/source/... object; uses create-only generation precondition",
340+
)
255341
args = parser.parse_args(argv)
256342
try:
257343
snapshot = build_recovery_source_snapshot(
@@ -261,13 +347,25 @@ def main(argv: list[str] | None = None) -> int:
261347
source_id=args.source_id,
262348
now=_parse_time(args.now),
263349
)
350+
output: dict[str, object] = {"snapshot": snapshot}
351+
if args.archive_gcs_uri:
352+
output["private_evidence_archive"] = archive_private_evidence_package(
353+
build_private_evidence_package(
354+
snapshot=snapshot,
355+
candidate_payload=_load_json(args.candidate, label="baseline candidate"),
356+
dual_review_payload=_load_json(args.dual_review, label="dual review"),
357+
),
358+
private_evidence_uri=args.archive_gcs_uri,
359+
)
264360
if args.publish_url:
265361
result = publish_recovery_source_snapshot(
266362
snapshot,
267363
publish_url=args.publish_url,
268364
token=os.environ.get(RECONCILIATION_RECOVERY_SYNC_TOKEN_ENV, ""),
269365
)
270-
print(json.dumps({"snapshot": snapshot, "publish": result}, ensure_ascii=False, sort_keys=True))
366+
output["publish"] = result
367+
if args.publish_url or args.archive_gcs_uri:
368+
print(json.dumps(output, ensure_ascii=False, sort_keys=True))
271369
else:
272370
print(json.dumps(snapshot, ensure_ascii=False, sort_keys=True))
273371
except (ValueError, RuntimeError) as exc:

tests/test_reconciliation_recovery_source_publisher.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
evaluate_broker_reconciliation_baseline_enrollment,
1010
)
1111
from scripts.publish_reconciliation_recovery_source import (
12+
archive_private_evidence_package,
13+
build_private_evidence_package,
1214
build_recovery_source_snapshot,
1315
publish_recovery_source_snapshot,
1416
)
@@ -150,3 +152,50 @@ def test_publish_rejects_non_recovery_endpoint() -> None:
150152
publish_url="https://console.example/api/manual-strategy-switch",
151153
token="dedicated-token",
152154
)
155+
156+
157+
def test_private_evidence_archive_is_create_only_and_never_uses_console_shape() -> None:
158+
start = datetime(2026, 8, 31, 1, 0, tzinfo=timezone.utc)
159+
candidate = _candidate_payload(start)
160+
candidate_value = candidate["candidate"]
161+
assert isinstance(candidate_value, dict)
162+
review = _dual_review(str(candidate_value["candidate_sha256"]))
163+
snapshot = build_recovery_source_snapshot(
164+
candidate_payload=candidate,
165+
dual_review_payload=review,
166+
recovery_id="ibkr-soxl-live-recovery",
167+
now=start + timedelta(minutes=3),
168+
)
169+
package = build_private_evidence_package(
170+
snapshot=snapshot,
171+
candidate_payload=candidate,
172+
dual_review_payload=review,
173+
)
174+
observed: dict[str, object] = {}
175+
176+
class Blob:
177+
def upload_from_string(self, value: str, **kwargs: object) -> None:
178+
observed["value"] = value
179+
observed["kwargs"] = kwargs
180+
181+
class Bucket:
182+
@staticmethod
183+
def blob(name: str) -> Blob:
184+
observed["object_name"] = name
185+
return Blob()
186+
187+
class Client:
188+
@staticmethod
189+
def bucket(name: str) -> Bucket:
190+
observed["bucket_name"] = name
191+
return Bucket()
192+
193+
result = archive_private_evidence_package(
194+
package,
195+
private_evidence_uri="gs://private-bucket/reconciliation-recovery/ibkr/source/ibkr-soxl-live-recovery/evidence.json",
196+
storage_client_factory=Client,
197+
)
198+
199+
assert result["uri"].startswith("gs://private-bucket/reconciliation-recovery/ibkr/source/")
200+
assert observed["kwargs"] == {"content_type": "application/json", "if_generation_match": 0}
201+
assert '"baseline_candidate"' in observed["value"] # type: ignore[operator]

0 commit comments

Comments
 (0)