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.
- nl2data_semantic_catalog_postgres/__init__.py +18 -0
- nl2data_semantic_catalog_postgres/client.py +139 -0
- nl2data_semantic_catalog_postgres/config.py +136 -0
- nl2data_semantic_catalog_postgres/envelope.py +327 -0
- nl2data_semantic_catalog_postgres/errors.py +188 -0
- nl2data_semantic_catalog_postgres/fake_postgres/__init__.py +53 -0
- nl2data_semantic_catalog_postgres/fake_postgres/driver.py +209 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_audit.py +243 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_drafts.py +84 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_maintenance.py +263 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_publications.py +249 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_schema.py +23 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_snapshots.py +197 -0
- nl2data_semantic_catalog_postgres/fake_postgres/handlers_versions.py +330 -0
- nl2data_semantic_catalog_postgres/fake_postgres/keys.py +99 -0
- nl2data_semantic_catalog_postgres/fake_postgres/pool.py +152 -0
- nl2data_semantic_catalog_postgres/fake_postgres/registry.py +144 -0
- nl2data_semantic_catalog_postgres/maintenance.py +235 -0
- nl2data_semantic_catalog_postgres/py.typed +0 -0
- nl2data_semantic_catalog_postgres/repositories/__init__.py +26 -0
- nl2data_semantic_catalog_postgres/repositories/activation.py +849 -0
- nl2data_semantic_catalog_postgres/repositories/audit_evidence.py +221 -0
- nl2data_semantic_catalog_postgres/repositories/drafts.py +156 -0
- nl2data_semantic_catalog_postgres/repositories/evidence.py +415 -0
- nl2data_semantic_catalog_postgres/repositories/publications.py +486 -0
- nl2data_semantic_catalog_postgres/repositories/snapshots.py +372 -0
- nl2data_semantic_catalog_postgres/schema.py +337 -0
- nl2data_semantic_catalog_postgres/sql.py +520 -0
- nl2data_semantic_catalog_postgres/store.py +716 -0
- nl2data_semantic_catalog_postgres/unit_of_work.py +589 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/METADATA +76 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/RECORD +34 -0
- nl2data_semantic_catalog_postgres-0.1.0.dist-info/WHEEL +5 -0
- 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)
|