3434
3535
3636RECONCILIATION_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+
168250def 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 :
0 commit comments