rememberstack 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 (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,279 @@
1
+ """S3-compatible MinIO object storage for the self-host profile."""
2
+
3
+ from typing import cast
4
+ from typing import NotRequired
5
+ from typing import Protocol
6
+ from typing import TypedDict
7
+
8
+ import boto3
9
+ from botocore.client import Config
10
+ from botocore.exceptions import ClientError
11
+ from pydantic import SecretStr
12
+ from pydantic_settings import BaseSettings
13
+ from pydantic_settings import SettingsConfigDict
14
+
15
+ from rememberstack.model import ObjectAlreadyExistsError
16
+ from rememberstack.model import ObjectKey
17
+ from rememberstack.model import ObjectKeyEscapesRootError
18
+
19
+
20
+ class MinIOSettings(BaseSettings):
21
+ """Connection settings for the self-host S3-compatible object store."""
22
+
23
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_MINIO_", extra="ignore")
24
+
25
+ endpoint_url: str
26
+ access_key: SecretStr
27
+ secret_key: SecretStr
28
+ region: str = "us-east-1"
29
+
30
+
31
+ class _StreamingBody(Protocol):
32
+ """The two response-body operations used by this adapter."""
33
+
34
+ def read(self) -> bytes:
35
+ """Read the complete response body."""
36
+ ...
37
+
38
+ def close(self) -> None:
39
+ """Release the underlying HTTP connection."""
40
+ ...
41
+
42
+
43
+ class _GetObjectOutput(TypedDict):
44
+ """The fields consumed from an S3 GetObject response."""
45
+
46
+ Body: _StreamingBody
47
+
48
+
49
+ class _HeadObjectOutput(TypedDict):
50
+ """The fields consumed from an S3 HeadObject response."""
51
+
52
+ Metadata: NotRequired[dict[str, str]]
53
+
54
+
55
+ class _ListedObject(TypedDict):
56
+ """One object identity returned by ListObjectsV2."""
57
+
58
+ Key: str
59
+
60
+
61
+ class _ListObjectsOutput(TypedDict):
62
+ """The fields consumed from one ListObjectsV2 response page."""
63
+
64
+ Contents: NotRequired[list[_ListedObject]]
65
+ IsTruncated: NotRequired[bool]
66
+ NextContinuationToken: NotRequired[str]
67
+
68
+
69
+ class _S3Client(Protocol):
70
+ """The narrow boto3 client subset the MinIO adapter owns."""
71
+
72
+ def head_bucket(self, *, Bucket: str) -> object:
73
+ """Check that one bucket is reachable."""
74
+ ...
75
+
76
+ def create_bucket(self, *, Bucket: str) -> object:
77
+ """Create one bucket."""
78
+ ...
79
+
80
+ def get_object(self, *, Bucket: str, Key: str) -> _GetObjectOutput:
81
+ """Read one object."""
82
+ ...
83
+
84
+ def put_object(
85
+ self,
86
+ *,
87
+ Bucket: str,
88
+ Key: str,
89
+ Body: bytes,
90
+ IfNoneMatch: str,
91
+ Metadata: dict[str, str],
92
+ ) -> object:
93
+ """Conditionally create one immutable object."""
94
+ ...
95
+
96
+ def head_object(self, *, Bucket: str, Key: str) -> _HeadObjectOutput:
97
+ """Read one object's metadata."""
98
+ ...
99
+
100
+ def delete_object(self, *, Bucket: str, Key: str) -> object:
101
+ """Delete one object idempotently."""
102
+ ...
103
+
104
+ def list_objects_v2(
105
+ self, *, Bucket: str, Prefix: str, ContinuationToken: str = ""
106
+ ) -> _ListObjectsOutput:
107
+ """List one page beneath a key prefix."""
108
+ ...
109
+
110
+
111
+ class MinIOObjectStore:
112
+ """Immutable objects in one explicitly selected MinIO bucket."""
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ bucket: str,
118
+ settings: MinIOSettings | None = None,
119
+ client: _S3Client | None = None,
120
+ ) -> None:
121
+ """Bind one bucket to either injected test client or configured MinIO."""
122
+ if not bucket:
123
+ raise ValueError("a MinIO object store requires a non-empty bucket")
124
+ if client is None and settings is None:
125
+ raise ValueError("MinIO settings are required when no client is injected")
126
+ self._bucket = bucket
127
+ self._client = client or _client(settings=cast("MinIOSettings", settings))
128
+
129
+ def ensure_bucket(self) -> None:
130
+ """Provision the configured bucket if it does not exist."""
131
+ try:
132
+ self._client.head_bucket(Bucket=self._bucket)
133
+ return
134
+ except ClientError as error:
135
+ if _error_code(error=error) not in {"404", "NoSuchBucket", "NotFound"}:
136
+ raise
137
+ try:
138
+ self._client.create_bucket(Bucket=self._bucket)
139
+ except ClientError as error:
140
+ if _error_code(error=error) not in {
141
+ "BucketAlreadyExists",
142
+ "BucketAlreadyOwnedByYou",
143
+ }:
144
+ raise
145
+
146
+ def read_bytes(self, *, key: ObjectKey) -> bytes:
147
+ """Read all bytes stored at one validated object key."""
148
+ response = self._client.get_object(
149
+ Bucket=self._bucket, Key=_validated_key(key=key)
150
+ )
151
+ body = response["Body"]
152
+ try:
153
+ return body.read()
154
+ finally:
155
+ body.close()
156
+
157
+ def write_bytes(
158
+ self, *, key: ObjectKey, content: bytes, storage_class: str | None = None
159
+ ) -> None:
160
+ """Create immutable bytes atomically, refusing an occupied key."""
161
+ metadata = {} if storage_class is None else {"storage-class": storage_class}
162
+ try:
163
+ self._client.put_object(
164
+ Bucket=self._bucket,
165
+ Key=_validated_key(key=key),
166
+ Body=content,
167
+ IfNoneMatch="*",
168
+ Metadata=metadata,
169
+ )
170
+ except ClientError as error:
171
+ if _error_code(error=error) not in {
172
+ "409",
173
+ "412",
174
+ "ConditionalRequestConflict",
175
+ "PreconditionFailed",
176
+ }:
177
+ raise
178
+ raise ObjectAlreadyExistsError(
179
+ f"object key {key.root!r} is already occupied; objects are immutable"
180
+ ) from error
181
+
182
+ def storage_class_of(self, *, key: ObjectKey) -> str | None:
183
+ """Return the routing class recorded in object metadata, when present."""
184
+ response = self._client.head_object(
185
+ Bucket=self._bucket, Key=_validated_key(key=key)
186
+ )
187
+ return response.get("Metadata", {}).get("storage-class")
188
+
189
+ def purge_objects(
190
+ self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
191
+ ) -> None:
192
+ """Idempotently delete exact keys and prefix-boundary descendants."""
193
+ targets = {_validated_key(key=key) for key in keys}
194
+ for prefix in prefixes:
195
+ targets.update(self._keys_under(prefix=_validated_key(key=prefix)))
196
+ for target in sorted(targets):
197
+ self._client.delete_object(Bucket=self._bucket, Key=target)
198
+
199
+ def verify_objects_purged(
200
+ self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
201
+ ) -> None:
202
+ """Fail when any exact key or prefix-boundary descendant remains."""
203
+ remaining: list[str] = []
204
+ for key in keys:
205
+ normalized = _validated_key(key=key)
206
+ if self._exists(normalized=normalized):
207
+ remaining.append(normalized)
208
+ for prefix in prefixes:
209
+ remaining.extend(self._keys_under(prefix=_validated_key(key=prefix)))
210
+ if remaining:
211
+ raise RuntimeError(
212
+ f"object purge verification found: {sorted(remaining)!r}"
213
+ )
214
+
215
+ def _exists(self, *, normalized: str) -> bool:
216
+ """Return whether one exact object exists, propagating non-absence errors."""
217
+ try:
218
+ self._client.head_object(Bucket=self._bucket, Key=normalized)
219
+ return True
220
+ except ClientError as error:
221
+ if _error_code(error=error) in {"404", "NoSuchKey", "NotFound"}:
222
+ return False
223
+ raise
224
+
225
+ def _keys_under(self, *, prefix: str) -> tuple[str, ...]:
226
+ """Enumerate exact and descendant keys without matching sibling prefixes."""
227
+ boundary = prefix.rstrip("/")
228
+ result: list[str] = []
229
+ continuation = ""
230
+ while True:
231
+ page = (
232
+ self._client.list_objects_v2(
233
+ Bucket=self._bucket, Prefix=boundary, ContinuationToken=continuation
234
+ )
235
+ if continuation
236
+ else self._client.list_objects_v2(Bucket=self._bucket, Prefix=boundary)
237
+ )
238
+ result.extend(
239
+ item["Key"]
240
+ for item in page.get("Contents", [])
241
+ if item["Key"] == boundary or item["Key"].startswith(f"{boundary}/")
242
+ )
243
+ if not page.get("IsTruncated", False):
244
+ return tuple(result)
245
+ continuation = page.get("NextContinuationToken", "")
246
+ if not continuation:
247
+ raise RuntimeError(
248
+ "MinIO returned a truncated object page without a continuation token"
249
+ )
250
+
251
+
252
+ def _client(*, settings: MinIOSettings) -> _S3Client:
253
+ """Construct the path-style S3 client supported by local MinIO."""
254
+ return cast(
255
+ "_S3Client",
256
+ boto3.client(
257
+ "s3",
258
+ endpoint_url=settings.endpoint_url,
259
+ aws_access_key_id=settings.access_key.get_secret_value(),
260
+ aws_secret_access_key=settings.secret_key.get_secret_value(),
261
+ region_name=settings.region,
262
+ config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
263
+ ),
264
+ )
265
+
266
+
267
+ def _validated_key(*, key: ObjectKey) -> str:
268
+ """Reject absolute and parent-traversal keys for local/S3 parity."""
269
+ parts = key.root.split("/")
270
+ if key.root.startswith("/") or ".." in parts:
271
+ raise ObjectKeyEscapesRootError(
272
+ f"object key {key.root!r} escapes the store root"
273
+ )
274
+ return key.root
275
+
276
+
277
+ def _error_code(*, error: ClientError) -> str:
278
+ """Return the provider's stable error code without trimming the exception."""
279
+ return str(error.response.get("Error", {}).get("Code", ""))
@@ -0,0 +1,249 @@
1
+ """Local-directory mount publisher: the four D51 read-only views (e0 §5).
2
+
3
+ Agents read the memory on their filesystem: the **corpus filesystem** they
4
+ browse first, the **artifacts** they drill into from a stub, the **raw**
5
+ originals — deliberately *off the navigation path* — and the Plane-K
6
+ checkout. Every view is read-only; writes always go through the pipeline
7
+ and Postgres stays the authority.
8
+
9
+ The self-host tier materializes what the cloud tier gets from a bucket
10
+ mount, and implements D51's three guardrails here rather than assuming
11
+ them of infrastructure:
12
+
13
+ 1. **Raw is off-path.** Nothing in the corpus tree links into it; reaching
14
+ an original means following an explicit `raw_uri` from a stub or from
15
+ `document.md` frontmatter — a deliberate act, never a browse default.
16
+ 2. **Data-access audit logging is mandatory.** The audit property came
17
+ from logging, not from keeping raw unmounted (a mount read is still a
18
+ read), so originals are readable only through `AuditedRawReader`, which
19
+ refuses an unattributed request rather than logging a blank.
20
+ 3. **Storage class routes by mime.** Media a multimodal harness actually
21
+ reads stays hot; text/office originals kept only for audit and
22
+ re-conversion go cold — this kills the grep-the-archive cost bug at the
23
+ source. Per-deployment config, like the D38 converter router.
24
+
25
+ The corpus view always serves the snapshot the registry marks latest, and
26
+ swaps whole trees atomically, so a browsing agent never walks a half-built
27
+ tree.
28
+ """
29
+
30
+ from datetime import datetime
31
+ from datetime import UTC
32
+ import json
33
+ import os
34
+ from pathlib import Path
35
+ import shutil
36
+ from typing import Final
37
+ from typing import Protocol
38
+ from uuid import UUID
39
+ from uuid import uuid4
40
+
41
+ from rememberstack.core import storage_class_for
42
+ from rememberstack.model import ObjectKey
43
+ from rememberstack.model import PublishedMounts
44
+ from rememberstack.ports.object_store import ObjectStorePort
45
+ from rememberstack.spine.projection import ProjectionCatalog
46
+
47
+ EMPTY_CORPUS_NOTE: Final = (
48
+ "# Corpus filesystem\n\n"
49
+ "> No P3 snapshot has been published yet. Run the corpus-filesystem\n"
50
+ "> builder; until then use the query API, which needs no projection.\n"
51
+ )
52
+
53
+
54
+ class RawAccessDenied(Exception):
55
+ """An unattributed raw read was refused (originals are audited)."""
56
+
57
+
58
+ class MountAdmission(Protocol):
59
+ """The composition-owned barrier checked before publishing serving paths."""
60
+
61
+ def assert_available(self, *, deployment_id: UUID) -> None:
62
+ """Raise while D74 keeps the deployment fail-closed."""
63
+ ...
64
+
65
+
66
+ class LocalMountPublisher:
67
+ """Publish the P3, artifact, raw, and Plane-K views as local trees."""
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ root: Path,
73
+ catalog: ProjectionCatalog | None = None,
74
+ corpusfs_store: ObjectStorePort | None = None,
75
+ artifacts_root: Path | None = None,
76
+ raw_root: Path | None = None,
77
+ knowledge_root: Path | None = None,
78
+ admission: MountAdmission,
79
+ ) -> None:
80
+ """Bind the publisher to its mount root, the P3 source, and the stores.
81
+
82
+ The artifact/raw/knowledge views point at the REAL store roots when
83
+ given (Codex review: an empty directory is not a usable mount);
84
+ without them the publisher provisions empty view roots — the
85
+ Phase-0 shape, still useful before any store exists.
86
+ """
87
+ self._root = root.resolve()
88
+ self._catalog = catalog
89
+ self._corpusfs_store = corpusfs_store
90
+ self._artifacts_root = artifacts_root
91
+ self._raw_root = raw_root
92
+ self._knowledge_root = knowledge_root
93
+ self._admission = admission
94
+
95
+ def publish(self, *, deployment_id: UUID) -> PublishedMounts:
96
+ """Publish and return the exact four read-only deployment views."""
97
+ self._admission.assert_available(deployment_id=deployment_id)
98
+ base = self._root / str(deployment_id)
99
+ base.mkdir(parents=True, exist_ok=True)
100
+ corpus = base / "p3"
101
+ self._materialize_corpus(deployment_id=deployment_id, link=corpus)
102
+ return PublishedMounts(
103
+ deployment_id=deployment_id,
104
+ p3=str(corpus),
105
+ artifacts=str(
106
+ self._view(base=base, name="artifacts", real=self._artifacts_root)
107
+ ),
108
+ # off the navigation path (D51): the tree never promotes raw —
109
+ # stubs carry an explicit pointer, and reads go through
110
+ # AuditedRawReader, which is the only audited path on this tier
111
+ raw=str(self._view(base=base, name="raw", real=self._raw_root)),
112
+ knowledge=str(
113
+ self._view(base=base, name="knowledge", real=self._knowledge_root)
114
+ ),
115
+ read_only=True,
116
+ )
117
+
118
+ def _view(self, *, base: Path, name: str, real: Path | None) -> Path:
119
+ """One view locator: the real store root when known, else an empty dir."""
120
+ if real is not None:
121
+ real.mkdir(parents=True, exist_ok=True)
122
+ return real.resolve()
123
+ placeholder = base / name
124
+ placeholder.mkdir(parents=True, exist_ok=True)
125
+ return placeholder
126
+
127
+ def _materialize_corpus(self, *, deployment_id: UUID, link: Path) -> None:
128
+ """Serve the LATEST PUBLISHED snapshot behind an ATOMIC pointer.
129
+
130
+ The mount path is a symlink to a versioned directory, and the swap
131
+ replaces that symlink with `os.replace` — atomic on POSIX. The
132
+ previous "rmtree then rename" left a window where the mount path
133
+ did not exist at all, so a reader could hit ENOENT mid-swap and two
134
+ publishers could delete each other's staging (Codex review).
135
+ """
136
+ if self._catalog is None or self._corpusfs_store is None:
137
+ link.mkdir(parents=True, exist_ok=True)
138
+ return
139
+ latest = self._catalog.latest_snapshot(
140
+ deployment_id=deployment_id, plane="P3_corpusfs"
141
+ )
142
+ if latest is None:
143
+ empty = link.parent / "p3-empty"
144
+ empty.mkdir(parents=True, exist_ok=True)
145
+ (empty / "llms.txt").write_text(EMPTY_CORPUS_NOTE, encoding="utf-8")
146
+ _point(link=link, target=empty)
147
+ return
148
+ version = str(latest["version"])
149
+ served = link.parent / f"p3-{version}"
150
+ if not (served / ".snapshot-version").exists():
151
+ prefix = str(latest["gcs_uri"])
152
+ manifest = json.loads(
153
+ self._corpusfs_store.read_bytes(
154
+ key=ObjectKey(f"{prefix}/MANIFEST.json")
155
+ )
156
+ )
157
+ # a unique staging dir per publisher: concurrent publishes of
158
+ # the same version never delete each other's work
159
+ staging = link.parent / f".staging-{version}-{uuid4().hex[:8]}"
160
+ for relative in manifest["files"]:
161
+ destination = staging / relative
162
+ destination.parent.mkdir(parents=True, exist_ok=True)
163
+ destination.write_bytes(
164
+ self._corpusfs_store.read_bytes(
165
+ key=ObjectKey(f"{prefix}/{relative}")
166
+ )
167
+ )
168
+ (staging / ".snapshot-version").write_text(version, encoding="utf-8")
169
+ try:
170
+ staging.rename(served) # atomic: the version dir appears whole
171
+ except OSError: # another publisher won the race — theirs is fine
172
+ shutil.rmtree(staging, ignore_errors=True)
173
+ _point(link=link, target=served)
174
+
175
+
176
+ class AuditedRawReader:
177
+ """The ONLY way to read an original — because reads must be logged.
178
+
179
+ D51's audit property comes from logging, not from keeping raw
180
+ unmounted: a mount read is still a read. So raw access is offered
181
+ exclusively through this reader, which records the accessor and the
182
+ stated purpose before returning bytes and refuses an unattributed
183
+ request outright rather than logging a blank.
184
+ """
185
+
186
+ def __init__(self, *, raw_store: ObjectStorePort, audit_log: Path) -> None:
187
+ """Bind the reader to the raw store and its append-only audit log."""
188
+ self._raw_store = raw_store
189
+ self._audit_log = audit_log
190
+
191
+ def read(
192
+ self, *, deployment_id: UUID, raw_uri: str, accessor: str, purpose: str
193
+ ) -> bytes:
194
+ """Read one original, recording who read it and why."""
195
+ if not accessor.strip() or not purpose.strip():
196
+ raise RawAccessDenied(
197
+ "raw access requires an accessor and a stated purpose:"
198
+ " originals are audited, never anonymously readable"
199
+ )
200
+ content = self._raw_store.read_bytes(key=ObjectKey(raw_uri))
201
+ self._append(
202
+ {
203
+ "at": datetime.now(tz=UTC).isoformat(),
204
+ "deployment_id": str(deployment_id),
205
+ "raw_uri": raw_uri,
206
+ "accessor": accessor,
207
+ "purpose": purpose,
208
+ "bytes": len(content),
209
+ }
210
+ )
211
+ return content
212
+
213
+ def entries(self) -> tuple[dict[str, object], ...]:
214
+ """The audit trail, oldest first (the operator's read surface)."""
215
+ if not self._audit_log.exists():
216
+ return ()
217
+ return tuple(
218
+ json.loads(line)
219
+ for line in self._audit_log.read_text(encoding="utf-8").splitlines()
220
+ if line.strip()
221
+ )
222
+
223
+ def _append(self, entry: dict[str, object]) -> None:
224
+ """Append one audit record (the log is append-only by construction)."""
225
+ self._audit_log.parent.mkdir(parents=True, exist_ok=True)
226
+ with self._audit_log.open("a", encoding="utf-8") as handle:
227
+ handle.write(json.dumps(entry) + "\n")
228
+
229
+
230
+ def _point(*, link: Path, target: Path) -> None:
231
+ """Atomically point the mount path at a versioned directory.
232
+
233
+ A symlink swapped with `os.replace` is atomic on POSIX: a reader either
234
+ sees the old snapshot or the new one, never a missing path.
235
+ """
236
+ staging_link = link.with_name(f".{link.name}-{uuid4().hex[:8]}")
237
+ staging_link.symlink_to(target, target_is_directory=True)
238
+ if link.exists() and not link.is_symlink():
239
+ shutil.rmtree(link) # a legacy real directory: replaced once
240
+ os.replace(staging_link, link)
241
+
242
+
243
+ __all__ = (
244
+ "AuditedRawReader",
245
+ "EMPTY_CORPUS_NOTE",
246
+ "LocalMountPublisher",
247
+ "RawAccessDenied",
248
+ "storage_class_for", # re-exported: the adapter applies this policy
249
+ )
@@ -0,0 +1,130 @@
1
+ """Local-filesystem object store adapter: immutable bytes under one root (D61/D62)."""
2
+
3
+ from pathlib import Path
4
+
5
+ from rememberstack.model import ObjectAlreadyExistsError
6
+ from rememberstack.model import ObjectKey
7
+ from rememberstack.model import ObjectKeyEscapesRootError
8
+
9
+
10
+ class LocalFSObjectStore:
11
+ """The self-host object store: one directory tree of immutable objects."""
12
+
13
+ def __init__(self, *, root: Path) -> None:
14
+ """Bind the store to its root directory, creating it if absent."""
15
+ self._root = root.resolve()
16
+ self._root.mkdir(parents=True, exist_ok=True)
17
+
18
+ def read_bytes(self, *, key: ObjectKey) -> bytes:
19
+ """Read all bytes stored under an existing object key."""
20
+ return self._path_for(key=key).read_bytes()
21
+
22
+ def write_bytes(
23
+ self, *, key: ObjectKey, content: bytes, storage_class: str | None = None
24
+ ) -> None:
25
+ """Create immutable bytes, failing rather than replacing an occupied key.
26
+
27
+ A local filesystem has no storage classes, so the D51 routing
28
+ decision is RECORDED beside the object instead of dropped — the
29
+ cloud adapter turns the same value into a real class, and either
30
+ way an operator can see what each original was routed to.
31
+ """
32
+ path = self._path_for(key=key)
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ try:
35
+ with path.open(mode="xb") as handle:
36
+ handle.write(content)
37
+ except FileExistsError as err:
38
+ raise ObjectAlreadyExistsError(
39
+ f"object key {key.root!r} is already occupied; objects are immutable"
40
+ ) from err
41
+ if storage_class is not None:
42
+ path.with_name(f"{path.name}.storage-class").write_text(
43
+ storage_class, encoding="utf-8"
44
+ )
45
+
46
+ def storage_class_of(self, *, key: ObjectKey) -> str | None:
47
+ """The class one object was routed to, when the writer declared it."""
48
+ marker = self._path_for(key=key).with_suffix("")
49
+ marker = self._path_for(key=key)
50
+ marker = marker.with_name(f"{marker.name}.storage-class")
51
+ return marker.read_text(encoding="utf-8") if marker.exists() else None
52
+
53
+ def purge_objects(
54
+ self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
55
+ ) -> None:
56
+ """Idempotently erase exact keys, storage markers, and prefix matches."""
57
+ exact_paths = tuple(self._path_for(key=key) for key in keys)
58
+ normalized_prefixes = tuple(
59
+ self._path_for(key=prefix).relative_to(self._root).as_posix()
60
+ for prefix in prefixes
61
+ )
62
+ for path in exact_paths:
63
+ path.unlink(missing_ok=True)
64
+ path.with_name(f"{path.name}.storage-class").unlink(missing_ok=True)
65
+ if normalized_prefixes:
66
+ for path in tuple(self._root.rglob("*")):
67
+ if not (path.is_file() or path.is_symlink()):
68
+ continue
69
+ relative = path.relative_to(self._root).as_posix()
70
+ if any(
71
+ relative == prefix.rstrip("/")
72
+ or relative.startswith(f"{prefix.rstrip('/')}/")
73
+ for prefix in normalized_prefixes
74
+ ):
75
+ path.unlink(missing_ok=True)
76
+ self._remove_empty_directories()
77
+
78
+ def verify_objects_purged(
79
+ self, *, keys: tuple[ObjectKey, ...], prefixes: tuple[ObjectKey, ...]
80
+ ) -> None:
81
+ """Prove exact keys and prefix-boundary descendants are absent."""
82
+ remaining = [
83
+ key.root
84
+ for key in keys
85
+ if self._path_for(key=key).exists()
86
+ or self._path_for(key=key)
87
+ .with_name(f"{self._path_for(key=key).name}.storage-class")
88
+ .exists()
89
+ ]
90
+ normalized_prefixes = tuple(
91
+ self._path_for(key=prefix).relative_to(self._root).as_posix().rstrip("/")
92
+ for prefix in prefixes
93
+ )
94
+ for path in self._root.rglob("*"):
95
+ if not (path.is_file() or path.is_symlink()):
96
+ continue
97
+ relative = path.relative_to(self._root).as_posix()
98
+ if any(
99
+ relative == prefix or relative.startswith(f"{prefix}/")
100
+ for prefix in normalized_prefixes
101
+ ):
102
+ remaining.append(relative)
103
+ if remaining:
104
+ raise RuntimeError(
105
+ f"object purge verification found: {sorted(remaining)!r}"
106
+ )
107
+
108
+ def _path_for(self, *, key: ObjectKey) -> Path:
109
+ """Resolve a key to a path strictly inside the root (no traversal)."""
110
+ candidate = (self._root / key.root).resolve()
111
+ if not candidate.is_relative_to(self._root):
112
+ raise ObjectKeyEscapesRootError(
113
+ f"object key {key.root!r} escapes the store root"
114
+ )
115
+ return candidate
116
+
117
+ def _remove_empty_directories(self) -> None:
118
+ """Prune empty object-key parents without ever removing the store root."""
119
+ directories = sorted(
120
+ (
121
+ path
122
+ for path in self._root.rglob("*")
123
+ if path.is_dir() and not path.is_symlink()
124
+ ),
125
+ key=lambda path: len(path.parts),
126
+ reverse=True,
127
+ )
128
+ for directory in directories:
129
+ if not any(directory.iterdir()):
130
+ directory.rmdir()