nl2data-semantic-catalog-postgres 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. nl2data_semantic_catalog_postgres/__init__.py +18 -0
  2. nl2data_semantic_catalog_postgres/client.py +139 -0
  3. nl2data_semantic_catalog_postgres/config.py +136 -0
  4. nl2data_semantic_catalog_postgres/envelope.py +327 -0
  5. nl2data_semantic_catalog_postgres/errors.py +188 -0
  6. nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
  7. nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
  8. nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
  9. nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
  10. nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
  11. nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
  12. nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
  13. nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
  14. nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
  15. nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
  16. nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
  17. nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
  18. nl2data_semantic_catalog_postgres/maintenance.py +235 -0
  19. nl2data_semantic_catalog_postgres/py.typed +0 -0
  20. nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
  21. nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
  22. nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
  23. nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
  24. nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
  25. nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
  26. nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
  27. nl2data_semantic_catalog_postgres/schema.py +337 -0
  28. nl2data_semantic_catalog_postgres/sql.py +520 -0
  29. nl2data_semantic_catalog_postgres/store.py +716 -0
  30. nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
  31. nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
  32. nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
  33. nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
  34. nl2data_semantic_catalog_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,849 @@
1
+ """Activation, version, supersession, and history repository.
2
+
3
+ Pointer moves are guarded by row locks: activation locks the pointer row,
4
+ revalidates the target (core validation, production evidence, and every
5
+ declared dependency), and pushes the previous active version onto
6
+ immutable bounded history; rollback restores the top of that history.
7
+ Multi-step compositions (activate-by-fingerprint, rollback-by-fingerprint,
8
+ version state changes) remain transactions owned by the store facade -
9
+ this repository never commits independently for them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from typing import Any
16
+
17
+ from nl2data_core.assembly.audit_evidence import (
18
+ AssemblyAuditEvidenceEntry,
19
+ AuditEventKind,
20
+ activation_audit_entry,
21
+ rollback_audit_entry,
22
+ )
23
+ from nl2data_core.bundles.catalog import (
24
+ BundleCatalogOutcome,
25
+ BundlePublication,
26
+ _expected_snapshot_fingerprint,
27
+ _failure,
28
+ _failure_from_activation_check,
29
+ _failure_from_validation,
30
+ _success,
31
+ )
32
+ from nl2data_core.bundles.models import BUNDLE_SCHEMA_VERSION, SemanticModelBundle
33
+ from nl2data_core.bundles.publication import (
34
+ LifecycleWitnessError,
35
+ PublishedVersionState,
36
+ SupersessionMetadata,
37
+ validate_lifecycle_witness,
38
+ witness_cause_type,
39
+ )
40
+ from nl2data_core.bundles.validation import validate_bundle
41
+ from nl2data_core.canonical import strict_sha256_fingerprint
42
+ from nl2data_core.control_plane.publication.contracts import (
43
+ PublicationRecordSet,
44
+ )
45
+ from nl2data_core.metadata.policy import ProductionActivationContext
46
+ from nl2data_core.verification.models import VerificationSuiteEvidence
47
+ from nl2data_core.verification.policy import PRODUCTION_POLICY
48
+
49
+ from ..envelope import ENVELOPE_SCHEMA_VERSION, ArtifactKind
50
+ from ..errors import SemanticCatalogError, SemanticCatalogErrorCode
51
+ from ..unit_of_work import CatalogUnitOfWork, _namespace, _parse_dt
52
+ from .audit_evidence import AuditEvidenceRepository
53
+ from .evidence import EvidenceRepository
54
+ from .publications import PublicationRepository
55
+
56
+
57
+ class ActivationRepository:
58
+ """Active pointer, published versions, and rollback history."""
59
+
60
+ def __init__(
61
+ self,
62
+ uow: CatalogUnitOfWork,
63
+ evidence: EvidenceRepository,
64
+ publications: PublicationRepository,
65
+ audit: AuditEvidenceRepository,
66
+ ) -> None:
67
+ self._uow = uow
68
+ self._evidence = evidence
69
+ self._publications = publications
70
+ self._audit = audit
71
+
72
+ def _build_pointer_audit_entry(
73
+ self,
74
+ conn: Any,
75
+ namespace: str,
76
+ *,
77
+ records: PublicationRecordSet,
78
+ bundle: SemanticModelBundle,
79
+ tenant_scope_fingerprint: str | None,
80
+ prior_active_fingerprint: str | None,
81
+ resulting_active_fingerprint: str,
82
+ entry_kind: AuditEventKind,
83
+ operator_audit_reference: str | None,
84
+ occurred_at: datetime,
85
+ ) -> AssemblyAuditEvidenceEntry | None:
86
+ """Build the activation or rollback entry for one pointer change.
87
+
88
+ Returns ``None`` for unscoped (legacy) callers, where a valid
89
+ tenant-scoped entry cannot exist; legacy unscoped pointer changes
90
+ are classified as legacy rather than fabricating evidence.
91
+ """
92
+ if tenant_scope_fingerprint is None:
93
+ return None
94
+ publication_entry = self._audit.find_publication_entry(
95
+ conn, namespace, bundle.fingerprint
96
+ )
97
+ binding = records.frozen_release_binding
98
+ if binding is not None:
99
+ source_scope = binding.source_scope_fingerprint
100
+ else:
101
+ source_scope = strict_sha256_fingerprint(
102
+ {"source_id": bundle.descriptor.source_id}
103
+ )
104
+ if records.audit is not None:
105
+ lifecycle_reference = records.audit.audit_id
106
+ elif publication_entry is not None:
107
+ lifecycle_reference = publication_entry.event_id
108
+ else:
109
+ lifecycle_reference = (
110
+ "publication-"
111
+ + bundle.fingerprint.removeprefix("sha256:")[:24]
112
+ )
113
+ prefix = "activate" if entry_kind is AuditEventKind.ACTIVATION else "rollback"
114
+ event_id = (
115
+ prefix
116
+ + "-"
117
+ + strict_sha256_fingerprint(
118
+ {
119
+ "bundle_id": bundle.bundle_id,
120
+ "prior_active_fingerprint": prior_active_fingerprint,
121
+ "resulting_active_fingerprint": resulting_active_fingerprint,
122
+ }
123
+ ).removeprefix("sha256:")[:24]
124
+ )
125
+ common: dict[str, Any] = {
126
+ "tenant_scope_fingerprint": tenant_scope_fingerprint,
127
+ "source_scope_fingerprint": source_scope,
128
+ "event_id": event_id,
129
+ "bundle_fingerprint": bundle.fingerprint,
130
+ "lifecycle_reference": lifecycle_reference,
131
+ "operator_audit_reference": operator_audit_reference,
132
+ "predecessor_event_ids": (
133
+ () if publication_entry is None else (publication_entry.event_id,)
134
+ ),
135
+ "occurred_at": occurred_at,
136
+ }
137
+ try:
138
+ if entry_kind is AuditEventKind.ROLLBACK:
139
+ return rollback_audit_entry(
140
+ prior_active_fingerprint=prior_active_fingerprint or "",
141
+ restored_fingerprint=resulting_active_fingerprint,
142
+ **common,
143
+ )
144
+ return activation_audit_entry(
145
+ resulting_active_fingerprint=resulting_active_fingerprint,
146
+ prior_active_fingerprint=prior_active_fingerprint,
147
+ **common,
148
+ )
149
+ except ValueError:
150
+ return None
151
+
152
+ def publication_records(
153
+ self,
154
+ bundle_id: str,
155
+ *,
156
+ tenant_scope_fingerprint: str | None = None,
157
+ ) -> tuple[BundlePublication, ...]:
158
+ """Return bounded publication metadata in supersession order."""
159
+ namespace = _namespace(tenant_scope_fingerprint)
160
+ records: list[BundlePublication] = []
161
+ with self._uow.transaction() as conn:
162
+ rows = self._uow.execute(
163
+ conn,
164
+ "list_published_versions",
165
+ (namespace, bundle_id),
166
+ ).fetchall()
167
+ for row in rows:
168
+ fingerprint = row["bundle_fingerprint"]
169
+ publication = self._uow.execute(
170
+ conn,
171
+ "read_publication_by_fingerprint",
172
+ (namespace, bundle_id, fingerprint),
173
+ ).fetchone()
174
+ if publication is None:
175
+ raise SemanticCatalogError(
176
+ SemanticCatalogErrorCode.ENVELOPE_REJECTED,
177
+ "published version references a missing Bundle artifact",
178
+ details={"cause_type": "MissingArtifact"},
179
+ )
180
+ bundle_envelope = self._uow.decode(
181
+ publication["envelope"],
182
+ ArtifactKind.BUNDLE,
183
+ row_schema_version=publication["schema_version"],
184
+ )
185
+ # Records revalidate the publication's persisted lifecycle
186
+ # records through the centralized integrity rule set; a
187
+ # record whose binding was stripped must fail closed
188
+ # instead of masquerading as a legacy publication.
189
+ persisted = self._evidence.validated_publication_records(
190
+ conn,
191
+ namespace,
192
+ bundle_id,
193
+ fingerprint,
194
+ audit_id=row["audit_id"],
195
+ )
196
+ records.append(
197
+ BundlePublication(
198
+ bundle=self._uow.bundle_from_envelope(bundle_envelope),
199
+ accepted_assertion_manifest=persisted.accepted_assertion_manifest,
200
+ audit=persisted.audit,
201
+ verification_evidence=persisted.verification_evidence,
202
+ frozen_release_binding=persisted.frozen_release_binding,
203
+ audit_evidence=persisted.audit_evidence,
204
+ state=PublishedVersionState(row["lifecycle_state"]),
205
+ supersession=SupersessionMetadata(
206
+ predecessor_fingerprint=row["predecessor_fingerprint"],
207
+ successor_fingerprint=row["successor_fingerprint"],
208
+ ),
209
+ published_at=_parse_dt(row["published_at"]),
210
+ )
211
+ )
212
+ return tuple(records)
213
+
214
+ def supersession_chain(
215
+ self,
216
+ bundle_id: str,
217
+ *,
218
+ tenant_scope_fingerprint: str | None = None,
219
+ ) -> tuple[BundlePublication, ...]:
220
+ """Return the predecessor-to-successor publication chain."""
221
+ return self.publication_records(
222
+ bundle_id,
223
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
224
+ )
225
+
226
+ def versions(
227
+ self,
228
+ bundle_id: str,
229
+ *,
230
+ tenant_scope_fingerprint: str | None = None,
231
+ ) -> tuple[SemanticModelBundle, ...]:
232
+ """Every published version of a Bundle as an immutable snapshot."""
233
+ namespace = _namespace(tenant_scope_fingerprint)
234
+ with self._uow.transaction() as conn:
235
+ rows = self._uow.execute(
236
+ conn, "list_publications", (namespace, bundle_id)
237
+ ).fetchall()
238
+ bundles = []
239
+ for row in rows:
240
+ envelope = self._uow.decode(
241
+ row["envelope"],
242
+ ArtifactKind.BUNDLE,
243
+ row_schema_version=row.get("schema_version"),
244
+ )
245
+ bundles.append(self._uow.bundle_from_envelope(envelope))
246
+ return tuple(bundles)
247
+
248
+ def active(
249
+ self,
250
+ bundle_id: str,
251
+ *,
252
+ tenant_scope_fingerprint: str | None = None,
253
+ ) -> SemanticModelBundle | None:
254
+ """The active validated Bundle, or ``None`` when not activated."""
255
+ namespace = _namespace(tenant_scope_fingerprint)
256
+ with self._uow.transaction() as conn:
257
+ pointer = self._uow.execute(
258
+ conn, "read_bundle_pointer", (namespace, bundle_id)
259
+ ).fetchone()
260
+ if pointer is None:
261
+ return None
262
+ row = self._uow.execute(
263
+ conn,
264
+ "read_publication",
265
+ (namespace, bundle_id, pointer["model_version"]),
266
+ ).fetchone()
267
+ if row is None:
268
+ return None
269
+ envelope = self._uow.decode(
270
+ row["envelope"],
271
+ ArtifactKind.BUNDLE,
272
+ row_schema_version=row["schema_version"],
273
+ )
274
+ bundle = self._uow.bundle_from_envelope(envelope)
275
+ version_row = self._uow.execute(
276
+ conn,
277
+ "read_published_version",
278
+ (namespace, bundle_id, bundle.fingerprint),
279
+ ).fetchone()
280
+ if version_row is None:
281
+ # The pointer is a witness that this version was activated;
282
+ # a publication without its lifecycle row is corruption.
283
+ raise SemanticCatalogError(
284
+ SemanticCatalogErrorCode.ENVELOPE_REJECTED,
285
+ "active publication has no published version record",
286
+ details={"cause_type": "PublicationVersionMissing"},
287
+ )
288
+ try:
289
+ # The pointer carries redundant fingerprint and version
290
+ # witnesses and must agree with an ACTIVE version row; a
291
+ # pointer that disagrees is state drift, not a legacy shape.
292
+ validate_lifecycle_witness(
293
+ bundle,
294
+ witness="pointer",
295
+ witness_fingerprint=pointer["bundle_fingerprint"],
296
+ witness_model_version=pointer["model_version"],
297
+ lifecycle_state=PublishedVersionState(
298
+ version_row["lifecycle_state"]
299
+ ),
300
+ require_state=PublishedVersionState.ACTIVE,
301
+ )
302
+ except LifecycleWitnessError as error:
303
+ raise SemanticCatalogError(
304
+ SemanticCatalogErrorCode.ENVELOPE_REJECTED,
305
+ error.message,
306
+ details={"cause_type": witness_cause_type(error.code)},
307
+ ) from error
308
+ # The active pointer is a read path too: its persisted lifecycle
309
+ # records must satisfy the centralized integrity rule set, so a
310
+ # tampered manifest, audit, evidence, or binding is never served
311
+ # as the active version. Legacy compatibility publications
312
+ # (plain Bundle, manifest-only) carry no evidence and revalidate
313
+ # through the Bundle envelope and manifest bundle-match alone.
314
+ self._evidence.validated_publication_records(
315
+ conn,
316
+ namespace,
317
+ bundle_id,
318
+ bundle.fingerprint,
319
+ audit_id=version_row["audit_id"],
320
+ )
321
+ return bundle
322
+
323
+ def activate(
324
+ self,
325
+ conn: Any,
326
+ bundle_id: str,
327
+ version: str,
328
+ *,
329
+ namespace: str,
330
+ now: datetime,
331
+ production: ProductionActivationContext | None = None,
332
+ tenant_scope_fingerprint: str | None = None,
333
+ operator_audit_reference: str | None = None,
334
+ entry_kind: AuditEventKind = AuditEventKind.ACTIVATION,
335
+ ) -> BundleCatalogOutcome:
336
+ """Point the active pointer at a published valid Bundle.
337
+
338
+ The connection is provided by the transaction owner: the pointer row
339
+ is locked, the target is revalidated, and only then is the pointer
340
+ swapped with the previous active version pushed onto immutable
341
+ history. Any rejection preserves the current pointer.
342
+ """
343
+ row = self._uow.execute(
344
+ conn,
345
+ "read_publication",
346
+ (namespace, bundle_id, version),
347
+ ).fetchone()
348
+ if row is None:
349
+ return _failure(
350
+ "not_found",
351
+ "bundle_not_found",
352
+ f"no published bundle '{bundle_id}' version '{version}' exists",
353
+ )
354
+ envelope = self._uow.decode(
355
+ row["envelope"], ArtifactKind.BUNDLE, row_schema_version=row["schema_version"]
356
+ )
357
+ bundle = self._uow.bundle_from_envelope(envelope)
358
+ version_row = self._uow.execute(
359
+ conn,
360
+ "read_published_version",
361
+ (namespace, bundle_id, bundle.fingerprint),
362
+ ).fetchone()
363
+ if version_row is None:
364
+ # Publish always writes the lifecycle row atomically with the
365
+ # publication; a missing row is corruption, not a legacy state.
366
+ return _failure(
367
+ "conflict",
368
+ "publication_version_missing",
369
+ "published version record is missing for this publication",
370
+ )
371
+ if (
372
+ PublishedVersionState(version_row["lifecycle_state"])
373
+ is PublishedVersionState.RETIRED
374
+ ):
375
+ return _failure(
376
+ "rejected",
377
+ "bundle_retired",
378
+ "retired bundle versions cannot be activated",
379
+ )
380
+ # Activation revalidates the publication's persisted lifecycle
381
+ # records through the centralized integrity rule set so a
382
+ # tampered record can never be activated.
383
+ records = self._evidence.validated_publication_records(
384
+ conn,
385
+ namespace,
386
+ bundle_id,
387
+ bundle.fingerprint,
388
+ audit_id=version_row["audit_id"],
389
+ )
390
+ result = validate_bundle(
391
+ bundle,
392
+ supported_schema_versions=(BUNDLE_SCHEMA_VERSION,),
393
+ expected_snapshot_fingerprint=_expected_snapshot_fingerprint(production),
394
+ )
395
+ if not result.valid:
396
+ return _failure_from_validation(result)
397
+ if production is not None:
398
+ check = production.check()
399
+ if not check.allowed:
400
+ return _failure_from_activation_check(check)
401
+ evidence = self._production_evidence(
402
+ conn,
403
+ namespace,
404
+ bundle_id,
405
+ bundle.fingerprint,
406
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
407
+ )
408
+ if evidence is None:
409
+ return _failure(
410
+ "rejected",
411
+ "verification_evidence_required",
412
+ "production activation requires passing production verification evidence",
413
+ )
414
+ for dependency in bundle.dependencies:
415
+ dep = self._uow.execute(
416
+ conn,
417
+ "read_publication_fingerprint",
418
+ (namespace, dependency.bundle_id, dependency.version),
419
+ ).fetchone()
420
+ if dep is None or dep["bundle_fingerprint"] != dependency.fingerprint:
421
+ return _failure(
422
+ "rejected",
423
+ "dependency_unavailable",
424
+ f"dependency '{dependency.dependency_id}' is unavailable "
425
+ "or has a different fingerprint",
426
+ member_id=dependency.dependency_id,
427
+ )
428
+ pointer = self._uow.execute(
429
+ conn, "lock_bundle_pointer", (namespace, bundle_id)
430
+ ).fetchone()
431
+ if pointer is None:
432
+ # A missing pointer means "never activated"; any ACTIVE version
433
+ # row alongside it is state drift (the pointer row was lost),
434
+ # and activating here would mint a second ACTIVE lifecycle row.
435
+ orphans = self._uow.execute(
436
+ conn, "list_published_versions", (namespace, bundle_id)
437
+ ).fetchall()
438
+ if any(
439
+ row["lifecycle_state"] == PublishedVersionState.ACTIVE.value
440
+ for row in orphans
441
+ ):
442
+ return _failure(
443
+ "conflict",
444
+ "orphan_active_version",
445
+ "an active version exists without an active pointer; "
446
+ "the lifecycle state is corrupt",
447
+ )
448
+ elif pointer["model_version"] == version:
449
+ self._uow.execute(
450
+ conn,
451
+ "set_published_version_state",
452
+ (
453
+ PublishedVersionState.ACTIVE.value,
454
+ namespace,
455
+ bundle_id,
456
+ bundle.fingerprint,
457
+ ),
458
+ )
459
+ return _success("activated", bundle)
460
+ # The activation entry is built before any pointer mutation and
461
+ # recorded only once the move succeeds; a scoped caller whose
462
+ # entry cannot be built is rejected instead of moving silently.
463
+ pointer_entry = self._build_pointer_audit_entry(
464
+ conn,
465
+ namespace,
466
+ records=records,
467
+ bundle=bundle,
468
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
469
+ prior_active_fingerprint=(
470
+ pointer["bundle_fingerprint"] if pointer is not None else None
471
+ ),
472
+ resulting_active_fingerprint=bundle.fingerprint,
473
+ entry_kind=entry_kind,
474
+ operator_audit_reference=operator_audit_reference,
475
+ occurred_at=now,
476
+ )
477
+ if pointer_entry is None and tenant_scope_fingerprint is not None:
478
+ return _failure(
479
+ "rejected",
480
+ "audit_evidence_invalid",
481
+ "activation audit evidence could not be built for this scope",
482
+ )
483
+ position = int(
484
+ self._uow.execute(
485
+ conn, "next_history_position", (namespace, bundle_id)
486
+ ).fetchone()["next_position"]
487
+ )
488
+ if pointer is not None:
489
+ self._uow.execute(
490
+ conn,
491
+ "insert_history",
492
+ (
493
+ namespace,
494
+ bundle_id,
495
+ position,
496
+ pointer["model_version"],
497
+ pointer["bundle_fingerprint"],
498
+ pointer["schema_version"],
499
+ _parse_dt(pointer["activated_at"]),
500
+ now,
501
+ ),
502
+ )
503
+ self._uow.execute(
504
+ conn,
505
+ "set_published_version_state",
506
+ (
507
+ PublishedVersionState.SUPERSEDED.value,
508
+ namespace,
509
+ bundle_id,
510
+ pointer["bundle_fingerprint"],
511
+ ),
512
+ )
513
+ self._uow.execute(
514
+ conn,
515
+ "upsert_bundle_pointer",
516
+ (
517
+ namespace,
518
+ bundle_id,
519
+ bundle.model_version,
520
+ bundle.fingerprint,
521
+ ENVELOPE_SCHEMA_VERSION,
522
+ now,
523
+ # A first-ever activation has no predecessor to record, so
524
+ # its pointer sits at sequence 0; any activation that pushed
525
+ # a history row sits exactly at that row's position. An
526
+ # empty history beside a sequence >= 1 is therefore always
527
+ # a discontinuity, never a legitimate "no history" state.
528
+ position if pointer is not None else 0,
529
+ ),
530
+ )
531
+ self._uow.execute(
532
+ conn,
533
+ "set_published_version_state",
534
+ (
535
+ PublishedVersionState.ACTIVE.value,
536
+ namespace,
537
+ bundle_id,
538
+ bundle.fingerprint,
539
+ ),
540
+ )
541
+ trim_below = position - self._uow.config.max_bundle_history + 1
542
+ if trim_below > 1:
543
+ self._uow.execute(
544
+ conn,
545
+ "trim_history",
546
+ (namespace, bundle_id, trim_below),
547
+ )
548
+ if pointer_entry is not None:
549
+ self._audit.insert_audit_entries(conn, namespace, [pointer_entry])
550
+ self._uow.insert_event(
551
+ conn,
552
+ "bundle_activated",
553
+ bundle_id,
554
+ namespace=namespace,
555
+ occurred_at=now,
556
+ )
557
+ return _success("activated", bundle)
558
+
559
+ def _production_evidence(
560
+ self,
561
+ conn: Any,
562
+ namespace: str,
563
+ bundle_id: str,
564
+ fingerprint: str,
565
+ *,
566
+ tenant_scope_fingerprint: str | None,
567
+ ) -> VerificationSuiteEvidence | None:
568
+ """Load fully validated evidence and check the production policy.
569
+
570
+ The immutable frozen-binding/audit/manifest cross-link validation
571
+ runs first and fails closed on tampered or legacy evidence; the
572
+ caller's tenant scope must match the evidence scope when supplied.
573
+ Returns ``None`` when the evidence does not satisfy the production
574
+ policy.
575
+ """
576
+ from nl2data_core.verification.suite import evidence_satisfies_policy
577
+
578
+ evidence = self._evidence.validated_verification_evidence(
579
+ conn, namespace, bundle_id, fingerprint
580
+ )
581
+ if evidence is None:
582
+ return None
583
+ if (
584
+ tenant_scope_fingerprint is not None
585
+ and evidence.tenant_scope_fingerprint != tenant_scope_fingerprint
586
+ ):
587
+ return None
588
+ if (
589
+ evidence.policy_profile != PRODUCTION_POLICY.policy_id
590
+ or evidence.policy_version != PRODUCTION_POLICY.policy_version
591
+ or evidence.policy_fingerprint != PRODUCTION_POLICY.fingerprint
592
+ or not evidence_satisfies_policy(evidence, policy=PRODUCTION_POLICY)
593
+ ):
594
+ return None
595
+ return evidence
596
+
597
+ def rollback(
598
+ self,
599
+ conn: Any,
600
+ bundle_id: str,
601
+ *,
602
+ namespace: str,
603
+ now: datetime,
604
+ production: ProductionActivationContext | None = None,
605
+ tenant_scope_fingerprint: str | None = None,
606
+ operator_audit_reference: str | None = None,
607
+ ) -> BundleCatalogOutcome:
608
+ """Move the active pointer to the previous active version.
609
+
610
+ Published artifacts are never mutated or deleted; only the pointer
611
+ changes, and rollback is possible only while a prior active version
612
+ exists and still revalidates.
613
+ """
614
+ pointer = self._uow.execute(
615
+ conn, "lock_bundle_pointer", (namespace, bundle_id)
616
+ ).fetchone()
617
+ if pointer is None:
618
+ return _failure(
619
+ "not_found",
620
+ "bundle_not_active",
621
+ f"bundle '{bundle_id}' has no active version",
622
+ )
623
+ top = self._uow.execute(
624
+ conn, "read_history_top", (namespace, bundle_id)
625
+ ).fetchone()
626
+ if top is None:
627
+ if int(pointer["activation_sequence"]) >= 1:
628
+ # The pointer's activation sequence witnesses that the
629
+ # current activation pushed a history row; an empty history
630
+ # beside it is deleted state, not a legacy "never activated"
631
+ # shape (first-ever activations sit at sequence 0).
632
+ return _failure(
633
+ "rejected",
634
+ "history_discontinuity",
635
+ f"bundle '{bundle_id}' rollback history is missing "
636
+ "although the active pointer records a previous activation",
637
+ )
638
+ return _failure(
639
+ "no_history",
640
+ "no_rollback_history",
641
+ f"bundle '{bundle_id}' has no previously active version",
642
+ )
643
+ row = self._uow.execute(
644
+ conn,
645
+ "read_publication",
646
+ (namespace, bundle_id, top["model_version"]),
647
+ ).fetchone()
648
+ if row is None:
649
+ return _failure(
650
+ "rejected",
651
+ "rollback_target_unavailable",
652
+ f"bundle '{bundle_id}' rollback target is no longer published",
653
+ )
654
+ envelope = self._uow.decode(
655
+ row["envelope"], ArtifactKind.BUNDLE, row_schema_version=row["schema_version"]
656
+ )
657
+ target = self._uow.bundle_from_envelope(envelope)
658
+ version_row = self._uow.execute(
659
+ conn,
660
+ "read_published_version",
661
+ (namespace, bundle_id, target.fingerprint),
662
+ ).fetchone()
663
+ if version_row is None:
664
+ return _failure(
665
+ "conflict",
666
+ "publication_version_missing",
667
+ "published version record is missing for this publication",
668
+ )
669
+ try:
670
+ # The history row carries the fingerprint and version of the
671
+ # record it activated; a disagreement is corruption, and the
672
+ # version row must not be retired. The top row must also sit
673
+ # exactly at the pointer's activation sequence: history rows
674
+ # are numbered by the activation that pushed them, so a lower
675
+ # position means the newest row was deleted and rolling back
676
+ # to the top would silently skip the version it recorded.
677
+ # Both checks live in the shared lifecycle witness validator.
678
+ validate_lifecycle_witness(
679
+ target,
680
+ witness="history",
681
+ witness_fingerprint=top["bundle_fingerprint"],
682
+ witness_model_version=top["model_version"],
683
+ lifecycle_state=PublishedVersionState(
684
+ version_row["lifecycle_state"]
685
+ ),
686
+ witness_position=int(top["position"]),
687
+ expected_position=int(pointer["activation_sequence"]),
688
+ )
689
+ except LifecycleWitnessError as error:
690
+ return _failure("rejected", error.code, error.message)
691
+ # Rollback revalidates the target's persisted lifecycle records
692
+ # through the centralized integrity rule set so a tampered
693
+ # record can never be restored.
694
+ records = self._evidence.validated_publication_records(
695
+ conn,
696
+ namespace,
697
+ bundle_id,
698
+ target.fingerprint,
699
+ audit_id=version_row["audit_id"],
700
+ )
701
+ if production is not None:
702
+ check = production.check()
703
+ if not check.allowed:
704
+ return _failure_from_activation_check(check)
705
+ result = validate_bundle(
706
+ target,
707
+ supported_schema_versions=(BUNDLE_SCHEMA_VERSION,),
708
+ expected_snapshot_fingerprint=_expected_snapshot_fingerprint(
709
+ production
710
+ ),
711
+ )
712
+ if not result.valid:
713
+ return _failure_from_validation(result)
714
+ evidence = self._production_evidence(
715
+ conn,
716
+ namespace,
717
+ bundle_id,
718
+ target.fingerprint,
719
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
720
+ )
721
+ if evidence is None:
722
+ return _failure(
723
+ "rejected",
724
+ "verification_evidence_required",
725
+ "production rollback requires passing production verification evidence",
726
+ )
727
+ # The rollback entry is built before any pointer mutation and
728
+ # recorded only once the move succeeds; both versions stay
729
+ # explainable with their prior and restored fingerprints.
730
+ rollback_entry = self._build_pointer_audit_entry(
731
+ conn,
732
+ namespace,
733
+ records=records,
734
+ bundle=target,
735
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
736
+ prior_active_fingerprint=pointer["bundle_fingerprint"],
737
+ resulting_active_fingerprint=target.fingerprint,
738
+ entry_kind=AuditEventKind.ROLLBACK,
739
+ operator_audit_reference=operator_audit_reference,
740
+ occurred_at=now,
741
+ )
742
+ if rollback_entry is None and tenant_scope_fingerprint is not None:
743
+ return _failure(
744
+ "rejected",
745
+ "audit_evidence_invalid",
746
+ "rollback audit evidence could not be built for this scope",
747
+ )
748
+ self._uow.execute(
749
+ conn,
750
+ "upsert_bundle_pointer",
751
+ (
752
+ namespace,
753
+ bundle_id,
754
+ target.model_version,
755
+ target.fingerprint,
756
+ ENVELOPE_SCHEMA_VERSION,
757
+ now,
758
+ # The restored version takes over the activation sequence
759
+ # slot below the consumed history row, keeping the
760
+ # "history ends at the activation sequence" invariant so
761
+ # the next rollback revalidates continuity against it.
762
+ int(top["position"]) - 1,
763
+ ),
764
+ )
765
+ self._uow.execute(
766
+ conn,
767
+ "set_published_version_state",
768
+ (
769
+ PublishedVersionState.SUPERSEDED.value,
770
+ namespace,
771
+ bundle_id,
772
+ pointer["bundle_fingerprint"],
773
+ ),
774
+ )
775
+ self._uow.execute(
776
+ conn,
777
+ "set_published_version_state",
778
+ (
779
+ PublishedVersionState.ACTIVE.value,
780
+ namespace,
781
+ bundle_id,
782
+ target.fingerprint,
783
+ ),
784
+ )
785
+ self._uow.execute(
786
+ conn,
787
+ "delete_history_top",
788
+ (namespace, bundle_id, top["position"]),
789
+ )
790
+ if rollback_entry is not None:
791
+ self._audit.insert_audit_entries(conn, namespace, [rollback_entry])
792
+ self._uow.insert_event(
793
+ conn,
794
+ "bundle_rolled_back",
795
+ bundle_id,
796
+ namespace=namespace,
797
+ occurred_at=now,
798
+ )
799
+ return _success("rolled_back", target)
800
+
801
+ def set_version_state(
802
+ self,
803
+ bundle_id: str,
804
+ fingerprint: str,
805
+ state: PublishedVersionState,
806
+ *,
807
+ tenant_scope_fingerprint: str | None = None,
808
+ ) -> BundleCatalogOutcome:
809
+ """Persist operator-managed deprecation or retirement metadata."""
810
+ if state not in {
811
+ PublishedVersionState.DEPRECATED,
812
+ PublishedVersionState.RETIRED,
813
+ }:
814
+ raise ValueError("state must be deprecated or retired")
815
+ bundle = self._publications.get_by_fingerprint(
816
+ bundle_id,
817
+ fingerprint,
818
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
819
+ )
820
+ if bundle is None:
821
+ return _failure(
822
+ "not_found", "bundle_not_found", "published bundle was not found"
823
+ )
824
+ active = self.active(
825
+ bundle_id,
826
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
827
+ )
828
+ if (
829
+ state is PublishedVersionState.RETIRED
830
+ and active is not None
831
+ and active.fingerprint == fingerprint
832
+ ):
833
+ return _failure(
834
+ "rejected",
835
+ "active_bundle_retirement",
836
+ "the active bundle cannot be retired",
837
+ )
838
+ namespace = _namespace(tenant_scope_fingerprint)
839
+ with self._uow.transaction() as conn:
840
+ self._uow.execute(
841
+ conn,
842
+ "set_published_version_state",
843
+ (state.value, namespace, bundle_id, fingerprint),
844
+ )
845
+ if state is PublishedVersionState.DEPRECATED:
846
+ return _success("deprecated", bundle)
847
+ return _success("retired", bundle)
848
+
849
+ __all__ = ["ActivationRepository"]