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,716 @@
1
+ """PostgreSQL-backed durable semantic catalog (compatibility facade).
2
+
3
+ Implements the replaceable :class:`SemanticSnapshotCatalog` boundary (and
4
+ the Bundle catalog operations) so hosts persist and coordinate the whole
5
+ metadata-to-Bundle lifecycle across restarts and workers. The facade owns
6
+ schema initialization and the transactions that span repositories; all
7
+ persistence mechanics live in the focused repositories under
8
+ :mod:`nl2data_semantic_catalog_postgres.repositories` over the shared
9
+ :class:`~nl2data_semantic_catalog_postgres.unit_of_work.CatalogUnitOfWork`.
10
+
11
+ Behavioral contract (unchanged): only bounded canonical envelopes are
12
+ stored; every write validates kind, schema version, canonical fingerprint,
13
+ and byte bounds before persistence, and every read revalidates the same
14
+ properties, so tampered, truncated, or forward-incompatible rows fail
15
+ closed. Every mutation is transactional: activation locks the pointer row
16
+ and revalidates under the lock, publication is idempotent through unique
17
+ constraints, and rollback moves the pointer only to a previously published
18
+ valid version while preserving immutable history. Records are scoped by
19
+ an opaque tenant scope namespace derived from the trusted scope
20
+ fingerprint; backend failures surface as normalized
21
+ :class:`SemanticCatalogError` values that never leak DSNs, credentials, or
22
+ raw driver text. The psycopg driver is optional and lazy via
23
+ :mod:`nl2data_semantic_catalog_postgres.client`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Callable, Sequence
29
+ from datetime import datetime
30
+ from typing import Any, overload
31
+
32
+ from nl2data_core.assembly.audit_evidence import (
33
+ MAX_TRAIL_ENTRIES,
34
+ AssemblyAuditEvidenceEntry,
35
+ AuditEventKind,
36
+ AuditTrail,
37
+ )
38
+ from nl2data_core.assembly.manifest import AcceptedAssertionManifest
39
+ from nl2data_core.assembly.models import AssemblyDraft
40
+ from nl2data_core.bundles.catalog import (
41
+ BundleCatalogOutcome,
42
+ BundlePublication,
43
+ _failure,
44
+ _success,
45
+ )
46
+ from nl2data_core.bundles.models import SemanticModelBundle
47
+ from nl2data_core.bundles.publication import (
48
+ PublishAuditRecord,
49
+ PublishedVersionState,
50
+ )
51
+ from nl2data_core.control_plane.publication.contracts import (
52
+ PublicationAggregate,
53
+ PublicationDraftBinding,
54
+ PublicationIntegrityError,
55
+ PublicationRecordSet,
56
+ build_publication_records,
57
+ )
58
+ from nl2data_core.metadata.catalog import CatalogReloadReport
59
+ from nl2data_core.metadata.drift import DriftDecision, DriftOverride
60
+ from nl2data_core.metadata.models import MetadataSnapshot
61
+ from nl2data_core.metadata.policy import (
62
+ ProductionActivationContext,
63
+ SnapshotActivationPolicy,
64
+ )
65
+ from nl2data_core.metadata.production import (
66
+ LedgerActivation,
67
+ SnapshotLifecycleRecord,
68
+ )
69
+ from nl2data_core.metadata.proposals import SemanticProposalSet
70
+ from nl2data_core.verification.models import VerificationSuiteEvidence
71
+
72
+ from .client import build_pool
73
+ from .config import SemanticCatalogConfig
74
+ from .errors import SemanticCatalogError, SemanticCatalogErrorCode
75
+ from .maintenance import cleanup as _cleanup
76
+ from .maintenance import reload_active as _reload_active
77
+ from .repositories import (
78
+ ActivationRepository,
79
+ AuditEvidenceRepository,
80
+ DraftRepository,
81
+ EvidenceRepository,
82
+ PublicationRepository,
83
+ SnapshotRepository,
84
+ )
85
+ from .schema import MIGRATIONS, SUPPORTED_SCHEMA_VERSION
86
+ from .sql import BOOTSTRAP_DDL, SQL_TEMPLATES
87
+ from .unit_of_work import CatalogUnitOfWork, _namespace
88
+
89
+ __all__ = ["MIGRATIONS", "SQL_TEMPLATES", "PostgreSQLSemanticCatalog"]
90
+
91
+
92
+ class PostgreSQLSemanticCatalog:
93
+ """Durable semantic catalog persisting safe envelopes to PostgreSQL.
94
+
95
+ The facade composes the focused repositories, owns schema
96
+ initialization and cross-repository transactions (publication,
97
+ activation, rollback), and delegates every capability to the repository
98
+ that owns that domain.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ dsn: str | None = None,
105
+ config: SemanticCatalogConfig | None = None,
106
+ pool: Any | None = None,
107
+ now: Callable[[], datetime] | None = None,
108
+ ) -> None:
109
+ """Build the catalog over a DSN (lazy psycopg pool) or an injected pool.
110
+
111
+ Exactly one of ``dsn`` or ``pool`` is required. The injected pool
112
+ seam (fake or host-managed) keeps the catalog testable without the
113
+ optional driver installed; ``now`` injects the client clock for
114
+ deterministic tests. The DSN itself is never stored or logged.
115
+ """
116
+ if (dsn is None) == (pool is None):
117
+ raise ValueError("exactly one of 'dsn' or 'pool' is required")
118
+ self._config = config or SemanticCatalogConfig(namespace="catalog")
119
+ self._schema = self._config.namespace
120
+ self._quoted_schema = f'"{self._schema}"'
121
+ if pool is not None:
122
+ resolved_pool = pool
123
+ else:
124
+ assert dsn is not None
125
+ resolved_pool = build_pool(
126
+ dsn,
127
+ pool_size=self._config.pool_size,
128
+ connect_timeout_seconds=self._config.connect_timeout_seconds,
129
+ command_timeout_seconds=self._config.command_timeout_seconds,
130
+ acquire_timeout_seconds=self._config.pool_acquire_timeout_seconds,
131
+ schema=self._schema,
132
+ )
133
+ self._uow = CatalogUnitOfWork(config=self._config, pool=resolved_pool, now=now)
134
+ self._snapshots = SnapshotRepository(self._uow)
135
+ self._drafts = DraftRepository(self._uow)
136
+ self._evidence = EvidenceRepository(self._uow)
137
+ self._audit = AuditEvidenceRepository(self._uow)
138
+ self._publications = PublicationRepository(self._uow, self._evidence, self._audit)
139
+ self._activation = ActivationRepository(
140
+ self._uow, self._evidence, self._publications, self._audit
141
+ )
142
+ self._initialize_schema()
143
+
144
+ # -- schema and connection ---------------------------------------------
145
+
146
+ @property
147
+ def schema(self) -> str:
148
+ """The deployment schema namespace owning every catalog table."""
149
+ return self._schema
150
+
151
+ def schema_version(self) -> int:
152
+ """The persisted schema version read from catalog metadata."""
153
+ with self._uow.transaction() as conn:
154
+ cursor = self._uow.execute(conn, "read_schema_version")
155
+ row = cursor.fetchone()
156
+ return int(row["value"]) if row is not None else 0
157
+
158
+ def _initialize_schema(self) -> None:
159
+ quoted_schema = self._quoted_schema
160
+ with self._uow.transaction() as conn:
161
+ try:
162
+ # The deployment namespace schema is created lazily so a
163
+ # fresh DSN never requires manual DDL before first use.
164
+ self._uow.execute_raw(
165
+ conn, f"CREATE SCHEMA IF NOT EXISTS {quoted_schema}"
166
+ )
167
+ self._uow.execute_raw(conn, BOOTSTRAP_DDL.format(schema=quoted_schema))
168
+ cursor = self._uow.execute(conn, "read_schema_version")
169
+ row = cursor.fetchone()
170
+ current = int(row["value"]) if row is not None else 0
171
+ target = self._config.schema_version
172
+ if current > target:
173
+ raise SemanticCatalogError(
174
+ SemanticCatalogErrorCode.SCHEMA_MISMATCH,
175
+ f"database schema version {current} is newer than the "
176
+ f"configured {target}",
177
+ details={
178
+ "database_schema_version": str(current),
179
+ "configured": str(target),
180
+ },
181
+ )
182
+ if current > SUPPORTED_SCHEMA_VERSION:
183
+ raise SemanticCatalogError(
184
+ SemanticCatalogErrorCode.SCHEMA_MISMATCH,
185
+ f"database schema version {current} is newer than "
186
+ f"supported {SUPPORTED_SCHEMA_VERSION}",
187
+ details={
188
+ "database_schema_version": str(current),
189
+ "supported": str(SUPPORTED_SCHEMA_VERSION),
190
+ },
191
+ )
192
+ for version in range(current + 1, target + 1):
193
+ for statement in MIGRATIONS[version]:
194
+ self._uow.execute_raw(
195
+ conn, statement.format(schema=quoted_schema)
196
+ )
197
+ if current < target:
198
+ self._uow.execute(conn, "write_schema_version", (str(target),))
199
+ except SemanticCatalogError:
200
+ raise
201
+ except Exception as error:
202
+ raise self._uow.map_backend_error(
203
+ error, operation="initialize"
204
+ ) from error
205
+
206
+ def close(self) -> None:
207
+ """Close the pool (idempotent); later operations fail closed."""
208
+ self._uow.close()
209
+
210
+ # -- snapshots and proposal sets ----------------------------------------
211
+
212
+ def register_snapshot(
213
+ self,
214
+ snapshot: MetadataSnapshot,
215
+ *,
216
+ tenant_scope_fingerprint: str,
217
+ retained_for_seconds: float | None = None,
218
+ ) -> SnapshotLifecycleRecord:
219
+ """Retain one snapshot as evidence (never activates by default)."""
220
+ return self._snapshots.register_snapshot(
221
+ snapshot, tenant_scope_fingerprint=tenant_scope_fingerprint,
222
+ retained_for_seconds=retained_for_seconds,
223
+ )
224
+
225
+ def snapshot(
226
+ self,
227
+ snapshot_fingerprint: str,
228
+ *,
229
+ tenant_scope_fingerprint: str,
230
+ ) -> MetadataSnapshot | None:
231
+ """The registered snapshot with the given fingerprint, or ``None``."""
232
+ return self._snapshots.snapshot(
233
+ snapshot_fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
234
+ )
235
+
236
+ def activate_snapshot(
237
+ self,
238
+ snapshot_fingerprint: str,
239
+ *,
240
+ tenant_scope_fingerprint: str,
241
+ policy: SnapshotActivationPolicy | None = None,
242
+ drift_decision: DriftDecision | None = None,
243
+ overrides: tuple[DriftOverride, ...] = (),
244
+ now: datetime | None = None,
245
+ ) -> LedgerActivation:
246
+ """Atomically activate a registered snapshot under production rules."""
247
+ return self._snapshots.activate_snapshot(
248
+ snapshot_fingerprint,
249
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
250
+ policy=policy, drift_decision=drift_decision,
251
+ overrides=overrides, now=now,
252
+ )
253
+
254
+ def active_snapshot(
255
+ self, source_id: str, tenant_scope_fingerprint: str
256
+ ) -> MetadataSnapshot | None:
257
+ """The active snapshot for one source/tenant scope, or ``None``."""
258
+ return self._snapshots.active_snapshot(source_id, tenant_scope_fingerprint)
259
+
260
+ def save_proposal_set(
261
+ self,
262
+ proposal_set: SemanticProposalSet,
263
+ *,
264
+ tenant_scope_fingerprint: str,
265
+ ) -> None:
266
+ """Persist the latest reviewed proposal set for its snapshot."""
267
+ self._snapshots.save_proposal_set(
268
+ proposal_set, tenant_scope_fingerprint=tenant_scope_fingerprint
269
+ )
270
+
271
+ def proposal_set(
272
+ self,
273
+ snapshot_fingerprint: str,
274
+ *,
275
+ tenant_scope_fingerprint: str,
276
+ ) -> SemanticProposalSet | None:
277
+ """The persisted proposal set for one snapshot, or ``None``."""
278
+ return self._snapshots.proposal_set(
279
+ snapshot_fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
280
+ )
281
+
282
+ # -- assembly drafts ------------------------------------------------------
283
+
284
+ def create(
285
+ self,
286
+ draft: AssemblyDraft,
287
+ *,
288
+ tenant_scope_fingerprint: str,
289
+ ) -> None:
290
+ """Persist a new tenant-scoped assembly draft."""
291
+ self._drafts.create(draft, tenant_scope_fingerprint=tenant_scope_fingerprint)
292
+
293
+ def get_draft(
294
+ self,
295
+ draft_id: str,
296
+ *,
297
+ tenant_scope_fingerprint: str,
298
+ ) -> AssemblyDraft | None:
299
+ """Load a tenant-scoped assembly draft by opaque identifier."""
300
+ return self._drafts.get_draft(
301
+ draft_id, tenant_scope_fingerprint=tenant_scope_fingerprint
302
+ )
303
+
304
+ def authoritative_release_binding_matches(
305
+ self,
306
+ binding: PublicationDraftBinding,
307
+ ) -> bool:
308
+ """Preflight the exact persisted draft before external verification work."""
309
+ return self._drafts.authoritative_release_binding_matches(binding)
310
+
311
+ def replace(
312
+ self,
313
+ draft: AssemblyDraft,
314
+ *,
315
+ expected_revision: int,
316
+ tenant_scope_fingerprint: str,
317
+ ) -> None:
318
+ """Replace a draft only when its persisted revision matches."""
319
+ self._drafts.replace(
320
+ draft, expected_revision=expected_revision,
321
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
322
+ )
323
+
324
+ # -- publication ------------------------------------------------------------
325
+
326
+ def publish(
327
+ self,
328
+ bundle: SemanticModelBundle,
329
+ *,
330
+ publication_aggregate: PublicationAggregate | None = None,
331
+ accepted_assertion_manifest: AcceptedAssertionManifest | None = None,
332
+ audit: PublishAuditRecord | None = None,
333
+ verification_evidence: VerificationSuiteEvidence | None = None,
334
+ production: ProductionActivationContext | None = None,
335
+ tenant_scope_fingerprint: str | None = None,
336
+ publication_binding: PublicationDraftBinding | None = None,
337
+ idempotency_key: str | None = None,
338
+ ) -> BundleCatalogOutcome:
339
+ """Atomically publish a Bundle and all supplied lifecycle records.
340
+
341
+ The facade owns the single publication transaction spanning every
342
+ repository write; the publication repository performs the ordered
343
+ validation and writes inside it.
344
+ """
345
+ if (
346
+ publication_binding is not None
347
+ and tenant_scope_fingerprint != publication_binding.tenant_scope_fingerprint
348
+ ):
349
+ raise ValueError("publication binding tenant scope mismatch")
350
+ if idempotency_key is not None and (not idempotency_key or len(idempotency_key) > 256):
351
+ raise ValueError("idempotency_key must be a bounded non-empty string")
352
+ if publication_aggregate is not None:
353
+ if publication_aggregate.bundle != bundle:
354
+ return _failure(
355
+ "rejected",
356
+ "publication_aggregate_mismatch",
357
+ "publication aggregate does not match the published bundle",
358
+ )
359
+ records = PublicationRecordSet.from_aggregate(publication_aggregate)
360
+ if (
361
+ records.frozen_release_binding is None
362
+ or records.frozen_release_binding.tenant_scope_fingerprint
363
+ != tenant_scope_fingerprint
364
+ ):
365
+ return _failure(
366
+ "rejected",
367
+ "publication_aggregate_mismatch",
368
+ "publication aggregate tenant scope does not match the "
369
+ "publication scope",
370
+ )
371
+ else:
372
+ # Compatibility publish arguments are converted into one
373
+ # validated record set at this boundary; repositories never
374
+ # see per-record arguments.
375
+ try:
376
+ records = build_publication_records(
377
+ bundle,
378
+ accepted_assertion_manifest=accepted_assertion_manifest,
379
+ audit=audit,
380
+ verification_evidence=verification_evidence,
381
+ )
382
+ except PublicationIntegrityError as error:
383
+ return _failure("rejected", error.code, error.message)
384
+ if records.frozen_release_binding is not None and (
385
+ records.frozen_release_binding.tenant_scope_fingerprint
386
+ != tenant_scope_fingerprint
387
+ ):
388
+ return _failure(
389
+ "rejected",
390
+ "verification_evidence_mismatch",
391
+ "verification evidence does not match the publication tenant scope",
392
+ )
393
+ namespace = _namespace(tenant_scope_fingerprint)
394
+ now = self._uow.now()
395
+ with self._uow.transaction() as conn:
396
+ return self._publications.publish(
397
+ conn,
398
+ bundle,
399
+ namespace=namespace,
400
+ now=now,
401
+ records=records,
402
+ production=production,
403
+ publication_binding=publication_binding,
404
+ idempotency_key=idempotency_key,
405
+ )
406
+
407
+ @overload
408
+ def get(
409
+ self,
410
+ draft_id: str,
411
+ /,
412
+ *,
413
+ tenant_scope_fingerprint: str,
414
+ ) -> AssemblyDraft | None: ...
415
+
416
+ @overload
417
+ def get(
418
+ self,
419
+ bundle_id: str,
420
+ version: str,
421
+ /,
422
+ *,
423
+ tenant_scope_fingerprint: str | None = None,
424
+ ) -> SemanticModelBundle | None: ...
425
+
426
+ def get(
427
+ self,
428
+ bundle_or_draft_id: str,
429
+ version: str | None = None,
430
+ /,
431
+ *,
432
+ tenant_scope_fingerprint: str | None = None,
433
+ ) -> AssemblyDraft | SemanticModelBundle | None:
434
+ """Load a draft by id or a published Bundle by id and version."""
435
+ if version is None:
436
+ if tenant_scope_fingerprint is None:
437
+ raise ValueError("draft reads require tenant_scope_fingerprint")
438
+ return self._drafts.get_draft(
439
+ bundle_or_draft_id, tenant_scope_fingerprint=tenant_scope_fingerprint
440
+ )
441
+ return self._publications.get(
442
+ bundle_or_draft_id, version,
443
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
444
+ )
445
+
446
+ def get_by_fingerprint(
447
+ self,
448
+ bundle_id: str,
449
+ fingerprint: str,
450
+ *,
451
+ tenant_scope_fingerprint: str | None = None,
452
+ ) -> SemanticModelBundle | None:
453
+ """Load an immutable Bundle by semantic fingerprint."""
454
+ return self._publications.get_by_fingerprint(
455
+ bundle_id, fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
456
+ )
457
+
458
+ # -- publication lifecycle records ------------------------------------------
459
+
460
+ def accepted_assertion_manifest(
461
+ self,
462
+ bundle_id: str,
463
+ fingerprint: str,
464
+ *,
465
+ tenant_scope_fingerprint: str | None = None,
466
+ ) -> AcceptedAssertionManifest | None:
467
+ """Load the immutable accepted-assertion manifest for a publication."""
468
+ return self._evidence.accepted_assertion_manifest(
469
+ bundle_id, fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
470
+ )
471
+
472
+ def publish_audit(
473
+ self,
474
+ bundle_id: str,
475
+ fingerprint: str,
476
+ *,
477
+ tenant_scope_fingerprint: str | None = None,
478
+ ) -> PublishAuditRecord | None:
479
+ """Load the immutable safe audit record for a publication."""
480
+ return self._evidence.publish_audit(
481
+ bundle_id, fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
482
+ )
483
+
484
+ def verification_evidence(
485
+ self,
486
+ bundle_id: str,
487
+ fingerprint: str,
488
+ *,
489
+ tenant_scope_fingerprint: str | None = None,
490
+ ) -> VerificationSuiteEvidence | None:
491
+ """Load immutable bounded verification evidence for a publication."""
492
+ return self._evidence.verification_evidence(
493
+ bundle_id, fingerprint, tenant_scope_fingerprint=tenant_scope_fingerprint
494
+ )
495
+
496
+ # -- versions, activation, and rollback ---------------------------------------
497
+
498
+ def publication_records(
499
+ self,
500
+ bundle_id: str,
501
+ *,
502
+ tenant_scope_fingerprint: str | None = None,
503
+ ) -> tuple[BundlePublication, ...]:
504
+ """Return bounded publication metadata in supersession order."""
505
+ return self._activation.publication_records(
506
+ bundle_id, tenant_scope_fingerprint=tenant_scope_fingerprint
507
+ )
508
+
509
+ def supersession_chain(
510
+ self,
511
+ bundle_id: str,
512
+ *,
513
+ tenant_scope_fingerprint: str | None = None,
514
+ ) -> tuple[BundlePublication, ...]:
515
+ """Return the predecessor-to-successor publication chain."""
516
+ return self._activation.supersession_chain(
517
+ bundle_id, tenant_scope_fingerprint=tenant_scope_fingerprint
518
+ )
519
+
520
+ def versions(
521
+ self,
522
+ bundle_id: str,
523
+ *,
524
+ tenant_scope_fingerprint: str | None = None,
525
+ ) -> tuple[SemanticModelBundle, ...]:
526
+ """Every published version of a Bundle as an immutable snapshot."""
527
+ return self._activation.versions(
528
+ bundle_id, tenant_scope_fingerprint=tenant_scope_fingerprint
529
+ )
530
+
531
+ def active(
532
+ self,
533
+ bundle_id: str,
534
+ *,
535
+ tenant_scope_fingerprint: str | None = None,
536
+ ) -> SemanticModelBundle | None:
537
+ """The active validated Bundle, or ``None`` when not activated."""
538
+ return self._activation.active(
539
+ bundle_id, tenant_scope_fingerprint=tenant_scope_fingerprint
540
+ )
541
+
542
+ def activate(
543
+ self,
544
+ bundle_id: str,
545
+ version: str,
546
+ *,
547
+ production: ProductionActivationContext | None = None,
548
+ tenant_scope_fingerprint: str | None = None,
549
+ operator_audit_reference: str | None = None,
550
+ ) -> BundleCatalogOutcome:
551
+ """Atomically point the active pointer at a published valid Bundle."""
552
+ namespace = _namespace(tenant_scope_fingerprint)
553
+ now = self._uow.now()
554
+ with self._uow.transaction() as conn:
555
+ return self._activation.activate(
556
+ conn, bundle_id, version, namespace=namespace, now=now,
557
+ production=production,
558
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
559
+ operator_audit_reference=operator_audit_reference,
560
+ )
561
+
562
+ def activate_fingerprint(
563
+ self,
564
+ bundle_id: str,
565
+ fingerprint: str,
566
+ *,
567
+ production: ProductionActivationContext | None = None,
568
+ tenant_scope_fingerprint: str | None = None,
569
+ operator_audit_reference: str | None = None,
570
+ ) -> BundleCatalogOutcome:
571
+ """Atomically activate a complete publication by semantic fingerprint."""
572
+ bundle = self._publications.get_by_fingerprint(
573
+ bundle_id, fingerprint,
574
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
575
+ )
576
+ if bundle is None:
577
+ return _failure(
578
+ "not_found",
579
+ "bundle_not_found",
580
+ f"no published bundle '{bundle_id}' fingerprint '{fingerprint}' exists",
581
+ )
582
+ return self.activate(
583
+ bundle_id,
584
+ bundle.model_version,
585
+ production=production,
586
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
587
+ operator_audit_reference=operator_audit_reference,
588
+ )
589
+
590
+ def rollback(
591
+ self,
592
+ bundle_id: str,
593
+ *,
594
+ production: ProductionActivationContext | None = None,
595
+ tenant_scope_fingerprint: str | None = None,
596
+ operator_audit_reference: str | None = None,
597
+ ) -> BundleCatalogOutcome:
598
+ """Move the active pointer to the previous active version."""
599
+ namespace = _namespace(tenant_scope_fingerprint)
600
+ now = self._uow.now()
601
+ with self._uow.transaction() as conn:
602
+ return self._activation.rollback(
603
+ conn, bundle_id, namespace=namespace, now=now,
604
+ production=production,
605
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
606
+ operator_audit_reference=operator_audit_reference,
607
+ )
608
+
609
+ def rollback_to_fingerprint(
610
+ self,
611
+ bundle_id: str,
612
+ fingerprint: str,
613
+ *,
614
+ production: ProductionActivationContext | None = None,
615
+ tenant_scope_fingerprint: str | None = None,
616
+ operator_audit_reference: str | None = None,
617
+ ) -> BundleCatalogOutcome:
618
+ """Change only the active pointer to a published semantic fingerprint."""
619
+ active = self._activation.active(
620
+ bundle_id, tenant_scope_fingerprint=tenant_scope_fingerprint
621
+ )
622
+ if active is None:
623
+ return _failure(
624
+ "not_found",
625
+ "bundle_not_active",
626
+ f"bundle '{bundle_id}' has no active version",
627
+ )
628
+ target = self._publications.get_by_fingerprint(
629
+ bundle_id, fingerprint,
630
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
631
+ )
632
+ if target is None:
633
+ return _failure(
634
+ "not_found",
635
+ "bundle_not_found",
636
+ f"no published bundle '{bundle_id}' fingerprint '{fingerprint}' exists",
637
+ )
638
+ if active.fingerprint == fingerprint:
639
+ return _success("rolled_back", target)
640
+ # A fingerprint rollback is a rollback, not an activation: the
641
+ # pointer-change audit entry must carry the rollback event kind.
642
+ namespace = _namespace(tenant_scope_fingerprint)
643
+ with self._uow.transaction() as conn:
644
+ outcome = self._activation.activate(
645
+ conn, bundle_id, target.model_version, namespace=namespace,
646
+ now=self._uow.now(), production=production,
647
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
648
+ operator_audit_reference=operator_audit_reference,
649
+ entry_kind=AuditEventKind.ROLLBACK,
650
+ )
651
+ return _success("rolled_back", target) if outcome.success else outcome
652
+
653
+ def set_version_state(
654
+ self,
655
+ bundle_id: str,
656
+ fingerprint: str,
657
+ state: PublishedVersionState,
658
+ *,
659
+ tenant_scope_fingerprint: str | None = None,
660
+ ) -> BundleCatalogOutcome:
661
+ """Persist operator-managed deprecation or retirement metadata."""
662
+ return self._activation.set_version_state(
663
+ bundle_id, fingerprint, state,
664
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
665
+ )
666
+
667
+ # -- assembly audit evidence -------------------------------------------------
668
+
669
+ def record_audit_entries(
670
+ self,
671
+ entries: Sequence[AssemblyAuditEvidenceEntry],
672
+ *,
673
+ tenant_scope_fingerprint: str,
674
+ ) -> None:
675
+ """Record externally supplied audit-evidence entries under one scope."""
676
+ self._audit.record_audit_entries(
677
+ entries, tenant_scope_fingerprint=tenant_scope_fingerprint
678
+ )
679
+
680
+ def audit_entries(
681
+ self,
682
+ *,
683
+ tenant_scope_fingerprint: str | None = None,
684
+ draft_id: str | None = None,
685
+ draft_revision_min: int | None = None,
686
+ draft_revision_max: int | None = None,
687
+ assertion_id: str | None = None,
688
+ bundle_fingerprint: str | None = None,
689
+ lifecycle_reference: str | None = None,
690
+ predecessor_event_id: str | None = None,
691
+ limit: int = MAX_TRAIL_ENTRIES,
692
+ cursor: str | None = None,
693
+ ) -> AuditTrail:
694
+ """Return one deterministic, bounded, tenant-scoped trail page."""
695
+ return self._audit.audit_entries(
696
+ tenant_scope_fingerprint=tenant_scope_fingerprint,
697
+ draft_id=draft_id,
698
+ draft_revision_min=draft_revision_min,
699
+ draft_revision_max=draft_revision_max,
700
+ assertion_id=assertion_id,
701
+ bundle_fingerprint=bundle_fingerprint,
702
+ lifecycle_reference=lifecycle_reference,
703
+ predecessor_event_id=predecessor_event_id,
704
+ limit=limit,
705
+ cursor=cursor,
706
+ )
707
+
708
+ # -- maintenance -----------------------------------------------------------
709
+
710
+ def cleanup(self, *, now: datetime | None = None) -> int:
711
+ """Remove expired inactive records; preserves active content."""
712
+ return _cleanup(self._uow, now=now)
713
+
714
+ def reload_active(self, *, now: datetime | None = None) -> CatalogReloadReport:
715
+ """Revalidate every active snapshot/Bundle pointer after startup."""
716
+ return _reload_active(self._uow, now=now)