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,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"]
|