Skip to content

Commit a820918

Browse files
FoundupFoundups Agent
andauthored
feat(holoindex): add Memex access policy receipts (#1106)
Slice: HOLOINDEX_MEMEX_ACCESS_POLICY_RECEIPT_PHASE1 Worker-Lane: RedDog operational memory substrate Co-authored-by: Foundups Agent <dev@foundups.com>
1 parent 9b6d242 commit a820918

5 files changed

Lines changed: 585 additions & 0 deletions

holo_index/ModLog.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# HoloIndex Package ModLog
22

3+
## [2026-07-16] HOLOINDEX_MEMEX_ACCESS_POLICY_RECEIPT_PHASE1
4+
5+
**Agent**: 0102 (Codex) | Commander: 012 | Gate: implementation slice
6+
**WSP**: 00, 15, 22, 50, 60, 97
7+
8+
- ADD `holo_index/memex_access_policy_receipt.py`: deterministic read-only
9+
access-policy receipt for governed Memex projection. The receipt binds
10+
principal, work order, FoundUp scope, source scope, sensitivity classes,
11+
allowed/denied record sections, record cap, TTL, and policy generation.
12+
- UPDATE `holo_index/memex_projection_adapter.py`: optional
13+
`access_policy_receipt` validation derives the projection
14+
`access_policy_digest` from the receipt id, rejects tampered policy receipts,
15+
and filters denied sections without dropping allowed siblings.
16+
- TEST `tests/test_holoindex_memex_access_policy_receipt.py` plus projection
17+
adapter regressions: deterministic receipt, tamper, expiry, replay,
18+
revocation, scope mismatch, unsupported sensitivity, section allow/deny,
19+
policy-bound projection, and AST no-write/no-exec guards.
20+
- Boundary: no Memex write, no HoloIndex write, no Brain/Breadcrumb write, no
21+
external retrieval, no runtime re-index, and no automatic snapshot supplier.
22+
Multi-FoundUp hardening and assignment-bound supplier remain downstream.
23+
324
## [2026-07-16] HOLOINDEX_MEMEX_PROJECTION_INTEGRITY_AND_REHYDRATION_GATE_PHASE1
425

526
**Agent**: 0102 (Codex) | Commander: 012 | Gate: implementation slice
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
"""Access-policy receipts for governed Memex projection.
2+
3+
The receipt binds who may project which FoundUp memory scope into HoloIndex
4+
shadow records. It is deterministic, read-only, and carries no private data.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import asdict, dataclass
10+
from datetime import datetime, timezone
11+
from typing import Any, Mapping, Sequence
12+
13+
from holo_index.query_receipt import digest_json
14+
15+
16+
SCHEMA_VERSION = "holoindex_memex_access_policy_receipt.v1"
17+
POLICY_READY = "MEMEX_ACCESS_POLICY_READY"
18+
POLICY_REJECTED = "MEMEX_ACCESS_POLICY_REJECTED"
19+
VERIFICATION_PASS = "PASS"
20+
21+
ALLOWED_SENSITIVITY_CLASSES = frozenset(
22+
{
23+
"public",
24+
"internal",
25+
"foundup_private",
26+
"operator_private",
27+
}
28+
)
29+
DEFAULT_ALLOWED_SECTIONS = (
30+
"identity",
31+
"current_state",
32+
"roadmap_state",
33+
"verified_outcome",
34+
)
35+
36+
37+
@dataclass(frozen=True)
38+
class MemexAccessPolicyReceipt:
39+
schema_version: str
40+
principal_id: str
41+
work_order_id: str
42+
foundup_scope: tuple[str, ...]
43+
source_scope: str
44+
sensitivity_classes: tuple[str, ...]
45+
allowed_record_sections: tuple[str, ...]
46+
denied_record_sections: tuple[str, ...]
47+
max_records: int
48+
issued_at: str
49+
expires_at: str
50+
policy_generation_id: str
51+
verification: str
52+
revoked: bool = False
53+
no_memex_write_performed: bool = True
54+
no_holoindex_write_performed: bool = True
55+
no_brain_write_performed: bool = True
56+
no_breadcrumb_write_performed: bool = True
57+
receipt_id: str = ""
58+
59+
def to_dict(self) -> dict[str, Any]:
60+
return asdict(self)
61+
62+
63+
@dataclass(frozen=True)
64+
class MemexAccessPolicyValidationResult:
65+
accepted: bool
66+
status: str
67+
receipt: MemexAccessPolicyReceipt | None
68+
rejection_reasons: tuple[str, ...]
69+
70+
def to_dict(self) -> dict[str, Any]:
71+
return {
72+
"accepted": self.accepted,
73+
"status": self.status,
74+
"receipt": self.receipt.to_dict() if self.receipt else None,
75+
"rejection_reasons": list(self.rejection_reasons),
76+
}
77+
78+
79+
def build_memex_access_policy_receipt(
80+
*,
81+
principal_id: str,
82+
work_order_id: str,
83+
foundup_scope: Sequence[str],
84+
source_scope: str,
85+
sensitivity_classes: Sequence[str],
86+
allowed_record_sections: Sequence[str] = DEFAULT_ALLOWED_SECTIONS,
87+
denied_record_sections: Sequence[str] = (),
88+
max_records: int = 32,
89+
issued_at: str,
90+
expires_at: str,
91+
policy_generation_id: str,
92+
) -> MemexAccessPolicyValidationResult:
93+
"""Build and validate a deterministic access-policy receipt."""
94+
95+
payload = {
96+
"schema_version": SCHEMA_VERSION,
97+
"principal_id": _clean(principal_id),
98+
"work_order_id": _clean(work_order_id),
99+
"foundup_scope": tuple(_unique_clean(foundup_scope)),
100+
"source_scope": _clean(source_scope),
101+
"sensitivity_classes": tuple(_unique_clean(sensitivity_classes)),
102+
"allowed_record_sections": tuple(_unique_clean(allowed_record_sections)),
103+
"denied_record_sections": tuple(_unique_clean(denied_record_sections)),
104+
"max_records": int(max_records or 0),
105+
"issued_at": _clean(issued_at),
106+
"expires_at": _clean(expires_at),
107+
"policy_generation_id": _clean(policy_generation_id),
108+
"verification": VERIFICATION_PASS,
109+
"revoked": False,
110+
"no_memex_write_performed": True,
111+
"no_holoindex_write_performed": True,
112+
"no_brain_write_performed": True,
113+
"no_breadcrumb_write_performed": True,
114+
}
115+
receipt = MemexAccessPolicyReceipt(**payload, receipt_id=digest_json(payload))
116+
return validate_memex_access_policy_receipt(receipt, now_iso=issued_at)
117+
118+
119+
def validate_memex_access_policy_receipt(
120+
receipt: MemexAccessPolicyReceipt | Mapping[str, Any],
121+
*,
122+
expected_foundup_id: str | None = None,
123+
expected_source_scope: str | None = None,
124+
expected_principal_id: str | None = None,
125+
expected_work_order_id: str | None = None,
126+
now_iso: str | None = None,
127+
seen_receipt_ids: Sequence[str] = (),
128+
revoked_receipt_ids: Sequence[str] = (),
129+
) -> MemexAccessPolicyValidationResult:
130+
typed, reasons = _receipt_from_any(receipt)
131+
if typed is None:
132+
return _reject(*reasons, "access_policy_receipt_malformed")
133+
134+
if typed.schema_version != SCHEMA_VERSION:
135+
reasons.append("schema_version_mismatch")
136+
if typed.verification != VERIFICATION_PASS:
137+
reasons.append("verification_not_pass")
138+
if typed.revoked:
139+
reasons.append("access_policy_revoked")
140+
if typed.receipt_id in {_clean(item) for item in seen_receipt_ids if _clean(item)}:
141+
reasons.append("access_policy_replayed")
142+
if typed.receipt_id in {_clean(item) for item in revoked_receipt_ids if _clean(item)}:
143+
reasons.append("access_policy_revoked")
144+
if not typed.foundup_scope:
145+
reasons.append("missing_foundup_scope")
146+
if not typed.source_scope:
147+
reasons.append("missing_source_scope")
148+
if not typed.policy_generation_id:
149+
reasons.append("missing_policy_generation_id")
150+
if typed.max_records <= 0:
151+
reasons.append("invalid_max_records")
152+
if set(typed.sensitivity_classes) - ALLOWED_SENSITIVITY_CLASSES:
153+
reasons.append("unsupported_sensitivity_class")
154+
if not typed.allowed_record_sections:
155+
reasons.append("missing_allowed_record_sections")
156+
if not (
157+
typed.no_memex_write_performed
158+
and typed.no_holoindex_write_performed
159+
and typed.no_brain_write_performed
160+
and typed.no_breadcrumb_write_performed
161+
):
162+
reasons.append("side_effect_attestation_missing")
163+
time_reason = _time_reason(issued_at=typed.issued_at, expires_at=typed.expires_at, now_iso=now_iso)
164+
if time_reason:
165+
reasons.append(time_reason)
166+
if expected_foundup_id and _clean(expected_foundup_id) not in typed.foundup_scope:
167+
reasons.append("expected_foundup_not_in_scope")
168+
if expected_source_scope and typed.source_scope != _clean(expected_source_scope):
169+
reasons.append("expected_source_scope_mismatch")
170+
if expected_principal_id and typed.principal_id != _clean(expected_principal_id):
171+
reasons.append("expected_principal_mismatch")
172+
if expected_work_order_id and typed.work_order_id != _clean(expected_work_order_id):
173+
reasons.append("expected_work_order_mismatch")
174+
175+
payload = _receipt_payload(typed)
176+
if digest_json(payload) != typed.receipt_id:
177+
reasons.append("access_policy_receipt_id_mismatch")
178+
179+
if reasons:
180+
return _reject(*reasons)
181+
return MemexAccessPolicyValidationResult(
182+
accepted=True,
183+
status=POLICY_READY,
184+
receipt=typed,
185+
rejection_reasons=(),
186+
)
187+
188+
189+
def section_allowed_by_policy(section: str, receipt: MemexAccessPolicyReceipt) -> bool:
190+
"""Return whether a projected record section is allowed by the receipt."""
191+
192+
normalized = _section_base(section)
193+
allowed = {_section_base(item) for item in receipt.allowed_record_sections}
194+
denied = {_section_base(item) for item in receipt.denied_record_sections}
195+
return normalized in allowed and normalized not in denied
196+
197+
198+
def _receipt_payload(receipt: MemexAccessPolicyReceipt) -> dict[str, Any]:
199+
return {
200+
"schema_version": receipt.schema_version,
201+
"principal_id": receipt.principal_id,
202+
"work_order_id": receipt.work_order_id,
203+
"foundup_scope": tuple(receipt.foundup_scope),
204+
"source_scope": receipt.source_scope,
205+
"sensitivity_classes": tuple(receipt.sensitivity_classes),
206+
"allowed_record_sections": tuple(receipt.allowed_record_sections),
207+
"denied_record_sections": tuple(receipt.denied_record_sections),
208+
"max_records": receipt.max_records,
209+
"issued_at": receipt.issued_at,
210+
"expires_at": receipt.expires_at,
211+
"policy_generation_id": receipt.policy_generation_id,
212+
"verification": receipt.verification,
213+
"revoked": receipt.revoked,
214+
"no_memex_write_performed": receipt.no_memex_write_performed,
215+
"no_holoindex_write_performed": receipt.no_holoindex_write_performed,
216+
"no_brain_write_performed": receipt.no_brain_write_performed,
217+
"no_breadcrumb_write_performed": receipt.no_breadcrumb_write_performed,
218+
}
219+
220+
221+
def _receipt_from_any(value: MemexAccessPolicyReceipt | Mapping[str, Any]) -> tuple[
222+
MemexAccessPolicyReceipt | None,
223+
list[str],
224+
]:
225+
if isinstance(value, MemexAccessPolicyReceipt):
226+
return value, []
227+
if not isinstance(value, Mapping):
228+
return None, ["access_policy_not_mapping"]
229+
try:
230+
receipt = MemexAccessPolicyReceipt(
231+
schema_version=_clean(value.get("schema_version")),
232+
principal_id=_clean(value.get("principal_id")),
233+
work_order_id=_clean(value.get("work_order_id")),
234+
foundup_scope=tuple(_unique_clean(value.get("foundup_scope") or ())),
235+
source_scope=_clean(value.get("source_scope")),
236+
sensitivity_classes=tuple(_unique_clean(value.get("sensitivity_classes") or ())),
237+
allowed_record_sections=tuple(_unique_clean(value.get("allowed_record_sections") or ())),
238+
denied_record_sections=tuple(_unique_clean(value.get("denied_record_sections") or ())),
239+
max_records=int(value.get("max_records") or 0),
240+
issued_at=_clean(value.get("issued_at")),
241+
expires_at=_clean(value.get("expires_at")),
242+
policy_generation_id=_clean(value.get("policy_generation_id")),
243+
verification=_clean(value.get("verification")),
244+
revoked=bool(value.get("revoked")),
245+
no_memex_write_performed=bool(value.get("no_memex_write_performed")),
246+
no_holoindex_write_performed=bool(value.get("no_holoindex_write_performed")),
247+
no_brain_write_performed=bool(value.get("no_brain_write_performed")),
248+
no_breadcrumb_write_performed=bool(value.get("no_breadcrumb_write_performed")),
249+
receipt_id=_clean(value.get("receipt_id")),
250+
)
251+
except Exception:
252+
return None, ["access_policy_receipt_parse_failed"]
253+
missing = []
254+
for key in (
255+
"schema_version",
256+
"principal_id",
257+
"work_order_id",
258+
"source_scope",
259+
"issued_at",
260+
"expires_at",
261+
"policy_generation_id",
262+
"verification",
263+
"receipt_id",
264+
):
265+
if not _clean(getattr(receipt, key)):
266+
missing.append(f"missing_{key}")
267+
return receipt, missing
268+
269+
270+
def _time_reason(*, issued_at: str, expires_at: str, now_iso: str | None) -> str:
271+
try:
272+
issued = _parse_time(issued_at)
273+
expires = _parse_time(expires_at)
274+
now = _parse_time(now_iso) if now_iso else datetime.now(timezone.utc)
275+
except Exception:
276+
return "access_policy_time_malformed"
277+
if expires <= issued:
278+
return "access_policy_expiry_not_after_issue"
279+
if now < issued:
280+
return "access_policy_not_yet_valid"
281+
if now > expires:
282+
return "access_policy_expired"
283+
return ""
284+
285+
286+
def _parse_time(value: str | None) -> datetime:
287+
text = _clean(value)
288+
if text.endswith("Z"):
289+
text = text[:-1] + "+00:00"
290+
parsed = datetime.fromisoformat(text)
291+
if parsed.tzinfo is None:
292+
parsed = parsed.replace(tzinfo=timezone.utc)
293+
return parsed.astimezone(timezone.utc)
294+
295+
296+
def _section_base(value: str) -> str:
297+
text = _clean(value)
298+
if text.startswith("verified_outcome"):
299+
return "verified_outcome"
300+
return text
301+
302+
303+
def _unique_clean(values: Sequence[Any]) -> tuple[str, ...]:
304+
if isinstance(values, (str, bytes)):
305+
return (_clean(values),) if _clean(values) else ()
306+
return tuple(dict.fromkeys(_clean(item) for item in values if _clean(item)))
307+
308+
309+
def _reject(*reasons: str) -> MemexAccessPolicyValidationResult:
310+
return MemexAccessPolicyValidationResult(
311+
accepted=False,
312+
status=POLICY_REJECTED,
313+
receipt=None,
314+
rejection_reasons=tuple(dict.fromkeys(reason for reason in reasons if reason)),
315+
)
316+
317+
318+
def _clean(value: Any) -> str:
319+
return str(value or "").strip()
320+
321+
322+
__all__ = [
323+
"DEFAULT_ALLOWED_SECTIONS",
324+
"MemexAccessPolicyReceipt",
325+
"MemexAccessPolicyValidationResult",
326+
"POLICY_READY",
327+
"POLICY_REJECTED",
328+
"SCHEMA_VERSION",
329+
"build_memex_access_policy_receipt",
330+
"section_allowed_by_policy",
331+
"validate_memex_access_policy_receipt",
332+
]

0 commit comments

Comments
 (0)