continuity-plane 0.1.0a1__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.
- continuity_plane/__init__.py +21 -0
- continuity_plane/artifact_store.py +318 -0
- continuity_plane/assertion_provenance.py +418 -0
- continuity_plane/authorization_audit.py +807 -0
- continuity_plane/checkpoint.py +532 -0
- continuity_plane/claim_evidence_gate.py +419 -0
- continuity_plane/cli.py +281 -0
- continuity_plane/codegraph_verification.py +700 -0
- continuity_plane/collaboration_notifications.py +1262 -0
- continuity_plane/compiled_skill_packet.py +318 -0
- continuity_plane/database/migrations/001_postgres_state.up.sql +62 -0
- continuity_plane/decision_evidence_projection.py +1003 -0
- continuity_plane/durable_continuation.py +625 -0
- continuity_plane/durable_operation.py +651 -0
- continuity_plane/durable_state_migration.py +309 -0
- continuity_plane/effect_scope_gate.py +377 -0
- continuity_plane/execution_packet.py +586 -0
- continuity_plane/experiment_lifecycle.py +157 -0
- continuity_plane/external_state_provider.py +532 -0
- continuity_plane/human_governance.py +790 -0
- continuity_plane/idea_continuity.py +110 -0
- continuity_plane/idea_review.py +794 -0
- continuity_plane/obsidian_vault.py +490 -0
- continuity_plane/postgres_state_store.py +365 -0
- continuity_plane/project_graph_projection.py +728 -0
- continuity_plane/relationship_impact_projection.py +633 -0
- continuity_plane/retrieval_routing.py +769 -0
- continuity_plane/sanitizer.py +136 -0
- continuity_plane/schema_governance.py +238 -0
- continuity_plane/schemas/m4-01/spdx-license-ids-3.28.0.json +737 -0
- continuity_plane/shared_state_mcp.py +1092 -0
- continuity_plane/shared_state_migration.py +511 -0
- continuity_plane/shared_work_ledger.py +1068 -0
- continuity_plane/skill_catalog.py +360 -0
- continuity_plane/skill_compatibility.py +703 -0
- continuity_plane/skill_manifest_set.py +549 -0
- continuity_plane/skill_resolver.py +721 -0
- continuity_plane/source_registry.py +359 -0
- continuity_plane/sqlite_state_store.py +1645 -0
- continuity_plane/sqlite_work_ledger.py +456 -0
- continuity_plane/state_events.py +1311 -0
- continuity_plane/state_mcp.py +3576 -0
- continuity_plane/state_store.py +266 -0
- continuity_plane/templates/MASTER.en.md +31 -0
- continuity_plane/templates/MASTER.md +28 -0
- continuity_plane/templates/STATUS.en.md +19 -0
- continuity_plane/templates/STATUS.md +17 -0
- continuity_plane/templates/__init__.py +1 -0
- continuity_plane/templates/project.yaml +16 -0
- continuity_plane/typed_state.py +1523 -0
- continuity_plane/verification_profile.py +1042 -0
- continuity_plane-0.1.0a1.dist-info/METADATA +156 -0
- continuity_plane-0.1.0a1.dist-info/RECORD +59 -0
- continuity_plane-0.1.0a1.dist-info/WHEEL +5 -0
- continuity_plane-0.1.0a1.dist-info/entry_points.txt +2 -0
- continuity_plane-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- continuity_plane-0.1.0a1.dist-info/licenses/NOTICE +5 -0
- continuity_plane-0.1.0a1.dist-info/licenses/THIRD_PARTY_NOTICES.md +30 -0
- continuity_plane-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Provider-neutral context control-plane primitives."""
|
|
2
|
+
|
|
3
|
+
from .source_registry import (
|
|
4
|
+
ProvenanceError,
|
|
5
|
+
SourceRegistry,
|
|
6
|
+
build_provenance,
|
|
7
|
+
validate_provenance,
|
|
8
|
+
)
|
|
9
|
+
from .sanitizer import SanitizationError, SanitizationFinding, SanitizationResult, sanitize_text, validate_admission
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ProvenanceError",
|
|
13
|
+
"SourceRegistry",
|
|
14
|
+
"build_provenance",
|
|
15
|
+
"validate_provenance",
|
|
16
|
+
"SanitizationError",
|
|
17
|
+
"SanitizationFinding",
|
|
18
|
+
"SanitizationResult",
|
|
19
|
+
"sanitize_text",
|
|
20
|
+
"validate_admission",
|
|
21
|
+
]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Local content-addressed artifact storage for bounded evidence access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, BinaryIO
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ARTIFACT_REF_SCHEMA_VERSION = "context.artifact-ref/v1alpha1"
|
|
17
|
+
ARTIFACT_DIGEST_ALGORITHM = "sha-256"
|
|
18
|
+
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
19
|
+
_URI_RE = re.compile(r"^artifact://sha256/([0-9a-f]{64})$")
|
|
20
|
+
_REF_FIELDS = frozenset(
|
|
21
|
+
{"schema_version", "digest_algorithm", "digest", "size_bytes", "artifact_uri"}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArtifactStoreError(RuntimeError):
|
|
26
|
+
"""Base error for local artifact operations."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ArtifactInputError(ArtifactStoreError):
|
|
30
|
+
"""Raised when an artifact source cannot be read as bytes."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArtifactIntegrityError(ArtifactStoreError):
|
|
34
|
+
"""Raised when a durable object is missing, changed, or malformed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArtifactRangeError(ArtifactStoreError):
|
|
38
|
+
"""Raised when a requested byte range is outside the artifact or bound."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ArtifactRef:
|
|
43
|
+
"""Stable identity and size of one raw byte artifact."""
|
|
44
|
+
|
|
45
|
+
digest: str
|
|
46
|
+
size_bytes: int
|
|
47
|
+
schema_version: str = ARTIFACT_REF_SCHEMA_VERSION
|
|
48
|
+
digest_algorithm: str = ARTIFACT_DIGEST_ALGORITHM
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if self.schema_version != ARTIFACT_REF_SCHEMA_VERSION:
|
|
52
|
+
raise ValueError("unsupported artifact ref schema_version")
|
|
53
|
+
if self.digest_algorithm != ARTIFACT_DIGEST_ALGORITHM:
|
|
54
|
+
raise ValueError("unsupported artifact digest_algorithm")
|
|
55
|
+
if not isinstance(self.digest, str) or not _DIGEST_RE.fullmatch(self.digest):
|
|
56
|
+
raise ValueError("artifact digest must be lowercase SHA-256")
|
|
57
|
+
if type(self.size_bytes) is not int or self.size_bytes < 0:
|
|
58
|
+
raise ValueError("artifact size_bytes must be a non-negative integer")
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def uri(self) -> str:
|
|
62
|
+
return f"artifact://sha256/{self.digest}"
|
|
63
|
+
|
|
64
|
+
def to_document(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"schema_version": self.schema_version,
|
|
67
|
+
"digest_algorithm": self.digest_algorithm,
|
|
68
|
+
"digest": self.digest,
|
|
69
|
+
"size_bytes": self.size_bytes,
|
|
70
|
+
"artifact_uri": self.uri,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_document(cls, document: dict[str, Any]) -> "ArtifactRef":
|
|
75
|
+
if not isinstance(document, dict) or frozenset(document) != _REF_FIELDS:
|
|
76
|
+
raise ValueError("artifact ref document fields are invalid")
|
|
77
|
+
ref = cls(
|
|
78
|
+
digest=document["digest"],
|
|
79
|
+
size_bytes=document["size_bytes"],
|
|
80
|
+
schema_version=document["schema_version"],
|
|
81
|
+
digest_algorithm=document["digest_algorithm"],
|
|
82
|
+
)
|
|
83
|
+
if document["artifact_uri"] != ref.uri:
|
|
84
|
+
raise ValueError("artifact ref URI does not match digest")
|
|
85
|
+
return ref
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_uri(cls, uri: str, size_bytes: int) -> "ArtifactRef":
|
|
89
|
+
if not isinstance(uri, str):
|
|
90
|
+
raise ValueError("artifact URI must be a string")
|
|
91
|
+
match = _URI_RE.fullmatch(uri)
|
|
92
|
+
if match is None:
|
|
93
|
+
raise ValueError("artifact URI is invalid")
|
|
94
|
+
return cls(digest=match.group(1), size_bytes=size_bytes)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _canonical_json(value: dict[str, Any]) -> str:
|
|
98
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_regular(path: Path) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fsync_directory(path: Path) -> None:
|
|
109
|
+
"""Persist directory entry updates where the host supports directory fsync."""
|
|
110
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
111
|
+
try:
|
|
112
|
+
descriptor = os.open(path, flags)
|
|
113
|
+
except OSError:
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
os.fsync(descriptor)
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
finally:
|
|
120
|
+
os.close(descriptor)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LocalArtifactStore:
|
|
124
|
+
"""Zero-service filesystem CAS with full-object integrity verification."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
root: str | Path,
|
|
129
|
+
*,
|
|
130
|
+
chunk_size: int = 1024 * 1024,
|
|
131
|
+
max_range_bytes: int = 1024 * 1024,
|
|
132
|
+
):
|
|
133
|
+
if type(chunk_size) is not int or chunk_size <= 0:
|
|
134
|
+
raise ValueError("chunk_size must be a positive integer")
|
|
135
|
+
if type(max_range_bytes) is not int or max_range_bytes <= 0:
|
|
136
|
+
raise ValueError("max_range_bytes must be a positive integer")
|
|
137
|
+
self.root = Path(root)
|
|
138
|
+
self.chunk_size = chunk_size
|
|
139
|
+
self.max_range_bytes = max_range_bytes
|
|
140
|
+
|
|
141
|
+
def initialize(self) -> None:
|
|
142
|
+
if self.root.is_symlink():
|
|
143
|
+
raise ArtifactIntegrityError("artifact root must not be a symlink")
|
|
144
|
+
if self.root.exists() and not self.root.is_dir():
|
|
145
|
+
raise ArtifactIntegrityError("artifact root is not a directory")
|
|
146
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
147
|
+
objects = self.root / "objects"
|
|
148
|
+
algorithm = objects / "sha256"
|
|
149
|
+
temporary = self.root / "tmp"
|
|
150
|
+
for directory in (self.root, objects, algorithm, temporary):
|
|
151
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
152
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
153
|
+
raise ArtifactIntegrityError("artifact store directory is invalid")
|
|
154
|
+
try:
|
|
155
|
+
directory.chmod(0o700)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
def _ensure_initialized(self) -> None:
|
|
160
|
+
expected = (self.root, self.root / "objects", self.root / "objects" / "sha256", self.root / "tmp")
|
|
161
|
+
if any(not path.is_dir() or path.is_symlink() for path in expected):
|
|
162
|
+
raise ArtifactStoreError("artifact store is not initialized")
|
|
163
|
+
|
|
164
|
+
def object_path(self, ref: ArtifactRef) -> Path:
|
|
165
|
+
return self._object_path(ref, create_shard=True)
|
|
166
|
+
|
|
167
|
+
def _object_path(self, ref: ArtifactRef, *, create_shard: bool) -> Path:
|
|
168
|
+
if not isinstance(ref, ArtifactRef):
|
|
169
|
+
raise TypeError("object_path requires ArtifactRef")
|
|
170
|
+
self._ensure_initialized()
|
|
171
|
+
shard = self.root / "objects" / "sha256" / ref.digest[:2]
|
|
172
|
+
if shard.is_symlink() or (shard.exists() and not shard.is_dir()):
|
|
173
|
+
raise ArtifactIntegrityError("artifact shard directory is invalid")
|
|
174
|
+
if create_shard:
|
|
175
|
+
shard.mkdir(mode=0o700, exist_ok=True)
|
|
176
|
+
return shard / ref.digest[2:]
|
|
177
|
+
|
|
178
|
+
def put_bytes(self, payload: bytes | bytearray | memoryview) -> ArtifactRef:
|
|
179
|
+
if not isinstance(payload, (bytes, bytearray, memoryview)):
|
|
180
|
+
raise ArtifactInputError("artifact payload must be bytes")
|
|
181
|
+
return self.put_stream(BinaryIOBytes(bytes(payload)))
|
|
182
|
+
|
|
183
|
+
def put_stream(self, source: BinaryIO) -> ArtifactRef:
|
|
184
|
+
self._ensure_initialized()
|
|
185
|
+
if not hasattr(source, "read"):
|
|
186
|
+
raise ArtifactInputError("artifact source must provide read()")
|
|
187
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
188
|
+
prefix=".artifact-", suffix=".tmp", dir=self.root / "tmp"
|
|
189
|
+
)
|
|
190
|
+
temporary = Path(temporary_name)
|
|
191
|
+
digest = hashlib.sha256()
|
|
192
|
+
size = 0
|
|
193
|
+
try:
|
|
194
|
+
with os.fdopen(descriptor, "wb") as destination:
|
|
195
|
+
while True:
|
|
196
|
+
try:
|
|
197
|
+
chunk = source.read(self.chunk_size)
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise ArtifactInputError("artifact source read failed") from exc
|
|
200
|
+
if not isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
201
|
+
raise ArtifactInputError("artifact source must return bytes")
|
|
202
|
+
chunk = bytes(chunk)
|
|
203
|
+
if not chunk:
|
|
204
|
+
break
|
|
205
|
+
digest.update(chunk)
|
|
206
|
+
size += len(chunk)
|
|
207
|
+
destination.write(chunk)
|
|
208
|
+
destination.flush()
|
|
209
|
+
os.fsync(destination.fileno())
|
|
210
|
+
|
|
211
|
+
ref = ArtifactRef(digest=digest.hexdigest(), size_bytes=size)
|
|
212
|
+
target = self.object_path(ref)
|
|
213
|
+
if target.exists() or target.is_symlink():
|
|
214
|
+
self._verify_path(target, ref)
|
|
215
|
+
return ref
|
|
216
|
+
try:
|
|
217
|
+
os.link(temporary, target)
|
|
218
|
+
temporary.unlink(missing_ok=True)
|
|
219
|
+
except FileExistsError:
|
|
220
|
+
self._verify_path(target, ref)
|
|
221
|
+
temporary.unlink(missing_ok=True)
|
|
222
|
+
except (NotImplementedError, OSError):
|
|
223
|
+
if target.exists() or target.is_symlink():
|
|
224
|
+
self._verify_path(target, ref)
|
|
225
|
+
temporary.unlink(missing_ok=True)
|
|
226
|
+
else:
|
|
227
|
+
os.replace(temporary, target)
|
|
228
|
+
try:
|
|
229
|
+
target.chmod(0o600)
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
_fsync_directory(target.parent)
|
|
233
|
+
return ref
|
|
234
|
+
except ArtifactStoreError:
|
|
235
|
+
temporary.unlink(missing_ok=True)
|
|
236
|
+
raise
|
|
237
|
+
except (OSError, ValueError) as exc:
|
|
238
|
+
temporary.unlink(missing_ok=True)
|
|
239
|
+
raise ArtifactStoreError("artifact publication failed") from exc
|
|
240
|
+
finally:
|
|
241
|
+
temporary.unlink(missing_ok=True)
|
|
242
|
+
|
|
243
|
+
def _verify_path(self, path: Path, ref: ArtifactRef) -> None:
|
|
244
|
+
if not path.exists() and not path.is_symlink():
|
|
245
|
+
raise ArtifactIntegrityError("artifact object is missing")
|
|
246
|
+
if path.is_symlink() or not _is_regular(path):
|
|
247
|
+
raise ArtifactIntegrityError("artifact object is not a regular file")
|
|
248
|
+
digest = hashlib.sha256()
|
|
249
|
+
size = 0
|
|
250
|
+
try:
|
|
251
|
+
with path.open("rb") as source:
|
|
252
|
+
while chunk := source.read(self.chunk_size):
|
|
253
|
+
digest.update(chunk)
|
|
254
|
+
size += len(chunk)
|
|
255
|
+
except OSError as exc:
|
|
256
|
+
raise ArtifactIntegrityError("artifact object cannot be read") from exc
|
|
257
|
+
if size != ref.size_bytes or digest.hexdigest() != ref.digest:
|
|
258
|
+
raise ArtifactIntegrityError("artifact object checksum or size mismatch")
|
|
259
|
+
|
|
260
|
+
def verify(self, ref: ArtifactRef) -> ArtifactRef:
|
|
261
|
+
self._verify_path(self._object_path(ref, create_shard=False), ref)
|
|
262
|
+
return ref
|
|
263
|
+
|
|
264
|
+
def read(self, ref: ArtifactRef) -> bytes:
|
|
265
|
+
return self._read_verified(ref, offset=0, length=ref.size_bytes)
|
|
266
|
+
|
|
267
|
+
def read_range(self, ref: ArtifactRef, *, offset: int, length: int) -> bytes:
|
|
268
|
+
if type(offset) is not int or offset < 0:
|
|
269
|
+
raise ArtifactRangeError("artifact range offset must be non-negative")
|
|
270
|
+
if type(length) is not int or length < 0:
|
|
271
|
+
raise ArtifactRangeError("artifact range length must be non-negative")
|
|
272
|
+
if length > self.max_range_bytes:
|
|
273
|
+
raise ArtifactRangeError("artifact range exceeds configured bound")
|
|
274
|
+
if offset > ref.size_bytes or offset + length > ref.size_bytes:
|
|
275
|
+
raise ArtifactRangeError("artifact range exceeds artifact bounds")
|
|
276
|
+
return self._read_verified(ref, offset=offset, length=length)
|
|
277
|
+
|
|
278
|
+
def _read_verified(self, ref: ArtifactRef, *, offset: int, length: int) -> bytes:
|
|
279
|
+
path = self._object_path(ref, create_shard=False)
|
|
280
|
+
if path.is_symlink() or not path.exists():
|
|
281
|
+
raise ArtifactIntegrityError("artifact object is missing or is a symlink")
|
|
282
|
+
if not _is_regular(path):
|
|
283
|
+
raise ArtifactIntegrityError("artifact object is not a regular file")
|
|
284
|
+
digest = hashlib.sha256()
|
|
285
|
+
size = 0
|
|
286
|
+
output = bytearray()
|
|
287
|
+
end = offset + length
|
|
288
|
+
try:
|
|
289
|
+
with path.open("rb") as source:
|
|
290
|
+
while chunk := source.read(self.chunk_size):
|
|
291
|
+
chunk_start = size
|
|
292
|
+
size += len(chunk)
|
|
293
|
+
digest.update(chunk)
|
|
294
|
+
chunk_end = size
|
|
295
|
+
overlap_start = max(offset, chunk_start)
|
|
296
|
+
overlap_end = min(end, chunk_end)
|
|
297
|
+
if overlap_start < overlap_end:
|
|
298
|
+
output.extend(chunk[overlap_start - chunk_start : overlap_end - chunk_start])
|
|
299
|
+
except OSError as exc:
|
|
300
|
+
raise ArtifactIntegrityError("artifact object cannot be read") from exc
|
|
301
|
+
if size != ref.size_bytes or digest.hexdigest() != ref.digest:
|
|
302
|
+
raise ArtifactIntegrityError("artifact object checksum or size mismatch")
|
|
303
|
+
return bytes(output)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class BinaryIOBytes:
|
|
307
|
+
"""Small file-like adapter that keeps put_bytes on the streaming path."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, payload: bytes):
|
|
310
|
+
self._payload = payload
|
|
311
|
+
self._offset = 0
|
|
312
|
+
|
|
313
|
+
def read(self, size: int = -1) -> bytes:
|
|
314
|
+
if size < 0:
|
|
315
|
+
size = len(self._payload) - self._offset
|
|
316
|
+
start = self._offset
|
|
317
|
+
self._offset = min(len(self._payload), self._offset + size)
|
|
318
|
+
return self._payload[start : self._offset]
|