diff --git a/continuityos/person_twin_admission_store.py b/continuityos/person_twin_admission_store.py index aed05975..b1023877 100644 --- a/continuityos/person_twin_admission_store.py +++ b/continuityos/person_twin_admission_store.py @@ -24,7 +24,7 @@ ) from .person_twin_authority_replay import _current_admission from .person_twin_privacy_provenance import validate_person_twin_record -from .person_twin_r21h_evidence import _require_current_meta +from .person_twin_r21h_evidence import MEMORY_BINDING_SCHEMA, _require_current_meta SCHEMA = "continuityos.person-twin.durable-admission/v1" MAX_RECORD_BYTES = 1024 * 1024 # bounded lab input, not a calibrated production limit @@ -346,6 +346,25 @@ def load_current(self) -> AdmissionSnapshot | None: self._con.execute("ROLLBACK") raise DurableAdmissionError("ADMISSION_CURRENT_READ_FAILED") from None + def read_current_memory_binding_bytes(self) -> bytes | None: + """Project persisted bindings from one verified read-only CURRENT snapshot. + + No intent or caller-supplied row ID is accepted. load_current() validates + schema, saved hashes, the actual Memory row, R3 receipt and pointer first. + This adds no SQL schema, writes, issuer, or fresh authorization. + """ + snapshot = self.load_current() + if snapshot is None: + return None + binding = snapshot.binding + return _canonical({ + "schema": MEMORY_BINDING_SCHEMA, + "pointer_sha256": _hash(snapshot.pointer_bytes), + "candidate_sha256": _hash(snapshot.candidate_bytes), + "memory_id": binding["memory_id"], "namespace": binding["namespace"], + "person_record_id": binding["candidate_id"], "source_record_id": binding["source_record_id"], + }) + def read_current_pointer_bytes(self) -> bytes | None: if self._phase == "PROMOTING" and self._con.in_transaction: row = self._current_row() @@ -431,7 +450,8 @@ def _result(self, status: str, reason: str, snapshot: AdmissionSnapshot | None = candidate_commit_state=self._candidate_commit_state, commit_state=self._commit_state, possible_persisted_effect=self._candidate_commit_state != "NOT_ATTEMPTED" or self._commit_state != "NOT_ATTEMPTED", - publication_id=self._next_publication, + attempt_publication_id=self._next_publication, + publication_id=(self._next_publication if self._commit_state == "CONFIRMED" else None), admission_receipt=None if snapshot is None else snapshot.receipt, **_CEILING) diff --git a/continuityos/person_twin_r21h_evidence.py b/continuityos/person_twin_r21h_evidence.py index 9c2ec4c0..9450fe67 100644 --- a/continuityos/person_twin_r21h_evidence.py +++ b/continuityos/person_twin_r21h_evidence.py @@ -22,6 +22,9 @@ from .sovereign_twin_runtime_r21b import TwinEvidence +MEMORY_BINDING_SCHEMA = "continuityos.person-twin.r21h-memory-binding/v1" + + class PersonTwinEvidenceError(ValueError): """Fail-closed, fixed-code error containing no protected input or anchors.""" @@ -70,6 +73,16 @@ def read_context(self) -> Mapping[str, Any]: ... +class PersonAdmissionMemoryReadStore(Protocol): + def read_current_memory_binding_bytes(self) -> bytes | None: + """Read the saved row binding and CURRENT commitments, never derive from intent. + + Required for R21H, in addition to the unchanged R4 read-store methods. + None or an unavailable/malformed read is DENY, not a legacy fallback. + """ + ... + + class MemoryReadStore(Protocol): def get(self, rid: int) -> Any: ... @@ -102,6 +115,29 @@ def _require_current_meta(raw: Any, at: str) -> None: raise PersonTwinEvidenceError("PERSON_MEMORY_NOT_YET_CURRENT") +def _require_admission_memory_binding(context: Mapping[str, Any], intent: PersonReadIntent) -> None: + reader = getattr(context["admission_store"], "read_current_memory_binding_bytes", None) + if not callable(reader): + raise PersonTwinEvidenceError("PERSON_ADMISSION_MEMORY_BINDING_UNAVAILABLE") + try: + stored = reader() + except Exception: + raise PersonTwinEvidenceError("PERSON_ADMISSION_MEMORY_BINDING_READ_FAILED") from None + expected = { + "schema": MEMORY_BINDING_SCHEMA, + "pointer_sha256": _canonical_hash(context["current_pointer"]), + "candidate_sha256": _canonical_hash(context["person_record"]), + "memory_id": intent.memory_id, "namespace": intent.namespace, + "person_record_id": intent.person_record_id, "source_record_id": intent.source_record_id, + } + expected_bytes = json.dumps(expected, ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False).encode("utf-8") + # Exact canonical bytes reject missing/extra fields, duplicates, and bool IDs. + # The persisted row binding comes from the read-store, never from the intent. + if type(stored) is not bytes or stored != expected_bytes: + raise PersonTwinEvidenceError("PERSON_ADMISSION_MEMORY_BINDING_MISMATCH") + + class PersonTwinR21HEvidenceAdapter: """R2 filter + R4 READ + exact existing-memory projection, without retrieval. @@ -184,6 +220,7 @@ def _read( binding = decision.get("binding", {}) if any(binding.get(key) != value for key, value in expected_binding.items()): raise PersonTwinEvidenceError("PERSON_R4_BINDING_MISMATCH") + _require_admission_memory_binding(context, intent) payload = source["payload"] text = payload.get("text") if isinstance(payload, dict) else None if not isinstance(text, str) or not text.strip(): diff --git a/docs/PERSON_ADMISSION_PUBLICATION_RESULT_A3_R1.md b/docs/PERSON_ADMISSION_PUBLICATION_RESULT_A3_R1.md new file mode 100644 index 00000000..20de5add --- /dev/null +++ b/docs/PERSON_ADMISSION_PUBLICATION_RESULT_A3_R1.md @@ -0,0 +1,94 @@ +# A3 R1: distinguish an attempted publication from a confirmed COMMIT + +Scope: the outer result of `PersonTwinAdmissionStore.admit()` only. Parent: +`fe04df409f86bc773d7210dbfaefa620afc3a231`, tree +`8b52fb2e3986e2c159cbcf38e0e56497e0369b4a` (local A4 candidate). +This correction is not a new admission issuer, SQL schema, R3/R4 algorithm, +backup/restore implementation, runtime generation or production admission. + +## Reproduced defect + +On the parent revision, denial immediately before the promotion COMMIT returns +HOLD / `commit_state=NOT_ATTEMPTED`, yet `publication_id` contains a generated +identifier absent from `person_admission_publications`. The staged candidate may +already be committed separately. A failed re-publication preserves the previous +CURRENT but similarly returns an identifier for the failed attempt. + +No successful authorization was observed on these HOLD paths. The defect is an +ambiguous result contract, not a demonstrated bypass of HOLD or a data breach. + +## Result contract + +The existing outer result schema name remains +`continuityos.person-twin.durable-admission/v1`. This is an additive field plus a +narrowing of the meaning of its existing `publication_id` field: + +- `attempt_publication_id`: the identifier allocated to this invocation's + prospective publication; null if no identifier was allocated. It can be used + for later explicit reconciliation. It is not proof of persistence, freshness, + authorization or successful rollback. +- `publication_id`: populated only when this invocation's promotion + `commit_state` is `CONFIRMED`. It equals `attempt_publication_id`. Otherwise + it is null. It never substitutes the pre-existing CURRENT identifier. +- `commit_state`: unchanged: `NOT_ATTEMPTED`, `UNKNOWN`, `CONFIRMED` describe + the promotion COMMIT observation. Candidate COMMIT state remains separate. +- `status`: unchanged. PASS still requires confirmed promotion and successful + fresh readback. A confirmed COMMIT can still be reported as HOLD when fresh + readback fails. Such a HOLD retains its confirmed `publication_id` but returns + no `admission_receipt` and grants no permission. + +| Observed case | attempt_publication_id | publication_id | Status / commit_state | +| --- | --- | --- | --- | +| Rejected before allocation | null | null | HOLD / NOT_ATTEMPTED | +| Rejected after allocation, before COMMIT | attempt ID | null | HOLD / NOT_ATTEMPTED | +| Promotion COMMIT response uncertain | attempt ID | null | HOLD / UNKNOWN | +| COMMIT confirmed, fresh readback failed | attempt ID | same ID | HOLD / CONFIRMED | +| COMMIT confirmed, fresh readback verified | attempt ID | same ID | PASS / CONFIRMED | + +If the COMMIT response is lost after SQLite actually commits, `publication_id` +still remains null: the operation could not confirm success. The attempt ID is +retained for investigation. If COMMIT fails before persistence, the same public +UNKNOWN result is conservative even when a later read proves there is no row. +The implementation does not automatically retry, reclassify UNKNOWN, or erase +possible effects. `ROLLBACK_UNVERIFIED` still suspends the store and retains +`possible_persisted_effect=true`. + +A non-null `publication_id` confirms the observed COMMIT on that connection; it +does NOT by itself attest current database-path identity, continued CURRENT +status, fresh consent, or successful post-COMMIT verification. Consumers must +continue checking the complete result and obtaining fresh authorization. + +## Compatibility boundaries + +The change affects only the outer Python outcome dictionary. Existing SQL DDL, +stored candidate/pointer/binding/R3 receipt bytes, `AdmissionSnapshot`, CURRENT +semantics, transaction ordering, public R21H evidence receipt and the product +demo's strict receipt validators are unchanged. There is no data migration. +Strict consumers of the outer dictionary must accept the newly documented +`attempt_publication_id`. Consumers previously treating every `publication_id` +as an attempt identifier must use the new field instead. + +## Tests and acceptance evidence + +The existing independent A3/A4/N4 acceptance bodies are rerun without changes. +A3 must change from red on the parent to green; A4 exact row binding and the +backup -> revoke -> restore test with a fresh external ledger must stay green. + +Nineteen new synthetic cases cover early and late denials, confirmed success, +COMMIT response loss before/after actual SQLite COMMIT for both phases, failed +or mismatched post-COMMIT readback, failed re-publication, identifier reset and +unverified rollback. Existing test bodies are preserved. All DBs are temporary. +A separate deterministic review compares old/new result functions across state +combinations and verifies that transaction methods and guards are unchanged. +Actual test receipts and the candidate manifest are external build artifacts. + +## Remaining limits + +Local synthetic tests do not replace fresh official wheel/CI, independent review +of this specific change, or owner signoff. A4 still has its own local candidate +and publication status. A3 does not address trust-origin, stale external ledger, +legacy effect-path bypass, OS isolation or cross-machine restoration questions. + +`NO_PUSH`, `NO_PR_MUTATION`, `NO_MERGE`, `NO_DEPLOY`, `C0 HOLD`, +`T11/T12 UNEXECUTED`, `NOT_PRODUCTION_ADMITTED`, `can_trade=false`, +`capital_permission=DENY` remain in force for this local stage. diff --git a/docs/PERSON_R21H_MEMORY_BINDING_A4_R1.md b/docs/PERSON_R21H_MEMORY_BINDING_A4_R1.md new file mode 100644 index 00000000..413e1381 --- /dev/null +++ b/docs/PERSON_R21H_MEMORY_BINDING_A4_R1.md @@ -0,0 +1,82 @@ +# Person R21H — exact saved Memory binding (A4 R1) + +Base: `b11af1a69957e77accc0a6d74dedb7c4dab8ee28`, tree +`69893ca2d8417c5b1904863b97e506f536168134` (Draft PR #213). +Scope: local A4 repair, not A3, a new runtime, an authority issuer, or a schema migration. + +## Reproduced defect + +An existing durable admission bound `memory_id=3`. A trusted-host read intent +selected another row `memory_id=4` with identical text and namespace. R2/R4 +validated the Person/source candidate, while R21H checked the selected row +against the intent and content only. The recording model received `mem:4`. +Different text, namespace, superseded metadata and absent rows were already +rejected. This was a P2 exact-source-reference defect, not a reproduced +cross-tenant disclosure or arbitrary-content read. + +## Correction and compatibility boundary + +R21H admission stores must now implement: + +```python +read_current_memory_binding_bytes() -> bytes | None +``` + +The unchanged R4 protocol and evaluator do not acquire this requirement. It is +required only when a host configures `PersonTwinR21HEvidenceAdapter`. The durable +`PersonTwinAdmissionStore` implements the method by calling the existing +`load_current()` and projecting the saved binding plus CURRENT commitments from +that single verified snapshot. No intent, requested id or namespace is passed +to this read method. It does not read from a second database, create a schema, +write an admission, cache ALLOW, or issue fresh consent/authority. + +The canonical UTF-8 JSON readback contains exactly: + +- `schema = continuityos.person-twin.r21h-memory-binding/v1`; +- `pointer_sha256`, `candidate_sha256`; +- `memory_id`, `namespace`, `person_record_id`, `source_record_id`. + +The adapter compares exact canonical bytes to its independently pinned intent +and R4 context **before** selecting the Memory row. Missing method, non-callable +method, read exception, no CURRENT, malformed/noncanonical bytes, extra fields, +duplicate keys and any binding mismatch deny the request. There is no fallback +to content-only matching. Existing integrations supplying only the minimal R4 +read-store interface now deliberately fail closed at R21H until they implement +this saved-binding read contract; do not synthesize the value from request pins. + +The repository's earlier in-memory R21H test store was extended only in its test +fixture: it saves the binding once after actual fixture admission/Memory seeding, +before a read intent or provider is constructed. The existing demo uses the +durable store and needs no change. Non-Person runtime paths are untouched. + +The saved-binding read runs independently in both prepare and final prompt +validation. Public receipt keys and schema are preserved exactly; no new public +commitment field is added. A changed binding after prepare denies before model +generation. The existing demo keeps its exact-key receipt validator unchanged. + +## Validation and limits + +Acceptance bodies from the prior A3/A4/N4 reproduction are preserved unchanged; +only their test import origin is redirected to this local checkout. The baseline +must reproduce A4. After correction A4 must become green while A3's documented +publication-result ambiguity remains red. A3 is not silently reclassified or fixed. + +New tests exercise warm chat and cold streaming, exact/identical-other/mismatched +rows, unavailable and corrupted readback, prompt-boundary drift, query-only reads, +no-CURRENT and suspended-store rejection. Existing R1-R4, R21H, demo, export, +backup and restore tests are included in local qualification. Run counts and +source hashes belong to the external immutable execution manifest, not a claim +that all repository tests or official wheel CI ran. + +The read port remains a trusted-host integration boundary, not a cryptographic +attestation or authentication of arbitrary Python objects. A malicious host that +supplies fabricated stores/identities is not solved here. Database identity across +distinct read stores, generation/ABA handling (A6), receipt provenance (A5), H3/C0 +native identity, independent revocation-witness freshness and real model egress +remain separate requirements. Restore continues to require a fresh external +revocation ledger. No production or live-data qualification is asserted. + +`NOT_PRODUCTION_ADMITTED`; `C0 HOLD`; `T11/T12 UNEXECUTED`; +`execution_authority=NONE`; `can_execute=false`; `can_trade=false`; +`capital_permission=DENY`. No push, PR mutation, merge, deploy, Scheduler, TPM, +installed-runtime change or real Memory access is part of this local stage. diff --git a/tests/test_person_twin_admission_store.py b/tests/test_person_twin_admission_store.py index 539eaa18..d3198501 100644 --- a/tests/test_person_twin_admission_store.py +++ b/tests/test_person_twin_admission_store.py @@ -631,3 +631,404 @@ def fail_promotion(): _admit(store, ctx, rid) with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"]) as fresh: assert fresh.load_current() is None + + +# A4: admission's stored row binding must constrain the existing R21H intent. +def _a4_attempt(path, identity, *, selected_id=None, cold=False, decorate=None): + with durable.PersonTwinAdmissionStore(path, identity=identity) as store: + snapshot = store.load_current() + context = _context(snapshot, store) + if decorate is not None: + context["admission_store"] = decorate(store) + intent = PersonReadIntent( + query_sha256=hashlib.sha256(QUERY.encode()).hexdigest(), + current_session_sha256=durable._hash(durable._canonical(context["current_session"])), + request_id=context["authorization_request"]["request_id"], + packet_id=context["authorization_preflight"]["packet_id"], + memory_id=snapshot.binding["memory_id"] if selected_id is None else selected_id, + namespace=snapshot.binding["namespace"], person_record_id=snapshot.candidate["id"], + source_record_id=snapshot.anchors["source_record"]["id"], + principal_id=context["principal_id"], scope=context["requested_scope"], + privacy_class=context["requested_privacy_class"], purpose=context["purpose"], + ) + class Provider: + reads = 0 + def read_context(self): + self.reads += 1 + return dict(context) + provider = Provider() + profile = replace(DEFAULT_PROFILES["fast"], model="synthetic-a4-binding") + model = demo._RecordingModel(profile, cold=cold) + runtime = SovereignTwinRuntime(str(path), client=model, + profiles={**DEFAULT_PROFILES, "fast": profile}, + person_evidence_adapter=PersonTwinR21HEvidenceAdapter(intent, provider)) + gets = [] + original_get = runtime.memory.store.get + def traced_get(rid): + gets.append(rid) + return original_get(rid) + runtime.memory.store.get = traced_get + try: + try: + answer = runtime.ask(QUERY) + except PersonTwinEvidenceError as exc: + return {"decision":"DENY", "reason":str(exc), "calls":len(model.calls), + "memory_gets":gets, "provider_reads":provider.reads} + assert len(model.calls) == 1 + rows = json.loads(model.calls[0]["system_prompt"].split("MEMORY_EVIDENCE_JSON:\n", 1)[1]) + return {"decision":answer.stats["person_evidence"]["decision"], "calls":len(model.calls), + "memory_gets":gets, "provider_reads":provider.reads, + "refs":[r["ref"] for r in rows], "receipt":answer.stats["person_evidence"]} + finally: + runtime.close() + + +@pytest.mark.parametrize("cold", [False, True]) +@pytest.mark.parametrize("selection", ["bound", "identical_other_row", "different_text", "different_namespace", "superseded", "missing"]) +def test_a4_exact_stored_row_binding_before_warm_and_streaming_models(ready, cold, selection): + from contextlib import closing + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as writer: + assert _admit(writer, ctx, rid)["status"] == "PASS" + selected = rid + if selection not in {"bound", "missing"}: + with closing(sqlite3.connect(path)) as con, con: + names = [r[1] for r in con.execute("PRAGMA table_info(items)") if r[1] != "id"] + columns = ",".join('"' + n + '"' for n in names) + selected = con.execute(f"INSERT INTO items ({columns}) SELECT {columns} FROM items WHERE id=?", (rid,)).lastrowid + assert selected != rid + if selection == "different_text": + con.execute("UPDATE items SET text=? WHERE id=?", ("SYNTHETIC_OTHER_TEXT", selected)) + elif selection == "different_namespace": + con.execute("UPDATE items SET namespace=? WHERE id=?", ("synthetic-other-ns", selected)) + elif selection == "superseded": + con.execute("UPDATE items SET meta=? WHERE id=?", ('{"superseded_by":999}', selected)) + elif selection == "missing": + selected = 999999 + before = path.read_bytes() + value = _a4_attempt(path, ctx["identity"], selected_id=selected, cold=cold) + assert path.read_bytes() == before + if selection == "bound": + assert value["decision"] == "ALLOW" and value["calls"] == 1 + assert value["refs"] == [f"mem:{rid}"] + assert set(value["receipt"]) == demo.PERSON_RECEIPT_KEYS + else: + assert value["decision"] == "DENY" and value["calls"] == 0, value + assert value["memory_gets"] == [] + assert value["reason"] == "PERSON_ADMISSION_MEMORY_BINDING_MISMATCH" + + +@pytest.mark.parametrize("fault", ["absent", "noncallable", "none", "text", "invalid_json", "duplicate_key", "noncanonical", "bool_id", "memory_id", "namespace", "pointer_sha256", "candidate_sha256", "person_record_id", "source_record_id", "extra", "exception"]) +def test_a4_binding_interface_has_no_fallback_or_caller_override(ready, fault): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as writer: + assert _admit(writer, ctx, rid)["status"] == "PASS" + def decorate(store): + class Proxy: + def read_current_pointer_bytes(self): + return store.read_current_pointer_bytes() + def read_candidate_bytes(self, candidate_id): + return store.read_candidate_bytes(candidate_id) + proxy = Proxy() + if fault == "absent": + return proxy + if fault == "noncallable": + proxy.read_current_memory_binding_bytes = b"not-a-callable" + return proxy + raw = store.read_current_memory_binding_bytes() + record = json.loads(raw) + def read_binding(): + if fault == "exception": + raise ValueError("SYNTHETIC_PRIVATE_A4_READ_ERROR") + if fault == "none": return None + if fault == "text": return raw.decode() + if fault == "invalid_json": return b"not json" + if fault == "duplicate_key": return b'{"memory_id":999,' + raw[1:] + if fault == "noncanonical": return json.dumps(record, indent=2).encode() + changed = dict(record) + if fault == "bool_id": changed["memory_id"] = True + elif fault == "extra": changed["unexpected"] = "must deny" + elif fault == "memory_id": changed["memory_id"] = rid + 1 + else: changed[fault] = "SYNTHETIC_WRONG_BINDING" + return durable._canonical(changed) + proxy.read_current_memory_binding_bytes = read_binding + return proxy + before = path.read_bytes() + result = _a4_attempt(path, ctx["identity"], decorate=decorate) + assert result["decision"] == "DENY" and result["calls"] == 0 + assert result["memory_gets"] == [] + assert "SYNTHETIC_PRIVATE" not in result["reason"] + assert "SYNTHETIC_WRONG" not in result["reason"] + assert path.read_bytes() == before + + +@pytest.mark.parametrize("cold", [False, True]) +def test_a4_read_binding_is_rechecked_at_the_final_prompt_boundary(ready, cold): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as writer: + assert _admit(writer, ctx, rid)["status"] == "PASS" + reads = [] + def decorate(store): + class Proxy: + def read_current_pointer_bytes(self): + return store.read_current_pointer_bytes() + def read_candidate_bytes(self, candidate_id): + return store.read_candidate_bytes(candidate_id) + def read_current_memory_binding_bytes(self): + raw = store.read_current_memory_binding_bytes() + reads.append(raw) + if len(reads) == 2: + value = json.loads(raw) + value["memory_id"] = rid + 1 + return durable._canonical(value) + return raw + return Proxy() + before = path.read_bytes() + value = _a4_attempt(path, ctx["identity"], cold=cold, decorate=decorate) + assert value["decision"] == "DENY" and value["calls"] == 0 + assert len(reads) == 2 and value["provider_reads"] == 2 + assert value["memory_gets"] == [rid] + assert path.read_bytes() == before + + +def test_a4_saved_binding_read_is_fresh_exact_and_nonmutating(ready): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"]) as empty: + assert empty.read_current_memory_binding_bytes() is None + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as writer: + assert _admit(writer, ctx, rid)["status"] == "PASS" + before = path.read_bytes() + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"]) as reader: + snapshot = reader.load_current() + first = reader.read_current_memory_binding_bytes() + assert first == reader.read_current_memory_binding_bytes() + value = json.loads(first) + assert first == durable._canonical(value) + assert value["memory_id"] == rid and value["namespace"] == NS + assert value["pointer_sha256"] == durable._hash(snapshot.pointer_bytes) + assert value["candidate_sha256"] == durable._hash(snapshot.candidate_bytes) + assert reader._con.execute("PRAGMA query_only").fetchone()[0] == 1 + reader._suspended = True + with pytest.raises(durable.DurableAdmissionError): + reader.read_current_memory_binding_bytes() + assert path.read_bytes() == before + + +# A3: a prospective transaction identifier is not proof of a committed publication. +def _a3_assert_result_ids(value, *, attempted, confirmed): + expected_keys = { + "schema", "status", "reason", "candidate_persisted", "candidate_commit_state", + "commit_state", "possible_persisted_effect", "publication_id", + "attempt_publication_id", "admission_receipt", "production_admission_status", + "execution_authority", "can_execute", "can_trade", "capital_permission", + } + assert set(value) == expected_keys + attempt = value["attempt_publication_id"] + if attempted: + assert type(attempt) is str and len(attempt) == 32 + assert set(attempt) <= set("0123456789abcdef") + else: + assert attempt is None + assert value["publication_id"] == (attempt if confirmed else None) + assert value["production_admission_status"] == "NOT_PRODUCTION_ADMITTED" + assert value["execution_authority"] == "NONE" + assert value["can_execute"] is False and value["can_trade"] is False + assert value["capital_permission"] == "DENY" + if value["status"] == "HOLD": + assert value["admission_receipt"] is None + assert "SYNTHETIC_PRIVATE_A3" not in json.dumps(value) + + +@pytest.mark.parametrize("effect,attempted,candidate", [ + ("admit", False, False), + ("candidate_write", False, False), + ("candidate_commit", False, False), + ("promotion_begin", False, True), + ("pointer_write", True, True), + ("promotion_commit", True, True), +]) +def test_a3_precommit_denial_has_no_confirmed_publication(ready, monkeypatch, effect, attempted, candidate): + path, ctx, rid = ready + calls = [] + original = durable.assert_current_effect_allowed + def deny_one(name): + if name == "person.admission." + effect: + calls.append(name) + raise RuntimeError("SYNTHETIC_PRIVATE_A3_ADDITIONAL_DENIAL") + return original(name) + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + with monkeypatch.context() as patch: + patch.setattr(durable, "assert_current_effect_allowed", deny_one) + value = _admit(store, ctx, rid) + assert calls == ["person.admission." + effect] + assert value["status"] == "HOLD" and value["commit_state"] == "NOT_ATTEMPTED" + assert value["publication_id"] is None, value + _a3_assert_result_ids(value, attempted=attempted, confirmed=False) + assert value["candidate_persisted"] is candidate + assert store.load_current() is None + assert store._con.execute("SELECT COUNT(*) FROM person_admission_publications").fetchone()[0] == 0 + assert store._con.execute("SELECT COUNT(*) FROM person_admission_candidates").fetchone()[0] == int(candidate) + + +def test_a3_success_retains_the_same_publication_and_actual_receipt(ready): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + value = _admit(store, ctx, rid) + snapshot = store.load_current() + assert value["status"] == "PASS" and value["commit_state"] == "CONFIRMED" + _a3_assert_result_ids(value, attempted=True, confirmed=True) + assert value["publication_id"] == snapshot.publication_id + assert durable._canonical(value["admission_receipt"]) == snapshot.receipt_bytes + # Attempt metadata belongs to the outer result, never to saved R3 bytes. + assert "attempt_publication_id" not in snapshot.receipt + assert snapshot.binding["memory_id"] == rid + + +class _A3CommitOutcomeFault: + def __init__(self, store, phase, *, after_sql): + self.store, self.real, self.phase = store, store._con, phase + self.after_sql, self.calls = after_sql, 0 + def __getattr__(self, name): + return getattr(self.real, name) + def execute(self, sql, args=()): + if sql == "COMMIT" and self.store._phase == self.phase: + self.calls += 1 + if self.after_sql: + self.real.execute(sql, args) + raise OSError("SYNTHETIC_PRIVATE_A3_COMMIT_RESPONSE_LOST") + return self.real.execute(sql, args) + + +@pytest.mark.parametrize("phase", ["CANDIDATE", "PROMOTING"]) +@pytest.mark.parametrize("after_sql", [False, True]) +def test_a3_uncertain_commit_preserves_attempt_not_confirmed_publication(ready, phase, after_sql): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + fault = _A3CommitOutcomeFault(store, phase, after_sql=after_sql) + store._con = fault + value = _admit(store, ctx, rid) + assert fault.calls == 1 # Never automatically retry an uncertain commit. + assert value["status"] == "HOLD" and value["possible_persisted_effect"] is True + assert value["publication_id"] is None, value + _a3_assert_result_ids(value, attempted=phase == "PROMOTING", confirmed=False) + assert value["commit_state"] == ("UNKNOWN" if phase == "PROMOTING" else "NOT_ATTEMPTED") + assert value["candidate_commit_state"] == ("CONFIRMED" if phase == "PROMOTING" else "UNKNOWN") + assert value["candidate_persisted"] is (True if phase == "PROMOTING" else None) + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"]) as fresh: + snapshot = fresh.load_current() + persisted = phase == "PROMOTING" and after_sql + assert (snapshot is not None) is persisted + if persisted: + assert snapshot.publication_id == value["attempt_publication_id"] + assert fresh._con.execute("SELECT COUNT(*) FROM person_admission_publications").fetchone()[0] == int(persisted) + assert fresh._con.execute("SELECT COUNT(*) FROM person_admission_candidates").fetchone()[0] == int(after_sql or phase == "PROMOTING") + + +@pytest.mark.parametrize("mode", ["unavailable", "mismatched-snapshot"]) +def test_a3_postcommit_hold_preserves_confirmed_id_without_allow_receipt(ready, monkeypatch, mode): + path, ctx, rid = ready + original = durable.PersonTwinAdmissionStore.load_current + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + def fault(instance): + actual = original(instance) + if instance is not store: + if mode == "unavailable": + raise OSError("SYNTHETIC_PRIVATE_A3_READBACK_UNAVAILABLE") + return replace(actual, publication_id="0" * 32) + return actual + with monkeypatch.context() as patch: + patch.setattr(durable.PersonTwinAdmissionStore, "load_current", fault) + value = _admit(store, ctx, rid) + assert value["status"] == "HOLD" and value["reason"] == "POSTCOMMIT_READBACK_FAILED" + assert value["commit_state"] == "CONFIRMED" and value["possible_persisted_effect"] is True + _a3_assert_result_ids(value, attempted=True, confirmed=True) + assert store.load_current().publication_id == value["publication_id"] + assert store._con.execute("SELECT COUNT(*) FROM person_admission_publications").fetchone()[0] == 1 + + +@pytest.mark.parametrize("phase", ["promotion_begin", "pointer_write", "promotion_commit", "unknown-before-sql"]) +def test_a3_failed_republication_does_not_report_previous_current_as_its_own(ready, monkeypatch, phase): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + first = _admit(store, ctx, rid) + assert first["status"] == "PASS" + before = store.load_current() + original = durable.assert_current_effect_allowed + def deny(name): + if name == "person.admission." + phase: + raise RuntimeError("SYNTHETIC_PRIVATE_A3_REPUBLICATION_DENIED") + return original(name) + with monkeypatch.context() as patch: + patch.setattr(durable, "assert_current_effect_allowed", deny) + if phase == "unknown-before-sql": + fault = _A3CommitOutcomeFault(store, "PROMOTING", after_sql=False) + store._con = fault + value = _admit(store, ctx, rid) + assert value["status"] == "HOLD" and value["publication_id"] is None + _a3_assert_result_ids(value, attempted=phase != "promotion_begin", confirmed=False) + assert value["attempt_publication_id"] != before.publication_id + assert store.load_current() == before + assert store._con.execute("SELECT COUNT(*) FROM person_admission_publications").fetchone()[0] == 1 + + +def test_a3_attempt_identifier_is_reset_between_explicit_calls(ready, monkeypatch): + path, ctx, rid = ready + original = durable.assert_current_effect_allowed + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + def deny_commit(name): + if name == "person.admission.promotion_commit": + raise RuntimeError("SYNTHETIC_PRIVATE_A3_DENY_COMMIT") + return original(name) + with monkeypatch.context() as patch: + patch.setattr(durable, "assert_current_effect_allowed", deny_commit) + first = _admit(store, ctx, rid) + _a3_assert_result_ids(first, attempted=True, confirmed=False) + def deny_start(name): + if name == "person.admission.admit": + raise RuntimeError("SYNTHETIC_PRIVATE_A3_DENY_START") + return original(name) + with monkeypatch.context() as patch: + patch.setattr(durable, "assert_current_effect_allowed", deny_start) + second = _admit(store, ctx, rid) + _a3_assert_result_ids(second, attempted=False, confirmed=False) + third = _admit(store, ctx, rid) + assert third["status"] == "PASS" + _a3_assert_result_ids(third, attempted=True, confirmed=True) + assert third["attempt_publication_id"] != first["attempt_publication_id"] + + +def test_a3_unverified_rollback_keeps_attempt_without_confirmation(ready, monkeypatch): + path, ctx, rid = ready + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"], read_only=False) as store: + class RollbackFault: + def __init__(self, real): + self.real, self.rollbacks = real, 0 + def __getattr__(self, name): + return getattr(self.real, name) + def execute(self, sql, args=()): + if sql == "ROLLBACK": + self.rollbacks += 1 + raise OSError("SYNTHETIC_PRIVATE_A3_ROLLBACK_UNVERIFIED") + return self.real.execute(sql, args) + fault = RollbackFault(store._con) + store._con = fault + original = durable.assert_current_effect_allowed + def deny(name): + if name == "person.admission.promotion_commit": + raise RuntimeError("SYNTHETIC_PRIVATE_A3_DENY_BEFORE_COMMIT") + return original(name) + with monkeypatch.context() as patch: + patch.setattr(durable, "assert_current_effect_allowed", deny) + value = _admit(store, ctx, rid) + assert fault.rollbacks == 1 + assert value["status"] == "HOLD" and value["reason"] == "ROLLBACK_UNVERIFIED" + assert value["commit_state"] == "NOT_ATTEMPTED" and value["possible_persisted_effect"] is True + _a3_assert_result_ids(value, attempted=True, confirmed=False) + with pytest.raises(durable.DurableAdmissionError): + store.load_current() + with pytest.raises(durable.DurableAdmissionError): + _admit(store, ctx, rid) + # Closing SQLite actually rolled it back, but the returned result must not + # retroactively claim a successful rollback that its own code could not verify. + with durable.PersonTwinAdmissionStore(path, identity=ctx["identity"]) as fresh: + assert fresh.load_current() is None diff --git a/tests/test_person_twin_r21h_evidence.py b/tests/test_person_twin_r21h_evidence.py index 668719f2..ac6edd1f 100644 --- a/tests/test_person_twin_r21h_evidence.py +++ b/tests/test_person_twin_r21h_evidence.py @@ -52,7 +52,13 @@ def _load_r4_fixture_module(): _R4_FIXTURE = _load_r4_fixture_module() -AdmissionStore = _R4_FIXTURE.Store +class AdmissionStore(_R4_FIXTURE.Store): + """Synthetic R21H fixture with a saved binding; plain R4 protocol is unchanged.""" + memory_binding_bytes = None + + def read_current_memory_binding_bytes(self): + return self.memory_binding_bytes + _hash = _R4_FIXTURE._hash _inputs = _R4_FIXTURE._inputs _policy = _R4_FIXTURE._policy @@ -254,6 +260,15 @@ def snapshot(tmp_path): current_id = writer.supersede(old_id, CURRENT_TEXT, namespace=NAMESPACE) finally: writer.store.con.close() + # Bind the issued admission to the actual seeded row once, before any intent + # or provider exists. Never reconstruct this fixture binding from read pins. + admission_store.memory_binding_bytes = json.dumps({ + "schema": "continuityos.person-twin.r21h-memory-binding/v1", + "pointer_sha256": hashlib.sha256(admission_store.pointer).hexdigest(), + "candidate_sha256": _hash(record), + "memory_id": current_id, "namespace": NAMESPACE, + "person_record_id": record["id"], "source_record_id": source["id"], + }, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") return SimpleNamespace( context=context, db=db, current_id=current_id, old_id=old_id, foreign_id=foreign_id,