From 223b0ff67ef0a362b05b10156af4556d06e6414e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:47:41 -0400 Subject: [PATCH 01/18] Replace copied table trigger hooks with mutation capabilities --- src/agent_comms/attempt_recovery.py | 4 +- src/agent_comms/attempt_start.py | 4 +- src/agent_comms/attempt_store.py | 2 +- src/agent_comms/cohort_foreground.py | 4 +- src/agent_comms/cohort_schema.py | 349 ++++-------------- src/agent_comms/coordinated_runtime_schema.py | 11 +- src/agent_comms/coordination_cohort.py | 17 +- src/agent_comms/coordination_response.py | 38 +- .../coordination_tables/__init__.py | 20 + .../coordination_tables/assignments.py | 126 ++----- .../coordination_tables/attempts.py | 190 +++------- .../coordination_tables/executions.py | 140 ++----- .../coordination_tables/participants.py | 106 ++---- .../coordination_tables/publications.py | 91 +---- .../coordination_tables/recovery.py | 63 +--- .../coordination_tables/responses.py | 175 ++------- src/agent_comms/cursor_owner.py | 4 +- src/agent_comms/execution_store.py | 2 +- src/agent_comms/historical_native_inputs.py | 8 +- src/agent_comms/input_drain.py | 2 +- src/agent_comms/native_input_record.py | 12 +- src/agent_comms/native_pi.py | 35 +- src/agent_comms/native_prompt_binding.py | 29 +- src/agent_comms/native_runtime_input.py | 62 +--- src/agent_comms/native_source_cursor.py | 10 +- .../optional_awareness_projection.py | 11 +- src/agent_comms/participant_store.py | 17 +- src/agent_comms/private_send_claim.py | 4 +- src/agent_comms/private_send_stage.py | 6 +- src/agent_comms/private_sidecar.py | 13 +- src/agent_comms/proven_source_coverage.py | 8 +- src/agent_comms/selected_claim.py | 4 +- src/agent_comms/selected_participant.py | 20 +- src/agent_comms/selected_tool_broker.py | 4 +- src/agent_comms/thread_management.py | 4 +- src/agent_comms/transcripts.py | 8 +- src/agent_comms/triage_native_sources.py | 44 +-- src/agent_comms/typed_table.py | 165 +++++++-- tests/test_acp_private_nk_delivery.py | 2 +- tests/test_claim_admission_verifier.py | 4 +- tests/test_claim_bus_read.py | 4 +- tests/test_coordinated_runtime.py | 10 +- tests/test_coordination_store.py | 6 +- tests/test_optional_awareness_projection.py | 2 +- tests/test_thread_identity.py | 6 +- tools/cutover/carry_declared_schema.py | 24 +- tools/cutover/carry_table_triggers.py | 103 ++++++ 47 files changed, 716 insertions(+), 1257 deletions(-) create mode 100644 tools/cutover/carry_table_triggers.py diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 33fabf8148..b23a6f5efb 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -80,14 +80,14 @@ def observe_native_release( is insufficient. """ from agent_comms.comms import Comms - from agent_comms.coordinated_runtime_schema import assert_native_runtime_schema + from agent_comms.native_runtime_input import NativeRuntimeTable if store.session.path.name != CoordinationStore.filename: raise RecoveryBlocked("native recovery requires the canonical coordination store") comms = Comms(store.session.path.parent) with store.session.boundary(comms.bus) as boundary: registry = boundary.registry - assert_native_runtime_schema(store.session._connection) + NativeRuntimeTable.require_installed(store.session._connection) snapshot = store.snapshots.get(execution_id) attempt = snapshot.attempt if attempt is None or not snapshot.is_current: diff --git a/src/agent_comms/attempt_start.py b/src/agent_comms/attempt_start.py index a53e948bf4..bd134bbc85 100644 --- a/src/agent_comms/attempt_start.py +++ b/src/agent_comms/attempt_start.py @@ -69,8 +69,8 @@ def validate(self, snapshot: RecoverySnapshot, participant: ParticipantSnapshot) execution = snapshot.execution ordinal = execution.lifecycle.next_attempt_ordinal if not participant.committed or ( - participant.owner_thread, - participant.participant_generation, + participant.owner_identity.owner_thread, + participant.owner_identity.generation, ) != (self.owner_thread, self.owner_generation): raise StaleFence("owner generation or thread changed") if (execution.revision, participant.pointer.pointer_revision) != ( diff --git a/src/agent_comms/attempt_store.py b/src/agent_comms/attempt_store.py index 03605fb76e..0754920b3e 100644 --- a/src/agent_comms/attempt_store.py +++ b/src/agent_comms/attempt_store.py @@ -59,7 +59,7 @@ def require_fence(self, fence: OwnerFence) -> tuple[RecoverySnapshot, AttemptRec attempt = snapshot.require_current_attempt() if ( attempt.authority != fence.authority - or self.participants.get(attempt.owner_lookup).participant_generation + or self.participants.get(attempt.owner_lookup).owner_identity.generation != attempt.owner_generation ): raise StaleFence("attempt fence is not current") diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index 143c45f127..7130323a25 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -8,7 +8,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination -from .cohort_schema import CohortDeliveryReceipts, assert_cohort_schema +from .cohort_schema import CohortDeliveryReceipts, CohortTable from .coordination_cohort import accept_delivery_cohort from .errors import RelationViolationError from .message_bus import MessageBus @@ -39,7 +39,7 @@ def accept(resource: Coordination) -> int: raise IdentityConflict("private initial wire root changed") authorized_after = max(after_seq, marker.claimed_after_seq) with resource.session.read(): - assert_cohort_schema(resource.session._connection) + CohortTable.require_installed(resource.session._connection) sealed = CohortDeliveryReceipts.sealed_sequences_after( resource.session._connection, wire_root_id=root_id, diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index 62bfb1edc4..c9987e5607 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -10,19 +10,27 @@ from agent_comms.coordination_errors import SchemaVersionError from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.coordination_tables.participants import OwnerGenerations, OwnerRecord, Participants from agent_comms.coordinator import Coordination from agent_comms.private_runtime_schema import PrivateRuntimeSchema -from .field_codec import MalformedRecord +from .field_codec import MalformedRecord, WireName from .typed_table import ( Column, + ColumnGuard, DurableTables, ForeignKey, + FrozenColumns, + GuardedColumns, + GuardedInserts, + GuardedRelatedInserts, + GuardedUpdates, + ImmutableRows, Index, - SharedFileTables, + NoDelete, SQLiteForeignKeys, SQLiteJournalMode, + SharedFileTables, TypedTable, ) @@ -48,7 +56,30 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(CohortTable, TypedTable, declared_name="claim_batch_receipts"): +class ClaimBatchAcknowledgements(GuardedInserts, GuardedUpdates, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): + + insert_condition = """NEW.sealed != 0""" + immutable_columns = ('wire_root_id', 'wire_seq', 'message_id', 'exact_target', 'envelope_digest', 'audience_digest', 'decisions_digest', 'member_count', 'claim_count', 'manifest_codec', 'resolver_version', 'policy_version', 'accepted_at_ms') + update_condition = """OLD.sealed != 0 OR NEW.sealed != 1""" + column_guards = ( + ColumnGuard(('sealed',), """(OLD.sealed = 0 AND NEW.sealed = 1) AND (((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE + d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) != OLD.member_count OR (SELECT COUNT(*) + FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND + d.kind = 'selected') != OLD.claim_count) OR ((SELECT COUNT(*) FROM claim_batch_members m WHERE + m.wire_root_id = OLD.wire_root_id AND m.wire_seq = OLD.wire_seq) != OLD.claim_count) OR (EXISTS ( SELECT 1 + FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND + d.ordinal >= OLD.member_count) OR EXISTS ( SELECT 1 FROM claim_batch_members m WHERE m.wire_root_id = + OLD.wire_root_id AND m.wire_seq = OLD.wire_seq AND m.ordinal >= OLD.claim_count)) OR (EXISTS ( SELECT 1 FROM + claim_batch_members m LEFT JOIN cohort_delivery_receipts d ON d.wire_root_id = m.wire_root_id AND d.wire_seq + = m.wire_seq AND d.claim_id = m.claim_id WHERE m.wire_root_id = OLD.wire_root_id AND m.wire_seq = + OLD.wire_seq AND (d.claim_id IS NULL OR m.ordinal != ( SELECT COUNT(*) FROM cohort_delivery_receipts + preceding WHERE preceding.wire_root_id = OLD.wire_root_id AND preceding.wire_seq = OLD.wire_seq AND + preceding.kind = 'selected' AND preceding.ordinal < d.ordinal )) )) OR (EXISTS ( SELECT 1 FROM + cohort_delivery_receipts d JOIN wake_claims c ON c.recipient_lookup = d.recipient_lookup AND c.wire_seq = + d.wire_seq WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND d.kind = + 'unmentioned_observer' )))"""), + ) + wire_root_id: Annotated[ str, Column( @@ -84,116 +115,16 @@ def require_selected_claim(self, assignment: WakeAssignment) -> None: if not self.sealed or self.message_id != assignment.message_id: raise ProjectionUnavailableError("selected source acknowledgement is unsealed or changed") - @classmethod - def trigger_bodies(cls): - return { - "cohort_receipt_insert_guard": ( - "\n" - " BEFORE INSERT ON claim_batch_receipts\n" - " WHEN NEW.sealed != 0\n" - " BEGIN SELECT RAISE(ABORT, 'cohort must be sealed after i" - "ts member rows'); END" - ), - "cohort_receipt_update_guard": ( - "\n" - " BEFORE UPDATE ON claim_batch_receipts\n" - " WHEN OLD.sealed != 0 OR NEW.sealed != 1\n" - " OR NEW.wire_root_id != OLD.wire_root_id OR NEW.wire_se" - "q != OLD.wire_seq\n" - " OR NEW.message_id != OLD.message_id OR NEW.exact_targe" - "t != OLD.exact_target\n" - " OR NEW.envelope_digest != OLD.envelope_digest\n" - " OR NEW.audience_digest != OLD.audience_digest\n" - " OR NEW.decisions_digest != OLD.decisions_digest\n" - " OR NEW.member_count != OLD.member_count OR NEW.claim_c" - "ount != OLD.claim_count\n" - " OR NEW.manifest_codec != OLD.manifest_codec\n" - " OR NEW.resolver_version != OLD.resolver_version\n" - " OR NEW.policy_version != OLD.policy_version\n" - " OR NEW.accepted_at_ms != OLD.accepted_at_ms\n" - " BEGIN SELECT RAISE(ABORT, 'cohort receipt facts are immu" - "table'); END" - ), - "cohort_receipt_seal_complete": ( - "\n" - " BEFORE UPDATE OF sealed ON claim_batch_receipts\n" - " WHEN OLD.sealed = 0 AND NEW.sealed = 1\n" - " BEGIN\n" - " SELECT RAISE(ABORT, 'incomplete cohort delivery rece" - "ipts')\n" - " WHERE (SELECT COUNT(*) FROM cohort_delivery_receipts" - " d\n" - " WHERE d.wire_root_id = OLD.wire_root_id AND d" - ".wire_seq = OLD.wire_seq)\n" - " != OLD.member_count\n" - " OR (SELECT COUNT(*) FROM cohort_delivery_receipts" - " d\n" - " WHERE d.wire_root_id = OLD.wire_root_id AND d" - ".wire_seq = OLD.wire_seq\n" - " AND d.kind = 'selected') != OLD.claim_count" - ";\n" - " SELECT RAISE(ABORT, 'incomplete cohort claim members" - "')\n" - " WHERE (SELECT COUNT(*) FROM claim_batch_members m\n" - " WHERE m.wire_root_id = OLD.wire_root_id AND m" - ".wire_seq = OLD.wire_seq)\n" - " != OLD.claim_count;\n" - " SELECT RAISE(ABORT, 'noncontiguous cohort receipt or" - "dinal')\n" - " WHERE EXISTS (\n" - " SELECT 1 FROM cohort_delivery_receipts d\n" - " WHERE d.wire_root_id = OLD.wire_root_id AND d.wi" - "re_seq = OLD.wire_seq\n" - " AND d.ordinal >= OLD.member_count)\n" - " OR EXISTS (\n" - " SELECT 1 FROM claim_batch_members m\n" - " WHERE m.wire_root_id = OLD.wire_root_id AND m.wi" - "re_seq = OLD.wire_seq\n" - " AND m.ordinal >= OLD.claim_count);\n" - " SELECT RAISE(ABORT, 'cohort selected claim order doe" - "s not match delivery order')\n" - " WHERE EXISTS (\n" - " SELECT 1 FROM claim_batch_members m\n" - " LEFT JOIN cohort_delivery_receipts d\n" - " ON d.wire_root_id = m.wire_root_id AND d.wire_" - "seq = m.wire_seq\n" - " AND d.claim_id = m.claim_id\n" - " WHERE m.wire_root_id = OLD.wire_root_id AND m.wi" - "re_seq = OLD.wire_seq\n" - " AND (d.claim_id IS NULL OR m.ordinal != (\n" - " SELECT COUNT(*) FROM cohort_delivery_receipt" - "s preceding\n" - " WHERE preceding.wire_root_id = OLD.wire_root" - "_id\n" - " AND preceding.wire_seq = OLD.wire_seq\n" - " AND preceding.kind = 'selected' AND preced" - "ing.ordinal < d.ordinal\n" - " ))\n" - " );\n" - " SELECT RAISE(ABORT, 'unmentioned observer has an exi" - "sting wake claim')\n" - " WHERE EXISTS (\n" - " SELECT 1 FROM cohort_delivery_receipts d\n" - " JOIN wake_claims c ON c.recipient_lookup = d.rec" - "ipient_lookup\n" - " AND c.wire_seq = d.wire_seq\n" - " WHERE d.wire_root_id = OLD.wire_root_id AND d.wi" - "re_seq = OLD.wire_seq\n" - " AND d.kind = 'unmentioned_observer'\n" - " );\n" - " END" - ), - "cohort_receipt_delete_guard": ( - "\n" - " BEFORE DELETE ON claim_batch_receipts\n" - " BEGIN SELECT RAISE(ABORT, 'cohort receipt cannot be dele" - "ted'); END" - ), - } +@dataclass(frozen=True, kw_only=True) +class ClaimBatchMembers(GuardedInserts, ImmutableRows, CohortTable, TypedTable): + insert_condition = """((SELECT sealed FROM claim_batch_receipts r WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = + NEW.wire_seq) != 0) OR (NOT EXISTS ( SELECT 1 FROM wake_claims c JOIN claim_batch_receipts r ON + r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq WHERE c.assignment_id = NEW.claim_id AND + c.recipient_lookup = NEW.recipient_lookup AND c.wire_seq = NEW.wire_seq AND c.message_id = r.message_id AND + c.resolver_version = r.resolver_version AND c.policy_version = r.policy_version AND c.accepted_at_ms = + r.accepted_at_ms))""" -@dataclass(frozen=True, kw_only=True) -class ClaimBatchMembers(CohortTable, TypedTable): wire_root_id: Annotated[str, Column(primary_key=True)] wire_seq: Annotated[int, Column(primary_key=True)] ordinal: Annotated[int, Column(primary_key=True, check="ordinal >= 0")] @@ -216,53 +147,21 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "cohort_members_insert_guard": ( - " BEFORE INSERT ON clai" - "m_batch_members\n" - " BEGIN\n" - " SELECT RAISE(ABORT, 'sealed cohort rejects members')" - "\n" - " WHERE (SELECT sealed FROM claim_batch_receipts r\n" - " WHERE r.wire_root_id = NEW.wire_root_id AND r" - ".wire_seq = NEW.wire_seq) != 0;\n" - " SELECT RAISE(ABORT, 'claim member does not match coh" - "ort acceptance')\n" - " WHERE NOT EXISTS (\n" - " SELECT 1 FROM wake_claims c\n" - " JOIN claim_batch_receipts r\n" - " ON r.wire_root_id = NEW.wire_root_id AND r.wir" - "e_seq = NEW.wire_seq\n" - " WHERE c.assignment_id = NEW.claim_id\n" - " AND c.recipient_lookup = NEW.recipient_lookup\n" - " AND c.wire_seq = NEW.wire_seq\n" - " AND c.message_id = r.message_id\n" - " AND c.resolver_version = r.resolver_version\n" - " AND c.policy_version = r.policy_version\n" - " AND c.accepted_at_ms = r.accepted_at_ms);\n" - " END" - ), - "cohort_members_update_guard": ( - " BEFORE UPDATE ON clai" - "m_batch_members\n" - " BEGIN SELECT RAISE(ABORT, 'cohort claim member is immuta" - "ble'); END" - ), - "cohort_members_delete_guard": ( - " BEFORE DELETE ON clai" - "m_batch_members\n" - " BEGIN SELECT RAISE(ABORT, 'cohort claim member cannot be" - " deleted'); END" - ), - } - - @dataclass(frozen=True, kw_only=True) -class CohortDeliveryReceipts(CohortTable, TypedTable): +class CohortDeliveryReceipts(GuardedRelatedInserts, GuardedInserts, ImmutableRows, CohortTable, TypedTable): # Delivery selection kind is distinct from this table's nominal identity. + + related_insert_target = WakeAssignment + related_insert_condition = """EXISTS ( SELECT 1 FROM cohort_delivery_receipts d JOIN claim_batch_receipts r ON + r.wire_root_id=d.wire_root_id AND r.wire_seq=d.wire_seq AND r.sealed=1 WHERE + d.recipient_lookup=NEW.recipient_lookup AND d.wire_seq=NEW.wire_seq AND d.kind='unmentioned_observer')""" + insert_condition = """((SELECT sealed FROM claim_batch_receipts r WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = + NEW.wire_seq) != 0) OR (NEW.kind = 'selected' AND NOT EXISTS ( SELECT 1 FROM claim_batch_members m JOIN + wake_claims c ON c.assignment_id = m.claim_id WHERE m.wire_root_id = NEW.wire_root_id AND m.wire_seq = + NEW.wire_seq AND m.claim_id = NEW.claim_id AND m.recipient_lookup = NEW.recipient_lookup AND c.recipient = + NEW.canonical_thread))""" + wire_root_id: Annotated[str, Column(primary_key=True)] wire_seq: Annotated[int, Column(primary_key=True)] ordinal: Annotated[int, Column(primary_key=True, check="ordinal >= 0")] @@ -319,67 +218,22 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "cohort_delivery_insert_guard": ( - " BEFORE INSERT ON coh" - "ort_delivery_receipts\n" - " BEGIN\n" - " SELECT RAISE(ABORT, 'sealed cohort rejects delivery " - "receipts')\n" - " WHERE (SELECT sealed FROM claim_batch_receipts r\n" - " WHERE r.wire_root_id = NEW.wire_root_id AND r" - ".wire_seq = NEW.wire_seq) != 0;\n" - " SELECT RAISE(ABORT, 'selected delivery has no matchi" - "ng claim member')\n" - " WHERE NEW.kind = 'selected' AND NOT EXISTS (\n" - " SELECT 1 FROM claim_batch_members m\n" - " JOIN wake_claims c ON c.assignment_id = m.claim_id\n" - " WHERE m.wire_root_id = NEW.wire_root_id AND m.wi" - "re_seq = NEW.wire_seq\n" - " AND m.claim_id = NEW.claim_id\n" - " AND m.recipient_lookup = NEW.recipient_lookup\n" - " AND c.recipient = NEW.canonical_thread);\n" - " END" - ), - "cohort_observer_claim_insert_guard": ( - " BEFORE INSERT " - "ON wake_claims\n" - " WHEN EXISTS (\n" - " SELECT 1 FROM cohort_delivery_receipts d\n" - " JOIN claim_batch_receipts r ON r.wire_root_id=d.wire" - "_root_id\n" - " AND r.wire_seq=d.wire_seq AND r.sealed=1\n" - " WHERE d.recipient_lookup=NEW.recipient_lookup AND d." - "wire_seq=NEW.wire_seq\n" - " AND d.kind='unmentioned_observer')\n" - " BEGIN SELECT RAISE(ABORT, 'no-wake observer cannot gain " - "an unbound wake claim'); END" - ), - "cohort_delivery_update_guard": ( - " BEFORE UPDATE ON coh" - "ort_delivery_receipts\n" - " BEGIN SELECT RAISE(ABORT, 'cohort delivery receipt is im" - "mutable'); END" - ), - "cohort_delivery_delete_guard": ( - " BEFORE DELETE ON coh" - "ort_delivery_receipts\n" - " BEGIN SELECT RAISE(ABORT, 'cohort delivery receipt canno" - "t be deleted'); END" - ), - } +@dataclass(frozen=True, kw_only=True) +class AwarenessClaimGenerations(OwnerRecord, GuardedInserts, ImmutableRows, AwarenessTable, TypedTable): + insert_condition = """(NOT EXISTS ( SELECT 1 FROM claim_batch_members m JOIN wake_claims c ON c.assignment_id=m.claim_id JOIN + claim_batch_receipts r ON r.wire_root_id=m.wire_root_id AND r.wire_seq=m.wire_seq JOIN owner_generations g + ON g.owner_lookup=m.recipient_lookup WHERE m.claim_id=NEW.claim_id AND m.wire_root_id=NEW.wire_root_id AND + m.wire_seq=NEW.wire_seq AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient=NEW.canonical_thread AND + c.message_id=r.message_id AND r.sealed=0 AND g.owner_thread=NEW.canonical_thread AND + g.generation=NEW.owner_generation))""" -@dataclass(frozen=True, kw_only=True) -class AwarenessClaimGenerations(AwarenessTable, TypedTable): claim_id: Annotated[str, Column(primary_key=True)] wire_root_id: str wire_seq: Annotated[int, Column(check="wire_seq > 0")] - recipient_lookup: str - canonical_thread: Annotated[ - str, Column(check="length(canonical_thread) BETWEEN 1 AND 256") + owner_lookup: Annotated[str, WireName("recipient_lookup"), Column(record_key=True)] + owner_thread: Annotated[ + str, WireName("canonical_thread"), Column(record_key=True, check="length(canonical_thread) BETWEEN 1 AND 256") ] owner_generation: Annotated[int, Column(check="owner_generation > 0")] @@ -389,26 +243,18 @@ def current(self, expected: OwnerGenerations) -> bool: if self.owner_generation < expected.generation: return False - if self.captured_owner != expected: + if self.owner_identity != expected: raise ProjectionUnavailableError("captured participant owner changed") return True - @property - def captured_owner(self) -> OwnerGenerations: - """The original acceptance's owner observation, not a current lease.""" - return OwnerGenerations( - owner_lookup=self.recipient_lookup, owner_thread=self.canonical_thread, - generation=self.owner_generation, - ) - def require_selection(self, assignment: WakeAssignment, acknowledgement: ClaimBatchAcknowledgements) -> None: """Verify this immutable acceptance capture against its original claim.""" from .wake_candidate_index import ProjectionUnavailableError expected = type(self)( claim_id=assignment.assignment_id, wire_root_id=acknowledgement.wire_root_id, - wire_seq=assignment.wire_seq, recipient_lookup=assignment.recipient_lookup, - canonical_thread=assignment.recipient, owner_generation=self.owner_generation, + wire_seq=assignment.wire_seq, owner_lookup=assignment.recipient_lookup, + owner_thread=assignment.recipient, owner_generation=self.owner_generation, ) if self != expected: raise ProjectionUnavailableError("selected source generation provenance changed") @@ -428,7 +274,7 @@ def require_obligation(self, execution, link, assignment, obligation) -> None: execution_id=obligation.execution_id, assignment_id=self.claim_id, ordinal=link.ordinal, ) - if self.captured_owner != original_owner or link != original_link: + if self.owner_identity != original_owner or link != original_link: raise ProjectionUnavailableError("open obligation has no exact owner provenance") if (self.claim_id, assignment.lifecycle.exact_target) != ( assignment.assignment_id, obligation.exact_target @@ -439,7 +285,7 @@ def require_obligation(self, execution, link, assignment, obligation) -> None: def references(cls): return ( ForeignKey(("claim_id",), WakeAssignment, ("assignment_id",)), - ForeignKey(("recipient_lookup",), Participants, ("participant_lookup",)), + ForeignKey(("owner_lookup",), Participants, ("participant_lookup",)), ForeignKey( ("wire_root_id", "wire_seq", "claim_id"), ClaimBatchMembers, @@ -447,57 +293,6 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "awareness_generation_insert_guard": ( - "\n" - " BEFORE INSERT ON awareness_claim_generations BEGIN\n" - " SELECT RAISE(ABORT, 'optional generation requires un" - "sealed exact claim/member')\n" - " WHERE NOT EXISTS (\n" - " SELECT 1 FROM claim_batch_members m JOIN wake_cl" - "aims c\n" - " ON c.assignment_id=m.claim_id JOIN claim_batch_rece" - "ipts r\n" - " ON r.wire_root_id=m.wire_root_id AND r.wire_se" - "q=m.wire_seq\n" - " JOIN owner_generations g ON g.owner_lookup=m.rec" - "ipient_lookup\n" - " WHERE m.claim_id=NEW.claim_id AND m.wire_root_id" - "=NEW.wire_root_id\n" - " AND m.wire_seq=NEW.wire_seq\n" - " AND m.recipient_lookup=NEW.recipient_lookup\n" - " AND c.recipient=NEW.canonical_thread\n" - " AND c.message_id=r.message_id AND r.sealed=0\n" - " AND g.owner_thread=NEW.canonical_thread\n" - " AND g.generation=NEW.owner_generation);\n" - " END" - ), - "awareness_generation_update_guard": ( - "\n" - " BEFORE UPDATE ON awareness_claim_generations\n" - " BEGIN SELECT RAISE(ABORT, 'optional generation is immuta" - "ble'); END" - ), - "awareness_generation_delete_guard": ( - "\n" - " BEFORE DELETE ON awareness_claim_generations\n" - " BEGIN SELECT RAISE(ABORT, 'optional generation cannot be" - " deleted'); END" - ), - } - - -def assert_cohort_schema(db: sqlite3.Connection) -> None: - if not CohortTable.stored_schema(db): - raise SchemaVersionError("private cohort schema is not installed") - if CohortTable.schema_differs(db) or SQLiteForeignKeys.read( - db.execute("PRAGMA foreign_keys") - ) != [SQLiteForeignKeys(True)]: - raise SchemaVersionError("private cohort schema is missing or drifted") - - def assert_optional_awareness_schema(db: sqlite3.Connection) -> None: if not AwarenessTable.stored_schema(db) or AwarenessTable.schema_differs(db): raise SchemaVersionError("optional awareness generation schema is unavailable") @@ -515,7 +310,7 @@ def install_private_cohort_schema(store: Coordination) -> None: CoordinatorTable.require_schema(db) if not CohortTable.stored_schema(db): CohortTable.create_schema(db) - assert_cohort_schema(db) + CohortTable.require_installed(db) if SQLiteJournalMode.read(db.execute("PRAGMA journal_mode")) != [ SQLiteJournalMode("delete") ]: diff --git a/src/agent_comms/coordinated_runtime_schema.py b/src/agent_comms/coordinated_runtime_schema.py index 850084656a..5ccc8f3ba3 100644 --- a/src/agent_comms/coordinated_runtime_schema.py +++ b/src/agent_comms/coordinated_runtime_schema.py @@ -11,18 +11,9 @@ from .typed_table import SQLiteForeignKeys -def assert_native_runtime_schema(db: sqlite3.Connection) -> None: - if not NativeRuntimeTable.stored_schema(db): - raise PublicationActivationBlocked("native runtime schema is not installed") - if NativeRuntimeTable.schema_differs(db) or SQLiteForeignKeys.read( - db.execute("PRAGMA foreign_keys") - ) != [SQLiteForeignKeys(True)]: - raise PublicationActivationBlocked("native runtime schema has drifted") - - def install_native_runtime_schema(store: Coordination) -> None: """Explicit fresh schema install. Existing state must match the declared schema.""" with store.session.writing() as db: if not NativeRuntimeTable.stored_schema(db): NativeRuntimeTable.create_schema(db) - assert_native_runtime_schema(db) + NativeRuntimeTable.require_installed(db) diff --git a/src/agent_comms/coordination_cohort.py b/src/agent_comms/coordination_cohort.py index 8d6e9c7eee..25ba8f22b1 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -15,14 +15,7 @@ from agent_comms.assignment_states import AssignmentState, PendingNotification from agent_comms.bus_publication import CommittedDelivery -from agent_comms.cohort_schema import ( - AwarenessClaimGenerations, - ClaimBatchAcknowledgements, - ClaimBatchMembers, - CohortDeliveryReceipts, - assert_cohort_schema, - assert_optional_awareness_schema, -) +from agent_comms.cohort_schema import AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, CohortDeliveryReceipts, CohortTable, assert_optional_awareness_schema from agent_comms.coordination_contracts import POLICY_VERSION, RESOLVER_VERSION from agent_comms.coordination_errors import ( IdentityConflict, @@ -214,8 +207,8 @@ def _record_optional_owner_generations( claim_id=assignment.assignment_id, wire_root_id=wire_root_id, wire_seq=wire_seq, - recipient_lookup=assignment.recipient_lookup, - canonical_thread=assignment.recipient, + owner_lookup=assignment.recipient_lookup, + owner_thread=assignment.recipient, owner_generation=current.generation, ).insert(db) except (sqlite3.Error, SchemaVersionError, IdentityConflict, MalformedRecord) as error: @@ -245,7 +238,7 @@ def accept_delivery_cohort( if wire_seq <= marker.claimed_after_seq: raise IdentityConflict("historical source precedes the current claim_scope floor") initial = source.delivery(wire_seq) - assert_cohort_schema(db) + CohortTable.require_installed(db) acknowledgement = ClaimBatchAcknowledgements.one(db, wire_root_id=wire_root_id, wire_seq=wire_seq) if acknowledgement is not None: return AlreadyApplied(_claim_record(db, initial)) @@ -360,7 +353,7 @@ def sealed_cohort_assignments( raise ValueError("acknowledgement-backed page bounds are invalid") with store.session.read(): db = store.session._connection - assert_cohort_schema(db) + CohortTable.require_installed(db) selected_states = AssignmentState.selection_predicate(state_capability, "c") return tuple( WakeAssignment.read( diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index 2475905158..a9be9fcf94 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import Annotated, Type -from agent_comms.cohort_schema import assert_cohort_schema +from agent_comms.cohort_schema import CohortTable from agent_comms.coordination_errors import ( IdentityConflict, PublicationActivationBlocked, @@ -52,8 +52,9 @@ Column, DurableTables, ForeignKey, - SharedFileTables, + ImmutableRows, SQLiteForeignKeys, + SharedFileTables, SqlValue, TypedRow, TypedTable, @@ -62,23 +63,15 @@ from .locked_store import RegistryFile -class ResponseTable(SharedFileTables, DurableTables, PrivateRuntimeSchema): +class ResponseTable(ImmutableRows, SharedFileTables, DurableTables, PrivateRuntimeSchema): """An immutable response definition with its write checks and append evidence.""" + schema_error = PublicationActivationBlocked + @classmethod def install(cls, store) -> None: install_private_response_schema(store) - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - return { - f"{cls.declared_name}_{operation.lower()}_guard": " " - f"BEFORE {operation} ON {cls.declared_name} " - "BEGIN SELECT RAISE(ABORT,'response evidence is frozen'); END" - for operation in ("UPDATE", "DELETE") - } - - @dataclass(frozen=True) class PublicationAppendDispatches(ResponseTable, TypedTable): execution_id: Annotated[str, Column(primary_key=True)] @@ -235,21 +228,12 @@ def require_source(self, initial, assignment: WakeAssignment) -> None: raise IdentityConflict("response sealed route differs from committed source") -def _assert_response_schema(db: sqlite3.Connection) -> None: - if not ResponseTable.stored_schema(db): - raise PublicationActivationBlocked("private response schema is not installed") - if ResponseTable.schema_differs(db) or SQLiteForeignKeys.read( - db.execute("PRAGMA foreign_keys") - ) != [SQLiteForeignKeys(True)]: - raise PublicationActivationBlocked("private response schema has drifted") - - def install_private_response_schema(store: Coordination) -> None: """Install the current response tables on a fresh coordinator only.""" with store.session.writing() as db: if not ResponseTable.stored_schema(db): ResponseTable.create_schema(db) - _assert_response_schema(db) + ResponseTable.require_installed(db) @dataclass(frozen=True, kw_only=True) @@ -351,8 +335,8 @@ def _require_cohort_assignments( Caller already holds the bus file lock before the SQL transaction. """ db = store.session._connection - _assert_response_schema(db) - assert_cohort_schema(db) + ResponseTable.require_installed(db) + CohortTable.require_installed(db) snapshot.require_wire_responses() metadata = bus.log.private_marker_unlocked() if metadata.root_id != wire_root_id: @@ -555,7 +539,7 @@ def _settle_fenced_response( # a first attempt from a lost response/crash. If it commits but the # process dies before appending, no automatic retry is authorized. with store.session.writing() as db: - _assert_response_schema(db) + ResponseTable.require_installed(db) terminal = _terminal_replay(store, fence, bus, wire_root_id, owner_witness, exact_target) if terminal is not None: return terminal @@ -575,7 +559,7 @@ def _settle_fenced_response( elif not prior.matches(wire_root_id, fence, exact_target): raise IdentityConflict("response dispatch belongs to another root or owner") with store.session.writing() as db: - _assert_response_schema(db) + ResponseTable.require_installed(db) terminal = _terminal_replay(store, fence, bus, wire_root_id, owner_witness, exact_target) if terminal is not None: return terminal diff --git a/src/agent_comms/coordination_tables/__init__.py b/src/agent_comms/coordination_tables/__init__.py index 8b06ac4101..a66e9d5844 100644 --- a/src/agent_comms/coordination_tables/__init__.py +++ b/src/agent_comms/coordination_tables/__init__.py @@ -2,3 +2,23 @@ Importing one row must not import unrelated annotation and UI consumers. """ + +from agent_comms.typed_table import ForeignKey, GuardedAfterWrites + + +class ExecutionReference: + """An execution-owned row cannot outlive its original execution.""" + + @classmethod + def references(cls) -> tuple[ForeignKey, ...]: + from .executions import ExecutionRecord + + return (ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"),) + + +class RetryPartition(GuardedAfterWrites): + """A failed execution cannot retain authorization to retry.""" + + after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e ON e.execution_id = b.execution_id WHERE + b.execution_id = NEW.execution_id AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal + IS NOT NULL)""" diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index da29a5e500..b9e580d660 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -22,15 +22,39 @@ from agent_comms.typed_table import ( Column, ForeignKey, + FrozenColumns, Generated, + GuardedInserts, + GuardedUpdates, + ImmutableRows, Index, + NoDelete, TypedTable, ) from agent_comms.wake_policy import MessageAudience @dataclass(frozen=True, slots=True) -class WakeAssignment(CoordinatorTable, TypedTable, declared_name="wake_claims"): +class WakeAssignment(GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): + immutable_columns = ('assignment_id', 'recipient_lookup', 'wire_seq', 'message_id', 'recipient', 'audience', 'resolver_version', 'policy_version', 'accepted_at_ms') + update_condition = """((json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind'))) OR + ((({assignment_mode_new})) IS NOT (({assignment_mode_old})) OR NEW.revision != OLD.revision + 1 OR + NEW.updated_at_ms < OLD.updated_at_ms) OR (((({assignment_verdict_old})) IS NOT NULL AND + (({assignment_verdict_new})) IS NOT (({assignment_verdict_old}))) OR ((json_extract(OLD.lifecycle, + '$.decision.execution_id')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT + (json_extract(OLD.lifecycle, '$.decision.execution_id'))) OR ((json_extract(OLD.lifecycle, + '$.decision.exact_target')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT + (json_extract(OLD.lifecycle, '$.decision.exact_target'))) OR ((json_extract(OLD.lifecycle, + '$.decision.exact_target')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT NULL + AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NULL) OR ((json_extract(OLD.lifecycle, + '$.decision.execution_id')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL + AND (json_extract(NEW.lifecycle, '$.kind')) != 'engaged')) OR ((json_extract(OLD.lifecycle, '$.kind')) != + (json_extract(NEW.lifecycle, '$.kind')) AND NOT ({assignment_edges} )) OR ((json_extract(NEW.lifecycle, + '$.kind')) = 'failed' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NULL AND + (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL) OR ((json_extract(OLD.lifecycle, + '$.kind')) = 'deferred' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NOT NULL AND + (json_extract(NEW.lifecycle, '$.kind')) IN ('triage_pending', 'full_pending'))""" + @property def recipient_identity(self): from agent_comms.audience_manifest import FrozenRecipient @@ -241,58 +265,15 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "claim_transition_frozen_facts": ( - """ -BEFORE UPDATE ON wake_claims -BEGIN - SELECT RAISE(ABORT, 'claim disposition must change') - WHERE (json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind')); - SELECT RAISE(ABORT, 'claim transition rewrites acceptance') - WHERE NEW.assignment_id IS NOT OLD.assignment_id - OR NEW.recipient_lookup IS NOT OLD.recipient_lookup - OR NEW.wire_seq IS NOT OLD.wire_seq - OR NEW.message_id IS NOT OLD.message_id - OR NEW.recipient IS NOT OLD.recipient - OR NEW.audience IS NOT OLD.audience - OR (({assignment_mode_new})) IS NOT (({assignment_mode_old})) - OR NEW.resolver_version IS NOT OLD.resolver_version - OR NEW.policy_version IS NOT OLD.policy_version - OR NEW.accepted_at_ms IS NOT OLD.accepted_at_ms - OR NEW.revision != OLD.revision + 1 - OR NEW.updated_at_ms < OLD.updated_at_ms; - SELECT RAISE(ABORT, 'claim transition erases engagement facts') - WHERE ((({assignment_verdict_old})) IS NOT NULL - AND (({assignment_verdict_new})) IS NOT (({assignment_verdict_old}))) - OR ((json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT (json_extract(OLD.lifecycle, '$.decision.execution_id'))) - OR ((json_extract(OLD.lifecycle, '$.decision.exact_target')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT (json_extract(OLD.lifecycle, '$.decision.exact_target'))) - OR ((json_extract(OLD.lifecycle, '$.decision.exact_target')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NULL) - OR ((json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.kind')) != 'engaged'); - SELECT RAISE(ABORT, 'claim disposition edge is not realizable') - WHERE (json_extract(OLD.lifecycle, '$.kind')) != (json_extract(NEW.lifecycle, '$.kind')) AND NOT ({assignment_edges} - ); - SELECT RAISE(ABORT, 'pre-engagement failure cannot invent execution') - WHERE (json_extract(NEW.lifecycle, '$.kind')) = 'failed' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NULL - AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL; - SELECT RAISE(ABORT, 'post-engagement deferral cannot become pending') - WHERE (json_extract(OLD.lifecycle, '$.kind')) = 'deferred' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.kind')) IN ('triage_pending', 'full_pending'); -END""" - ), - "wake_claim_delete_frozen": ( - """ BEFORE DELETE ON wake_claims BEGIN - SELECT RAISE(ABORT, 'wake claim cannot be deleted'); -END""" - ), - } +@dataclass(frozen=True, slots=True) +class ExecutionAssignmentLink(GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="execution_claims"): + insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.status IN + ({unstarted_execution_names})) OR NEW.ordinal != (SELECT count(*) FROM execution_claims WHERE execution_id = + NEW.execution_id)) OR (NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id = + e.execution_id WHERE e.execution_id = NEW.execution_id AND c.assignment_id = NEW.assignment_id AND + c.exact_target IS NOT NULL AND c.recipient_lookup = e.owner_lookup ))""" -@dataclass(frozen=True, slots=True) -class ExecutionAssignmentLink(CoordinatorTable, TypedTable, declared_name="execution_claims"): execution_id: Annotated[str, Column(primary_key=True)] assignment_id: Annotated[str, WireName("claim_id"), Column(unique=True)] ordinal: Annotated[int, Column(primary_key=True, check="ordinal>=0")] @@ -316,46 +297,3 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "execution_claim_membership_insert": ( - """ BEFORE INSERT ON - execution_claims - WHEN NOT EXISTS (SELECT 1 FROM executions e WHERE e.execution_id = - NEW.execution_id - AND e.status IN ({unstarted_execution_names})) - OR NEW.ordinal != (SELECT count(*) FROM execution_claims - WHERE execution_id = NEW.execution_id) - BEGIN SELECT RAISE(ABORT, - 'execution claims require initial contiguous membership' ); END""" - ), - "execution_claim_target_insert": ( - """ - BEFORE INSERT ON execution_claims - WHEN NOT EXISTS ( - SELECT 1 FROM executions e JOIN wake_claims c - ON c.execution_id = e.execution_id - WHERE e.execution_id = NEW.execution_id AND c.assignment_id = NEW.assignment_id - AND c.exact_target IS NOT NULL - AND c.recipient_lookup = e.owner_lookup - ) - BEGIN - SELECT RAISE(ABORT, 'claim target does not match execution target'); - END""" - ), - "execution_claim_target_update": ( - """ - BEFORE UPDATE ON execution_claims - BEGIN - SELECT RAISE(ABORT, 'execution claim relation is immutable'); - END""" - ), - "execution_claim_delete_frozen": ( - """ BEFORE DELETE ON - execution_claims BEGIN - SELECT RAISE(ABORT, 'execution claim relation cannot be deleted' - ); - END""" - ), - } diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 055e12eaa0..1f5cd84be6 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -21,17 +21,24 @@ StaleFence, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import OwnerGenerations +from agent_comms.coordination_tables.participants import OwnerRecord from agent_comms.field_codec import projected, SnapshotExclude from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - TextStorage, Column, + ColumnGuard, ForeignKey, + FrozenColumns, Generated, + GuardedColumns, + GuardedInserts, + GuardedUpdates, + NoDelete, + TextStorage, TypedTable, ) +from agent_comms.coordination_tables import ExecutionReference, RetryPartition if TYPE_CHECKING: from agent_comms.coordination_snapshot import RecoverySnapshot @@ -79,7 +86,43 @@ def require_retry(self, snapshot: RecoverySnapshot) -> None: @dataclass(frozen=True, slots=True) -class AttemptRecord(CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): +class AttemptRecord(OwnerRecord, GuardedInserts, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): + + insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.owner_lookup = + NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts AND ((NEW.attempt_ordinal = 1 AND (e.status = + 'pending' OR (e.status = 'active' AND e.current_attempt_ordinal = 1))) OR (NEW.attempt_ordinal > 1 AND + (e.status = 'deferred' OR (e.status = 'active' AND e.current_attempt_ordinal = NEW.attempt_ordinal)))) AND + NEW.attempt_ordinal = 1 + coalesce( (SELECT max(a.attempt_ordinal) FROM attempts a WHERE a.execution_id = + e.execution_id), 0))) OR (NOT EXISTS (SELECT 1 FROM owner_generations g WHERE g.owner_lookup = + NEW.owner_lookup AND g.owner_thread = NEW.owner_thread AND g.generation = NEW.owner_generation)) OR + ((json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 OR (CASE WHEN + json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, + '$.backend_done') END) != 0 OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN + ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) != 0) OR + (NEW.attempt_ordinal > 1 AND ( NOT EXISTS (SELECT 1 FROM retry_disposition_basis b WHERE b.execution_id = + NEW.execution_id AND b.authorized = 1) OR EXISTS (SELECT 1 FROM current_executions p WHERE p.owner_lookup = + NEW.owner_lookup AND p.execution_id IS NOT NULL))) OR (NEW.attempt_ordinal > 1 AND NOT EXISTS ( SELECT 1 + FROM attempts a WHERE a.execution_id = NEW.execution_id AND a.attempt_ordinal = NEW.attempt_ordinal - 1 AND + a.phase IN ({retry_attempt_phases}) AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > + a.owner_generation AND NEW.owner_token_digest != a.owner_token_digest))""" + immutable_columns = ('execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest', 'created_at_ms') + update_condition = """NEW.revision != OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms OR ((CASE WHEN + json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, + '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN + ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) OR ((CASE + WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE + json_extract(OLD.lifecycle, '$.process_dead') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') + IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) = 0) OR + (OLD.last_progress_at_ms IS NOT NULL AND (NEW.last_progress_at_ms IS NULL OR NEW.last_progress_at_ms < + OLD.last_progress_at_ms)) OR ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND + (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, + '$.lease_expires_at_ms')) < (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) OR + ((json_extract(OLD.lifecycle, '$.kind')) IN ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, + '$.kind')) = (json_extract(OLD.lifecycle, '$.kind')))""" + column_guards = ( + ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind')) != (json_extract(OLD.lifecycle, '$.kind')) AND NOT ({attempt_edges})"""), + ) + def require_attempt(self) -> AttemptRecord: return self @@ -127,14 +170,6 @@ def authority(self) -> AttemptAuthority: self.owner_token_digest, ) - @property - def owner_identity(self) -> OwnerGenerations: - return OwnerGenerations( - owner_lookup=self.owner_lookup, - owner_thread=self.owner_thread, - generation=self.owner_generation, - ) - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] attempt_ordinal: Annotated[int, Column(primary_key=True, check="attempt_ordinal > 0")] owner_lookup: str @@ -260,81 +295,12 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "attempt_insert_authorized": ( - """ BEFORE INSERT ON attempts BEGIN - SELECT RAISE(ABORT, 'attempt must be contiguous and within execution budget') - WHERE NOT EXISTS ( - SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id - AND e.owner_lookup = NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts - AND ((NEW.attempt_ordinal = 1 AND - (e.status = 'pending' OR - (e.status = 'active' AND e.current_attempt_ordinal = 1))) - OR (NEW.attempt_ordinal > 1 AND - (e.status = 'deferred' OR - (e.status = 'active' AND e.current_attempt_ordinal = NEW.attempt_ordinal)))) - AND NEW.attempt_ordinal = 1 + coalesce( - (SELECT max(a.attempt_ordinal) FROM attempts a - WHERE a.execution_id = e.execution_id), 0)); - SELECT RAISE(ABORT, 'attempt generation must match owner counter') - WHERE NOT EXISTS (SELECT 1 FROM owner_generations g - WHERE g.owner_lookup = NEW.owner_lookup AND g.owner_thread = NEW.owner_thread - AND g.generation = NEW.owner_generation); - SELECT RAISE(ABORT, 'new attempt requires a fresh active fence') - WHERE (json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 - OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) != 0 OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) != 0; - SELECT RAISE(ABORT, 'retry requires derived authorization and no current owner') - WHERE NEW.attempt_ordinal > 1 AND ( - NOT EXISTS (SELECT 1 FROM retry_disposition_basis b - WHERE b.execution_id = NEW.execution_id AND b.authorized = 1) - OR EXISTS (SELECT 1 FROM current_executions p - WHERE p.owner_lookup = NEW.owner_lookup AND p.execution_id IS NOT NULL)); - SELECT RAISE(ABORT, 'previous attempt must be dead with an older distinct fence') - WHERE NEW.attempt_ordinal > 1 AND NOT EXISTS ( - SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id - AND a.attempt_ordinal = NEW.attempt_ordinal - 1 - AND a.phase IN ({retry_attempt_phases}) - AND a.backend_done = 1 AND a.process_dead = 1 - AND NEW.owner_generation > a.owner_generation - AND NEW.owner_token_digest != a.owner_token_digest); -END""" - ), - "attempt_phase_edge": ( - """ BEFORE UPDATE OF lifecycle ON attempts -WHEN (json_extract(NEW.lifecycle, '$.kind')) != (json_extract(OLD.lifecycle, '$.kind')) AND NOT ({attempt_edges}) -BEGIN SELECT RAISE(ABORT, 'attempt phase transition is not declared'); END""" - ), - "attempt_frozen_facts": ( - """ BEFORE UPDATE ON attempts -WHEN NEW.execution_id IS NOT OLD.execution_id - OR NEW.attempt_ordinal != OLD.attempt_ordinal - OR NEW.owner_lookup IS NOT OLD.owner_lookup OR NEW.owner_thread IS NOT OLD.owner_thread - OR NEW.owner_generation != OLD.owner_generation - OR NEW.owner_token_digest IS NOT OLD.owner_token_digest - OR NEW.created_at_ms != OLD.created_at_ms - OR NEW.revision != OLD.revision + 1 - OR NEW.updated_at_ms < OLD.updated_at_ms - OR ((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) - OR ((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, '$.process_dead') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) = 0) - OR (OLD.last_progress_at_ms IS NOT NULL AND - (NEW.last_progress_at_ms IS NULL OR NEW.last_progress_at_ms < OLD.last_progress_at_ms)) - OR ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) < (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) - OR ((json_extract(OLD.lifecycle, '$.kind')) IN ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind'))) -BEGIN SELECT RAISE(ABORT, 'attempt transition rewrites frozen facts'); END""" - ), - "attempt_delete_frozen": ( - """ BEFORE DELETE ON attempts BEGIN - SELECT RAISE(ABORT, 'attempt cannot be deleted'); -END""" - ), - } - - @dataclass(frozen=True, slots=True) -class ReplayAssessments(CoordinatorTable, TypedTable): +class ReplayAssessments(ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): + immutable_columns = ('execution_id',) + update_condition = """NEW.revision != OLD.revision + 1 OR (NEW.facts | OLD.facts) != NEW.facts OR (OLD.replay_safe = 0 AND + NEW.replay_safe = 1) OR (OLD.side_effects_possible = 1 AND NEW.side_effects_possible = 0)""" + @property def allows_retry(self) -> bool: return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible @@ -449,57 +415,3 @@ def __post_init__(self) -> None: unique = (("execution_id", "retry_authorized"),) - @classmethod - def references(cls): - from agent_comms.coordination_tables.executions import ExecutionRecord - - return ( - ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"), - ) - - @classmethod - def trigger_bodies(cls): - return { - "replay_assessment_monotonic": ( - """ -BEFORE UPDATE ON replay_assessments -WHEN NEW.execution_id IS NOT OLD.execution_id - OR NEW.revision != OLD.revision + 1 - OR (NEW.facts | OLD.facts) != NEW.facts - OR (OLD.replay_safe = 0 AND NEW.replay_safe = 1) - OR (OLD.side_effects_possible = 1 AND NEW.side_effects_possible = 0) -BEGIN - SELECT RAISE(ABORT, 'replay assessment cannot erase ambiguity'); -END""" - ), - "replay_assessment_delete_frozen": ( - """ BEFORE DELETE ON -replay_assessments BEGIN - SELECT RAISE(ABORT, 'replay assessment cannot be deleted' ); -END""" - ), - "failed_retry_partition_replay_insert": ( - """ AFTER INSERT ON -replay_assessments -WHEN EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e - ON e.execution_id = b.execution_id - WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1 - AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL) -BEGIN SELECT RAISE(ABORT, 'authorized retry cannot settle failed' ); -END""" - ), - "failed_retry_partition_replay_update": ( - """ AFTER UPDATE ON -replay_assessments -WHEN EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e - ON e.execution_id = b.execution_id - WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1 - AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL) -BEGIN SELECT RAISE(ABORT, 'authorized retry cannot settle failed' ); -END""" - ), - } diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 45057c2c5d..d7392f3b3b 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -21,12 +21,18 @@ from agent_comms.reason_codes import ReasonCode from agent_comms.thread_text import AbsentText, GivenText, TextChoice from agent_comms.typed_table import ( - IntegerStorage, - TextStorage, Column, + ColumnGuard, ForeignKey, + FrozenColumns, Generated, + GuardedColumns, + GuardedInserts, + GuardedUpdates, Index, + IntegerStorage, + NoDelete, + TextStorage, TypedTable, ) @@ -89,7 +95,11 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) -class CurrentExecutions(CoordinatorTable, TypedTable): +class CurrentExecutions(GuardedInserts, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): + insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations WHERE owner_lookup = NEW.owner_lookup)""" + immutable_columns = ('owner_lookup',) + update_condition = """NEW.pointer_revision != OLD.pointer_revision + 1""" + @property def unresolved(self) -> bool: return self.execution_id.pointed @@ -144,30 +154,6 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "current_pointer_insert_owner": ( - """ BEFORE INSERT ON current_executions -WHEN NOT EXISTS (SELECT 1 FROM owner_generations - WHERE owner_lookup = NEW.owner_lookup) -BEGIN SELECT RAISE(ABORT, 'current pointer owner is not registered'); END""" - ), - "current_pointer_update_owner": ( - """ BEFORE UPDATE ON current_executions -WHEN NEW.owner_lookup IS NOT OLD.owner_lookup - OR NEW.pointer_revision != OLD.pointer_revision + 1 -BEGIN SELECT RAISE(ABORT, 'current pointer owner/revision mismatch'); END""" - ), - "current_pointer_delete_frozen": ( - """ BEFORE DELETE ON -current_executions BEGIN - SELECT RAISE(ABORT, 'current pointer cannot be deleted' ); -END""" - ), - } - - _RETRY_BASIS = """SELECT e.execution_id, CASE WHEN e.current_attempt_ordinal IS NOT NULL AND e.current_attempt_ordinal < e.max_attempts @@ -181,7 +167,31 @@ def trigger_bodies(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(CoordinatorTable, TypedTable, declared_name="executions"): +class ExecutionRecord(GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): + + immutable_columns = ('execution_id', 'origin', 'owner_lookup', 'owner_thread', 'max_attempts', 'created_at_ms') + update_condition = """NEW.revision != OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms OR (json_extract(NEW.lifecycle, + '$.kind') = 'active' AND (json_extract(OLD.lifecycle, '$.kind') NOT IN ({startable_execution_names}) OR + json_extract(NEW.lifecycle, '$.ordinal') != coalesce(json_extract(OLD.lifecycle, '$.ordinal'), 0) + 1)) OR + (json_extract(NEW.lifecycle, '$.kind') != 'active' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT + json_extract(OLD.lifecycle, '$.ordinal'))""" + column_guards = ( + ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind') != json_extract(OLD.lifecycle, '$.kind') AND NOT ({execution_edges})) + OR (json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND + json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM attempts a WHERE + a.execution_id = NEW.execution_id AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') AND + a.owner_lookup = NEW.owner_lookup AND a.phase IN ({failed_attempt_phases}) )) OR ((NEW.origin = 'wire') AND + ((NOT EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id)) OR + (json_extract(NEW.lifecycle, '$.kind') = 'completed' AND EXISTS ( SELECT 1 FROM obligations WHERE + execution_id = NEW.execution_id AND success_terminal != 1)) OR (json_extract(NEW.lifecycle, '$.kind') = + 'deferred' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND EXISTS ( SELECT 1 FROM obligations + WHERE execution_id = NEW.execution_id AND retryable != 1)))) OR (json_extract(NEW.lifecycle, '$.kind') = + 'failed' AND EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id)) OR + (json_extract(NEW.lifecycle, '$.kind') = 'failed' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL + AND EXISTS (SELECT 1 FROM retry_disposition_basis b WHERE b.execution_id = NEW.execution_id AND b.authorized + = 1))"""), + ) + retry_view = "retry_disposition_basis" @classmethod @@ -396,77 +406,3 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "execution_status_edge": ( - """ BEFORE UPDATE OF lifecycle ON executions -WHEN json_extract(NEW.lifecycle, '$.kind') != json_extract(OLD.lifecycle, '$.kind') AND NOT ({execution_edges}) -BEGIN SELECT RAISE(ABORT, 'execution status transition is not declared'); END""" - ), - "execution_failed_attempt": ( - """ BEFORE UPDATE OF lifecycle ON executions -WHEN json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) - AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM attempts a - WHERE a.execution_id = NEW.execution_id - AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') - AND a.owner_lookup = NEW.owner_lookup - AND a.phase IN ({failed_attempt_phases}) - ) -BEGIN SELECT RAISE(ABORT, 'failed execution requires a failed attempt'); END""" - ), - "execution_frozen_facts": ( - """ BEFORE UPDATE ON executions -WHEN NEW.execution_id IS NOT OLD.execution_id OR NEW.origin IS NOT OLD.origin - OR NEW.owner_lookup IS NOT OLD.owner_lookup - OR NEW.owner_thread IS NOT OLD.owner_thread OR NEW.max_attempts != OLD.max_attempts - OR NEW.created_at_ms != OLD.created_at_ms OR NEW.revision != OLD.revision + 1 - OR NEW.updated_at_ms < OLD.updated_at_ms OR - (json_extract(NEW.lifecycle, '$.kind') = 'active' AND - (json_extract(OLD.lifecycle, '$.kind') NOT IN ({startable_execution_names}) OR - json_extract(NEW.lifecycle, '$.ordinal') != coalesce(json_extract(OLD.lifecycle, '$.ordinal'), 0) + 1)) OR - (json_extract(NEW.lifecycle, '$.kind') != 'active' AND - json_extract(NEW.lifecycle, '$.ordinal') IS NOT json_extract(OLD.lifecycle, '$.ordinal')) -BEGIN SELECT RAISE(ABORT, 'execution transition rewrites frozen authority'); END""" - ), - "execution_response_finality": """ -BEFORE UPDATE OF lifecycle ON executions -WHEN NEW.origin = 'wire' -BEGIN - SELECT RAISE(ABORT, 'wire execution requires original response obligations') - WHERE NOT EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id); - SELECT RAISE(ABORT, 'completed execution requires all original responses') - WHERE json_extract(NEW.lifecycle, '$.kind') = 'completed' AND EXISTS ( - SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id AND success_terminal != 1); - SELECT RAISE(ABORT, 'deferred execution requires all original responses retryable') - WHERE json_extract(NEW.lifecycle, '$.kind') = 'deferred' - AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND EXISTS ( - SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id AND retryable != 1); -END""", - "execution_failure_receipt_guard": ( - """ BEFORE UPDATE OF lifecycle -ON executions -WHEN json_extract(NEW.lifecycle, '$.kind') = 'failed' AND EXISTS ( - SELECT 1 FROM publication_receipts WHERE execution_id = - NEW.execution_id) -BEGIN SELECT RAISE(ABORT, -'failed execution cannot erase publication receipt' ); END""" - ), - "execution_delete_frozen": ( - """ BEFORE DELETE ON executions BEGIN - SELECT RAISE(ABORT, 'execution cannot be deleted'); -END""" - ), - "failed_retry_partition_update": ( - """ BEFORE UPDATE OF lifecycle -ON executions -WHEN json_extract(NEW.lifecycle, '$.kind') = 'failed' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL - AND EXISTS (SELECT 1 FROM retry_disposition_basis b - WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1) -BEGIN SELECT RAISE(ABORT, 'authorized retry cannot settle failed' ); -END""" - ), - } diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index d74f763eac..775d9ee49c 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -10,13 +10,40 @@ from agent_comms.coordination_schema import CoordinatorTable from agent_comms.typed_table import ( Column, + ColumnGuard, ForeignKey, + FrozenColumns, + GuardedColumns, + GuardedUpdates, + ImmutableRows, + NoDelete, TypedTable, ) +class OwnerRecord: + """Stored owner components produce one coordinator identity, never live permission.""" + + owner_lookup: str + owner_thread: str + owner_generation: int + + @property + def owner_identity(self) -> OwnerGenerations: + return OwnerGenerations( + owner_lookup=self.owner_lookup, owner_thread=self.owner_thread, + generation=self.owner_generation, + ) + + @dataclass(frozen=True, kw_only=True) -class Participants(CoordinatorTable, TypedTable): +class Participants(GuardedColumns, NoDelete, CoordinatorTable, TypedTable): + + column_guards = ( + ColumnGuard(('participant_lookup',), """1"""), + ColumnGuard(('committed',), """OLD.committed = 1 AND NEW.committed = 0"""), + ) + participant_lookup: Annotated[ str, Column( @@ -27,36 +54,8 @@ class Participants(CoordinatorTable, TypedTable): display_name: Annotated[str, Column(check="length(display_name) BETWEEN 1 AND 256")] committed: bool - @classmethod - def trigger_bodies(cls): - return { - "participant_identity_immutable": ( - """ -BEFORE UPDATE OF participant_lookup ON participants -BEGIN - SELECT RAISE(ABORT, 'participant lookup identity is immutable'); -END""" - ), - "participant_commit_monotonic": ( - """ -BEFORE UPDATE OF committed ON participants -WHEN OLD.committed = 1 AND NEW.committed = 0 -BEGIN - SELECT RAISE(ABORT, 'participant commitment cannot be revoked'); -END""" - ), - "participant_identity_delete_frozen": ( - """ -BEFORE DELETE ON participants -BEGIN - SELECT RAISE(ABORT, 'participant lookup identity cannot be deleted'); -END""" - ), - } - - @dataclass(frozen=True, kw_only=True) -class ParticipantAliases(CoordinatorTable, TypedTable): +class ParticipantAliases(ImmutableRows, CoordinatorTable, TypedTable): alias: Annotated[str, Column(primary_key=True, check="length(alias) BETWEEN 1 AND 256")] participant_lookup: str renamed_at_ms: Annotated[int, Column(check="renamed_at_ms >= 0")] @@ -65,28 +64,14 @@ class ParticipantAliases(CoordinatorTable, TypedTable): def references(cls): return (ForeignKey(("participant_lookup",), Participants, ("participant_lookup",)),) - @classmethod - def trigger_bodies(cls): - return { - "participant_alias_identity_immutable": ( - """ -BEFORE UPDATE ON participant_aliases -BEGIN - SELECT RAISE(ABORT, 'participant alias identity is immutable'); -END""" - ), - "participant_alias_delete_frozen": ( - """ -BEFORE DELETE ON participant_aliases -BEGIN - SELECT RAISE(ABORT, 'participant alias cannot be deleted'); -END""" - ), - } +@dataclass(frozen=True, kw_only=True) +class OwnerGenerations(NoDelete, GuardedUpdates, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable): + immutable_columns = ('owner_lookup',) + update_condition = """(NEW.generation != OLD.generation AND ( NEW.generation != OLD.generation + 1 OR EXISTS (SELECT 1 FROM + attempts WHERE owner_lookup = OLD.owner_lookup AND owner_generation = OLD.generation AND phase NOT IN + ({terminal_attempt_names}))))""" -@dataclass(frozen=True, kw_only=True) -class OwnerGenerations(ParticipantIdentity, CoordinatorTable, TypedTable): owner_lookup: Annotated[str, Column(primary_key=True)] owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] generation: Annotated[int, Column(check="generation > 0")] @@ -106,24 +91,3 @@ def references(cls): def lookup(self) -> str: return self.owner_lookup - @classmethod - def trigger_bodies(cls): - return { - "owner_generation_delete_frozen": ( - """ BEFORE DELETE ON -owner_generations BEGIN - SELECT RAISE(ABORT, 'owner generation counter cannot be deleted' - ); -END""" - ), - "owner_generation_monotonic": ( - """ BEFORE UPDATE ON owner_generations -WHEN NEW.owner_lookup IS NOT OLD.owner_lookup - OR (NEW.generation != OLD.generation AND ( - NEW.generation != OLD.generation + 1 - OR EXISTS (SELECT 1 FROM attempts WHERE owner_lookup = OLD.owner_lookup - AND owner_generation = OLD.generation - AND phase NOT IN ({terminal_attempt_names})))) -BEGIN SELECT RAISE(ABORT, 'owner generation cannot advance with active attempts'); END""" - ), - } diff --git a/src/agent_comms/coordination_tables/publications.py b/src/agent_comms/coordination_tables/publications.py index 9a54bb26b7..6f0b996213 100644 --- a/src/agent_comms/coordination_tables/publications.py +++ b/src/agent_comms/coordination_tables/publications.py @@ -23,14 +23,21 @@ ExactStorage, ForeignKey, Generated, + GuardedInserts, + ImmutableRows, TypedTable, + ValidatedInserts, ) @dataclass(frozen=True, slots=True) -class PublicationIntents(CoordinatorTable, TypedTable): +class PublicationIntents(ValidatedInserts, GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable): """One obligation route's frozen response: the exact Message it publishes.""" + insert_condition = """NOT EXISTS ( SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id AND exact_target = + NEW.exact_target AND state = 'pending' )""" + + validator_function: ClassVar[str] = "coordination_validate_publication_intent" @classmethod @@ -125,44 +132,6 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "publication_intent_envelope_authority": ( - f""" BEFORE INSERT ON -publication_intents -WHEN {cls.validator_function}( - {", ".join("NEW." + field.name for field in fields(cls) if field.init)}) != 1 -BEGIN SELECT RAISE(ABORT, 'publication intent envelope is invalid' ); -END""" - ), - "publication_intent_requires_obligation": ( - """ -BEFORE INSERT ON publication_intents -WHEN NOT EXISTS ( - SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id - AND exact_target = NEW.exact_target AND state = 'pending' -) -BEGIN - SELECT RAISE(ABORT, 'publication intent requires matching pending obligation'); -END""" - ), - "publication_intent_update_frozen": ( - """ -BEFORE UPDATE ON publication_intents -BEGIN - SELECT RAISE(ABORT, 'publication intent is frozen'); -END""" - ), - "publication_intent_delete_frozen": ( - """ -BEFORE DELETE ON publication_intents -BEGIN - SELECT RAISE(ABORT, 'publication intent is frozen'); -END""" - ), - } - @classmethod def validate_sql(cls, *values: object) -> int: """SQLite positional input follows the declared columns; decode and validate once.""" @@ -179,7 +148,12 @@ def validate_sql(cls, *values: object) -> int: @dataclass(frozen=True, kw_only=True) -class PublicationEvidence(CoordinatorTable, TypedTable, declared_name="publication_receipts"): +class PublicationEvidence(GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="publication_receipts"): + insert_condition = """(EXISTS (SELECT 1 FROM executions WHERE execution_id = NEW.execution_id AND status = 'failed')) OR + (NEW.message_id != ( SELECT expected_message_id FROM publication_intents WHERE execution_id = + NEW.execution_id AND exact_target = NEW.exact_target )) OR (NOT EXISTS ( SELECT 1 FROM obligations WHERE + execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND state = 'publishing' ))""" + @property def reference(self) -> MessageReference: return MessageReference(self.seq, self.message_id) @@ -235,40 +209,3 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "publication_receipt_matches_authorities": ( - """ -BEFORE INSERT ON publication_receipts -BEGIN - SELECT RAISE(ABORT, 'failed execution cannot accept publication receipt') - WHERE EXISTS (SELECT 1 FROM executions - WHERE execution_id = NEW.execution_id AND status = 'failed'); - SELECT RAISE(ABORT, 'publication receipt message mismatch') - WHERE NEW.message_id != ( - SELECT expected_message_id FROM publication_intents - WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target - ); - SELECT RAISE(ABORT, 'publication receipt requires publishing obligation') - WHERE NOT EXISTS ( - SELECT 1 FROM obligations - WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND state = 'publishing' - ); -END""" - ), - "publication_receipt_is_frozen": ( - """ -BEFORE UPDATE ON publication_receipts -BEGIN - SELECT RAISE(ABORT, 'publication receipt is frozen'); -END""" - ), - "publication_receipt_delete_frozen": ( - """ -BEFORE DELETE ON publication_receipts -BEGIN - SELECT RAISE(ABORT, 'publication receipt is frozen'); -END""" - ), - } diff --git a/src/agent_comms/coordination_tables/recovery.py b/src/agent_comms/coordination_tables/recovery.py index b2904ae1c8..d949b1aa3b 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -13,14 +13,19 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorTable +from agent_comms.coordination_tables import ExecutionReference from agent_comms.recovery_states import RecoveryCondition from agent_comms.reason_codes import GivenReasonCode, RecoveryDetail from agent_comms.typed_table import ( - TextStorage, Column, ForeignKey, + FrozenColumns, + GuardedUpdates, + ImmutableRows, Index, + NoDelete, RowId, + TextStorage, TypedTable, UnallocatedRowId, ) @@ -38,7 +43,11 @@ class ACPClientConnectivity(StrEnum): @dataclass(frozen=True, slots=True) -class ConnectivityFacet(CoordinatorTable, TypedTable, declared_name="connectivity"): +class ConnectivityFacet(ExecutionReference, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="connectivity"): + + immutable_columns = ('execution_id',) + update_condition = """NEW.revision != OLD.revision + 1 OR NEW.observed_at_ms < OLD.observed_at_ms""" + execution_id: Annotated[str, field_codec.SnapshotExclude(), Column(primary_key=True)] owner: OwnerConnectivity acp_client: ACPClientConnectivity @@ -52,37 +61,8 @@ def __post_init__(self) -> None: if self.revision <= 0 or self.observed_at_ms < 0: raise ValueError("revision must be positive and observed_at_ms non-negative") - @classmethod - def references(cls): - from agent_comms.coordination_tables.executions import ExecutionRecord - - return ( - ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"), - ) - - @classmethod - def trigger_bodies(cls): - return { - "connectivity_observation_monotonic": ( - """ - BEFORE UPDATE ON connectivity - WHEN NEW.execution_id IS NOT OLD.execution_id - OR NEW.revision != OLD.revision + 1 - OR NEW.observed_at_ms < OLD.observed_at_ms - BEGIN - SELECT RAISE(ABORT, 'connectivity revision or observation regressed'); - END""" - ), - "connectivity_delete_frozen": ( - """ BEFORE DELETE ON connectivity BEGIN - SELECT RAISE(ABORT, 'connectivity cannot be deleted'); - END""" - ), - } - - @dataclass(frozen=True, slots=True) -class RecoveryAudit(CoordinatorTable, TypedTable): +class RecoveryAudit(ImmutableRows, CoordinatorTable, TypedTable): # Recovery condition is a stored fact; row identity is the table family tag. execution_id: Annotated[str, field_codec.SnapshotExclude()] @@ -121,25 +101,6 @@ def references(cls): ), ) - @classmethod - def trigger_bodies(cls): - return { - "recovery_audit_update_frozen": ( - """ -BEFORE UPDATE ON recovery_audit -BEGIN - SELECT RAISE(ABORT, 'recovery audit is append-only'); -END""" - ), - "recovery_audit_delete_frozen": ( - """ -BEFORE DELETE ON recovery_audit -BEGIN - SELECT RAISE(ABORT, 'recovery audit is append-only'); -END""" - ), - } - def __post_init__(self) -> None: validate_execution_id(self.execution_id) if self.attempt <= 0 or self.elapsed_ms < 0 or self.observed_at_ms < 0: diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index 2b341268be..fe088d29f7 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -16,18 +16,51 @@ from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - TextStorage, Column, + ColumnGuard, ForeignKey, + FrozenColumns, Generated, + GuardedColumns, + GuardedInserts, + GuardedUpdates, + NoDelete, + TextStorage, TypedTable, ) +from agent_comms.coordination_tables import ExecutionReference, RetryPartition @dataclass(frozen=True, slots=True) -class ResponseObligation(CoordinatorTable, TypedTable, declared_name="obligations"): +class ResponseObligation(ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): """One original reply-route obligation within an execution.""" + immutable_columns = ('execution_id', 'exact_target', 'created_at_ms') + update_condition = """(json_extract(NEW.lifecycle, '$.kind') = json_extract(OLD.lifecycle, '$.kind')) OR (NEW.revision != + OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms) OR ((json_extract(NEW.lifecycle, '$.kind') IN + ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 FROM publication_intents WHERE execution_id + = NEW.execution_id AND exact_target = NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') = + 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id AND + exact_target = NEW.exact_target AND message_id = json_extract(NEW.lifecycle, '$.message_id') AND seq = + json_extract(NEW.lifecycle, '$.seq') ))))""" + insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE + e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR + (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" + column_guards = ( + ColumnGuard(('lifecycle',), """(json_extract(OLD.lifecycle, '$.kind') != json_extract(NEW.lifecycle, '$.kind') AND NOT ({obligation_edges} + )) OR (json_extract(OLD.lifecycle, '$.kind') = 'published' AND ( json_extract(NEW.lifecycle, '$.message_id') + IS NOT json_extract(OLD.lifecycle, '$.message_id') OR json_extract(NEW.lifecycle, '$.seq') IS NOT + json_extract(OLD.lifecycle, '$.seq') )) OR (EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = + OLD.execution_id AND exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') != + 'published') OR (EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND + exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}))"""), + ColumnGuard(('exact_target',), """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE + e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR (EXISTS + (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = + OLD.exact_target))"""), + ) + + @staticmethod def response_record(records, exact_target): return next((row for row in records if row.exact_target == exact_target), None) @@ -141,141 +174,3 @@ def __post_init__(self) -> None: ("execution_id", "exact_target", "publication_settled"), ) - @classmethod - def references(cls): - from agent_comms.coordination_tables.executions import ExecutionRecord - - return ( - ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"), - ) - - @classmethod - def trigger_bodies(cls): - return {**cls._retry_trigger_bodies(), **cls._lifecycle_trigger_bodies(), **cls._publication_trigger_bodies()} - - @classmethod - def _retry_trigger_bodies(cls): - return { - "failed_retry_partition_obligation_insert": """ AFTER INSERT -ON obligations -WHEN EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e - ON e.execution_id = b.execution_id - WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1 - AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL) -BEGIN SELECT RAISE(ABORT, 'authorized retry cannot settle failed' ); -END""", - "failed_retry_partition_obligation_update": """ AFTER UPDATE -ON obligations -WHEN EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e - ON e.execution_id = b.execution_id - WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1 - AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL) -BEGIN SELECT RAISE(ABORT, 'authorized retry cannot settle failed' ); -END""", - } - - @classmethod - def _lifecycle_trigger_bodies(cls): - return { - "obligation_same_state_frozen": """ BEFORE UPDATE ON obligations -WHEN json_extract(NEW.lifecycle, '$.kind') = json_extract(OLD.lifecycle, '$.kind') -BEGIN SELECT RAISE(ABORT, 'obligation disposition must change'); END""", - "obligation_declared_edge": """ -BEFORE UPDATE OF lifecycle ON obligations -WHEN json_extract(OLD.lifecycle, '$.kind') != json_extract(NEW.lifecycle, '$.kind') AND NOT ({obligation_edges} -) -BEGIN - SELECT RAISE(ABORT, 'obligation state transition is not declared'); -END""", - "obligation_frozen_facts": """ -BEFORE UPDATE ON obligations -WHEN NEW.execution_id IS NOT OLD.execution_id - OR NEW.exact_target IS NOT OLD.exact_target - OR NEW.created_at_ms != OLD.created_at_ms - OR NEW.revision != OLD.revision + 1 - OR NEW.updated_at_ms < OLD.updated_at_ms -BEGIN - SELECT RAISE(ABORT, 'obligation transition rewrites frozen facts'); -END""", - "obligation_target_matches_execution_insert": """ -BEFORE INSERT ON obligations -WHEN NOT EXISTS ( - SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id - WHERE e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target -) -BEGIN - SELECT RAISE(ABORT, 'obligation target does not match wire execution'); -END""", - "obligation_target_matches_execution_update": """ -BEFORE UPDATE OF exact_target ON obligations -WHEN NOT EXISTS ( - SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id - WHERE e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target -) -BEGIN - SELECT RAISE(ABORT, 'obligation target does not match wire execution'); -END""", - "obligation_delete_frozen": """ BEFORE DELETE ON obligations BEGIN - SELECT RAISE(ABORT, 'obligation cannot be deleted'); -END""", - } - - @classmethod - def _publication_trigger_bodies(cls): - return { - "published_obligation_receipt_is_frozen": """ -BEFORE UPDATE OF lifecycle ON obligations -WHEN json_extract(OLD.lifecycle, '$.kind') = 'published' AND ( - json_extract(NEW.lifecycle, '$.message_id') IS NOT json_extract(OLD.lifecycle, '$.message_id') - OR json_extract(NEW.lifecycle, '$.seq') IS NOT json_extract(OLD.lifecycle, '$.seq') -) -BEGIN - SELECT RAISE(ABORT, 'published obligation receipt is frozen'); -END""", - "obligation_publication_transition": """ -BEFORE UPDATE ON obligations -WHEN json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}) -BEGIN - SELECT RAISE(ABORT, 'publishing obligation requires intent') - WHERE NOT EXISTS ( - SELECT 1 FROM publication_intents - WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target - ); - SELECT RAISE(ABORT, 'published obligation requires matching receipt') - WHERE json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( - SELECT 1 FROM publication_receipts - WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target - AND message_id = json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq') - ); -END""", - "obligation_publication_insert": """ -BEFORE INSERT ON obligations -WHEN json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}) -BEGIN - SELECT RAISE(ABORT, 'publication obligation starts pending'); -END""", - "obligation_receipt_requires_published": """ -BEFORE UPDATE OF lifecycle ON obligations -WHEN EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) - AND json_extract(NEW.lifecycle, '$.kind') != 'published' -BEGIN - SELECT RAISE(ABORT, 'frozen receipt requires published obligation'); -END""", - "obligation_state_with_intent": """ -BEFORE UPDATE OF lifecycle ON obligations -WHEN EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) - AND json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}) -BEGIN - SELECT RAISE(ABORT, 'frozen intent cannot return to pending obligation'); -END""", - "obligation_target_frozen": """ -BEFORE UPDATE OF exact_target ON obligations -WHEN EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) -BEGIN - SELECT RAISE(ABORT, 'publication target is frozen'); -END""", - } diff --git a/src/agent_comms/cursor_owner.py b/src/agent_comms/cursor_owner.py index 679bde7aeb..16e26cb100 100644 --- a/src/agent_comms/cursor_owner.py +++ b/src/agent_comms/cursor_owner.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, replace from typing import TYPE_CHECKING -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination from .historical_native_inputs import HistoricalNativeInput @@ -48,7 +48,7 @@ def require_live(self, bus: MessageBus, registry: RegistrySnapshot, reason: str) self.require_snapshot(registry, reason) def require_participant(self, store: Coordination, reason: str) -> None: - assert_native_runtime_schema(store.session._connection) + NativeRuntimeTable.require_installed(store.session._connection) person = store.participants.get(self.lookup) if not person.committed or person.owner_identity != self.participant_identity: raise StaleFence(reason) diff --git a/src/agent_comms/execution_store.py b/src/agent_comms/execution_store.py index fda8d3a67d..a2d582de05 100644 --- a/src/agent_comms/execution_store.py +++ b/src/agent_comms/execution_store.py @@ -115,7 +115,7 @@ def _create(self, db, execution_id, origin, owner_lookup, owner_thread, raise IdentityConflict("execution identity conflicts") return AlreadyApplied(snapshot) participant = self.participants.get(owner_lookup) - if not participant.committed or participant.owner_thread != owner_thread: + if not participant.committed or participant.owner_identity.owner_thread != owner_thread: raise IdentityConflict("execution requires committed current owner") if not isinstance(max_attempts, int) or max_attempts <= 0: raise ValueError("max_attempts must be positive") diff --git a/src/agent_comms/historical_native_inputs.py b/src/agent_comms/historical_native_inputs.py index 76ea87b1ce..454a5c3c0b 100644 --- a/src/agent_comms/historical_native_inputs.py +++ b/src/agent_comms/historical_native_inputs.py @@ -16,8 +16,8 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination -from .cohort_schema import assert_cohort_schema -from .coordinated_runtime_schema import assert_native_runtime_schema +from .cohort_schema import CohortTable +from agent_comms.native_runtime_input import NativeRuntimeTable from .declared_family import DeclaredFamily from .field_codec import FieldCodec, Id128Text, WireName from .native_input_record import ( @@ -126,8 +126,8 @@ def read_historical_native_inputs( raise IdentityConflict("historical native input view requires a committed snapshot") with store.session.read(): db = store.session._connection - assert_cohort_schema(db) - assert_native_runtime_schema(db) + CohortTable.require_installed(db) + NativeRuntimeTable.require_installed(db) sources = _HistoricalSource.read( db.execute( "SELECT n.input_id,c.assignment_id,c.wire_seq,c.message_id FROM native_runtime_input n " diff --git a/src/agent_comms/input_drain.py b/src/agent_comms/input_drain.py index 69efcb7a81..bcb9c392a1 100644 --- a/src/agent_comms/input_drain.py +++ b/src/agent_comms/input_drain.py @@ -517,7 +517,7 @@ def _private_observation_revision( ) cursor = CursorOwner( thread=owner.thread, claimed_generation=owner.claimed_generation, - wire_root_id=root_id, generation=participant.participant_generation, + wire_root_id=root_id, generation=participant.owner_identity.generation, ).cursor(store.session._connection) original = store.session.path.stat() observation = PrivateObservation diff --git a/src/agent_comms/native_input_record.py b/src/agent_comms/native_input_record.py index 112c0dbe3b..3bbb4bb633 100644 --- a/src/agent_comms/native_input_record.py +++ b/src/agent_comms/native_input_record.py @@ -14,7 +14,7 @@ from .coordination_contracts import validate_execution_id from .coordination_errors import IdentityConflict, UnrecordedNativeContext -from .coordination_tables.participants import OwnerGenerations +from .coordination_tables.participants import OwnerGenerations, OwnerRecord from .declared_family import DeclaredFamily from .field_codec import ( FieldCodec, @@ -406,7 +406,7 @@ def require_recorded_input(self, owner, db) -> None: owner.require_recorded_reference(db, self) -class NativeInputRecord(ABC): +class NativeInputRecord(OwnerRecord, ABC): input_id: str execution: NativeInputExecution owner_lookup: str @@ -417,14 +417,6 @@ class NativeInputRecord(ABC): def reference_stage(self) -> type[NativeInputExecution]: return type(self.execution) - @property - def owner_identity(self) -> OwnerGenerations: - return OwnerGenerations( - owner_lookup=self.owner_lookup, - owner_thread=self.owner_thread, - generation=self.owner_generation, - ) - @property def identity(self) -> NativeInputIdentity: return NativeInputIdentity( diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index b2d5014186..4af06575f0 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -38,7 +38,14 @@ NativeSessionFileRole, TrustedAncestorRole, ) -from .typed_table import Column, DurableTables, Index, TypedTable +from .typed_table import ( + Column, + DurableTables, + GuardedInserts, + ImmutableRows, + Index, + TypedTable, +) if TYPE_CHECKING: from . import pi_events as pi @@ -201,33 +208,21 @@ def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextPr @dataclass(frozen=True, slots=True) -class NativeContextJournal(NativeContextRecord, TypedTable, DurableTables): +class NativeContextJournal(GuardedInserts, ImmutableRows, NativeContextRecord, TypedTable, DurableTables): """One proof declaration owns wire facts and the indexed durable journal.""" + insert_condition = """NEW.request_generation < COALESCE((SELECT MAX(request_generation) FROM native_context_journal), 0) OR + EXISTS(SELECT 1 FROM native_context_journal WHERE request_generation=NEW.request_generation AND + (llm_context_digest != NEW.llm_context_digest OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id + != (SELECT session_id FROM native_context_journal ORDER BY session_id LIMIT 1)""" + + schema: Literal[2] type: Literal["context_committed"] without_rowid = True indexes = (Index(("request_generation",)), Index(("session_id",))) - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - table = cls.declared_name - return { - f"{table}_append": f""" BEFORE INSERT ON {table} - WHEN NEW.request_generation < COALESCE((SELECT MAX(request_generation) FROM {table}), 0) - OR EXISTS(SELECT 1 FROM {table} WHERE request_generation=NEW.request_generation - AND (llm_context_digest != NEW.llm_context_digest - OR committed_at_ms != NEW.committed_at_ms)) - OR NEW.session_id != (SELECT session_id FROM {table} ORDER BY session_id LIMIT 1) - BEGIN SELECT RAISE(ABORT,'native proof lineage differs'); END""", - **{ - f"{table}_{action.lower()}": " " - f"BEFORE {action} ON {table} BEGIN SELECT RAISE(ABORT,'native proof is append only'); END" - for action in ("UPDATE", "DELETE") - }, - } - @classmethod def native_contract(cls) -> dict[str, Any]: """Generate the native writer's SQLite contract from this declaration.""" diff --git a/src/agent_comms/native_prompt_binding.py b/src/agent_comms/native_prompt_binding.py index db50a3e9e7..9372afc802 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -20,8 +20,8 @@ from agent_comms.coordinator import Coordination from agent_comms.private_runtime_schema import PrivateRuntimeSchema -from .cohort_schema import assert_cohort_schema -from .coordinated_runtime_schema import assert_native_runtime_schema +from .cohort_schema import CohortTable +from agent_comms.native_runtime_input import NativeRuntimeTable from .field_codec import FieldCodec, Id128Text, MalformedRecord from .locked_store import DurableFile, SQLiteFile from .native_entries import NativeEvidenceRead @@ -33,7 +33,14 @@ from .private_sidecar import create_sidecar_file, native_request_digest, sidecar_connection from .reservation_rules import ReservationViolationError from .threads import Thread -from .typed_table import Column, IntegerStorage, TextStorage, TypedRow, TypedTable +from .typed_table import ( + Column, + ImmutableRows, + IntegerStorage, + TextStorage, + TypedRow, + TypedTable, +) if TYPE_CHECKING: from .private_send_stage import NativeSendStage @@ -45,7 +52,7 @@ def binding_store_path(store: Coordination) -> Path: @dataclass(frozen=True, slots=True) -class PromptBinding(DurableFile, SQLiteFile, NativeInputRecord, TypedTable, PrivateRuntimeSchema): +class PromptBinding(ImmutableRows, DurableFile, SQLiteFile, NativeInputRecord, TypedTable, PrivateRuntimeSchema): filename = "native_prompt_bindings.sqlite3" @classmethod @@ -86,16 +93,6 @@ def execution(self) -> NativeInputExecution: "AND length(expected_prompt_digest)=64 AND expected_prompt_digest NOT GLOB '*[^0-9a-f]*'", ) - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - return { - f"{cls.declared_name}_{operation.lower()}_guard": " " - f"BEFORE {operation} ON {cls.declared_name} " - "BEGIN SELECT RAISE(ABORT,'prelaunch binding is immutable'); END" - for operation in ("UPDATE", "DELETE") - } - - @dataclass(frozen=True) class _BindingRoot(TypedRow): wire_root_id: str @@ -145,8 +142,8 @@ def bind_expected_prompt( path = binding_store_path(store) with sidecar_connection(path, PromptBinding) as sidecar: with store.session.writing() as db: - assert_native_runtime_schema(db) - assert_cohort_schema(db) + NativeRuntimeTable.require_installed(db) + CohortTable.require_installed(db) ParticipantOwner(owner, generation).require(store, stage.recipient_lookup) reserved = NativeRuntimeInput.one(db, input_id=input_id) if reserved is None: diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 28725f998a..a3afaa15b1 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -40,7 +40,10 @@ from .typed_table import ( Column, DurableTables, + FrozenColumns, + GuardedUpdates, IntegerStorage, + NoDelete, SharedFileTables, TextStorage, TypedRow, @@ -81,7 +84,14 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) -class NativeRuntimeInput(NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): +class NativeRuntimeInput(GuardedUpdates, FrozenColumns, NoDelete, NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): + immutable_columns = ('input_id', 'stage', 'execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest') + update_condition = """(OLD.{sent_owner_claimed_generation} IS NOT NULL AND NEW.{sent_owner_claimed_generation} IS NOT + OLD.{sent_owner_claimed_generation}) OR (NEW.{sent_owner_claimed_generation} IS NULL AND NEW.session_id IS + NOT NULL) OR (OLD.session_id IS NOT NULL AND (NEW.session_id IS NOT OLD.session_id OR NEW.session_file IS + NOT OLD.session_file)) OR OLD.session_entry_id IS NOT NULL OR (NEW.session_id IS NULL AND + NEW.{sent_owner_claimed_generation} IS NULL)""" + @classmethod def source_membership_sql(cls) -> str: return " UNION ALL ".join( @@ -301,36 +311,12 @@ def commit_context( if updated.rowcount != 1: raise StaleFence("native proof was previously committed") - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - name = cls.declared_name - return { - f"{name}_identity_guard": f""" - BEFORE UPDATE ON {name} - WHEN NEW.input_id IS NOT OLD.input_id OR NEW.stage IS NOT OLD.stage - OR NEW.execution_id IS NOT OLD.execution_id - OR NEW.attempt_ordinal IS NOT OLD.attempt_ordinal - OR NEW.owner_lookup IS NOT OLD.owner_lookup - OR NEW.owner_thread IS NOT OLD.owner_thread - OR NEW.owner_generation IS NOT OLD.owner_generation - OR NEW.owner_token_digest IS NOT OLD.owner_token_digest - OR (OLD.{{sent_owner_claimed_generation}} - IS NOT NULL - AND NEW.{{sent_owner_claimed_generation}} - IS NOT OLD.{{sent_owner_claimed_generation}}) - OR (NEW.{{sent_owner_claimed_generation}} IS NULL AND NEW.session_id IS NOT NULL) - OR (OLD.session_id IS NOT NULL AND - (NEW.session_id IS NOT OLD.session_id OR NEW.session_file IS NOT OLD.session_file)) - OR OLD.session_entry_id IS NOT NULL - OR (NEW.session_id IS NULL AND NEW.{{sent_owner_claimed_generation}} IS NULL) - BEGIN SELECT RAISE(ABORT,'native runtime input identity is frozen'); END""", - f"{name}_delete_guard": f" BEFORE DELETE ON {name} " - "BEGIN SELECT RAISE(ABORT,'native runtime input cannot be deleted'); END", - } - - @dataclass(frozen=True) -class CurrentNativeCursor(NativeInputContext, NativeRuntimeTable, TypedTable): +class CurrentNativeCursor(GuardedUpdates, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): + immutable_columns = ('wire_root_id', 'recipient_lookup', 'owner_thread', 'owner_generation', 'owner_claimed_generation') + update_condition = """NEW.covered_seq < OLD.covered_seq OR NEW.injected_seq < OLD.injected_seq OR (NEW.injected_seq = + OLD.injected_seq AND NEW.input_id IS NOT OLD.input_id)""" + @property def context_anchor(self) -> NativeColumn: return self.session_id @@ -387,19 +373,3 @@ def owner_identity(self): "AND session_id IS NOT NULL AND request_generation>0)", ) - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - name = cls.declared_name - return { - f"{name}_update_guard": f""" BEFORE UPDATE ON {name} - WHEN NEW.wire_root_id IS NOT OLD.wire_root_id - OR NEW.recipient_lookup IS NOT OLD.recipient_lookup - OR NEW.owner_thread IS NOT OLD.owner_thread - OR NEW.owner_generation IS NOT OLD.owner_generation - OR NEW.{{owner_claimed_generation}} IS NOT OLD.{{owner_claimed_generation}} - OR NEW.covered_seq < OLD.covered_seq OR NEW.injected_seq < OLD.injected_seq - OR (NEW.injected_seq = OLD.injected_seq AND NEW.input_id IS NOT OLD.input_id) - BEGIN SELECT RAISE(ABORT,'native source cursor cannot regress'); END""", - f"{name}_delete_guard": f" BEFORE DELETE ON {name} " - "BEGIN SELECT RAISE(ABORT,'native source cursor cannot be deleted'); END", - } diff --git a/src/agent_comms/native_source_cursor.py b/src/agent_comms/native_source_cursor.py index 463918a315..c6d89aa855 100644 --- a/src/agent_comms/native_source_cursor.py +++ b/src/agent_comms/native_source_cursor.py @@ -4,7 +4,7 @@ from functools import partial from typing import TYPE_CHECKING -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_database import CoordinationStore from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination @@ -63,7 +63,7 @@ async def refresh_async(cls, bus, *, wire_root_id, owner_name, source_reads): def _refresh_owned(cls, bus, wire_root_id, owner_name, source_reads, store): owner, claim_scope = bus._registry.live_owner_with_claim(owner_name) person = store.participants.get(owner.incarnation.lookup) - return cls._advance_owned(bus, wire_root_id, owner, claim_scope, person.participant_generation, + return cls._advance_owned(bus, wire_root_id, owner, claim_scope, person.owner_identity.generation, None, store, source_reads=source_reads) def _coverage(self, lookup, contention=None): @@ -113,12 +113,12 @@ def read(self, *, owner_name, source_reads): captured = RegistryOwner.capture(boundary.registry, owner_name, reason) with self.store.session.read(): db = self.store.session._connection - assert_native_runtime_schema(db) + NativeRuntimeTable.require_installed(db) person = self.store.participants.get(captured.thread.incarnation.lookup) - if not person.committed or person.owner_thread != owner_name: + if not person.committed or person.owner_identity.owner_thread != owner_name: raise StaleFence('current native cursor recipient is not committed') identity = CursorOwner(wire_root_id=self.wire_root_id, thread=captured.thread, - generation=person.participant_generation, + generation=person.owner_identity.generation, claimed_generation=captured.claimed_generation) state = identity.state(db, marker.claimed_after_seq) sources = self._coverage(identity.lookup) diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index e576c20e41..cfa1976eae 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -31,14 +31,7 @@ from .audience_manifest import FrozenRecipient from .bus_publication import CommittedDelivery from .child_process import ProcessIdentity -from .cohort_schema import ( - AwarenessClaimGenerations, - ClaimBatchAcknowledgements, - ClaimBatchMembers, - CohortDeliveryReceipts, - assert_cohort_schema, - assert_optional_awareness_schema, -) +from .cohort_schema import AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, CohortDeliveryReceipts, CohortTable, assert_optional_awareness_schema from .context_segments.optional_awareness import ( CompleteAwareness, OmittedAwareness, @@ -301,7 +294,7 @@ def _verify_schema_and_owner( owner: Thread, ) -> None: CoordinatorTable.require_schema(db) - assert_cohort_schema(db) + CohortTable.require_installed(db) assert_optional_awareness_schema(db) people = _ParticipantOwner.read( db.execute( diff --git a/src/agent_comms/participant_store.py b/src/agent_comms/participant_store.py index 3238ef4d91..d29e716800 100644 --- a/src/agent_comms/participant_store.py +++ b/src/agent_comms/participant_store.py @@ -41,17 +41,9 @@ class ParticipantSnapshot: display_name: str committed: bool aliases: tuple[str, ...] - owner_thread: str - participant_generation: Annotated[int, WireName("generation")] + owner_identity: OwnerGenerations pointer: CurrentExecutions - @property - def owner_identity(self) -> OwnerGenerations: - return OwnerGenerations( - owner_lookup=self.lookup, owner_thread=self.owner_thread, - generation=self.participant_generation, - ) - class ParticipantStore: def __init__(self, session: CoordinationSession) -> None: @@ -80,8 +72,7 @@ def get(self, lookup: str) -> ParticipantSnapshot: person.display_name, person.committed, aliases, - generation.owner_thread, - generation.generation, + generation, pointer, ) @@ -155,7 +146,7 @@ def rename( raise ValueError("alias and display name must be bounded") with self.session.writing() as db: person = self.get(lookup) - if person.participant_generation != expected_generation: + if person.owner_identity.generation != expected_generation: raise StaleRevision("owner generation changed") existing = ParticipantAliases.one(self.session._connection, alias=alias) if existing is not None: @@ -216,7 +207,7 @@ def _update_owner( person = self.get(lookup) if not person.committed: raise IdentityConflict("uncommitted participant cannot own a generation") - if person.participant_generation != expected_generation: + if person.owner_identity.generation != expected_generation: raise StaleRevision("owner generation changed") OwnerGenerations.update( db, diff --git a/src/agent_comms/private_send_claim.py b/src/agent_comms/private_send_claim.py index 82a7527244..229c2f2a58 100644 --- a/src/agent_comms/private_send_claim.py +++ b/src/agent_comms/private_send_claim.py @@ -15,7 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_errors import IdentityConflict, RetryScheduled from .coordinator import Coordination from .diagnostics import ( @@ -254,7 +254,7 @@ def _start_once(self, identity: NativeSessionIdentity) -> None: self.owner.require_snapshot( registry, "recipient registry owner changed before native send" ) - assert_native_runtime_schema(db) + NativeRuntimeTable.require_installed(db) self.participant.require(store, self.stage.recipient_lookup) reserved = self.stage.require_reservation( db, self.input_id, self.participant, self.token_digest diff --git a/src/agent_comms/private_send_stage.py b/src/agent_comms/private_send_stage.py index 1dc2d4ad46..e5f330ca60 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -13,7 +13,7 @@ from collections.abc import Iterator from .assignment_states import DeferredAssignment, FailedAssignment -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_errors import IdentityConflict, RetryScheduled, StaleFence from .coordination_tables.assignments import WakeAssignment from .coordinator import Coordination @@ -92,7 +92,7 @@ async def fail_unknown(self, bus, owner, witness) -> None: def reserve(self, store: Coordination, owner: ParticipantOwner, token_digest: str) -> str: input_id = Id128Text.mint() with store.session.writing() as db: - assert_native_runtime_schema(db) + NativeRuntimeTable.require_installed(db) owner.require(store, self.recipient_lookup) self.reserve_claim(store, db) NativeRuntimeInput( @@ -117,7 +117,7 @@ def pending_input( ) -> NativeRuntimeInput: """Read current durable ownership inside the caller's read/write transaction.""" db = store.session._connection - assert_native_runtime_schema(db) + NativeRuntimeTable.require_installed(db) owner.require(store, self.recipient_lookup) row = NativeRuntimeInput.one(db, input_id=input_id) if row is None: diff --git a/src/agent_comms/private_sidecar.py b/src/agent_comms/private_sidecar.py index b789a33fa9..ae3d032476 100644 --- a/src/agent_comms/private_sidecar.py +++ b/src/agent_comms/private_sidecar.py @@ -35,6 +35,7 @@ from .typed_table import ( AllObjectsTables, Column, + ImmutableRows, SQLiteForeignKeys, SQLiteSchemaObject, TypedRow, @@ -202,20 +203,10 @@ def _connect(raw: bytes | None) -> sqlite3.Connection: @dataclass(frozen=True) -class SnapshotMeta(TypedTable): +class SnapshotMeta(ImmutableRows, TypedTable): singleton: Annotated[Literal[1], Column(primary_key=True)] ddl_digest: str - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - return { - f"{cls.declared_name}_{operation.lower()}_guard": " " - f"BEFORE {operation} ON {cls.declared_name} " - "BEGIN SELECT RAISE(ABORT,'snapshot schema is immutable'); END" - for operation in ("UPDATE", "DELETE") - } - - @dataclass(frozen=True) class _Database(TypedRow): seq: int diff --git a/src/agent_comms/proven_source_coverage.py b/src/agent_comms/proven_source_coverage.py index bbd1c4c77f..db50810eae 100644 --- a/src/agent_comms/proven_source_coverage.py +++ b/src/agent_comms/proven_source_coverage.py @@ -22,8 +22,8 @@ from .audience_manifest import FrozenRecipient from .bus_publication import CommittedDelivery -from .cohort_schema import ClaimBatchAcknowledgements, assert_cohort_schema -from .coordinated_runtime_schema import assert_native_runtime_schema +from .cohort_schema import ClaimBatchAcknowledgements, CohortTable +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_cohort import AcceptedCohort, _claim_record from .historical_native_inputs import HistoricalNativeInput, read_historical_native_inputs from .message_bus import MessageBus @@ -291,8 +291,8 @@ def _page(self, request: CoveragePage) -> tuple[PrefixWitness, tuple[CommittedDe def _accepted_cohort(self, initial: CommittedDelivery) -> AcceptedCohort | None: with self.store.session.read(): db = self.store.session._connection - assert_cohort_schema(db) - assert_native_runtime_schema(db) + CohortTable.require_installed(db) + NativeRuntimeTable.require_installed(db) sealed = ClaimBatchAcknowledgements.one( db, wire_root_id=self.wire_root_id, wire_seq=initial.message.seq ) diff --git a/src/agent_comms/selected_claim.py b/src/agent_comms/selected_claim.py index eb78a84f5e..684e042b50 100644 --- a/src/agent_comms/selected_claim.py +++ b/src/agent_comms/selected_claim.py @@ -16,7 +16,7 @@ from agent_comms.coordinator import Coordination from .bus_publication import CommittedDelivery -from .cohort_schema import assert_cohort_schema +from .cohort_schema import CohortTable from .comms import Comms from .coordination_cohort import _claim_record from .coordination_database import CoordinationStore @@ -72,7 +72,7 @@ def _verify_selected_wake_state( if generation != claim_scope.owner_claimed_generation or turn.id != claim_scope.turn_id: raise IdentityConflict("Wake source or owner turn does not match") with store.session.read(): - assert_cohort_schema(store.session._connection) + CohortTable.require_installed(store.session._connection) acknowledgement = _claim_record(store.session._connection, initial) if not any( assignment.assignment_id == claim_scope.wake_assignment_id diff --git a/src/agent_comms/selected_participant.py b/src/agent_comms/selected_participant.py index 2cf709de3e..cd5184d773 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -11,12 +11,12 @@ from agent_comms.reason_codes import GivenReasonCode from .agent_events import CompactionEvent, NativePhaseChanged from .bus_publication import CommittedDelivery -from .cohort_schema import assert_cohort_schema +from .cohort_schema import CohortTable from .comms import Comms -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_cohort import _claim_record, pending_sealed_assignments from .coordination_errors import IdentityConflict, StaleFence -from .coordination_response import LiveResponseOwner, _assert_response_schema +from .coordination_response import LiveResponseOwner, ResponseTable from .coordination_snapshot import RecoverySnapshot from .coordination_tables.assignments import WakeAssignment from .coordination_tables.executions import ExecutionOrigin @@ -104,7 +104,7 @@ async def prepare_attempt(self, snapshot: RecoverySnapshot) -> None: expected_generation=self.identity.generation, ).value self.identity = ParticipantOwner( - self.owner.thread, participant.participant_generation + self.owner.thread, participant.owner_identity.generation ) await self.require_current() @@ -156,7 +156,7 @@ async def select(cls, comms: Comms, store: Coordination, root_id: str, name: str if not pending: yield None return - identity = ParticipantOwner(owner.thread, participant.participant_generation) + identity = ParticipantOwner(owner.thread, participant.owner_identity.generation) with cursor_delivery.measurements.operation("model_selection"): model = await Coordination.run_worker(lambda: cls.configured_model(comms, name)) lease = cls.lease(comms, owner) @@ -236,11 +236,11 @@ def prepare( thread=thread, claimed_generation=generation ).resolve_retained(store, bus) with store.session.read(): - assert_cohort_schema(store.session._connection) - _assert_response_schema(store.session._connection) - assert_native_runtime_schema(store.session._connection) + CohortTable.require_installed(store.session._connection) + ResponseTable.require_installed(store.session._connection) + NativeRuntimeTable.require_installed(store.session._connection) participant = store.participants.get(lookup) - identity = ParticipantOwner(thread, participant.participant_generation) + identity = ParticipantOwner(thread, participant.owner_identity.generation) identity.require(store, lookup) pending = pending_sealed_assignments( store, lookup, thread.name, after_seq=after_seq @@ -308,7 +308,7 @@ def capture(): def verify(resource: Coordination): with resource.session.read(): - assert_cohort_schema(resource.session._connection) + CohortTable.require_installed(resource.session._connection) for assignment, initial in zip(assignments, originals, strict=True): acknowledgement = _claim_record(resource.session._connection, initial) if not any(row.assignment_id == assignment.assignment_id diff --git a/src/agent_comms/selected_tool_broker.py b/src/agent_comms/selected_tool_broker.py index 2ee36a6204..c4893ae01e 100644 --- a/src/agent_comms/selected_tool_broker.py +++ b/src/agent_comms/selected_tool_broker.py @@ -21,7 +21,7 @@ from agent_comms.coordinator import Coordination from .comms import Comms -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_errors import CoordinationReadUnavailable from .coordination_tables.participants import OwnerGenerations from .envelope_claim_transitions import ExistingFileClaim, WakeClaim @@ -282,7 +282,7 @@ def verify_sent_full_input( def _require_sent_full_input( scoped: Coordination, claim_scope: WakeClaim, owner_name: str, input_id: str ) -> None: - assert_native_runtime_schema(scoped.session._connection) + NativeRuntimeTable.require_installed(scoped.session._connection) row = NativeRuntimeInput.one(scoped.session._connection, input_id=input_id) if row is None: raise SelectedToolDenied("Selected tool has no reserved FULL input") diff --git a/src/agent_comms/thread_management.py b/src/agent_comms/thread_management.py index 026008ff17..f37aabc8f8 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -637,10 +637,10 @@ def renamed(self) -> tuple[str, str]: person = store.participants.get(before.incarnation.require_recorded().lookup) except ParticipantNotRegistered: return self.registry_renamed() - if not person.committed or person.owner_thread != before.name: + if not person.committed or person.owner_identity.owner_thread != before.name: raise RelationViolationError( "Private coordinator owner differs from the registered owner.") - generation = person.participant_generation + generation = person.owner_identity.generation PrivateOwnerRenameIntent(lookup=person.lookup, old=before.name, new=new_name, generation=generation, wire_root_id=self.registry_privacy.root_id.hex(), diff --git a/src/agent_comms/transcripts.py b/src/agent_comms/transcripts.py index de2afe4977..e2751720c6 100644 --- a/src/agent_comms/transcripts.py +++ b/src/agent_comms/transcripts.py @@ -14,10 +14,10 @@ from .agent_backend import RuntimeLocation from .bus_publication import CommittedDelivery -from .coordinated_runtime_schema import assert_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_database import CoordinationStore from .coordination_errors import CoordinationReadUnavailable, StaleRevision, TranscriptChanged -from .coordination_response import _assert_response_schema +from .coordination_response import ResponseTable from .declared_family import DeclaredFamily from .errors import RelationViolationError from .external_transcript import ExternalTranscript @@ -399,8 +399,8 @@ def publication_source(cls, root): @classmethod def capture(cls, db): - assert_native_runtime_schema(db) - _assert_response_schema(db) + NativeRuntimeTable.require_installed(db) + ResponseTable.require_installed(db) return db @classmethod diff --git a/src/agent_comms/triage_native_sources.py b/src/agent_comms/triage_native_sources.py index 1bc9a465cd..c1bd1c4a3d 100644 --- a/src/agent_comms/triage_native_sources.py +++ b/src/agent_comms/triage_native_sources.py @@ -9,13 +9,26 @@ from .coordination_errors import IdentityConflict from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable -from .typed_table import Column, TypedTable +from .typed_table import ( + Column, + GuardedInserts, + ImmutableRows, + TypedTable, +) @dataclass(frozen=True) -class TriageNativeSources( +class TriageNativeSources(GuardedInserts, ImmutableRows, NativeRuntimeTable, TypedTable, declared_name="native_runtime_triage_sources" ): + + insert_condition = """(NOT EXISTS (SELECT 1 FROM native_runtime_input n WHERE n.input_id=NEW.input_id AND n.stage='triage')) OR + (EXISTS (SELECT 1 FROM json_each(NEW.assignment_ids) member LEFT JOIN wake_claims c ON + c.assignment_id=member.value JOIN native_runtime_input n ON n.input_id=NEW.input_id WHERE c.assignment_id IS + NULL OR c.recipient_lookup<>n.owner_lookup) OR EXISTS (SELECT 1 FROM native_runtime_triage_sources s, + json_each(s.assignment_ids) previous JOIN json_each(NEW.assignment_ids) member ON + member.value=previous.value))""" + input_id: Annotated[ str, Column(primary_key=True, references=(NativeRuntimeInput, "input_id")) ] @@ -28,30 +41,3 @@ def __post_init__(self): if not self.assignment_ids or len(set(self.assignment_ids)) != len(self.assignment_ids): raise IdentityConflict("Native input membership must name distinct original sources") - @classmethod - def trigger_bodies(cls): - from .native_input_record import TriageNativeExecution - - guards = { - f"{cls.declared_name}_{operation.lower()}_guard": - f" BEFORE {operation} " - f"ON {cls.declared_name} BEGIN SELECT RAISE(ABORT,'native source membership is frozen'); END" - for operation in ("UPDATE", "DELETE") - } - guards[f"{cls.declared_name}_stage_guard"] = ( - f" BEFORE INSERT ON {cls.declared_name} " - "WHEN NOT EXISTS (SELECT 1 FROM native_runtime_input n " - f"WHERE n.input_id=NEW.input_id AND n.stage='{TriageNativeExecution.declared_name}') " - "BEGIN SELECT RAISE(ABORT,'only triage owns separate source membership'); END" - ) - guards[f"{cls.declared_name}_membership_guard"] = ( - f" BEFORE INSERT ON {cls.declared_name} " - "WHEN EXISTS (SELECT 1 FROM json_each(NEW.assignment_ids) member " - "LEFT JOIN wake_claims c ON c.assignment_id=member.value " - "JOIN native_runtime_input n ON n.input_id=NEW.input_id " - "WHERE c.assignment_id IS NULL OR c.recipient_lookup<>n.owner_lookup) " - f"OR EXISTS (SELECT 1 FROM {cls.declared_name} s, json_each(s.assignment_ids) previous " - "JOIN json_each(NEW.assignment_ids) member ON member.value=previous.value) " - "BEGIN SELECT RAISE(ABORT,'triage source is absent, belongs to another owner or was reserved'); END" - ) - return guards diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index a99efa4182..5bca0ad657 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -641,6 +641,13 @@ def require_schema(cls, db: sqlite3.Connection) -> None: if cls.schema_differs(db): raise cls.schema_error(f"{cls.__name__} differs from its declaration") + @classmethod + def require_installed(cls, db: sqlite3.Connection) -> None: + """Installed durable families require their declaration and enforced relations.""" + cls.require_schema(db) + if SQLiteForeignKeys.read(db.execute("PRAGMA foreign_keys")) != [SQLiteForeignKeys(True)]: + raise cls.schema_error(f"{cls.__name__} foreign-key enforcement is disabled") + @classmethod def create_schema(cls, db: sqlite3.Connection) -> None: for row in cls.schema_rows(): @@ -655,36 +662,135 @@ def reconcile_schema(cls, db: sqlite3.Connection) -> None: class RowMutation(Enum): """SQLite's destructive row events own their immutable-history guards.""" + INSERT = "INSERT" UPDATE = "UPDATE" DELETE = "DELETE" - def abort_trigger(self, row: type[TypedTable]) -> SQLiteSchemaObject: - name = f"{row.declared_name}_{self.value.lower()}_abort" + def abort_trigger( + self, row: type[TypedTable], *, columns: tuple[str, ...] = (), when: str = "", + message: str = "Rows are immutable", after: bool = False, suffix: str = "", + ) -> SQLiteSchemaObject: + suffix = "_" + "_".join(columns) if columns else suffix + timing = "AFTER" if after else "BEFORE" + name = f"{row.declared_name}_{self.value.lower()}{suffix}_{timing.lower()}_abort" + selected = " OF " + row._column_list(columns) if columns else "" + condition = " WHEN " + row._sql(when) if when else "" return SQLiteSchemaObject( name, - f'CREATE TRIGGER "{name}" BEFORE {self.value} ON "{row.declared_name}" ' - "BEGIN SELECT RAISE(ABORT, 'Bus history is append-only'); END", + f'CREATE TRIGGER "{name}" {timing} {self.value}{selected} ON "{row.declared_name}"' + f"{condition} BEGIN SELECT RAISE(ABORT, '{message}'); END", ) -class AppendOnlyTables(TableSchema): - """Declared history tables reject UPDATE and DELETE, even from raw SQL.""" +class MutationCapability(ABC): + """Each MRO declaration contributes guards; siblings never override one another.""" @classmethod - def _append_only_triggers(cls) -> dict[str, str]: - triggers = (mutation.abort_trigger(row) - for row in cls.schema_rows() for mutation in RowMutation) - return {trigger.name: trigger.sql for trigger in triggers} + @abstractmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: ... + +class NoUpdate(MutationCapability): @classmethod - def declared_schema(cls) -> dict[str, str]: - return super().declared_schema() | cls._append_only_triggers() + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return (RowMutation.UPDATE.abort_trigger(row, message="Rows cannot be updated"),) + +class NoDelete(MutationCapability): @classmethod - def create_schema(cls, db: sqlite3.Connection) -> None: - super().create_schema(db) - for statement in cls._append_only_triggers().values(): - db.execute(statement) + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return (RowMutation.DELETE.abort_trigger(row, message="Rows cannot be deleted"),) + + +@dataclass(frozen=True) +class ColumnGuard: + """A predicate on an UPDATE's selected columns, not another SQL trigger template.""" + + columns: tuple[str, ...] + condition: str = "1" + + def trigger(self, row: type[TypedTable]) -> SQLiteSchemaObject: + return RowMutation.UPDATE.abort_trigger( + row, columns=self.columns, when=self.condition, message="Column change is refused") + + +class GuardedColumns(MutationCapability): + column_guards: ClassVar[tuple[ColumnGuard, ...]] = () + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return tuple(guard.trigger(row) for guard in row.column_guards) + + +class FrozenColumns(MutationCapability): + immutable_columns: ClassVar[tuple[str, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + columns = row._fields_named(row.immutable_columns) + condition = " OR ".join( + f'NEW."{column.wire_name}" IS NOT OLD."{column.wire_name}"' for column in columns) + return (RowMutation.UPDATE.abort_trigger( + row, columns=row.immutable_columns, when=condition, message="Column values are immutable"),) + + +class GuardedUpdates(MutationCapability): + update_condition: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return (RowMutation.UPDATE.abort_trigger( + row, when=row.update_condition, message="Update violates row invariants"),) + + +class GuardedInserts(MutationCapability): + insert_condition: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return (RowMutation.INSERT.abort_trigger( + row, when=row.insert_condition, message="Insert violates row membership"),) + + +class GuardedRelatedInserts(MutationCapability): + related_insert_target: ClassVar[type[TypedTable]] + related_insert_condition: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return (RowMutation.INSERT.abort_trigger( + row.related_insert_target, when=row.related_insert_condition, + message="Insert violates related row membership"),) + + +class ValidatedInserts(MutationCapability): + validator_function: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + arguments = ", ".join(f'NEW."{field.wire_name}"' for field in row._fields() if field.init) + return (RowMutation.INSERT.abort_trigger( + row, when=f"{row.validator_function}({arguments}) != 1", suffix="_validation", + message="Insert fails declared validation"),) + + +class GuardedAfterWrites(MutationCapability): + after_write_condition: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return tuple(mutation.abort_trigger( + row, when=row.after_write_condition, after=True, + message="Write violates related row invariants") + for mutation in (RowMutation.INSERT, RowMutation.UPDATE)) + + +class ImmutableRows(NoUpdate, NoDelete): + """A row family refuses both destructive mutations.""" + + +class AppendOnlyTables(ImmutableRows, TableSchema): + """Declared history tables reject UPDATE and DELETE, even from raw SQL.""" class SharedFileTables(TableSchema): @@ -889,15 +995,28 @@ def schema_objects(cls, *, if_not_exists: bool = False) -> dict[str, str]: return statements @classmethod - def trigger_bodies(cls) -> dict[str, str]: - """Named trigger suffixes, including their leading SQL whitespace.""" - return {} + def triggers(cls, *, if_not_exists: bool = False) -> dict[str, str]: + guards = ( + trigger for capability in cls.__mro__ + if issubclass(capability, MutationCapability) + and "mutation_guards" in vars(capability) and capability is not MutationCapability + for trigger in capability.mutation_guards(cls) + ) + statements: dict[str, str] = {} + for trigger in guards: + if trigger.name in statements: + raise TypeError(f"{cls.__name__} declares two guards named {trigger.name}") + statements[trigger.name] = trigger.sql.replace( + "CREATE TRIGGER ", "CREATE TRIGGER IF NOT EXISTS ", 1 + ) if if_not_exists else trigger.sql + return statements @classmethod - def triggers(cls, *, if_not_exists: bool = False) -> dict[str, str]: - existence = "IF NOT EXISTS " if if_not_exists else "" - return {name: f"CREATE TRIGGER {existence}{name}{cls._sql(body)}" - for name, body in cls.trigger_bodies().items()} + def structure_objects(cls, *, if_not_exists: bool = False) -> dict[str, str]: + """Tables, indexes and views, with mutation guards excluded by their declarations.""" + guards = cls.triggers() + return {name: sql for name, sql in cls.schema_objects(if_not_exists=if_not_exists).items() + if name not in guards} @classmethod def ddl(cls, *, if_not_exists: bool = False) -> tuple[str, ...]: diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index 5994148a86..ce848fdee8 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -95,7 +95,7 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag with Coordination(str(comms.root / "coordination.sqlite3")) as store: after = store.participants.get(lookup) assert after.owner_thread == "gamma" - assert after.participant_generation == before.participant_generation + assert after.owner_identity.generation == before.owner_identity.generation assert after.pointer == active.pointer with pytest.raises(StaleRevision): store.participants.rename_owner(lookup, "wrong", expected_generation=2) diff --git a/tests/test_claim_admission_verifier.py b/tests/test_claim_admission_verifier.py index 7e0c95a82b..fe97ee8a49 100644 --- a/tests/test_claim_admission_verifier.py +++ b/tests/test_claim_admission_verifier.py @@ -97,7 +97,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( execution_id, 1, owner.name, - person.participant_generation, + person.owner_identity.generation, prepare_fence_token(), expected_execution_revision=snapshot.execution.revision, expected_pointer_revision=snapshot.pointer_revision, @@ -114,7 +114,7 @@ def test_selected_wake_verifier_refuses_no_wake_and_stale_authority( operation_id="f" * 32, owner_claimed_generation=claimed_generation, turn_id=owner.require_turn_lease().turn_id, - participant_generation=person.participant_generation, + participant_generation=person.owner_identity.generation, attempt_ordinal=1, ) verify_selected_wake(comms, store, claim_scope, owner.name) diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 2feab3ed36..6374375882 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -114,7 +114,7 @@ def selected_turn(): "execution", expected_revision=snapshot.execution.revision ).value store.attempts.start(AttemptStart( - "execution", 1, owner.name, person.participant_generation, prepare_fence_token(), + "execution", 1, owner.name, person.owner_identity.generation, prepare_fence_token(), expected_execution_revision=snapshot.execution.revision, expected_pointer_revision=snapshot.pointer_revision, )) @@ -129,7 +129,7 @@ def selected_turn(): operation_id="f" * 32, owner_claimed_generation=generation, turn_id=owner.require_turn_lease().turn_id, - participant_generation=person.participant_generation, + participant_generation=person.owner_identity.generation, attempt_ordinal=1, ) yield comms, store, claim_scope, resource diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index 006790c221..25cc81b30a 100644 --- a/tests/test_coordinated_runtime.py +++ b/tests/test_coordinated_runtime.py @@ -17,10 +17,8 @@ from agent_comms.comms import Comms from agent_comms.notification_progress import HumanReadProgress from agent_comms.coordinated_runtime import SelectedExecution, SessionSetupFailed -from agent_comms.coordinated_runtime_schema import ( - assert_native_runtime_schema, - install_native_runtime_schema, -) +from agent_comms.coordinated_runtime_schema import install_native_runtime_schema +from agent_comms.native_runtime_input import NativeRuntimeTable from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments from agent_comms.coordination_errors import ( IdentityConflict, @@ -170,10 +168,10 @@ def test_native_runtime_schema_explicit_install_and_drift_fail_closed(tmp_path: ) install_native_runtime_schema(store) install_native_runtime_schema(store) - assert_native_runtime_schema(store.session._connection) + NativeRuntimeTable.require_installed(store.session._connection) store.session._connection.execute("DROP TRIGGER native_runtime_input_delete_guard") with pytest.raises(PublicationActivationBlocked): - assert_native_runtime_schema(store.session._connection) + NativeRuntimeTable.require_installed(store.session._connection) async def test_untrusted_pi_fails_before_any_bus_or_sql_mutation(tmp_path: Path, *, native_runtime_package, cursor_delivery) -> None: diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 9a0e6a8341..23cb790a70 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -219,12 +219,12 @@ def test_registration_complete_atomic_reopen_and_rename(db_path: Path) -> None: db.participants.advance_generation("owner", "thread", expected_generation=1) db.close() with store(db_path) as reopened: - assert reopened.participants.get("owner").participant_generation == 2 + assert reopened.participants.get("owner").owner_identity.generation == 2 assert reopened.participants.get("owner").committed assert reopened.participants.get("owner").aliases == ("owner", "Owner New") replay = reopened.participants.register("owner", "Owner", "thread", committed=False) assert isinstance(replay, AlreadyApplied) - assert replay.value.participant_generation == 2 + assert replay.value.owner_identity.generation == 2 assert replay.value.display_name == "Owner New" @@ -1296,7 +1296,7 @@ def test_concurrent_registration_one_writer_one_replay(db_path: Path) -> None: assert process.exitcode == 0 assert sorted(results) == ["AlreadyApplied", "Applied"] with store(db_path) as db: - assert db.participants.get("owner").participant_generation == 1 + assert db.participants.get("owner").owner_identity.generation == 1 assert ( db.session._connection.execute("SELECT count(*) FROM participants").fetchone()[0] == 1 ) diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index ec871fcbdc..350c3354be 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -96,7 +96,7 @@ def _projection( registry=Registration(index.log.path.with_name("registry.json")), expected_participant_generation=store.participants.get( RecordedThreadIncarnation("fixture", owner.created_at).lookup - ).participant_generation, + ).owner_identity.generation, expected_claimed_generation=claimed_generation, max_rows=max_rows, ) diff --git a/tests/test_thread_identity.py b/tests/test_thread_identity.py index c71a0c408b..6406d37823 100644 --- a/tests/test_thread_identity.py +++ b/tests/test_thread_identity.py @@ -217,12 +217,12 @@ def test_coordination_assignment_generation_is_independent_of_registry_process(t with Coordination(str(tmp_path / "coordination.sqlite3")) as store: participant = store.participants.register("lookup", "Owner", "owner", committed=True).value advanced = store.participants.advance_generation( - "lookup", "owner", expected_generation=participant.participant_generation + "lookup", "owner", expected_generation=participant.owner_identity.generation ).value - assert advanced.participant_generation == participant.participant_generation + 1 + assert advanced.owner_identity.generation == participant.owner_identity.generation + 1 assert registry.snapshot().owner_identity("owner") == initial registry.register(registry.require("owner"), new_owner=True) - assert store.participants.get("lookup").participant_generation == advanced.participant_generation + assert store.participants.get("lookup").owner_identity.generation == advanced.owner_identity.generation def test_saved_read_ledger_survives_reopen_and_new_ack(tmp_path): diff --git a/tools/cutover/carry_declared_schema.py b/tools/cutover/carry_declared_schema.py index 8adf8c6ce4..b65712d7ae 100644 --- a/tools/cutover/carry_declared_schema.py +++ b/tools/cutover/carry_declared_schema.py @@ -3,9 +3,9 @@ Files written before index names were derived from what each index covers hold positional names (delivery_0_idx), and files written before every file kind was compared with its declaration hold a sealed copy of it: a meta table with a -digest or version, triggers freezing it, and PRAGMA user_version. Each file is -converted in place inside one write transaction: indexes, triggers and views -come from the declarations of the tables it holds, the sealed copies go, and +digest or version and PRAGMA user_version. Each file is converted in place inside +one write transaction: indexes and views come from the declarations of the tables +it holds, the sealed copies go, and every table keeps its rows. The files are every durable SQLite file the bus declares and every saved session's @@ -77,10 +77,6 @@ class IndexKind(CatalogKind): named_by_coverage = True -class TriggerKind(CatalogKind): - pass - - class ViewKind(CatalogKind): pass @@ -112,7 +108,7 @@ def row(self, path: Path) -> type[TypedTable]: row = self.declaration(path) if derived(row): raise RelationViolationError(f"{path}: {self.name} is derived; it rebuilds, never carried") - if self.sql != row.schema_objects()[row.declared_name]: + if self.sql != row.structure_objects()[row.declared_name]: raise RelationViolationError(f"{path}: table {self.name} columns differ") return row @@ -123,7 +119,7 @@ def render(self) -> str: def stored(cls, db) -> tuple[StoredObject, ...]: return tuple(cls.read(db.execute( "SELECT type,name,tbl_name,sql FROM sqlite_master " - "WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type,name"))) + "WHERE sql IS NOT NULL AND type != 'trigger' AND name NOT LIKE 'sqlite_%' ORDER BY type,name"))) def derived(row: type[TypedTable]) -> bool: @@ -183,7 +179,7 @@ def pending(self) -> bool: """Whether its catalog differs from what its tables' declarations derive.""" rows = [item.row(self.path) for item in self.objects if item.type.holds_rows and not item.retired] - declared = {name: sql for row in rows for name, sql in row.schema_objects().items()} + declared = {name: sql for row in rows for name, sql in row.structure_objects().items()} return (self.user_version != 0 or {item.name: item.sql for item in self.objects} != declared) @@ -210,7 +206,7 @@ def carry(path: Path) -> FileCarried: tables = [item for item in before if item.type.holds_rows] kept = [item for item in tables if not item.retired] rows = tuple(item.row(path) for item in kept) - declared = {name: sql for row in rows for name, sql in row.schema_objects().items()} + declared = {name: sql for row in rows for name, sql in row.structure_objects().items()} counts = RowCount.of(db, kept) retired = RowCount.of(db, [item for item in tables if item.retired]) for item in before: @@ -220,8 +216,10 @@ def carry(path: Path) -> FileCarried: if item.retired: item.drop(db) for row in rows: - row.create(db, if_not_exists=True) - if differences := SQLiteSchemaObject.differences(TypedTable.objects_of(db, rows), declared): + for statement in row.structure_objects(if_not_exists=True).values(): + db.execute(statement) + stored = [SQLiteSchemaObject(item.name, item.sql) for item in StoredObject.stored(db)] + if differences := SQLiteSchemaObject.differences(stored, declared): raise RelationViolationError(f"{path}: still differs: {differences}") if RowCount.of(db, kept) != counts: raise RelationViolationError(f"{path}: row counts changed") diff --git a/tools/cutover/carry_table_triggers.py b/tools/cutover/carry_table_triggers.py new file mode 100644 index 0000000000..fafe217b7b --- /dev/null +++ b/tools/cutover/carry_table_triggers.py @@ -0,0 +1,103 @@ +"""Rewrite declared mutation guards once, retaining every application row. + +SnapshotMeta is the sole exception: its existing schema digest changes in the same +transaction as its guards, after the old digest has been checked against the stored +schema. No runtime reader accepts both schemas. Retire after installation. +""" +from __future__ import annotations + +import hashlib +import json +from contextlib import closing, nullcontext +from pathlib import Path + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.errors import RelationViolationError +from agent_comms.installer.carry import Carried, Carry +from agent_comms.locked_store import DurableFile, SQLiteFile +from agent_comms.private_sidecar import SnapshotMeta, _digest +from agent_comms.typed_table import SQLiteSchemaObject, SQLiteTableName, TableSchema, TypedTable + + +class TableTriggers(Carry): + """One install writer for stored triggers and their sidecar schema digest.""" + + @classmethod + def paths(cls, root: Path) -> tuple[Path, ...]: + # Explicit offline discovery; ordinary TypedTable reads only imported members. + tuple(TypedTable.__registry__.values()) + paths = { + path for owner, directory in SQLiteFile.declarations_in(root) + if issubclass(owner, DurableFile) for path in owner.matching_in(directory) + } + for path in paths: + cls.require_confined(root, path) + return tuple(sorted(paths)) + + @classmethod + def rewrite(cls, path: Path, *, apply: bool) -> int: + opener = CoordinationStore.writer if apply else CoordinationStore.read_snapshot + with closing(opener(path)) as db: + transaction = SQLiteFile.write_transaction(db) if apply else nullcontext(db) + with transaction: + rows = tuple(TypedTable.member_named(table.name) + for table in SQLiteTableName.stored(db)) + declared = {name: sql for row in rows for name, sql in row.schema_objects().items()} + structure = {name: sql for row in rows for name, sql in row.structure_objects().items()} + stored = SQLiteSchemaObject.read(db.execute( + "SELECT name,sql FROM sqlite_master WHERE sql IS NOT NULL " + "AND name NOT LIKE 'sqlite_%' ORDER BY rowid")) + actual_guards = SQLiteSchemaObject.read(db.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' ORDER BY rowid")) + guard_names = {guard.name for guard in actual_guards} + actual_structure = [item for item in stored if item.name not in guard_names] + if differences := SQLiteSchemaObject.differences(actual_structure, structure): + raise RelationViolationError(f"{path}: table/index/view differs: {differences}") + expected = {name: sql for name, sql in declared.items() if name not in structure} + digest_changed = False + digest = "" + if SnapshotMeta in rows: + (row,) = (row for row in rows if row is not SnapshotMeta) + (meta,) = SnapshotMeta.select(db) + # Sidecar digests use declaration insertion order, which is also + # the catalog's creation order before this first trigger carry. + old_schema = {item.name: item.sql for item in stored} + old_digest = hashlib.sha256(json.dumps( + old_schema, separators=(",", ":")).encode()).hexdigest() + digest = _digest(row) + complete = not SQLiteSchemaObject.differences(stored, declared) + if meta.ddl_digest != old_digest and not (meta.ddl_digest == digest and complete): + raise RelationViolationError(f"{path}: sidecar schema digest is unknown") + digest_changed = meta.ddl_digest != digest + differences = SQLiteSchemaObject.differences(actual_guards, expected) + if not differences and not digest_changed: + return 0 + if not apply: + return 1 + remaining = {guard.name: guard.sql for guard in actual_guards} + metadata_guards = SnapshotMeta.triggers() if digest_changed else {} + for guard in actual_guards: + if expected.get(guard.name) != guard.sql or guard.name in metadata_guards: + db.execute('DROP TRIGGER "' + guard.name.replace('"', '""') + '"') + del remaining[guard.name] + if digest_changed: + SnapshotMeta.update(db, where="singleton=1", ddl_digest=digest) + for name, sql in expected.items(): + if remaining.get(name) != sql: + db.execute(sql) + if differences := SQLiteSchemaObject.differences( + TableSchema.stored_schema(db), declared + ): + raise RelationViolationError(f"{path}: schema still differs: {differences}") + return 1 + + @classmethod + def backup_files(cls, root: Path) -> tuple[Path, ...]: + return tuple(member for path in cls.paths(root) if cls.rewrite(path, apply=False) + for member in SQLiteFile.files(path) if member.exists()) + + @classmethod + def carry(cls, root: Path, *, apply: bool) -> Carried: + changed = tuple(path for path in cls.paths(root) if cls.rewrite(path, apply=apply)) + return Carried(len(changed), {"files": len(changed)}, + tuple(str(path.relative_to(root)) for path in changed) if apply else ()) From 7dfbfbd5ff18ffe33ea0c0fc581e6b99cac73ec6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 10:55:01 -0400 Subject: [PATCH 02/18] Declare one-way revisions and monotone column rules --- src/agent_comms/cohort_schema.py | 10 ++- src/agent_comms/coordinated_runtime_schema.py | 3 - src/agent_comms/coordination_response.py | 2 - .../coordination_tables/assignments.py | 12 ++- .../coordination_tables/attempts.py | 34 ++++---- .../coordination_tables/executions.py | 21 +++-- .../coordination_tables/participants.py | 7 +- .../coordination_tables/recovery.py | 9 ++- .../coordination_tables/responses.py | 21 ++--- src/agent_comms/native_runtime_input.py | 9 ++- src/agent_comms/participant_store.py | 3 +- src/agent_comms/typed_table.py | 80 +++++++++++++++++++ 12 files changed, 157 insertions(+), 54 deletions(-) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index c9987e5607..910c79be6f 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -24,11 +24,10 @@ GuardedColumns, GuardedInserts, GuardedRelatedInserts, - GuardedUpdates, ImmutableRows, Index, NoDelete, - SQLiteForeignKeys, + OneWayColumn, SQLiteJournalMode, SharedFileTables, TypedTable, @@ -56,11 +55,14 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(GuardedInserts, GuardedUpdates, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): +class ClaimBatchAcknowledgements(OneWayColumn, GuardedInserts, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): + + transition_column = 'sealed' + transition_from = 0 + transition_to = 1 insert_condition = """NEW.sealed != 0""" immutable_columns = ('wire_root_id', 'wire_seq', 'message_id', 'exact_target', 'envelope_digest', 'audience_digest', 'decisions_digest', 'member_count', 'claim_count', 'manifest_codec', 'resolver_version', 'policy_version', 'accepted_at_ms') - update_condition = """OLD.sealed != 0 OR NEW.sealed != 1""" column_guards = ( ColumnGuard(('sealed',), """(OLD.sealed = 0 AND NEW.sealed = 1) AND (((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) != OLD.member_count OR (SELECT COUNT(*) diff --git a/src/agent_comms/coordinated_runtime_schema.py b/src/agent_comms/coordinated_runtime_schema.py index 5ccc8f3ba3..4c027993fa 100644 --- a/src/agent_comms/coordinated_runtime_schema.py +++ b/src/agent_comms/coordinated_runtime_schema.py @@ -2,13 +2,10 @@ from __future__ import annotations -import sqlite3 -from agent_comms.coordination_errors import PublicationActivationBlocked from agent_comms.coordinator import Coordination from .native_runtime_input import NativeRuntimeTable -from .typed_table import SQLiteForeignKeys def install_native_runtime_schema(store: Coordination) -> None: diff --git a/src/agent_comms/coordination_response.py b/src/agent_comms/coordination_response.py index a9be9fcf94..c99ef3dd09 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -15,7 +15,6 @@ from __future__ import annotations -import sqlite3 import time from dataclasses import dataclass from functools import partial @@ -53,7 +52,6 @@ DurableTables, ForeignKey, ImmutableRows, - SQLiteForeignKeys, SharedFileTables, SqlValue, TypedRow, diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index b9e580d660..31879b02c9 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -27,20 +27,24 @@ GuardedInserts, GuardedUpdates, ImmutableRows, + IncrementingColumn, Index, + MonotoneColumns, NoDelete, + NonDecreasing, TypedTable, ) from agent_comms.wake_policy import MessageAudience @dataclass(frozen=True, slots=True) -class WakeAssignment(GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): +class WakeAssignment(IncrementingColumn, MonotoneColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): + column_orders = (NonDecreasing(('updated_at_ms',)),) + immutable_columns = ('assignment_id', 'recipient_lookup', 'wire_seq', 'message_id', 'recipient', 'audience', 'resolver_version', 'policy_version', 'accepted_at_ms') update_condition = """((json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind'))) OR - ((({assignment_mode_new})) IS NOT (({assignment_mode_old})) OR NEW.revision != OLD.revision + 1 OR - NEW.updated_at_ms < OLD.updated_at_ms) OR (((({assignment_verdict_old})) IS NOT NULL AND - (({assignment_verdict_new})) IS NOT (({assignment_verdict_old}))) OR ((json_extract(OLD.lifecycle, + ((({assignment_mode_new})) IS NOT (({assignment_mode_old}))) OR (((({assignment_verdict_old})) IS NOT NULL + AND (({assignment_verdict_new})) IS NOT (({assignment_verdict_old}))) OR ((json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT (json_extract(OLD.lifecycle, '$.decision.execution_id'))) OR ((json_extract(OLD.lifecycle, '$.decision.exact_target')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 1f5cd84be6..ce28746f16 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -26,6 +26,7 @@ from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( + AccumulatingBits, Column, ColumnGuard, ForeignKey, @@ -34,7 +35,11 @@ GuardedColumns, GuardedInserts, GuardedUpdates, + IncrementingColumn, + MonotoneColumns, NoDelete, + NonDecreasing, + NonIncreasing, TextStorage, TypedTable, ) @@ -86,7 +91,9 @@ def require_retry(self, snapshot: RecoverySnapshot) -> None: @dataclass(frozen=True, slots=True) -class AttemptRecord(OwnerRecord, GuardedInserts, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): +class AttemptRecord(IncrementingColumn, MonotoneColumns, OwnerRecord, GuardedInserts, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): + + column_orders = (NonDecreasing(('updated_at_ms', 'last_progress_at_ms')),) insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.owner_lookup = NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts AND ((NEW.attempt_ordinal = 1 AND (e.status = @@ -106,19 +113,17 @@ class AttemptRecord(OwnerRecord, GuardedInserts, GuardedColumns, GuardedUpdates, a.phase IN ({retry_attempt_phases}) AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > a.owner_generation AND NEW.owner_token_digest != a.owner_token_digest))""" immutable_columns = ('execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest', 'created_at_ms') - update_condition = """NEW.revision != OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms OR ((CASE WHEN - json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, - '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN - ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) OR ((CASE + update_condition = """((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE + json_extract(OLD.lifecycle, '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') + IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) OR ((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, '$.process_dead') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) = 0) OR - (OLD.last_progress_at_ms IS NOT NULL AND (NEW.last_progress_at_ms IS NULL OR NEW.last_progress_at_ms < - OLD.last_progress_at_ms)) OR ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND - (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, - '$.lease_expires_at_ms')) < (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) OR - ((json_extract(OLD.lifecycle, '$.kind')) IN ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, - '$.kind')) = (json_extract(OLD.lifecycle, '$.kind')))""" + ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, + '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) < + (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) OR ((json_extract(OLD.lifecycle, '$.kind')) IN + ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, + '$.kind')))""" column_guards = ( ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind')) != (json_extract(OLD.lifecycle, '$.kind')) AND NOT ({attempt_edges})"""), ) @@ -296,10 +301,11 @@ def references(cls): ) @dataclass(frozen=True, slots=True) -class ReplayAssessments(ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): +class ReplayAssessments(IncrementingColumn, MonotoneColumns, AccumulatingBits, ExecutionReference, RetryPartition, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): + column_orders = (NonDecreasing(('side_effects_possible',)), NonIncreasing(('replay_safe',)),) + accumulating_columns = ('facts',) + immutable_columns = ('execution_id',) - update_condition = """NEW.revision != OLD.revision + 1 OR (NEW.facts | OLD.facts) != NEW.facts OR (OLD.replay_safe = 0 AND - NEW.replay_safe = 1) OR (OLD.side_effects_possible = 1 AND NEW.side_effects_possible = 0)""" @property def allows_retry(self) -> bool: diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index d7392f3b3b..b1ac710b25 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -29,9 +29,12 @@ GuardedColumns, GuardedInserts, GuardedUpdates, + IncrementingColumn, Index, IntegerStorage, + MonotoneColumns, NoDelete, + NonDecreasing, TextStorage, TypedTable, ) @@ -95,10 +98,11 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) -class CurrentExecutions(GuardedInserts, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): +class CurrentExecutions(IncrementingColumn, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): + revision_column = 'pointer_revision' + insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations WHERE owner_lookup = NEW.owner_lookup)""" immutable_columns = ('owner_lookup',) - update_condition = """NEW.pointer_revision != OLD.pointer_revision + 1""" @property def unresolved(self) -> bool: @@ -167,14 +171,15 @@ def references(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): +class ExecutionRecord(IncrementingColumn, MonotoneColumns, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): + + column_orders = (NonDecreasing(('updated_at_ms',)),) immutable_columns = ('execution_id', 'origin', 'owner_lookup', 'owner_thread', 'max_attempts', 'created_at_ms') - update_condition = """NEW.revision != OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms OR (json_extract(NEW.lifecycle, - '$.kind') = 'active' AND (json_extract(OLD.lifecycle, '$.kind') NOT IN ({startable_execution_names}) OR - json_extract(NEW.lifecycle, '$.ordinal') != coalesce(json_extract(OLD.lifecycle, '$.ordinal'), 0) + 1)) OR - (json_extract(NEW.lifecycle, '$.kind') != 'active' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT - json_extract(OLD.lifecycle, '$.ordinal'))""" + update_condition = """(json_extract(NEW.lifecycle, '$.kind') = 'active' AND (json_extract(OLD.lifecycle, '$.kind') NOT IN + ({startable_execution_names}) OR json_extract(NEW.lifecycle, '$.ordinal') != + coalesce(json_extract(OLD.lifecycle, '$.ordinal'), 0) + 1)) OR (json_extract(NEW.lifecycle, '$.kind') != + 'active' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT json_extract(OLD.lifecycle, '$.ordinal'))""" column_guards = ( ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind') != json_extract(OLD.lifecycle, '$.kind') AND NOT ({execution_edges})) OR (json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index 775d9ee49c..b70bd63838 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -16,7 +16,9 @@ GuardedColumns, GuardedUpdates, ImmutableRows, + MonotoneColumns, NoDelete, + NonDecreasing, TypedTable, ) @@ -37,11 +39,12 @@ def owner_identity(self) -> OwnerGenerations: @dataclass(frozen=True, kw_only=True) -class Participants(GuardedColumns, NoDelete, CoordinatorTable, TypedTable): +class Participants(MonotoneColumns, GuardedColumns, NoDelete, CoordinatorTable, TypedTable): + + column_orders = (NonDecreasing(('committed',)),) column_guards = ( ColumnGuard(('participant_lookup',), """1"""), - ColumnGuard(('committed',), """OLD.committed = 1 AND NEW.committed = 0"""), ) participant_lookup: Annotated[ diff --git a/src/agent_comms/coordination_tables/recovery.py b/src/agent_comms/coordination_tables/recovery.py index d949b1aa3b..6cf9e9af0f 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -20,10 +20,12 @@ Column, ForeignKey, FrozenColumns, - GuardedUpdates, ImmutableRows, + IncrementingColumn, Index, + MonotoneColumns, NoDelete, + NonDecreasing, RowId, TextStorage, TypedTable, @@ -43,10 +45,11 @@ class ACPClientConnectivity(StrEnum): @dataclass(frozen=True, slots=True) -class ConnectivityFacet(ExecutionReference, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="connectivity"): +class ConnectivityFacet(IncrementingColumn, MonotoneColumns, ExecutionReference, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="connectivity"): + + column_orders = (NonDecreasing(('observed_at_ms',)),) immutable_columns = ('execution_id',) - update_condition = """NEW.revision != OLD.revision + 1 OR NEW.observed_at_ms < OLD.observed_at_ms""" execution_id: Annotated[str, field_codec.SnapshotExclude(), Column(primary_key=True)] owner: OwnerConnectivity diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index fe088d29f7..a5c961cdc3 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -18,13 +18,15 @@ from agent_comms.typed_table import ( Column, ColumnGuard, - ForeignKey, FrozenColumns, Generated, GuardedColumns, GuardedInserts, GuardedUpdates, + IncrementingColumn, + MonotoneColumns, NoDelete, + NonDecreasing, TextStorage, TypedTable, ) @@ -32,17 +34,18 @@ @dataclass(frozen=True, slots=True) -class ResponseObligation(ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): +class ResponseObligation(IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): + column_orders = (NonDecreasing(('updated_at_ms',)),) + """One original reply-route obligation within an execution.""" immutable_columns = ('execution_id', 'exact_target', 'created_at_ms') - update_condition = """(json_extract(NEW.lifecycle, '$.kind') = json_extract(OLD.lifecycle, '$.kind')) OR (NEW.revision != - OLD.revision + 1 OR NEW.updated_at_ms < OLD.updated_at_ms) OR ((json_extract(NEW.lifecycle, '$.kind') IN - ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 FROM publication_intents WHERE execution_id - = NEW.execution_id AND exact_target = NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') = - 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id AND - exact_target = NEW.exact_target AND message_id = json_extract(NEW.lifecycle, '$.message_id') AND seq = - json_extract(NEW.lifecycle, '$.seq') ))))""" + update_condition = """(json_extract(NEW.lifecycle, '$.kind') = json_extract(OLD.lifecycle, '$.kind')) OR (FALSE) OR + ((json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 + FROM publication_intents WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target )) OR + (json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts + WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND message_id = + json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq') ))))""" insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index a3afaa15b1..30768adda8 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -43,7 +43,9 @@ FrozenColumns, GuardedUpdates, IntegerStorage, + MonotoneColumns, NoDelete, + NonDecreasing, SharedFileTables, TextStorage, TypedRow, @@ -312,10 +314,11 @@ def commit_context( raise StaleFence("native proof was previously committed") @dataclass(frozen=True) -class CurrentNativeCursor(GuardedUpdates, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): +class CurrentNativeCursor(MonotoneColumns, GuardedUpdates, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): + column_orders = (NonDecreasing(('covered_seq', 'injected_seq')),) + immutable_columns = ('wire_root_id', 'recipient_lookup', 'owner_thread', 'owner_generation', 'owner_claimed_generation') - update_condition = """NEW.covered_seq < OLD.covered_seq OR NEW.injected_seq < OLD.injected_seq OR (NEW.injected_seq = - OLD.injected_seq AND NEW.input_id IS NOT OLD.input_id)""" + update_condition = """(NEW.injected_seq = OLD.injected_seq AND NEW.input_id IS NOT OLD.input_id)""" @property def context_anchor(self) -> NativeColumn: diff --git a/src/agent_comms/participant_store.py b/src/agent_comms/participant_store.py index d29e716800..7439430969 100644 --- a/src/agent_comms/participant_store.py +++ b/src/agent_comms/participant_store.py @@ -2,7 +2,6 @@ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass from agent_comms.coordination_contracts import ( @@ -25,7 +24,7 @@ ParticipantAliases, Participants, ) -from agent_comms.field_codec import MalformedRecord, WireName +from agent_comms.field_codec import MalformedRecord @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 5bca0ad657..bcecfdd71b 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -734,6 +734,86 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... row, columns=row.immutable_columns, when=condition, message="Column values are immutable"),) +class OneWayColumn(MutationCapability): + """Every update consumes one declared old-to-new column transition.""" + + transition_column: ClassVar[str] + transition_from: ClassVar[int] + transition_to: ClassVar[int] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + column = row._column_list((row.transition_column,)) + condition = (f"OLD.{column} IS NOT {row.transition_from} " + f"OR NEW.{column} IS NOT {row.transition_to}") + return (RowMutation.UPDATE.abort_trigger( + row, when=condition, suffix="_one_way", message="Column transition is one-way"),) + + +class IncrementingColumn(MutationCapability): + revision_column: ClassVar[str] = "revision" + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + column = row._column_list((row.revision_column,)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"NEW.{column} != OLD.{column} + 1", suffix="_revision", + message="Revision must advance once"),) + + +@dataclass(frozen=True) +class ColumnOrder(ABC): + columns: tuple[str, ...] + + @property + @abstractmethod + def comparison(self) -> str: ... + + @property + @abstractmethod + def suffix(self) -> str: ... + + def trigger(self, row: type[TypedTable]) -> SQLiteSchemaObject: + conditions = ( + f'(OLD."{field.wire_name}" IS NOT NULL AND (NEW."{field.wire_name}" IS NULL ' + f'OR NEW."{field.wire_name}" {self.comparison} OLD."{field.wire_name}"))' + for field in row._fields_named(self.columns)) + return RowMutation.UPDATE.abort_trigger( + row, when=" OR ".join(conditions), suffix=self.suffix, + message="Column ordering is monotone") + + +class NonDecreasing(ColumnOrder): + comparison = "<" + suffix = "_increasing" + + +class NonIncreasing(ColumnOrder): + comparison = ">" + suffix = "_decreasing" + + +class MonotoneColumns(MutationCapability): + column_orders: ClassVar[tuple[ColumnOrder, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + return tuple(order.trigger(row) for order in row.column_orders) + + +class AccumulatingBits(MutationCapability): + accumulating_columns: ClassVar[tuple[str, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + conditions = ( + f'((NEW."{field.wire_name}" | OLD."{field.wire_name}") != NEW."{field.wire_name}")' + for field in row._fields_named(row.accumulating_columns)) + return (RowMutation.UPDATE.abort_trigger( + row, when=" OR ".join(conditions), suffix="_accumulating", + message="Observed bits cannot be erased"),) + + class GuardedUpdates(MutationCapability): update_condition: ClassVar[str] From 5d5b82c8c81226282daebac2d372c680322e5baa Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:10:42 -0400 Subject: [PATCH 03/18] Derive lifecycle and generation guards from their declarations --- .../coordination_tables/assignments.py | 34 ++-- .../coordination_tables/attempts.py | 29 +-- .../coordination_tables/executions.py | 16 +- .../coordination_tables/participants.py | 9 +- .../coordination_tables/responses.py | 25 +-- src/agent_comms/execution_states.py | 40 +++- src/agent_comms/typed_table.py | 169 ++++++++++++++++- tests/test_table_mutation_capabilities.py | 175 ++++++++++++++++++ tests/test_table_trigger_carry.py | 61 ++++++ 9 files changed, 476 insertions(+), 82 deletions(-) create mode 100644 tests/test_table_mutation_capabilities.py create mode 100644 tests/test_table_trigger_carry.py diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index 31879b02c9..7ae85e9a4b 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -5,7 +5,7 @@ from typing import Annotated, ClassVar from dataclasses import dataclass, replace -from agent_comms.assignment_states import AssignmentState +from agent_comms.assignment_states import AssignmentState, EngagedAssignment from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, POLICY_VERSION, @@ -21,43 +21,37 @@ from agent_comms.message_reference import MessageReference from agent_comms.typed_table import ( Column, + FirstValueState, ForeignKey, FrozenColumns, Generated, GuardedInserts, - GuardedUpdates, ImmutableRows, IncrementingColumn, Index, + LifecycleChanges, + LifecycleTransitions, MonotoneColumns, NoDelete, NonDecreasing, + RequiredColumn, TypedTable, + WriteOnceColumns, ) from agent_comms.wake_policy import MessageAudience @dataclass(frozen=True, slots=True) -class WakeAssignment(IncrementingColumn, MonotoneColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): +class WakeAssignment(WriteOnceColumns, FirstValueState, RequiredColumn, LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): + write_once_columns = ('triage_verdict', 'execution_id', 'exact_target') + initial_value_column = 'execution_id' + initial_value_state = EngagedAssignment + dependent_column = 'exact_target' + required_column = 'execution_id' + column_orders = (NonDecreasing(('updated_at_ms',)),) - immutable_columns = ('assignment_id', 'recipient_lookup', 'wire_seq', 'message_id', 'recipient', 'audience', 'resolver_version', 'policy_version', 'accepted_at_ms') - update_condition = """((json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind'))) OR - ((({assignment_mode_new})) IS NOT (({assignment_mode_old}))) OR (((({assignment_verdict_old})) IS NOT NULL - AND (({assignment_verdict_new})) IS NOT (({assignment_verdict_old}))) OR ((json_extract(OLD.lifecycle, - '$.decision.execution_id')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT - (json_extract(OLD.lifecycle, '$.decision.execution_id'))) OR ((json_extract(OLD.lifecycle, - '$.decision.exact_target')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT - (json_extract(OLD.lifecycle, '$.decision.exact_target'))) OR ((json_extract(OLD.lifecycle, - '$.decision.exact_target')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.exact_target')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NULL) OR ((json_extract(OLD.lifecycle, - '$.decision.execution_id')) IS NULL AND (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.kind')) != 'engaged')) OR ((json_extract(OLD.lifecycle, '$.kind')) != - (json_extract(NEW.lifecycle, '$.kind')) AND NOT ({assignment_edges} )) OR ((json_extract(NEW.lifecycle, - '$.kind')) = 'failed' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NULL AND - (json_extract(NEW.lifecycle, '$.decision.execution_id')) IS NOT NULL) OR ((json_extract(OLD.lifecycle, - '$.kind')) = 'deferred' AND (json_extract(OLD.lifecycle, '$.decision.execution_id')) IS NOT NULL AND - (json_extract(NEW.lifecycle, '$.kind')) IN ('triage_pending', 'full_pending'))""" + immutable_columns = ('assignment_id', 'recipient_lookup', 'wire_seq', 'message_id', 'recipient', 'audience', 'resolver_version', 'policy_version', 'accepted_at_ms', 'wake_mode') @property def recipient_identity(self): diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index ce28746f16..70f46b3093 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -21,24 +21,25 @@ StaleFence, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import OwnerRecord +from agent_comms.coordination_tables.participants import OwnerGenerations, OwnerRecord from agent_comms.field_codec import projected, SnapshotExclude from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( AccumulatingBits, Column, - ColumnGuard, + CounterDependent, + FinalizedLifecycle, ForeignKey, FrozenColumns, Generated, - GuardedColumns, GuardedInserts, - GuardedUpdates, IncrementingColumn, + LifecycleTransitions, MonotoneColumns, NoDelete, NonDecreasing, + NonDecreasingWhenPresent, NonIncreasing, TextStorage, TypedTable, @@ -91,9 +92,11 @@ def require_retry(self, snapshot: RecoverySnapshot) -> None: @dataclass(frozen=True, slots=True) -class AttemptRecord(IncrementingColumn, MonotoneColumns, OwnerRecord, GuardedInserts, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): +class AttemptRecord(LifecycleTransitions, FinalizedLifecycle, CounterDependent, IncrementingColumn, MonotoneColumns, OwnerRecord, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): - column_orders = (NonDecreasing(('updated_at_ms', 'last_progress_at_ms')),) + counter_reference = ForeignKey(('owner_lookup','owner_generation'), OwnerGenerations, ('owner_lookup','generation')) + + column_orders = (NonDecreasing(('updated_at_ms', 'last_progress_at_ms', 'backend_done', 'process_dead')), NonDecreasingWhenPresent(('lease_expires_at_ms',))) insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.owner_lookup = NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts AND ((NEW.attempt_ordinal = 1 AND (e.status = @@ -113,20 +116,6 @@ class AttemptRecord(IncrementingColumn, MonotoneColumns, OwnerRecord, GuardedIns a.phase IN ({retry_attempt_phases}) AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > a.owner_generation AND NEW.owner_token_digest != a.owner_token_digest))""" immutable_columns = ('execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest', 'created_at_ms') - update_condition = """((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE - json_extract(OLD.lifecycle, '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') - IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) OR ((CASE - WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE - json_extract(OLD.lifecycle, '$.process_dead') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') - IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) = 0) OR - ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, - '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) < - (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) OR ((json_extract(OLD.lifecycle, '$.kind')) IN - ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, - '$.kind')))""" - column_guards = ( - ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind')) != (json_extract(OLD.lifecycle, '$.kind')) AND NOT ({attempt_edges})"""), - ) def require_attempt(self) -> AttemptRecord: return self diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index b1ac710b25..a913081b80 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -28,13 +28,14 @@ Generated, GuardedColumns, GuardedInserts, - GuardedUpdates, IncrementingColumn, Index, IntegerStorage, + LifecycleTransitions, MonotoneColumns, NoDelete, NonDecreasing, + OrdinalTransitions, TextStorage, TypedTable, ) @@ -171,18 +172,12 @@ def references(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(IncrementingColumn, MonotoneColumns, GuardedColumns, GuardedUpdates, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): +class ExecutionRecord(LifecycleTransitions, OrdinalTransitions, IncrementingColumn, MonotoneColumns, GuardedColumns, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): column_orders = (NonDecreasing(('updated_at_ms',)),) immutable_columns = ('execution_id', 'origin', 'owner_lookup', 'owner_thread', 'max_attempts', 'created_at_ms') - update_condition = """(json_extract(NEW.lifecycle, '$.kind') = 'active' AND (json_extract(OLD.lifecycle, '$.kind') NOT IN - ({startable_execution_names}) OR json_extract(NEW.lifecycle, '$.ordinal') != - coalesce(json_extract(OLD.lifecycle, '$.ordinal'), 0) + 1)) OR (json_extract(NEW.lifecycle, '$.kind') != - 'active' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT json_extract(OLD.lifecycle, '$.ordinal'))""" - column_guards = ( - ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind') != json_extract(OLD.lifecycle, '$.kind') AND NOT ({execution_edges})) - OR (json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND + column_guards = (ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') AND a.owner_lookup = NEW.owner_lookup AND a.phase IN ({failed_attempt_phases}) )) OR ((NEW.origin = 'wire') AND @@ -194,8 +189,7 @@ class ExecutionRecord(IncrementingColumn, MonotoneColumns, GuardedColumns, Guard 'failed' AND EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id)) OR (json_extract(NEW.lifecycle, '$.kind') = 'failed' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND EXISTS (SELECT 1 FROM retry_disposition_basis b WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1))"""), - ) + = 1))"""),) retry_view = "retry_disposition_basis" diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index b70bd63838..6723937ef5 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -11,10 +11,10 @@ from agent_comms.typed_table import ( Column, ColumnGuard, + CounterAdvance, ForeignKey, FrozenColumns, GuardedColumns, - GuardedUpdates, ImmutableRows, MonotoneColumns, NoDelete, @@ -68,12 +68,11 @@ def references(cls): return (ForeignKey(("participant_lookup",), Participants, ("participant_lookup",)),) @dataclass(frozen=True, kw_only=True) -class OwnerGenerations(NoDelete, GuardedUpdates, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable): +class OwnerGenerations(CounterAdvance, NoDelete, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable): + + counter_column = 'generation' immutable_columns = ('owner_lookup',) - update_condition = """(NEW.generation != OLD.generation AND ( NEW.generation != OLD.generation + 1 OR EXISTS (SELECT 1 FROM - attempts WHERE owner_lookup = OLD.owner_lookup AND owner_generation = OLD.generation AND phase NOT IN - ({terminal_attempt_names}))))""" owner_lookup: Annotated[str, Column(primary_key=True)] owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index a5c961cdc3..c5e72c79ff 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -24,6 +24,8 @@ GuardedInserts, GuardedUpdates, IncrementingColumn, + LifecycleChanges, + LifecycleTransitions, MonotoneColumns, NoDelete, NonDecreasing, @@ -34,34 +36,27 @@ @dataclass(frozen=True, slots=True) -class ResponseObligation(IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): +class ResponseObligation(LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): column_orders = (NonDecreasing(('updated_at_ms',)),) """One original reply-route obligation within an execution.""" immutable_columns = ('execution_id', 'exact_target', 'created_at_ms') - update_condition = """(json_extract(NEW.lifecycle, '$.kind') = json_extract(OLD.lifecycle, '$.kind')) OR (FALSE) OR - ((json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 + update_condition = """(json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 FROM publication_intents WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND message_id = - json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq') ))))""" + json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq') )))""" insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" - column_guards = ( - ColumnGuard(('lifecycle',), """(json_extract(OLD.lifecycle, '$.kind') != json_extract(NEW.lifecycle, '$.kind') AND NOT ({obligation_edges} - )) OR (json_extract(OLD.lifecycle, '$.kind') = 'published' AND ( json_extract(NEW.lifecycle, '$.message_id') - IS NOT json_extract(OLD.lifecycle, '$.message_id') OR json_extract(NEW.lifecycle, '$.seq') IS NOT - json_extract(OLD.lifecycle, '$.seq') )) OR (EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = - OLD.execution_id AND exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') != - 'published') OR (EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND - exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}))"""), - ColumnGuard(('exact_target',), """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE + column_guards = (ColumnGuard(('lifecycle',), """(EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = OLD.execution_id AND exact_target = + OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') != 'published') OR (EXISTS (SELECT 1 FROM + publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) AND + json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}))"""), ColumnGuard(('exact_target',), """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR (EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = - OLD.exact_target))"""), - ) + OLD.exact_target))"""),) @staticmethod diff --git a/src/agent_comms/execution_states.py b/src/agent_comms/execution_states.py index c719f58172..3e7e2705ce 100644 --- a/src/agent_comms/execution_states.py +++ b/src/agent_comms/execution_states.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar @@ -23,6 +23,34 @@ from .coordination_snapshot import RecoverySnapshot +class OrdinalRule(ABC): + increment: int = 0 + predecessors: type = LifecycleState + + @property + @abstractmethod + def first(self) -> int | None: ... + + def accepts(self, following: int | None, previous: ExecutionState) -> bool: + ordinal = previous.current_attempt_ordinal + expected = self.first if ordinal is None else ordinal + self.increment + return isinstance(previous, self.predecessors) and following == expected + + +class PreservedOrdinal(OrdinalRule): + first = None + + +@dataclass(frozen=True) +class AdvancedOrdinal(OrdinalRule): + predecessors: type + increment: int = 1 + + @property + def first(self) -> int: + return self.increment + + @dataclass(frozen=True) class ExecutionState(DeclaredFamily, LifecycleState, affix="Execution"): active: ClassVar[bool] = False @@ -78,8 +106,10 @@ def can_retry(self, *, authorized: bool, attempt, is_current: bool) -> bool: def accepts_attempt(self, phase) -> bool: return False + ordinal_rule: ClassVar[OrdinalRule] = PreservedOrdinal() + def accepts_previous(self, previous: ExecutionState) -> bool: - return self.current_attempt_ordinal == previous.current_attempt_ordinal + return self.ordinal_rule.accepts(self.current_attempt_ordinal, previous) def validate_snapshot(self, snapshot, authorized: bool) -> None: """State-specific cross-lifecycle rules, after common identities agree.""" @@ -150,6 +180,7 @@ def __post_init__(self): class ActiveExecution(AttemptExecution): active = True + ordinal_rule = AdvancedOrdinal(StartsAttempt) def require_final_response(self) -> None: pass @@ -165,11 +196,6 @@ def assignment_state(cls) -> type[AssignmentState]: def accepts_attempt(self, phase): return not phase.terminal - def accepts_previous(self, previous): - return ( - previous.starts_attempt and self.ordinal == (previous.current_attempt_ordinal or 0) + 1 - ) - def validate_snapshot(self, snapshot, authorized): if not snapshot.is_current: raise IntegrityViolationError("active snapshot must carry exact owner pointer") diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index bcecfdd71b..a24f27b25e 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -29,6 +29,7 @@ Self, TypeVar, Union, + cast, get_args, get_origin, ) @@ -47,6 +48,7 @@ compile_decoder, ) from .locked_store import DurableFile, FileStore, RuntimeFile, SQLiteFile +from .lifecycle import LifecycleState RelatedRow = TypeVar("RelatedRow", bound="TypedRow") @@ -429,6 +431,17 @@ class _Field(SqlValue): nullable: bool column: Column + def reference(self, alias: str) -> str: + return f"{alias}.{_identifier(self.wire_name)}" + + @property + def lifecycle_family(self) -> type[LifecycleState]: + return cast(type[LifecycleState], _base_type(self.annotation)[0]) + + def discriminator(self, alias: str) -> str: + family = cast(type[DeclaredFamily], _base_type(self.annotation)[0]) + return f"json_extract({self.reference(alias)}, '$.{family.family_discriminator}')" + @classmethod def for_field(cls, plan: FieldPlan) -> _Field: column = Column.declared(plan.annotation) @@ -670,7 +683,7 @@ def abort_trigger( self, row: type[TypedTable], *, columns: tuple[str, ...] = (), when: str = "", message: str = "Rows are immutable", after: bool = False, suffix: str = "", ) -> SQLiteSchemaObject: - suffix = "_" + "_".join(columns) if columns else suffix + suffix = ("_" + "_".join(columns) if columns else "") + suffix timing = "AFTER" if after else "BEFORE" name = f"{row.declared_name}_{self.value.lower()}{suffix}_{timing.lower()}_abort" selected = " OF " + row._column_list(columns) if columns else "" @@ -731,7 +744,7 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... condition = " OR ".join( f'NEW."{column.wire_name}" IS NOT OLD."{column.wire_name}"' for column in columns) return (RowMutation.UPDATE.abort_trigger( - row, columns=row.immutable_columns, when=condition, message="Column values are immutable"),) + row, when=condition, suffix="_frozen", message="Column values are immutable"),) class OneWayColumn(MutationCapability): @@ -773,10 +786,13 @@ def comparison(self) -> str: ... @abstractmethod def suffix(self) -> str: ... + def violation(self, previous: str, following: str) -> str: + return f"{following} IS NULL OR {following} {self.comparison} {previous}" + def trigger(self, row: type[TypedTable]) -> SQLiteSchemaObject: conditions = ( - f'(OLD."{field.wire_name}" IS NOT NULL AND (NEW."{field.wire_name}" IS NULL ' - f'OR NEW."{field.wire_name}" {self.comparison} OLD."{field.wire_name}"))' + f"({field.reference('OLD')} IS NOT NULL AND " + f"({self.violation(field.reference('OLD'), field.reference('NEW'))}))" for field in row._fields_named(self.columns)) return RowMutation.UPDATE.abort_trigger( row, when=" OR ".join(conditions), suffix=self.suffix, @@ -793,6 +809,13 @@ class NonIncreasing(ColumnOrder): suffix = "_decreasing" +class NonDecreasingWhenPresent(NonDecreasing): + suffix = "_present_increasing" + + def violation(self, previous: str, following: str) -> str: + return f"{following} IS NOT NULL AND {following} {self.comparison} {previous}" + + class MonotoneColumns(MutationCapability): column_orders: ClassVar[tuple[ColumnOrder, ...]] @@ -814,6 +837,144 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... message="Observed bits cannot be erased"),) +class WriteOnceColumns(MutationCapability): + write_once_columns: ClassVar[tuple[str, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + condition = " OR ".join( + f"({field.reference('OLD')} IS NOT NULL AND " + f"{field.reference('NEW')} IS NOT {field.reference('OLD')})" + for field in row._fields_named(row.write_once_columns)) + return (RowMutation.UPDATE.abort_trigger( + row, when=condition, suffix="_write_once", message="Recorded values are immutable"),) + + +class RequiredColumn(MutationCapability): + dependent_column: ClassVar[str] + required_column: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + dependent, required = row._fields_named((row.dependent_column, row.required_column)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{dependent.reference('NEW')} IS NOT NULL AND " + f"{required.reference('NEW')} IS NULL", suffix="_required", + message="Dependent value requires its original reference"),) + + +class FirstValueState(MutationCapability): + initial_value_column: ClassVar[str] + initial_value_state: ClassVar[type[LifecycleState]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (value,) = row._fields_named((row.initial_value_column,)) + (lifecycle,) = row._fields_named(("lifecycle",)) + names = ",".join(sql_literal(state.declared_name) for state in + lifecycle.lifecycle_family.members_with(row.initial_value_state)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{value.reference('OLD')} IS NULL AND {value.reference('NEW')} IS NOT NULL " + f"AND {lifecycle.discriminator('NEW')} NOT IN ({names})", suffix="_first_value", + message="Initial value requires its declared lifecycle"),) + + +class LifecycleTransitions(MutationCapability): + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (field,) = row._fields_named(("lifecycle",)) + previous, following = field.discriminator("OLD"), field.discriminator("NEW") + edges = " OR ".join( + f"({previous} = {sql_literal(name)} AND {following} IN " + f"({','.join(sql_literal(target) for target in sorted(targets))}))" + for name, targets in field.lifecycle_family.transition_table().items() if targets) or "0" + return (RowMutation.UPDATE.abort_trigger( + row, columns=("lifecycle",), when=f"{previous} != {following} AND NOT ({edges})", + suffix="_edges", message="Lifecycle transition is not declared"),) + + +class OrdinalTransitions(MutationCapability): + """Python and SQLite consume each state's same ordinal-rule declaration.""" + + ordinal_column: ClassVar[str] = "current_attempt_ordinal" + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + from .execution_states import ExecutionState + + (state,) = row._fields_named(("lifecycle",)) + (ordinal,) = row._fields_named((row.ordinal_column,)) + family = cast(type[ExecutionState], state.lifecycle_family) + violations = [] + for member in family.members_with(family): + rule = member.ordinal_rule + first = "NULL" if rule.first is None else str(rule.first) + expected = (f"CASE WHEN {ordinal.reference('OLD')} IS NULL THEN {first} " + f"ELSE {ordinal.reference('OLD')} + {rule.increment} END") + allowed = ",".join(sql_literal(previous.declared_name) + for previous in family.members_with(rule.predecessors)) + violations.append( + f"({state.discriminator('NEW')} = {sql_literal(member.declared_name)} AND " + f"({state.discriminator('OLD')} NOT IN ({allowed}) OR " + f"{ordinal.reference('NEW')} IS NOT ({expected})))") + return (RowMutation.UPDATE.abort_trigger( + row, when=" OR ".join(violations), suffix="_ordinal", + message="Attempt ordinal violates lifecycle rule"),) + + +class LifecycleChanges(MutationCapability): + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (field,) = row._fields_named(("lifecycle",)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{field.discriminator('OLD')} = {field.discriminator('NEW')}", + suffix="_changed", message="Lifecycle disposition must change"),) + + +class FinalizedLifecycle(MutationCapability): + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (field,) = row._fields_named(("lifecycle",)) + closed = ",".join(sql_literal(name) for name, targets in + field.lifecycle_family.transition_table().items() if not targets) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{field.discriminator('OLD')} = {field.discriminator('NEW')} AND " + f"{field.discriminator('OLD')} IN ({closed})", suffix="_final", + message="Final lifecycle cannot be rewritten"),) + + +class CounterDependent: + """A historical counter reference blocks its owner's advance while still live.""" + + counter_reference: ClassVar[ForeignKey] + + +class CounterAdvance(MutationCapability): + counter_column: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (counter,) = row._fields_named((row.counter_column,)) + blocked = [] + for dependent in TypedTable.members_with(CounterDependent): + reference = dependent.counter_reference + if reference.target is not row: + continue + keys = " AND ".join( + f"{source.reference('dependent')} = {target.reference('OLD')}" + for source, target in zip(dependent._fields_named(reference.columns), + row._fields_named(reference.target_columns), strict=True)) + (state,) = dependent._fields_named(("lifecycle",)) + closed = ",".join(sql_literal(name) for name, targets in + state.lifecycle_family.transition_table().items() if not targets) + blocked.append(f"EXISTS (SELECT 1 FROM {_identifier(dependent.declared_name)} dependent " + f"WHERE {keys} AND {state.discriminator('dependent')} NOT IN ({closed}))") + condition = f"{counter.reference('NEW')} != {counter.reference('OLD')} AND (" + " OR ".join( + [f"{counter.reference('NEW')} != {counter.reference('OLD')} + 1", *blocked]) + ")" + return (RowMutation.UPDATE.abort_trigger( + row, when=condition, suffix="_counter", message="Counter advance has live dependents"),) + + class GuardedUpdates(MutationCapability): update_condition: ClassVar[str] diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py new file mode 100644 index 0000000000..5816b50447 --- /dev/null +++ b/tests/test_table_mutation_capabilities.py @@ -0,0 +1,175 @@ +"""Raw SQLite mutations exercise the declaration-derived capability family.""" +from contextlib import closing +from dataclasses import dataclass +from typing import Annotated + +import pytest + +from agent_comms.coordination_database import CoordinationStore +from agent_comms.coordination_schema import CoordinatorTable +from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.declared_family import DeclaredFamily +from agent_comms.lifecycle import LifecycleState +from agent_comms.typed_table import ( + AccumulatingBits, Column, ColumnGuard, FinalizedLifecycle, FirstValueState, + FrozenColumns, GuardedColumns, ImmutableRows, IncrementingColumn, LifecycleChanges, + LifecycleTransitions, MonotoneColumns, NonDecreasing, NonDecreasingWhenPresent, + NonIncreasing, OneWayColumn, RequiredColumn, TypedTable, WriteOnceColumns, +) +import sqlite3 + + +@dataclass(frozen=True) +class OrderedRow(IncrementingColumn, FrozenColumns, MonotoneColumns, AccumulatingBits, TypedTable): + identity: Annotated[int, Column(primary_key=True)] + revision: int + rising: int + falling: int + bits: int + optional: int | None + immutable_columns = ("identity",) + accumulating_columns = ("bits",) + column_orders = ( + NonDecreasing(("rising",)), NonIncreasing(("falling",)), + NonDecreasingWhenPresent(("optional",)), + ) + + +@dataclass(frozen=True) +class SealedRow(OneWayColumn, TypedTable): + sealed: int + transition_column = "sealed" + transition_from = 0 + transition_to = 1 + + +@dataclass(frozen=True) +class FrozenRow(ImmutableRows, TypedTable): + value: int + + +@dataclass(frozen=True) +class GuardedRow(GuardedColumns, TypedTable): + value: int + column_guards = (ColumnGuard(("value",)),) + + +class Phase(DeclaredFamily, LifecycleState, affix="Phase"): + pass + + +@dataclass(frozen=True) +class StartPhase(Phase): + @classmethod + def successors(cls): + return (EndPhase,) + + +@dataclass(frozen=True) +class EndPhase(Phase): + @classmethod + def successors(cls): + return () + + +@dataclass(frozen=True) +class PhasedRow(LifecycleTransitions, FinalizedLifecycle, TypedTable): + identity: Annotated[int, Column(primary_key=True)] + lifecycle: Phase + + +@dataclass(frozen=True) +class ChangingRow(LifecycleChanges, PhasedRow): + pass + + +@dataclass(frozen=True) +class ReferencedRow(WriteOnceColumns, FirstValueState, RequiredColumn, TypedTable): + lifecycle: Phase + value: int | None + reference: int | None + write_once_columns = ("value",) + initial_value_column = "value" + initial_value_state = StartPhase + dependent_column = "value" + required_column = "reference" + + +@pytest.fixture +def db(): + with closing(CoordinationStore.writer(":memory:", isolation_level=None)) as connection: + for row in (OrderedRow, SealedRow, FrozenRow, GuardedRow, PhasedRow, ChangingRow, ReferencedRow): + row.create(connection) + yield connection + + +@pytest.mark.parametrize("changes", [ + {"revision": 3}, {"identity": 2}, {"rising": 9}, {"falling": 11}, + {"bits": 0}, {"optional": 9}, +]) +def test_ordered_columns_refuse_regression(db, changes): + OrderedRow(1, 1, 10, 10, 1, 10).insert(db) + with pytest.raises(sqlite3.IntegrityError): + OrderedRow.update(db, where="identity=1", **({"revision": 2} | changes)) + assert OrderedRow.select(db) == [OrderedRow(1, 1, 10, 10, 1, 10)] + + +def test_ordered_columns_accept_progress_and_optional_retirement(db): + OrderedRow(1, 1, 10, 10, 1, 10).insert(db) + OrderedRow.update(db, where="identity=1", revision=2, rising=11, falling=9, bits=3, optional=None) + assert OrderedRow.select(db) == [OrderedRow(1, 2, 11, 9, 3, None)] + + +def test_one_way_column_consumes_the_declared_transition(db): + SealedRow(0).insert(db) + SealedRow.update(db, where="1", sealed=1) + for value in (0, 1): + with pytest.raises(sqlite3.IntegrityError): + SealedRow.update(db, where="1", sealed=value) + + +@pytest.mark.parametrize("statement", ["UPDATE frozen SET value=2", "DELETE FROM frozen"]) +def test_immutable_rows_refuse_both_destructive_events(db, statement): + FrozenRow(1).insert(db) + with pytest.raises(sqlite3.IntegrityError): + db.execute(statement) + + +def test_column_selection_is_guarded_even_when_the_value_is_equal(db): + GuardedRow(1).insert(db) + with pytest.raises(sqlite3.IntegrityError): + GuardedRow.update(db, where="1", value=1) + + +def test_lifecycle_edges_and_terminal_payload_are_owned(db): + PhasedRow(1, StartPhase()).insert(db) + PhasedRow.update(db, where="identity=1", lifecycle=EndPhase()) + for phase in (StartPhase(), EndPhase()): + with pytest.raises(sqlite3.IntegrityError): + PhasedRow.update(db, where="identity=1", lifecycle=phase) + ChangingRow(1, StartPhase()).insert(db) + with pytest.raises(sqlite3.IntegrityError): + ChangingRow.update(db, where="identity=1", lifecycle=StartPhase()) + + +def test_nullable_reference_is_written_once_in_its_declared_state(db): + ReferencedRow(StartPhase(), None, None).insert(db) + with pytest.raises(sqlite3.IntegrityError): + ReferencedRow.update(db, where="1", value=1) + ReferencedRow.update(db, where="1", value=1, reference=2) + with pytest.raises(sqlite3.IntegrityError): + ReferencedRow.update(db, where="1", value=2) + db.execute("DELETE FROM referenced") + ReferencedRow(EndPhase(), None, 2).insert(db) + with pytest.raises(sqlite3.IntegrityError): + ReferencedRow.update(db, where="1", value=1) + + +def test_owner_counter_refuses_skipping_a_generation(): + with closing(CoordinationStore.writer(":memory:", isolation_level=None)) as connection: + CoordinatorTable.create_schema(connection) + Participants(participant_lookup="owner", display_name="owner", committed=True).insert(connection) + OwnerGenerations(owner_lookup="owner", owner_thread="owner", generation=1).insert(connection) + with pytest.raises(sqlite3.IntegrityError): + OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=3) + OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=2) diff --git a/tests/test_table_trigger_carry.py b/tests/test_table_trigger_carry.py new file mode 100644 index 0000000000..e62bd75504 --- /dev/null +++ b/tests/test_table_trigger_carry.py @@ -0,0 +1,61 @@ +"""The trigger carry preserves application rows and refuses an unknown sidecar.""" +import sqlite3 +from contextlib import closing + +import pytest + +from cutover.carry_table_triggers import TableTriggers +from agent_comms.coordination_database import CoordinationStore +from agent_comms.coordination_schema import CoordinatorTable +from agent_comms.coordination_tables.participants import Participants +from agent_comms.errors import RelationViolationError +from agent_comms.native_prompt_binding import PromptBinding +from agent_comms.private_sidecar import create_sidecar_file +from agent_comms.typed_table import SQLiteTableName, TableSchema + + +def saved_rows(db): + return {table.name: tuple(db.execute(f'SELECT * FROM "{table.name}"')) + for table in SQLiteTableName.stored(db)} + + +def test_trigger_rewrite_preserves_rows_and_is_idempotent(tmp_path): + path = tmp_path / "coordination.sqlite3" + with closing(CoordinationStore.writer(path)) as db: + CoordinatorTable.create_schema(db) + Participants(participant_lookup="owner", display_name="owner", committed=True).insert(db) + (delete_guard,) = (guard for guard in Participants.triggers() if "_delete_" in guard) + db.execute(f'DROP TRIGGER "{delete_guard}"') + db.execute('CREATE TRIGGER previous_guard BEFORE DELETE ON participants ' + "BEGIN SELECT RAISE(ABORT, 'frozen'); END") + db.commit() + before = saved_rows(db) + schema = TableSchema.stored_schema(db) + assert TableTriggers.rewrite(path, apply=False) == 1 + with closing(CoordinationStore.read_snapshot(path)) as db: + assert TableSchema.stored_schema(db) == schema + assert TableTriggers.rewrite(path, apply=True) == 1 + assert TableTriggers.rewrite(path, apply=True) == 0 + with closing(CoordinationStore.read_snapshot(path)) as db: + assert not CoordinatorTable.schema_differs(db) + assert saved_rows(db) == before + with closing(CoordinationStore.writer(path)) as db: + with pytest.raises(sqlite3.IntegrityError): + db.execute("DELETE FROM participants") + + +def test_unknown_sidecar_is_not_blessed(tmp_path): + tmp_path.chmod(0o700) + path = tmp_path / PromptBinding.filename + create_sidecar_file(path, PromptBinding) + with closing(CoordinationStore.writer(path)) as db: + db.execute('CREATE TRIGGER unknown_guard BEFORE INSERT ON prompt_binding BEGIN SELECT 1; END') + db.commit() + before = saved_rows(db) + schema = TableSchema.stored_schema(db) + with pytest.raises(RelationViolationError) as failure: + TableTriggers.rewrite(path, apply=True) + assert str(path) in str(failure.value) + with closing(CoordinationStore.read_snapshot(path)) as db: + assert saved_rows(db) == before + assert TableSchema.stored_schema(db) == schema From 3781d758b4e613dbee97244f3629495f83eab0d6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 11:24:58 -0400 Subject: [PATCH 04/18] Declare native proof lifetime and cursor mutation rules --- src/agent_comms/cohort_schema.py | 7 +- src/agent_comms/coordination_schema.py | 16 -- .../coordination_tables/responses.py | 6 +- src/agent_comms/native_runtime_input.py | 20 +-- src/agent_comms/typed_table.py | 54 ++++++- tests/test_table_mutation_capabilities.py | 140 +++++++++++++++++- tests/test_table_trigger_carry.py | 7 +- 7 files changed, 206 insertions(+), 44 deletions(-) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index 910c79be6f..f53ec6b874 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -27,7 +27,7 @@ ImmutableRows, Index, NoDelete, - OneWayColumn, + InitialOneWayColumn, SQLiteJournalMode, SharedFileTables, TypedTable, @@ -55,16 +55,15 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(OneWayColumn, GuardedInserts, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): +class ClaimBatchAcknowledgements(InitialOneWayColumn, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): transition_column = 'sealed' transition_from = 0 transition_to = 1 - insert_condition = """NEW.sealed != 0""" immutable_columns = ('wire_root_id', 'wire_seq', 'message_id', 'exact_target', 'envelope_digest', 'audience_digest', 'decisions_digest', 'member_count', 'claim_count', 'manifest_codec', 'resolver_version', 'policy_version', 'accepted_at_ms') column_guards = ( - ColumnGuard(('sealed',), """(OLD.sealed = 0 AND NEW.sealed = 1) AND (((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE + ColumnGuard(('sealed',), """(((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) != OLD.member_count OR (SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND d.kind = 'selected') != OLD.claim_count) OR ((SELECT COUNT(*) FROM claim_batch_members m WHERE diff --git a/src/agent_comms/coordination_schema.py b/src/agent_comms/coordination_schema.py index e58b25712b..6db9315dd6 100644 --- a/src/agent_comms/coordination_schema.py +++ b/src/agent_comms/coordination_schema.py @@ -66,18 +66,6 @@ def _sql_members(family, predicate=lambda member: True): ) -def _sql_edges(family, column): - return ( - " OR ".join( - f"({column.format(row='OLD')} = {_sql_values((name,))} " - f"AND {column.format(row='NEW')} IN ({_sql_values(sorted(edges))}))" - for name, edges in family.transition_table().items() - if edges - ) - or "0" - ) - - _CONTEXT_FAMILIES: Final = ( ExecutionState, AttemptState, AssignmentState, WakePolicy, ResponseState, RecoveryCondition, ) @@ -93,10 +81,6 @@ def _schema_context(*members): wake_names=_sql_members(WakePolicy), response_names=_sql_members(ResponseState), recovery_names=_sql_members(RecoveryCondition), - execution_edges=_sql_edges(ExecutionState, "json_extract({row}.lifecycle, '$.kind')"), - attempt_edges=_sql_edges(AttemptState, "json_extract({row}.lifecycle, '$.kind')"), - assignment_edges=_sql_edges(AssignmentState, "json_extract({row}.lifecycle, '$.kind')"), - obligation_edges=_sql_edges(ResponseState, "json_extract({row}.lifecycle, '$.kind')"), terminal_attempt_names=_sql_members(AttemptState, lambda member: member.terminal), failed_attempt_phases=_sql_members(AttemptState, lambda member: member.failed), retry_attempt_phases=_sql_values( diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index c5e72c79ff..901cbc5a5c 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -22,7 +22,7 @@ Generated, GuardedColumns, GuardedInserts, - GuardedUpdates, + GuardedUpdateMembership, IncrementingColumn, LifecycleChanges, LifecycleTransitions, @@ -36,13 +36,13 @@ @dataclass(frozen=True, slots=True) -class ResponseObligation(LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdates, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): +class ResponseObligation(LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdateMembership, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): column_orders = (NonDecreasing(('updated_at_ms',)),) """One original reply-route obligation within an execution.""" immutable_columns = ('execution_id', 'exact_target', 'created_at_ms') - update_condition = """(json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 + update_membership_condition = """(json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 FROM publication_intents WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target )) OR (json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND message_id = diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 30768adda8..a3690154c5 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -41,7 +41,10 @@ Column, DurableTables, FrozenColumns, - GuardedUpdates, + FinalizedColumn, + RequiredUpdateColumns, + StableReferenceColumns, + WriteOnceColumns, IntegerStorage, MonotoneColumns, NoDelete, @@ -86,13 +89,11 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) -class NativeRuntimeInput(GuardedUpdates, FrozenColumns, NoDelete, NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): +class NativeRuntimeInput(WriteOnceColumns, FinalizedColumn, RequiredUpdateColumns, FrozenColumns, NoDelete, NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): immutable_columns = ('input_id', 'stage', 'execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest') - update_condition = """(OLD.{sent_owner_claimed_generation} IS NOT NULL AND NEW.{sent_owner_claimed_generation} IS NOT - OLD.{sent_owner_claimed_generation}) OR (NEW.{sent_owner_claimed_generation} IS NULL AND NEW.session_id IS - NOT NULL) OR (OLD.session_id IS NOT NULL AND (NEW.session_id IS NOT OLD.session_id OR NEW.session_file IS - NOT OLD.session_file)) OR OLD.session_entry_id IS NOT NULL OR (NEW.session_id IS NULL AND - NEW.{sent_owner_claimed_generation} IS NULL)""" + write_once_columns = ('sent_owner_claimed_generation', 'session_id', 'session_file') + finalized_column = 'session_entry_id' + required_update_columns = ('sent_owner_claimed_generation',) @classmethod def source_membership_sql(cls) -> str: @@ -314,11 +315,12 @@ def commit_context( raise StaleFence("native proof was previously committed") @dataclass(frozen=True) -class CurrentNativeCursor(MonotoneColumns, GuardedUpdates, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): +class CurrentNativeCursor(MonotoneColumns, StableReferenceColumns, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): column_orders = (NonDecreasing(('covered_seq', 'injected_seq')),) immutable_columns = ('wire_root_id', 'recipient_lookup', 'owner_thread', 'owner_generation', 'owner_claimed_generation') - update_condition = """(NEW.injected_seq = OLD.injected_seq AND NEW.input_id IS NOT OLD.input_id)""" + reference_column = 'injected_seq' + stable_reference_columns = ('input_id',) @property def context_anchor(self) -> NativeColumn: diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index a24f27b25e..941638245b 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -763,6 +763,15 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... row, when=condition, suffix="_one_way", message="Column transition is one-way"),) +class InitialOneWayColumn(OneWayColumn): + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + column = row._column_list((row.transition_column,)) + return (RowMutation.INSERT.abort_trigger( + row, when=f"NEW.{column} IS NOT {row.transition_from}", suffix="_initial_value", + message="Initial value must precede its transition"),) + + class IncrementingColumn(MutationCapability): revision_column: ClassVar[str] = "revision" @@ -850,6 +859,43 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... row, when=condition, suffix="_write_once", message="Recorded values are immutable"),) +class RequiredUpdateColumns(MutationCapability): + required_update_columns: ClassVar[tuple[str, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + condition = " OR ".join(f"{field.reference('NEW')} IS NULL" + for field in row._fields_named(row.required_update_columns)) + return (RowMutation.UPDATE.abort_trigger( + row, when=condition, suffix="_present", message="Update requires recorded references"),) + + +class FinalizedColumn(MutationCapability): + finalized_column: ClassVar[str] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (field,) = row._fields_named((row.finalized_column,)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{field.reference('OLD')} IS NOT NULL", suffix="_recorded", + message="Recorded row cannot be changed"),) + + +class StableReferenceColumns(MutationCapability): + reference_column: ClassVar[str] + stable_reference_columns: ClassVar[tuple[str, ...]] + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + (reference,) = row._fields_named((row.reference_column,)) + changes = " OR ".join( + f"{field.reference('NEW')} IS NOT {field.reference('OLD')}" + for field in row._fields_named(row.stable_reference_columns)) + return (RowMutation.UPDATE.abort_trigger( + row, when=f"{reference.reference('NEW')} IS {reference.reference('OLD')} AND ({changes})", + suffix="_reference", message="Values require a new reference"),) + + class RequiredColumn(MutationCapability): dependent_column: ClassVar[str] required_column: ClassVar[str] @@ -975,13 +1021,14 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... row, when=condition, suffix="_counter", message="Counter advance has live dependents"),) -class GuardedUpdates(MutationCapability): - update_condition: ClassVar[str] +class GuardedUpdateMembership(MutationCapability): + update_membership_condition: ClassVar[str] @classmethod def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: return (RowMutation.UPDATE.abort_trigger( - row, when=row.update_condition, message="Update violates row invariants"),) + row, when=row.update_membership_condition, suffix="_membership", + message="Update requires published membership"),) class GuardedInserts(MutationCapability): @@ -1001,6 +1048,7 @@ class GuardedRelatedInserts(MutationCapability): def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: return (RowMutation.INSERT.abort_trigger( row.related_insert_target, when=row.related_insert_condition, + suffix=f"_related_{row.declared_name}", message="Insert violates related row membership"),) diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py index 5816b50447..77adc1dc15 100644 --- a/tests/test_table_mutation_capabilities.py +++ b/tests/test_table_mutation_capabilities.py @@ -1,20 +1,27 @@ """Raw SQLite mutations exercise the declaration-derived capability family.""" from contextlib import closing from dataclasses import dataclass -from typing import Annotated +from typing import Annotated, ClassVar import pytest from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.coordination_tables.executions import ExecutionRecord +from agent_comms.execution_states import ( + ActiveExecution, CompletedExecution, DeferredExecution, ExecutionState, PendingExecution, +) from agent_comms.declared_family import DeclaredFamily from agent_comms.lifecycle import LifecycleState from agent_comms.typed_table import ( - AccumulatingBits, Column, ColumnGuard, FinalizedLifecycle, FirstValueState, - FrozenColumns, GuardedColumns, ImmutableRows, IncrementingColumn, LifecycleChanges, + AccumulatingBits, Column, ColumnGuard, FinalizedColumn, FinalizedLifecycle, FirstValueState, + FrozenColumns, Generated, GuardedAfterWrites, GuardedColumns, GuardedInserts, GuardedRelatedInserts, + GuardedUpdateMembership, InitialOneWayColumn, + ImmutableRows, IncrementingColumn, LifecycleChanges, LifecycleTransitions, MonotoneColumns, NonDecreasing, NonDecreasingWhenPresent, - NonIncreasing, OneWayColumn, RequiredColumn, TypedTable, WriteOnceColumns, + NonIncreasing, OrdinalTransitions, RequiredColumn, RequiredUpdateColumns, StableReferenceColumns, TypedTable, + ValidatedInserts, WriteOnceColumns, ) import sqlite3 @@ -36,7 +43,7 @@ class OrderedRow(IncrementingColumn, FrozenColumns, MonotoneColumns, Accumulatin @dataclass(frozen=True) -class SealedRow(OneWayColumn, TypedTable): +class SealedRow(InitialOneWayColumn, TypedTable): sealed: int transition_column = "sealed" transition_from = 0 @@ -54,6 +61,50 @@ class GuardedRow(GuardedColumns, TypedTable): column_guards = (ColumnGuard(("value",)),) +@dataclass(frozen=True) +class InsertCheckedRow(GuardedInserts, GuardedUpdateMembership, TypedTable): + value: int + insert_condition = "NEW.value < 0" + update_membership_condition = "NEW.value < 0" + + +@dataclass(frozen=True) +class RelatedGuardRow(GuardedRelatedInserts, TypedTable): + value: int + related_insert_target = InsertCheckedRow + related_insert_condition = "NEW.value > 10" + + +@dataclass(frozen=True) +class AfterWriteRow(GuardedAfterWrites, TypedTable): + value: int + after_write_condition = "NEW.value < 0" + + +@dataclass(frozen=True) +class ValidatedRow(ValidatedInserts, TypedTable): + value: int + validator_function = "test_positive" + + +@dataclass(frozen=True) +class ProofRow(WriteOnceColumns, RequiredUpdateColumns, FinalizedColumn, TypedTable): + claim: int | None + session: str | None + proof: int | None + write_once_columns = ("claim", "session") + required_update_columns = ("claim",) + finalized_column = "proof" + + +@dataclass(frozen=True) +class PointerRow(StableReferenceColumns, TypedTable): + ordinal: int + value: int | None + reference_column = "ordinal" + stable_reference_columns = ("value",) + + class Phase(DeclaredFamily, LifecycleState, affix="Phase"): pass @@ -83,6 +134,13 @@ class ChangingRow(LifecycleChanges, PhasedRow): pass +@dataclass(frozen=True) +class OrdinalRow(LifecycleTransitions, OrdinalTransitions, TypedTable): + lifecycle: ExecutionState + current_attempt_ordinal: ClassVar[Generated] = vars(ExecutionRecord)["current_attempt_ordinal"] + max_attempts: int = 3 + + @dataclass(frozen=True) class ReferencedRow(WriteOnceColumns, FirstValueState, RequiredColumn, TypedTable): lifecycle: Phase @@ -98,8 +156,11 @@ class ReferencedRow(WriteOnceColumns, FirstValueState, RequiredColumn, TypedTabl @pytest.fixture def db(): with closing(CoordinationStore.writer(":memory:", isolation_level=None)) as connection: - for row in (OrderedRow, SealedRow, FrozenRow, GuardedRow, PhasedRow, ChangingRow, ReferencedRow): + for row in (OrderedRow, SealedRow, FrozenRow, GuardedRow, PhasedRow, ChangingRow, + ReferencedRow, InsertCheckedRow, RelatedGuardRow, AfterWriteRow, ValidatedRow, + OrdinalRow, ProofRow, PointerRow): row.create(connection) + connection.create_function("test_positive", 1, lambda value: int(value > 0)) yield connection @@ -121,6 +182,8 @@ def test_ordered_columns_accept_progress_and_optional_retirement(db): def test_one_way_column_consumes_the_declared_transition(db): + with pytest.raises(sqlite3.IntegrityError): + SealedRow(1).insert(db) SealedRow(0).insert(db) SealedRow.update(db, where="1", sealed=1) for value in (0, 1): @@ -165,6 +228,71 @@ def test_nullable_reference_is_written_once_in_its_declared_state(db): ReferencedRow.update(db, where="1", value=1) +@pytest.mark.parametrize("previous, following, allowed", [ + (PendingExecution(), ActiveExecution(1), True), + (PendingExecution(), ActiveExecution(2), False), + (ActiveExecution(1), CompletedExecution(2), False), + (DeferredExecution(1), ActiveExecution(2), True), +]) +def test_python_and_sql_use_the_state_ordinal_declaration(db, previous, following, allowed): + assert following.accepts_previous(previous) is allowed + OrdinalRow(previous).insert(db) + if allowed: + OrdinalRow.update(db, where="1", lifecycle=following) + else: + with pytest.raises(sqlite3.IntegrityError): + OrdinalRow.update(db, where="1", lifecycle=following) + + +@pytest.mark.parametrize("value", [-1, 11]) +def test_insert_and_related_membership_are_composed(db, value): + with pytest.raises(sqlite3.IntegrityError): + InsertCheckedRow(value).insert(db) + InsertCheckedRow(1).insert(db) + + +def test_update_membership_is_guarded(db): + InsertCheckedRow(1).insert(db) + with pytest.raises(sqlite3.IntegrityError): + InsertCheckedRow.update(db, where="1", value=-1) + + +def test_proof_requires_its_reference_and_then_freezes_the_row(db): + ProofRow(None, None, None).insert(db) + with pytest.raises(sqlite3.IntegrityError): + ProofRow.update(db, where="1", session="session") + ProofRow.update(db, where="1", claim=1) + ProofRow.update(db, where="1", session="session") + with pytest.raises(sqlite3.IntegrityError): + ProofRow.update(db, where="1", session="different") + ProofRow.update(db, where="1", proof=1) + with pytest.raises(sqlite3.IntegrityError): + ProofRow.update(db, where="1", claim=1) + + +def test_pointer_values_require_a_new_reference(db): + PointerRow(0, None).insert(db) + with pytest.raises(sqlite3.IntegrityError): + PointerRow.update(db, where="1", value=1) + PointerRow.update(db, where="1", ordinal=1, value=1) + assert PointerRow.select(db) == [PointerRow(1, 1)] + + +def test_after_write_guard_refuses_both_events(db): + with pytest.raises(sqlite3.IntegrityError): + AfterWriteRow(-1).insert(db) + AfterWriteRow(1).insert(db) + with pytest.raises(sqlite3.IntegrityError): + AfterWriteRow.update(db, where="1", value=-1) + + +def test_declared_insert_validator_receives_the_field_values(db): + with pytest.raises(sqlite3.IntegrityError): + ValidatedRow(-1).insert(db) + ValidatedRow(1).insert(db) + assert ValidatedRow.select(db) == [ValidatedRow(1)] + + def test_owner_counter_refuses_skipping_a_generation(): with closing(CoordinationStore.writer(":memory:", isolation_level=None)) as connection: CoordinatorTable.create_schema(connection) diff --git a/tests/test_table_trigger_carry.py b/tests/test_table_trigger_carry.py index e62bd75504..5389ffff1f 100644 --- a/tests/test_table_trigger_carry.py +++ b/tests/test_table_trigger_carry.py @@ -4,22 +4,23 @@ import pytest -from cutover.carry_table_triggers import TableTriggers +from tools.cutover.carry_table_triggers import TableTriggers from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.participants import Participants from agent_comms.errors import RelationViolationError from agent_comms.native_prompt_binding import PromptBinding from agent_comms.private_sidecar import create_sidecar_file -from agent_comms.typed_table import SQLiteTableName, TableSchema +from agent_comms.typed_table import SQLiteTableName, TableSchema, TypedTable def saved_rows(db): - return {table.name: tuple(db.execute(f'SELECT * FROM "{table.name}"')) + return {table.name: tuple(tuple(values) for values in db.execute(f'SELECT * FROM "{table.name}"')) for table in SQLiteTableName.stored(db)} def test_trigger_rewrite_preserves_rows_and_is_idempotent(tmp_path): + tuple(TypedTable.__registry__.values()) path = tmp_path / "coordination.sqlite3" with closing(CoordinationStore.writer(path)) as db: CoordinatorTable.create_schema(db) From 49618f2a462e4fb35c4c5ec1caa79524c017e481 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 14:01:03 -0400 Subject: [PATCH 05/18] Derive attempt prediction and SQL transitions from one ordinal increment --- src/agent_comms/execution_states.py | 30 ++++++++++------------------- src/agent_comms/typed_table.py | 4 +--- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/agent_comms/execution_states.py b/src/agent_comms/execution_states.py index 3e7e2705ce..2d3fa88fa8 100644 --- a/src/agent_comms/execution_states.py +++ b/src/agent_comms/execution_states.py @@ -2,7 +2,7 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar @@ -23,22 +23,20 @@ from .coordination_snapshot import RecoverySnapshot -class OrdinalRule(ABC): +class OrdinalRule: increment: int = 0 predecessors: type = LifecycleState - @property - @abstractmethod - def first(self) -> int | None: ... + def next_ordinal(self, previous: ExecutionState) -> int: + return (previous.current_attempt_ordinal or 0) + self.increment - def accepts(self, following: int | None, previous: ExecutionState) -> bool: - ordinal = previous.current_attempt_ordinal - expected = self.first if ordinal is None else ordinal + self.increment - return isinstance(previous, self.predecessors) and following == expected + def accepts(self, following: ExecutionState, previous: ExecutionState) -> bool: + return (isinstance(previous, self.predecessors) + and following.current_attempt_ordinal == (self.next_ordinal(previous) or None)) class PreservedOrdinal(OrdinalRule): - first = None + """Keep the previous attempt, including its absence before the first start.""" @dataclass(frozen=True) @@ -46,10 +44,6 @@ class AdvancedOrdinal(OrdinalRule): predecessors: type increment: int = 1 - @property - def first(self) -> int: - return self.increment - @dataclass(frozen=True) class ExecutionState(DeclaredFamily, LifecycleState, affix="Execution"): @@ -68,7 +62,7 @@ def current_attempt_ordinal(self) -> int | None: @property def next_attempt_ordinal(self) -> int: - return 1 + return ActiveExecution.ordinal_rule.next_ordinal(self) def require_attempt_start(self) -> None: raise IdentityConflict("execution cannot start an attempt") @@ -109,7 +103,7 @@ def accepts_attempt(self, phase) -> bool: ordinal_rule: ClassVar[OrdinalRule] = PreservedOrdinal() def accepts_previous(self, previous: ExecutionState) -> bool: - return self.ordinal_rule.accepts(self.current_attempt_ordinal, previous) + return self.ordinal_rule.accepts(self, previous) def validate_snapshot(self, snapshot, authorized: bool) -> None: """State-specific cross-lifecycle rules, after common identities agree.""" @@ -160,10 +154,6 @@ class AttemptReference: def current_attempt_ordinal(self) -> int | None: return self.ordinal - @property - def next_attempt_ordinal(self) -> int: - return (self.ordinal or 0) + 1 - @classmethod def load(cls, ordinal: int | None) -> ExecutionState: return cls(ordinal) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 941638245b..f305de21d7 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -954,9 +954,7 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... violations = [] for member in family.members_with(family): rule = member.ordinal_rule - first = "NULL" if rule.first is None else str(rule.first) - expected = (f"CASE WHEN {ordinal.reference('OLD')} IS NULL THEN {first} " - f"ELSE {ordinal.reference('OLD')} + {rule.increment} END") + expected = f"NULLIF(COALESCE({ordinal.reference('OLD')}, 0) + {rule.increment}, 0)" allowed = ",".join(sql_literal(previous.declared_name) for previous in family.members_with(rule.predecessors)) violations.append( From 580ff2cfb4b7474a36d055e644dcda72f8fc1944 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 14:04:28 -0400 Subject: [PATCH 06/18] Keep ordinal-family typing out of runtime imports --- src/agent_comms/typed_table.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index f305de21d7..e02f5a809c 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -22,6 +22,7 @@ from functools import lru_cache from pathlib import Path from typing import ( + TYPE_CHECKING, Annotated, ClassVar, Literal, @@ -50,6 +51,9 @@ from .locked_store import DurableFile, FileStore, RuntimeFile, SQLiteFile from .lifecycle import LifecycleState +if TYPE_CHECKING: + from .execution_states import ExecutionState + RelatedRow = TypeVar("RelatedRow", bound="TypedRow") @@ -946,11 +950,9 @@ class OrdinalTransitions(MutationCapability): @classmethod def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: - from .execution_states import ExecutionState - (state,) = row._fields_named(("lifecycle",)) (ordinal,) = row._fields_named((row.ordinal_column,)) - family = cast(type[ExecutionState], state.lifecycle_family) + family = cast("type[ExecutionState]", state.lifecycle_family) violations = [] for member in family.members_with(family): rule = member.ordinal_rule From f277a269e27083e9ba41dcc209ed8823df433271 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:11:04 -0400 Subject: [PATCH 07/18] Refresh native schema pin for declared table rules --- stack/pi-native.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stack/pi-native.sha256 b/stack/pi-native.sha256 index 719accbfc6..499333f7ce 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -22,7 +22,7 @@ fef47246f4ce18621bae844a940e7d009cefc7df7cdf00ccb98198d9c407a696 dist/core/sess 4fa4e9a0ba80f43b560eb827dbd0985ea56624170b51401b39398d76ffcbcefa dist/core/export-html/index.js d7ad93bcb341424fa79014ce2fec4226326cfab1e8d5fee27c1859138e120161 dist/core/sdk.js 37ae95f1f0ca7c88a75f3c83101e3d7b621c15afced8ac8ec287f958f150cb30 dist/core/native-proof-journal.js -b382dd789961aa8d9954239a1ce869ee8baa1388bf4d37c4d812ac9a2dbb76e7 dist/core/native-proof-schema.js +57ea5e66bfa8452d4df018d067c92cdb0c58c068d8d7be1fafac66cff02bfd88 dist/core/native-proof-schema.js aae7f609bfa65d29ed16e311456921a88221622e08cf6a1f5c7aab8e58bd69e6 node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js bd6cfc604781d55db4dc34a0463544c1f323611429ac8d5fc5ebde31642132fb agent-comms-extensions/global-agent-comms/index.mjs 7162c92d37ec9d070f918ce05c4be8c8c191cf3e1559351d56c4cd4e6e050dba dist/agent-comms-imports.json @@ -56,5 +56,5 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 8e8276bbabcd898136d431345611928626fad6b6f03ac572f1d8506e6ece326f dist/core/system-prompt.d.ts 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 -# agent-comms-native-tree-v1 0b7dbc9e7da3c7720cda1b4c55d1fb990bca7a0d65735e5f1cd7d892ef4c5327 +# agent-comms-native-tree-v1 e7e7518c79e83968156dd5ef33f4de320ab842d9950360800bd9d47061229db8 # agent-comms-native-inputs-v1 fa2384dd95207ef775033d5dfa49ad8a9a6ff71d6e3e2c5fbd0f286be07e3d1a From 05b01d96cf575d63349f4da1ba4d9f72f028e52c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:14:11 -0400 Subject: [PATCH 08/18] Derive native schema drift check from the declared guards --- tests/test_coordinated_runtime.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index 25cc81b30a..8228867033 100644 --- a/tests/test_coordinated_runtime.py +++ b/tests/test_coordinated_runtime.py @@ -18,7 +18,7 @@ from agent_comms.notification_progress import HumanReadProgress from agent_comms.coordinated_runtime import SelectedExecution, SessionSetupFailed from agent_comms.coordinated_runtime_schema import install_native_runtime_schema -from agent_comms.native_runtime_input import NativeRuntimeTable +from agent_comms.native_runtime_input import NativeRuntimeInput, NativeRuntimeTable from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments from agent_comms.coordination_errors import ( IdentityConflict, @@ -169,7 +169,8 @@ def test_native_runtime_schema_explicit_install_and_drift_fail_closed(tmp_path: install_native_runtime_schema(store) install_native_runtime_schema(store) NativeRuntimeTable.require_installed(store.session._connection) - store.session._connection.execute("DROP TRIGGER native_runtime_input_delete_guard") + trigger = next(iter(NativeRuntimeInput.triggers())) + store.session._connection.execute(f'DROP TRIGGER "{trigger}"') with pytest.raises(PublicationActivationBlocked): NativeRuntimeTable.require_installed(store.session._connection) From 85db618bd7adc2e4acde9d58b7a589ef9831f1f9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:22:18 -0400 Subject: [PATCH 09/18] Keep execution references and replay rows with their execution owner --- src/agent_comms/attempt_recovery.py | 3 +- src/agent_comms/attempt_store.py | 3 +- src/agent_comms/coordination_snapshot.py | 15 +- .../coordination_tables/__init__.py | 20 --- .../coordination_tables/attempts.py | 133 +------------- .../coordination_tables/executions.py | 165 +++++++++++++++++- .../coordination_tables/recovery.py | 2 +- .../coordination_tables/responses.py | 2 +- src/agent_comms/durable_turn.py | 3 +- src/agent_comms/recovery_reader.py | 3 +- src/agent_comms/turn_failure.py | 4 +- tests/test_coordination.py | 3 +- tests/test_coordination_store.py | 3 +- tests/test_s3_legality_behavior.py | 3 +- 14 files changed, 189 insertions(+), 173 deletions(-) diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index b23a6f5efb..48b598c8e7 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -28,7 +28,8 @@ ) from agent_comms.coordination_results import Applied from agent_comms.coordination_snapshot import RecoverySnapshot -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import CurrentExecutions from agent_comms.coordination_tables.recovery import ( RecoveryAudit, diff --git a/src/agent_comms/attempt_store.py b/src/agent_comms/attempt_store.py index 0754920b3e..e710f0c618 100644 --- a/src/agent_comms/attempt_store.py +++ b/src/agent_comms/attempt_store.py @@ -18,7 +18,8 @@ from agent_comms.coordination_session import CoordinationSession, NativeBoundary from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.recovery import ( ACPClientConnectivity, ConnectivityFacet, diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index cbd0161ca2..3e36b4b7c9 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -18,10 +18,16 @@ AttemptRecord, NoPreviousAttempt, PreviousAttempt, - ReplayAssessments, ) from agent_comms.coordination_tables.executions import ( - ExecutionOrigin, ExecutionRecord, NoPointedAttempt, NoPointedExecution, PointerAttempt, PointerExecution, + CurrentExecutions, + ExecutionOrigin, + ExecutionRecord, + NoPointedAttempt, + NoPointedExecution, + PointerAttempt, + PointerExecution, + ReplayAssessments, ) from agent_comms.coordination_tables.publications import PublicationEvidence, PublicationIntents from agent_comms.coordination_tables.recovery import ConnectivityFacet, RecoveryAudit @@ -232,11 +238,6 @@ def settle( self, session, outcome, reason_code: ReasonCode, boundary: NativeBoundary | None = None, ) -> None: - from .coordination_tables.assignments import WakeAssignment - from .coordination_tables.attempts import AttemptRecord, ReplayAssessments - from .coordination_tables.executions import CurrentExecutions, ExecutionRecord - from .coordination_tables.responses import ResponseObligation - db = session._connection execution, attempt = self.execution, self.attempt if self.publication_intents: diff --git a/src/agent_comms/coordination_tables/__init__.py b/src/agent_comms/coordination_tables/__init__.py index a66e9d5844..8b06ac4101 100644 --- a/src/agent_comms/coordination_tables/__init__.py +++ b/src/agent_comms/coordination_tables/__init__.py @@ -2,23 +2,3 @@ Importing one row must not import unrelated annotation and UI consumers. """ - -from agent_comms.typed_table import ForeignKey, GuardedAfterWrites - - -class ExecutionReference: - """An execution-owned row cannot outlive its original execution.""" - - @classmethod - def references(cls) -> tuple[ForeignKey, ...]: - from .executions import ExecutionRecord - - return (ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"),) - - -class RetryPartition(GuardedAfterWrites): - """A failed execution cannot retain authorization to retry.""" - - after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e ON e.execution_id = b.execution_id WHERE - b.execution_id = NEW.execution_id AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL)""" diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 768289e220..9ac08517f4 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, replace +from dataclasses import dataclass from enum import IntFlag from typing import TYPE_CHECKING, Final, Annotated, ClassVar @@ -14,19 +14,13 @@ require_nonempty, validate_execution_id, ) -from agent_comms.coordination_errors import ( - IdentityConflict, - IntegrityViolationError, - RecoveryBlocked, - StaleFence, -) +from agent_comms.coordination_errors import RecoveryBlocked, StaleFence from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.participants import OwnerGenerations, OwnerRecord from agent_comms.field_codec import projected, SnapshotExclude from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - AccumulatingBits, Column, CounterDependent, FinalizedLifecycle, @@ -40,11 +34,9 @@ NoDelete, NonDecreasing, NonDecreasingWhenPresent, - NonIncreasing, TextStorage, TypedTable, ) -from agent_comms.coordination_tables import ExecutionReference, RetryPartition if TYPE_CHECKING: from agent_comms.coordination_snapshot import RecoverySnapshot @@ -288,124 +280,3 @@ def references(cls): deferred=True, ), ) - -@dataclass(frozen=True, slots=True) -class ReplayAssessments(IncrementingColumn, MonotoneColumns, AccumulatingBits, ExecutionReference, RetryPartition, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): - column_orders = (NonDecreasing(('side_effects_possible',)), NonIncreasing(('replay_safe',)),) - accumulating_columns = ('facts',) - - immutable_columns = ('execution_id',) - - @property - def allows_retry(self) -> bool: - return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible - - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] - facts: Annotated[ReplayFact, Column(check="facts >= 0 AND facts <= 1023")] - replay_safe: Annotated[bool, Column()] - side_effects_possible: Annotated[bool, Column()] - revision: Annotated[int, Column(check="revision > 0")] - - @classmethod - def accumulate(cls, db, execution_id: str, observed: ReplayFact) -> None: - """Join original observations under the caller's existing attempt fence. - - Missing replay evidence cannot establish safety. Only an existing - assessment may retain that fact, and every incoming observation can - revoke it. Output facts remain monotonic across all attempts. - """ - if not observed or cls.exists( - db, - where=( - "execution_id=? AND (facts & ?) = ? AND replay_safe=0 AND side_effects_possible=1" - ), - parameters=(execution_id, int(observed), int(observed)), - ): - return - # Most streamed deltas repeat a fact already durably joined. Read only - # that answer, not a decoded row or another write-lock acquisition. - # A new fact joins atomically: another connection may have accumulated - # different evidence since the existence check, even in autocommit use. - statement, values = cls(execution_id, observed, False, True, 1)._insertion() - db.execute( - statement + """ - ON CONFLICT (execution_id) DO UPDATE SET - facts = facts | excluded.facts, - replay_safe = 0, - side_effects_possible = 1, - revision = revision + 1 - WHERE (facts | excluded.facts) != facts - OR replay_safe != 0 OR side_effects_possible != 1 - """, - values, - ) - - @classmethod - def authorize_if_unobserved(cls, db, execution_id: str) -> None: - """Record provider-failure proof only while no output fact exists.""" - before = cls.one(db, execution_id=execution_id) - if before is None: - cls(execution_id, ReplayFact.NONE, True, False, 1).record(db, None) - - @classmethod - def revoke(cls, db, execution_id: str) -> None: - """A nonretryable failure consumes any earlier provider-failure proof.""" - before = cls.one(db, execution_id=execution_id) - if before is not None and before.replay_safe: - replace(before, replay_safe=False, revision=before.revision + 1).record(db, before) - - def require_successor(self, after: ReplayAssessments) -> None: - accumulated = replace( - after, - facts=self.facts | after.facts, - replay_safe=self.replay_safe and after.replay_safe, - side_effects_possible=self.side_effects_possible or after.side_effects_possible, - ) - if accumulated != after: - raise IdentityConflict("replay facts cannot be erased") - - def record(self, db, before: ReplayAssessments | None) -> bool: - """Persist monotonic replay evidence inside the checked transaction.""" - - if before is not None: - if replace(before, revision=self.revision) == self: - return False - before.require_successor(self) - ReplayAssessments.update( - db, - where="execution_id=?", - parameters=(self.execution_id,), - facts=self.facts, - replay_safe=self.replay_safe, - side_effects_possible=self.side_effects_possible, - revision=before.revision + 1, - ) - else: - self.insert(db) - return True - - def __post_init__(self) -> None: - validate_execution_id(self.execution_id) - if self.revision <= 0: - raise ValueError("replay revision must be positive") - object.__setattr__(self, "facts", ReplayFact(self.facts)) - if int(self.facts) < 0 or int(self.facts) & ~APPROVED_REPLAY_FACT_MASK: - raise ValueError("unrecognized replay fact bits") - if self.facts != ReplayFact.NONE and self.replay_safe: - raise IntegrityViolationError("an execution with ambiguity facts cannot be replay-safe") - - retry_authorized: ClassVar[Generated] = Generated( - int, - Column( - generated=( - "CASE WHEN facts = 0 AND replay_safe = 1 AND side_effects_possibl" - "e = 0\n" - " THEN 1 ELSE 0 END" - ), - nullable=True, - ), - ) - - checks = ("facts = 0 OR replay_safe = 0",) - - unique = (("execution_id", "retry_authorized"),) diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 1620f9a5d7..d9e0965598 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -4,7 +4,7 @@ from abc import abstractmethod from typing import Annotated, ClassVar -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import StrEnum from agent_comms.coordination_contracts import ( @@ -13,19 +13,25 @@ require_nonempty, validate_execution_id, ) -from agent_comms.coordination_errors import IntegrityViolationError +from agent_comms.coordination_errors import IdentityConflict, IntegrityViolationError from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments +from agent_comms.coordination_tables.attempts import ( + APPROVED_REPLAY_FACT_MASK, + AttemptRecord, + ReplayFact, +) from agent_comms.execution_states import ExecutionState from agent_comms.field_codec import NullMember, SingleFieldValue, SnapshotExclude, SubclassUnion, projected from agent_comms.reason_codes import ReasonCode from agent_comms.thread_text import AbsentText, GivenText, TextChoice from agent_comms.typed_table import ( + AccumulatingBits, Column, ColumnGuard, ForeignKey, FrozenColumns, Generated, + GuardedAfterWrites, GuardedColumns, GuardedInserts, IncrementingColumn, @@ -35,6 +41,7 @@ MonotoneColumns, NoDelete, NonDecreasing, + NonIncreasing, OrdinalTransitions, TextStorage, TypedTable, @@ -369,7 +376,6 @@ def __post_init__(self) -> None: @classmethod def references(cls): from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink - from agent_comms.coordination_tables.attempts import ReplayAssessments from agent_comms.coordination_tables.participants import Participants return ( @@ -404,3 +410,154 @@ def references(cls): deferred=True, ), ) + + +class ExecutionReference: + """An execution-owned row cannot outlive its original execution.""" + + @classmethod + def references(cls) -> tuple[ForeignKey, ...]: + return ( + ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"), + ) + + +class RetryPartition(GuardedAfterWrites): + """A failed execution cannot retain authorization to retry.""" + + after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e ON e.execution_id = b.execution_id WHERE + b.execution_id = NEW.execution_id AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal + IS NOT NULL)""" + + +@dataclass(frozen=True, slots=True) +class ReplayAssessments( + IncrementingColumn, + MonotoneColumns, + AccumulatingBits, + ExecutionReference, + RetryPartition, + FrozenColumns, + NoDelete, + CoordinatorTable, + TypedTable, +): + column_orders = ( + NonDecreasing(("side_effects_possible",)), + NonIncreasing(("replay_safe",)), + ) + accumulating_columns = ("facts",) + immutable_columns = ("execution_id",) + + @property + def allows_retry(self) -> bool: + return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible + + execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] + facts: Annotated[ReplayFact, Column(check="facts >= 0 AND facts <= 1023")] + replay_safe: Annotated[bool, Column()] + side_effects_possible: Annotated[bool, Column()] + revision: Annotated[int, Column(check="revision > 0")] + + @classmethod + def accumulate(cls, db, execution_id: str, observed: ReplayFact) -> None: + """Join original observations under the caller's existing attempt fence. + + Missing replay evidence cannot establish safety. Only an existing + assessment may retain that fact, and every incoming observation can + revoke it. Output facts remain monotonic across all attempts. + """ + if not observed or cls.exists( + db, + where=( + "execution_id=? AND (facts & ?) = ? AND replay_safe=0 AND side_effects_possible=1" + ), + parameters=(execution_id, int(observed), int(observed)), + ): + return + # Most streamed deltas repeat a fact already durably joined. Read only + # that answer, not a decoded row or another write-lock acquisition. + # A new fact joins atomically: another connection may have accumulated + # different evidence since the existence check, even in autocommit use. + statement, values = cls(execution_id, observed, False, True, 1)._insertion() + db.execute( + statement + """ + ON CONFLICT (execution_id) DO UPDATE SET + facts = facts | excluded.facts, + replay_safe = 0, + side_effects_possible = 1, + revision = revision + 1 + WHERE (facts | excluded.facts) != facts + OR replay_safe != 0 OR side_effects_possible != 1 + """, + values, + ) + + @classmethod + def authorize_if_unobserved(cls, db, execution_id: str) -> None: + """Record provider-failure proof only while no output fact exists.""" + before = cls.one(db, execution_id=execution_id) + if before is None: + cls(execution_id, ReplayFact.NONE, True, False, 1).record(db, None) + + @classmethod + def revoke(cls, db, execution_id: str) -> None: + """A nonretryable failure consumes any earlier provider-failure proof.""" + before = cls.one(db, execution_id=execution_id) + if before is not None and before.replay_safe: + replace(before, replay_safe=False, revision=before.revision + 1).record(db, before) + + def require_successor(self, after: ReplayAssessments) -> None: + accumulated = replace( + after, + facts=self.facts | after.facts, + replay_safe=self.replay_safe and after.replay_safe, + side_effects_possible=self.side_effects_possible or after.side_effects_possible, + ) + if accumulated != after: + raise IdentityConflict("replay facts cannot be erased") + + def record(self, db, before: ReplayAssessments | None) -> bool: + """Persist monotonic replay evidence inside the checked transaction.""" + + if before is not None: + if replace(before, revision=self.revision) == self: + return False + before.require_successor(self) + ReplayAssessments.update( + db, + where="execution_id=?", + parameters=(self.execution_id,), + facts=self.facts, + replay_safe=self.replay_safe, + side_effects_possible=self.side_effects_possible, + revision=before.revision + 1, + ) + else: + self.insert(db) + return True + + def __post_init__(self) -> None: + validate_execution_id(self.execution_id) + if self.revision <= 0: + raise ValueError("replay revision must be positive") + object.__setattr__(self, "facts", ReplayFact(self.facts)) + if int(self.facts) < 0 or int(self.facts) & ~APPROVED_REPLAY_FACT_MASK: + raise ValueError("unrecognized replay fact bits") + if self.facts != ReplayFact.NONE and self.replay_safe: + raise IntegrityViolationError("an execution with ambiguity facts cannot be replay-safe") + + retry_authorized: ClassVar[Generated] = Generated( + int, + Column( + generated=( + "CASE WHEN facts = 0 AND replay_safe = 1 AND side_effects_possibl" + "e = 0\n" + " THEN 1 ELSE 0 END" + ), + nullable=True, + ), + ) + + checks = ("facts = 0 OR replay_safe = 0",) + unique = (("execution_id", "retry_authorized"),) diff --git a/src/agent_comms/coordination_tables/recovery.py b/src/agent_comms/coordination_tables/recovery.py index 6cf9e9af0f..9e07020176 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -13,7 +13,7 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables import ExecutionReference +from agent_comms.coordination_tables.executions import ExecutionReference from agent_comms.recovery_states import RecoveryCondition from agent_comms.reason_codes import GivenReasonCode, RecoveryDetail from agent_comms.typed_table import ( diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index 7f22feb333..089b82fa6e 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -32,7 +32,7 @@ TextStorage, TypedTable, ) -from agent_comms.coordination_tables import ExecutionReference, RetryPartition +from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/durable_turn.py b/src/agent_comms/durable_turn.py index ec758e1381..8278fecc17 100644 --- a/src/agent_comms/durable_turn.py +++ b/src/agent_comms/durable_turn.py @@ -23,7 +23,8 @@ ) from .attempt_store import AttemptStore from .coordination_errors import IdentityConflict -from .coordination_tables.attempts import ReplayAssessments, ReplayFact +from .coordination_tables.attempts import ReplayFact +from .coordination_tables.executions import ReplayAssessments from .coordination_session import NativeBoundary from .coordinator import Coordination from .mro_dispatch import MroDispatch, handles diff --git a/src/agent_comms/recovery_reader.py b/src/agent_comms/recovery_reader.py index 22e0f89661..a7b32f1543 100644 --- a/src/agent_comms/recovery_reader.py +++ b/src/agent_comms/recovery_reader.py @@ -11,7 +11,8 @@ from agent_comms.coordination_session import CoordinationSession from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments +from agent_comms.coordination_tables.attempts import AttemptRecord +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ( ExecutionRecord, ) diff --git a/src/agent_comms/turn_failure.py b/src/agent_comms/turn_failure.py index 3aaddcc921..7086ebf53f 100644 --- a/src/agent_comms/turn_failure.py +++ b/src/agent_comms/turn_failure.py @@ -34,7 +34,7 @@ def supersedes(self, previous: TurnFailure | None) -> bool: def prepare_execution_retry(self, db, execution_id: str) -> None: """Consume any earlier provider proof for this nonretryable failure.""" - from .coordination_tables.attempts import ReplayAssessments + from .coordination_tables.executions import ReplayAssessments ReplayAssessments.revoke(db, execution_id) @@ -144,7 +144,7 @@ class RetryableProviderFailure: def prepare_execution_retry(self, db, execution_id: str) -> None: """A provider refusal may retry only while durable output facts are absent.""" - from .coordination_tables.attempts import ReplayAssessments + from .coordination_tables.executions import ReplayAssessments ReplayAssessments.authorize_if_unobserved(db, execution_id) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 8ed2563d45..7bc802b49a 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -35,7 +35,8 @@ MessageAudience, WakeAssignment, ) -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ( CurrentExecutions, ExecutionOrigin, diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 23cb790a70..a62b4901f6 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -43,7 +43,8 @@ ) from agent_comms.coordination_results import AlreadyApplied, Applied from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.attempts import ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ExecutionOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, diff --git a/tests/test_s3_legality_behavior.py b/tests/test_s3_legality_behavior.py index 66fe927fde..d3128b7b5e 100644 --- a/tests/test_s3_legality_behavior.py +++ b/tests/test_s3_legality_behavior.py @@ -10,7 +10,8 @@ from agent_comms.attempt_states import AttemptState from agent_comms.coordination_errors import IntegrityViolationError from agent_comms.coordination_snapshot import RecoverySnapshot -from agent_comms.coordination_tables.attempts import ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ExecutionOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, From 2a93e4f81e48a8fc7a05d218ad7589ea2a7c5e56 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:34:21 -0400 Subject: [PATCH 10/18] Name sealed deliveries and clarify their constraint declarations --- src/agent_comms/cohort_foreground.py | 4 +- src/agent_comms/cohort_schema.py | 125 +++++++++++------- .../context_segments/optional_awareness.py | 11 +- src/agent_comms/coordinated_runtime_schema.py | 1 - src/agent_comms/coordination_cohort.py | 17 ++- .../coordination_tables/attempts.py | 77 +++++++---- .../coordination_tables/executions.py | 80 +++++++---- .../coordination_tables/participants.py | 8 +- .../coordination_tables/publications.py | 27 ++-- .../coordination_tables/recovery.py | 15 ++- .../coordination_tables/responses.py | 68 +++++++--- src/agent_comms/native_pi.py | 28 ++-- src/agent_comms/native_prompt_binding.py | 9 +- src/agent_comms/native_runtime_input.py | 47 +++++-- src/agent_comms/native_source_cursor.py | 3 +- .../optional_awareness_projection.py | 11 +- src/agent_comms/selected_tool_broker.py | 6 +- src/agent_comms/triage_native_sources.py | 24 ++-- src/agent_comms/typed_table.py | 28 ++-- tests/test_cohort_schema.py | 8 +- tests/test_coordination_cohort.py | 6 +- tools/cutover/carry_table_triggers.py | 57 +++++--- 22 files changed, 441 insertions(+), 219 deletions(-) diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index 7130323a25..3c4324293b 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -8,7 +8,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination -from .cohort_schema import CohortDeliveryReceipts, CohortTable +from .cohort_schema import CohortDeliveries, CohortTable from .coordination_cohort import accept_delivery_cohort from .errors import RelationViolationError from .message_bus import MessageBus @@ -40,7 +40,7 @@ def accept(resource: Coordination) -> int: authorized_after = max(after_seq, marker.claimed_after_seq) with resource.session.read(): CohortTable.require_installed(resource.session._connection) - sealed = CohortDeliveryReceipts.sealed_sequences_after( + sealed = CohortDeliveries.sealed_sequences_after( resource.session._connection, wire_root_id=root_id, recipient_lookup=lookup, diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index f53ec6b874..9c0692d45c 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -26,10 +26,10 @@ GuardedRelatedInserts, ImmutableRows, Index, - NoDelete, InitialOneWayColumn, - SQLiteJournalMode, + NoDelete, SharedFileTables, + SQLiteJournalMode, TypedTable, ) @@ -55,30 +55,47 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(InitialOneWayColumn, FrozenColumns, GuardedColumns, NoDelete, CohortTable, TypedTable, declared_name="claim_batch_receipts"): - - transition_column = 'sealed' +class ClaimBatchAcknowledgements( + InitialOneWayColumn, + FrozenColumns, + GuardedColumns, + NoDelete, + CohortTable, + TypedTable, + declared_name="claim_batch_receipts", +): + transition_column = "sealed" transition_from = 0 transition_to = 1 - - immutable_columns = ('wire_root_id', 'wire_seq', 'message_id', 'exact_target', 'envelope_digest', 'audience_digest', 'decisions_digest', 'member_count', 'claim_count', 'manifest_codec', 'resolver_version', 'policy_version', 'accepted_at_ms') + immutable_columns = ( + "wire_root_id", "wire_seq", "message_id", "exact_target", "envelope_digest", + "audience_digest", "decisions_digest", "member_count", "claim_count", "manifest_codec", + "resolver_version", "policy_version", "accepted_at_ms", + ) column_guards = ( - ColumnGuard(('sealed',), """(((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE - d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) != OLD.member_count OR (SELECT COUNT(*) - FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND - d.kind = 'selected') != OLD.claim_count) OR ((SELECT COUNT(*) FROM claim_batch_members m WHERE - m.wire_root_id = OLD.wire_root_id AND m.wire_seq = OLD.wire_seq) != OLD.claim_count) OR (EXISTS ( SELECT 1 - FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND - d.ordinal >= OLD.member_count) OR EXISTS ( SELECT 1 FROM claim_batch_members m WHERE m.wire_root_id = - OLD.wire_root_id AND m.wire_seq = OLD.wire_seq AND m.ordinal >= OLD.claim_count)) OR (EXISTS ( SELECT 1 FROM - claim_batch_members m LEFT JOIN cohort_delivery_receipts d ON d.wire_root_id = m.wire_root_id AND d.wire_seq - = m.wire_seq AND d.claim_id = m.claim_id WHERE m.wire_root_id = OLD.wire_root_id AND m.wire_seq = - OLD.wire_seq AND (d.claim_id IS NULL OR m.ordinal != ( SELECT COUNT(*) FROM cohort_delivery_receipts - preceding WHERE preceding.wire_root_id = OLD.wire_root_id AND preceding.wire_seq = OLD.wire_seq AND - preceding.kind = 'selected' AND preceding.ordinal < d.ordinal )) )) OR (EXISTS ( SELECT 1 FROM - cohort_delivery_receipts d JOIN wake_claims c ON c.recipient_lookup = d.recipient_lookup AND c.wire_seq = - d.wire_seq WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq AND d.kind = - 'unmentioned_observer' )))"""), + ColumnGuard(('sealed',), """(((SELECT COUNT(*) FROM cohort_delivery_receipts d + WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) + != OLD.member_count OR (SELECT COUNT(*) FROM cohort_delivery_receipts d + WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq + AND d.kind = 'selected') != OLD.claim_count) + OR ((SELECT COUNT(*) FROM claim_batch_members m + WHERE m.wire_root_id = OLD.wire_root_id AND m.wire_seq = OLD.wire_seq) + != OLD.claim_count) OR (EXISTS (SELECT 1 FROM cohort_delivery_receipts d + WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq + AND d.ordinal >= OLD.member_count) OR EXISTS (SELECT 1 FROM claim_batch_members m + WHERE m.wire_root_id = OLD.wire_root_id AND m.wire_seq = OLD.wire_seq + AND m.ordinal >= OLD.claim_count)) OR (EXISTS (SELECT 1 FROM claim_batch_members m + LEFT JOIN cohort_delivery_receipts d ON d.wire_root_id = m.wire_root_id + AND d.wire_seq = m.wire_seq AND d.claim_id = m.claim_id + WHERE m.wire_root_id = OLD.wire_root_id AND m.wire_seq = OLD.wire_seq + AND (d.claim_id IS NULL OR m.ordinal != ( + SELECT COUNT(*) FROM cohort_delivery_receipts preceding + WHERE preceding.wire_root_id = OLD.wire_root_id AND preceding.wire_seq = OLD.wire_seq + AND preceding.kind = 'selected' AND preceding.ordinal < d.ordinal)))) + OR (EXISTS (SELECT 1 FROM cohort_delivery_receipts d + JOIN wake_claims c ON c.recipient_lookup = d.recipient_lookup AND c.wire_seq = d.wire_seq + WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq + AND d.kind = 'unmentioned_observer')))"""), ) wire_root_id: Annotated[ @@ -119,12 +136,14 @@ def require_selected_claim(self, assignment: WakeAssignment) -> None: @dataclass(frozen=True, kw_only=True) class ClaimBatchMembers(GuardedInserts, ImmutableRows, CohortTable, TypedTable): - insert_condition = """((SELECT sealed FROM claim_batch_receipts r WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = - NEW.wire_seq) != 0) OR (NOT EXISTS ( SELECT 1 FROM wake_claims c JOIN claim_batch_receipts r ON - r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq WHERE c.assignment_id = NEW.claim_id AND - c.recipient_lookup = NEW.recipient_lookup AND c.wire_seq = NEW.wire_seq AND c.message_id = r.message_id AND - c.resolver_version = r.resolver_version AND c.policy_version = r.policy_version AND c.accepted_at_ms = - r.accepted_at_ms))""" + insert_condition = """((SELECT sealed FROM claim_batch_receipts r + WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq) != 0) + OR (NOT EXISTS (SELECT 1 FROM wake_claims c JOIN claim_batch_receipts r + ON r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq + WHERE c.assignment_id = NEW.claim_id AND c.recipient_lookup = NEW.recipient_lookup + AND c.wire_seq = NEW.wire_seq AND c.message_id = r.message_id + AND c.resolver_version = r.resolver_version AND c.policy_version = r.policy_version + AND c.accepted_at_ms = r.accepted_at_ms))""" wire_root_id: Annotated[str, Column(primary_key=True)] wire_seq: Annotated[int, Column(primary_key=True)] @@ -149,19 +168,27 @@ def references(cls): ) @dataclass(frozen=True, kw_only=True) -class CohortDeliveryReceipts(GuardedRelatedInserts, GuardedInserts, ImmutableRows, CohortTable, TypedTable): - # Delivery selection kind is distinct from this table's nominal identity. - +class CohortDeliveries( + GuardedRelatedInserts, + GuardedInserts, + ImmutableRows, + CohortTable, + TypedTable, + declared_name="cohort_delivery_receipts", +): + """Sealed deliveries, including observers that receive no execution claim.""" related_insert_target = WakeAssignment - related_insert_condition = """EXISTS ( SELECT 1 FROM cohort_delivery_receipts d JOIN claim_batch_receipts r ON - r.wire_root_id=d.wire_root_id AND r.wire_seq=d.wire_seq AND r.sealed=1 WHERE - d.recipient_lookup=NEW.recipient_lookup AND d.wire_seq=NEW.wire_seq AND d.kind='unmentioned_observer')""" - insert_condition = """((SELECT sealed FROM claim_batch_receipts r WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = - NEW.wire_seq) != 0) OR (NEW.kind = 'selected' AND NOT EXISTS ( SELECT 1 FROM claim_batch_members m JOIN - wake_claims c ON c.assignment_id = m.claim_id WHERE m.wire_root_id = NEW.wire_root_id AND m.wire_seq = - NEW.wire_seq AND m.claim_id = NEW.claim_id AND m.recipient_lookup = NEW.recipient_lookup AND c.recipient = - NEW.canonical_thread))""" + related_insert_condition = """EXISTS (SELECT 1 FROM cohort_delivery_receipts d + JOIN claim_batch_receipts r ON r.wire_root_id=d.wire_root_id AND r.wire_seq=d.wire_seq + AND r.sealed=1 WHERE d.recipient_lookup=NEW.recipient_lookup AND d.wire_seq=NEW.wire_seq + AND d.kind='unmentioned_observer')""" + insert_condition = """((SELECT sealed FROM claim_batch_receipts r + WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq) != 0) + OR (NEW.kind = 'selected' AND NOT EXISTS (SELECT 1 FROM claim_batch_members m + JOIN wake_claims c ON c.assignment_id = m.claim_id WHERE m.wire_root_id = NEW.wire_root_id + AND m.wire_seq = NEW.wire_seq AND m.claim_id = NEW.claim_id + AND m.recipient_lookup = NEW.recipient_lookup AND c.recipient = NEW.canonical_thread))""" wire_root_id: Annotated[str, Column(primary_key=True)] wire_seq: Annotated[int, Column(primary_key=True)] @@ -222,19 +249,23 @@ def references(cls): @dataclass(frozen=True, kw_only=True) class AwarenessClaimGenerations(OwnerRecord, GuardedInserts, ImmutableRows, AwarenessTable, TypedTable): - insert_condition = """(NOT EXISTS ( SELECT 1 FROM claim_batch_members m JOIN wake_claims c ON c.assignment_id=m.claim_id JOIN - claim_batch_receipts r ON r.wire_root_id=m.wire_root_id AND r.wire_seq=m.wire_seq JOIN owner_generations g - ON g.owner_lookup=m.recipient_lookup WHERE m.claim_id=NEW.claim_id AND m.wire_root_id=NEW.wire_root_id AND - m.wire_seq=NEW.wire_seq AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient=NEW.canonical_thread AND - c.message_id=r.message_id AND r.sealed=0 AND g.owner_thread=NEW.canonical_thread AND - g.generation=NEW.owner_generation))""" + insert_condition = """(NOT EXISTS (SELECT 1 FROM claim_batch_members m + JOIN wake_claims c ON c.assignment_id=m.claim_id JOIN claim_batch_receipts r + ON r.wire_root_id=m.wire_root_id AND r.wire_seq=m.wire_seq JOIN owner_generations g + ON g.owner_lookup=m.recipient_lookup WHERE m.claim_id=NEW.claim_id + AND m.wire_root_id=NEW.wire_root_id AND m.wire_seq=NEW.wire_seq + AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient=NEW.canonical_thread + AND c.message_id=r.message_id AND r.sealed=0 AND g.owner_thread=NEW.canonical_thread + AND g.generation=NEW.owner_generation))""" claim_id: Annotated[str, Column(primary_key=True)] wire_root_id: str wire_seq: Annotated[int, Column(check="wire_seq > 0")] owner_lookup: Annotated[str, WireName("recipient_lookup"), Column(record_key=True)] owner_thread: Annotated[ - str, WireName("canonical_thread"), Column(record_key=True, check="length(canonical_thread) BETWEEN 1 AND 256") + str, + WireName("canonical_thread"), + Column(record_key=True, check="length(canonical_thread) BETWEEN 1 AND 256"), ] owner_generation: Annotated[int, Column(check="owner_generation > 0")] diff --git a/src/agent_comms/context_segments/optional_awareness.py b/src/agent_comms/context_segments/optional_awareness.py index b2c36d6359..ff144299a8 100644 --- a/src/agent_comms/context_segments/optional_awareness.py +++ b/src/agent_comms/context_segments/optional_awareness.py @@ -17,7 +17,12 @@ from agent_comms.coordination_tables.executions import ExecutionRecord from agent_comms.coordination_tables.responses import ResponseObligation -from ..cohort_schema import AwarenessClaimGenerations, ClaimBatchMembers, ClaimBatchAcknowledgements, CohortDeliveryReceipts +from ..cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + ClaimBatchMembers, + CohortDeliveries, +) from ..field_codec import FieldCodec from ..turn_context import ContextSegment, InstructionFile, InstructionSegment, TurnContext from ..wake_candidate_index import ProjectionUnavailableError @@ -35,7 +40,7 @@ def capture( assignment: WakeAssignment, acknowledgement: ClaimBatchAcknowledgements, member: ClaimBatchMembers, - delivery: CohortDeliveryReceipts, + delivery: CohortDeliveries, generation: AwarenessClaimGenerations, ) -> _SelectedDecision: acknowledgement.require_selected_claim(assignment) @@ -48,7 +53,7 @@ def capture( ) if member != expected_member: raise ProjectionUnavailableError("selected source is not the recorded batch member") - expected_delivery = CohortDeliveryReceipts( + expected_delivery = CohortDeliveries( wire_root_id=acknowledgement.wire_root_id, wire_seq=assignment.wire_seq, ordinal=delivery.ordinal, diff --git a/src/agent_comms/coordinated_runtime_schema.py b/src/agent_comms/coordinated_runtime_schema.py index 4c027993fa..35ff34b862 100644 --- a/src/agent_comms/coordinated_runtime_schema.py +++ b/src/agent_comms/coordinated_runtime_schema.py @@ -2,7 +2,6 @@ from __future__ import annotations - from agent_comms.coordinator import Coordination from .native_runtime_input import NativeRuntimeTable diff --git a/src/agent_comms/coordination_cohort.py b/src/agent_comms/coordination_cohort.py index 25ba8f22b1..1f10178f2e 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -10,12 +10,19 @@ import hashlib import logging import sqlite3 -from typing import Annotated from dataclasses import dataclass +from typing import Annotated from agent_comms.assignment_states import AssignmentState, PendingNotification from agent_comms.bus_publication import CommittedDelivery -from agent_comms.cohort_schema import AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, CohortDeliveryReceipts, CohortTable, assert_optional_awareness_schema +from agent_comms.cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + ClaimBatchMembers, + CohortDeliveries, + CohortTable, + assert_optional_awareness_schema, +) from agent_comms.coordination_contracts import POLICY_VERSION, RESOLVER_VERSION from agent_comms.coordination_errors import ( IdentityConflict, @@ -136,7 +143,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte for ordinal, assignment in enumerate(expected) ]: raise IdentityConflict("sealed K claim members conflict with committed bus") - deliveries = CohortDeliveryReceipts.select( + deliveries = CohortDeliveries.select( db, where="wire_root_id=? AND wire_seq=?", parameters=(initial.wire_root_id, message.seq), @@ -144,7 +151,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte ) selected = {assignment.recipient_lookup: assignment.assignment_id for assignment in expected} if deliveries != [ - CohortDeliveryReceipts( + CohortDeliveries( wire_root_id=initial.wire_root_id, wire_seq=message.seq, ordinal=ordinal, @@ -280,7 +287,7 @@ def accept_delivery_cohort( } for ordinal, recipient in enumerate(initial.audience.recipients): assignment_id = selected.get(recipient.recipient_lookup) - CohortDeliveryReceipts( + CohortDeliveries( wire_root_id=wire_root_id, wire_seq=wire_seq, ordinal=ordinal, diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 9ac08517f4..3df0172056 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from enum import IntFlag -from typing import TYPE_CHECKING, Final, Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Final from agent_comms.attempt_states import AttemptState from agent_comms.coordination_contracts import ( @@ -17,7 +17,7 @@ from agent_comms.coordination_errors import RecoveryBlocked, StaleFence from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.participants import OwnerGenerations, OwnerRecord -from agent_comms.field_codec import projected, SnapshotExclude +from agent_comms.field_codec import SnapshotExclude, projected from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( @@ -84,30 +84,55 @@ def require_retry(self, snapshot: RecoverySnapshot) -> None: @dataclass(frozen=True, slots=True) -class AttemptRecord(LifecycleTransitions, FinalizedLifecycle, CounterDependent, IncrementingColumn, MonotoneColumns, OwnerRecord, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): - - counter_reference = ForeignKey(('owner_lookup','owner_generation'), OwnerGenerations, ('owner_lookup','generation')) - - column_orders = (NonDecreasing(('updated_at_ms', 'last_progress_at_ms', 'backend_done', 'process_dead')), NonDecreasingWhenPresent(('lease_expires_at_ms',))) - - insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.owner_lookup = - NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts AND ((NEW.attempt_ordinal = 1 AND (e.status = - 'pending' OR (e.status = 'active' AND e.current_attempt_ordinal = 1))) OR (NEW.attempt_ordinal > 1 AND - (e.status = 'deferred' OR (e.status = 'active' AND e.current_attempt_ordinal = NEW.attempt_ordinal)))) AND - NEW.attempt_ordinal = 1 + coalesce( (SELECT max(a.attempt_ordinal) FROM attempts a WHERE a.execution_id = - e.execution_id), 0))) OR (NOT EXISTS (SELECT 1 FROM owner_generations g WHERE g.owner_lookup = - NEW.owner_lookup AND g.owner_thread = NEW.owner_thread AND g.generation = NEW.owner_generation)) OR - ((json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 OR (CASE WHEN - json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, - '$.backend_done') END) != 0 OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN - ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) != 0) OR - (NEW.attempt_ordinal > 1 AND ( NOT EXISTS (SELECT 1 FROM retry_disposition_basis b WHERE b.execution_id = - NEW.execution_id AND b.authorized = 1) OR EXISTS (SELECT 1 FROM current_executions p WHERE p.owner_lookup = - NEW.owner_lookup AND p.execution_id IS NOT NULL))) OR (NEW.attempt_ordinal > 1 AND NOT EXISTS ( SELECT 1 - FROM attempts a WHERE a.execution_id = NEW.execution_id AND a.attempt_ordinal = NEW.attempt_ordinal - 1 AND - a.phase IN ({retry_attempt_phases}) AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > - a.owner_generation AND NEW.owner_token_digest != a.owner_token_digest))""" - immutable_columns = ('execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest', 'created_at_ms') +class AttemptRecord( + LifecycleTransitions, + FinalizedLifecycle, + CounterDependent, + IncrementingColumn, + MonotoneColumns, + OwnerRecord, + GuardedInserts, + FrozenColumns, + NoDelete, + CoordinatorTable, + TypedTable, + PreviousAttempt, + declared_name="attempts", +): + counter_reference = ForeignKey( + ("owner_lookup", "owner_generation"), OwnerGenerations, ("owner_lookup", "generation") + ) + column_orders = ( + NonDecreasing(("updated_at_ms", "last_progress_at_ms", "backend_done", "process_dead")), + NonDecreasingWhenPresent(("lease_expires_at_ms",)), + ) + insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e + WHERE e.execution_id = NEW.execution_id AND e.owner_lookup = NEW.owner_lookup + AND NEW.attempt_ordinal <= e.max_attempts AND ((NEW.attempt_ordinal = 1 + AND (e.status = 'pending' OR (e.status = 'active' AND e.current_attempt_ordinal = 1))) + OR (NEW.attempt_ordinal > 1 AND (e.status = 'deferred' OR (e.status = 'active' + AND e.current_attempt_ordinal = NEW.attempt_ordinal)))) + AND NEW.attempt_ordinal = 1 + coalesce((SELECT max(a.attempt_ordinal) FROM attempts a + WHERE a.execution_id = e.execution_id), 0))) + OR (NOT EXISTS (SELECT 1 FROM owner_generations g WHERE g.owner_lookup = NEW.owner_lookup + AND g.owner_thread = NEW.owner_thread AND g.generation = NEW.owner_generation)) + OR ((json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 + OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) + THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) != 0 + OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) + THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) != 0) + OR (NEW.attempt_ordinal > 1 AND (NOT EXISTS (SELECT 1 FROM retry_disposition_basis b + WHERE b.execution_id = NEW.execution_id AND b.authorized = 1) + OR EXISTS (SELECT 1 FROM current_executions p WHERE p.owner_lookup = NEW.owner_lookup + AND p.execution_id IS NOT NULL))) OR (NEW.attempt_ordinal > 1 AND NOT EXISTS ( + SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id + AND a.attempt_ordinal = NEW.attempt_ordinal - 1 AND a.phase IN ({retry_attempt_phases}) + AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > a.owner_generation + AND NEW.owner_token_digest != a.owner_token_digest))""" + immutable_columns = ( + "execution_id", "attempt_ordinal", "owner_lookup", "owner_thread", "owner_generation", + "owner_token_digest", "created_at_ms", + ) def require_attempt(self) -> AttemptRecord: return self diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index d9e0965598..afe9fe8f78 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -3,9 +3,9 @@ from __future__ import annotations from abc import abstractmethod -from typing import Annotated, ClassVar from dataclasses import dataclass, replace from enum import StrEnum +from typing import Annotated, ClassVar from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, @@ -21,7 +21,13 @@ ReplayFact, ) from agent_comms.execution_states import ExecutionState -from agent_comms.field_codec import NullMember, SingleFieldValue, SnapshotExclude, SubclassUnion, projected +from agent_comms.field_codec import ( + NullMember, + SingleFieldValue, + SnapshotExclude, + SubclassUnion, + projected, +) from agent_comms.reason_codes import ReasonCode from agent_comms.thread_text import AbsentText, GivenText, TextChoice from agent_comms.typed_table import ( @@ -106,10 +112,12 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) -class CurrentExecutions(IncrementingColumn, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable): - revision_column = 'pointer_revision' - - insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations WHERE owner_lookup = NEW.owner_lookup)""" +class CurrentExecutions( + IncrementingColumn, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable +): + revision_column = "pointer_revision" + insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations + WHERE owner_lookup = NEW.owner_lookup)""" immutable_columns = ('owner_lookup',) @property @@ -179,24 +187,42 @@ def references(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(LifecycleTransitions, OrdinalTransitions, IncrementingColumn, MonotoneColumns, GuardedColumns, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions"): - - column_orders = (NonDecreasing(('updated_at_ms',)),) - - immutable_columns = ('execution_id', 'origin', 'owner_lookup', 'owner_thread', 'max_attempts', 'created_at_ms') - column_guards = (ColumnGuard(('lifecycle',), """(json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND - json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM attempts a WHERE - a.execution_id = NEW.execution_id AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') AND - a.owner_lookup = NEW.owner_lookup AND a.phase IN ({failed_attempt_phases}) )) OR ((NEW.origin = 'wire') AND - ((NOT EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id)) OR - (json_extract(NEW.lifecycle, '$.kind') = 'completed' AND EXISTS ( SELECT 1 FROM obligations WHERE - execution_id = NEW.execution_id AND success_terminal != 1)) OR (json_extract(NEW.lifecycle, '$.kind') = - 'deferred' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND EXISTS ( SELECT 1 FROM obligations - WHERE execution_id = NEW.execution_id AND retryable != 1)))) OR (json_extract(NEW.lifecycle, '$.kind') = - 'failed' AND EXISTS ( SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id)) OR - (json_extract(NEW.lifecycle, '$.kind') = 'failed' AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL - AND EXISTS (SELECT 1 FROM retry_disposition_basis b WHERE b.execution_id = NEW.execution_id AND b.authorized - = 1))"""),) +class ExecutionRecord( + LifecycleTransitions, + OrdinalTransitions, + IncrementingColumn, + MonotoneColumns, + GuardedColumns, + FrozenColumns, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="executions", +): + column_orders = (NonDecreasing(("updated_at_ms",)),) + immutable_columns = ( + "execution_id", "origin", "owner_lookup", "owner_thread", "max_attempts", "created_at_ms", + ) + column_guards = ( + ColumnGuard(("lifecycle",), """(json_extract(NEW.lifecycle, '$.kind') + IN ({optional_attempt_execution_names}) AND json_extract(NEW.lifecycle, '$.ordinal') + IS NOT NULL AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id + AND a.attempt_ordinal = json_extract(NEW.lifecycle, '$.ordinal') + AND a.owner_lookup = NEW.owner_lookup AND a.phase IN ({failed_attempt_phases}))) + OR ((NEW.origin = 'wire') AND ((NOT EXISTS (SELECT 1 FROM obligations + WHERE execution_id = NEW.execution_id)) + OR (json_extract(NEW.lifecycle, '$.kind') = 'completed' + AND EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id + AND success_terminal != 1)) OR (json_extract(NEW.lifecycle, '$.kind') = 'deferred' + AND json_extract(NEW.lifecycle, '$.ordinal') IS NOT NULL AND EXISTS ( + SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id AND retryable != 1)))) + OR (json_extract(NEW.lifecycle, '$.kind') = 'failed' AND EXISTS ( + SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id)) + OR (json_extract(NEW.lifecycle, '$.kind') = 'failed' + AND json_extract(NEW.lifecycle, '$.ordinal') + IS NOT NULL AND EXISTS (SELECT 1 FROM retry_disposition_basis b + WHERE b.execution_id = NEW.execution_id AND b.authorized = 1))"""), + ) retry_view = "retry_disposition_basis" @@ -425,9 +451,9 @@ def references(cls) -> tuple[ForeignKey, ...]: class RetryPartition(GuardedAfterWrites): """A failed execution cannot retain authorization to retry.""" - after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e ON e.execution_id = b.execution_id WHERE - b.execution_id = NEW.execution_id AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal - IS NOT NULL)""" + after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e + ON e.execution_id = b.execution_id WHERE b.execution_id = NEW.execution_id + AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal IS NOT NULL)""" @dataclass(frozen=True, slots=True) diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index ceb17cfd87..f5d974f4a0 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -2,12 +2,12 @@ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass +from typing import Annotated -from agent_comms.participant_identity import ParticipantIdentity from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordination_schema import CoordinatorTable +from agent_comms.participant_identity import ParticipantIdentity from agent_comms.typed_table import ( Column, ColumnGuard, @@ -68,7 +68,9 @@ def references(cls): return (ForeignKey(("participant_lookup",), Participants, ("participant_lookup",)),) @dataclass(frozen=True, kw_only=True) -class OwnerGenerations(CounterAdvance, NoDelete, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable): +class OwnerGenerations( + CounterAdvance, NoDelete, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable +): counter_column = 'generation' diff --git a/src/agent_comms/coordination_tables/publications.py b/src/agent_comms/coordination_tables/publications.py index f4c2629981..dbe409674a 100644 --- a/src/agent_comms/coordination_tables/publications.py +++ b/src/agent_comms/coordination_tables/publications.py @@ -31,11 +31,14 @@ @dataclass(frozen=True, slots=True) -class PublicationIntents(ValidatedInserts, GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable): +class PublicationIntents( + ValidatedInserts, GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable +): """One obligation route's frozen response: the exact Message it publishes.""" - insert_condition = """NOT EXISTS ( SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id AND exact_target = - NEW.exact_target AND state = 'pending' )""" + insert_condition = """NOT EXISTS (SELECT 1 FROM obligations + WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target + AND state = 'pending')""" validator_function: ClassVar[str] = "coordination_validate_publication_intent" @@ -148,11 +151,19 @@ def validate_sql(cls, *values: object) -> int: @dataclass(frozen=True, kw_only=True) -class PublicationEvidence(GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="publication_receipts"): - insert_condition = """(EXISTS (SELECT 1 FROM executions WHERE execution_id = NEW.execution_id AND status = 'failed')) OR - (NEW.message_id != ( SELECT expected_message_id FROM publication_intents WHERE execution_id = - NEW.execution_id AND exact_target = NEW.exact_target )) OR (NOT EXISTS ( SELECT 1 FROM obligations WHERE - execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND state = 'publishing' ))""" +class PublicationEvidence( + GuardedInserts, + ImmutableRows, + CoordinatorTable, + TypedTable, + declared_name="publication_receipts", +): + insert_condition = """(EXISTS (SELECT 1 FROM executions + WHERE execution_id = NEW.execution_id AND status = 'failed')) + OR (NEW.message_id != (SELECT expected_message_id FROM publication_intents + WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target)) + OR (NOT EXISTS (SELECT 1 FROM obligations WHERE execution_id = NEW.execution_id + AND exact_target = NEW.exact_target AND state = 'publishing'))""" @property def reference(self) -> MessageReference: diff --git a/src/agent_comms/coordination_tables/recovery.py b/src/agent_comms/coordination_tables/recovery.py index 9e07020176..ef1189ed0c 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass from dataclasses import field as dataclass_field from enum import StrEnum +from typing import Annotated from agent_comms import field_codec from agent_comms.coordination_contracts import ( @@ -14,8 +14,8 @@ ) from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.executions import ExecutionReference -from agent_comms.recovery_states import RecoveryCondition from agent_comms.reason_codes import GivenReasonCode, RecoveryDetail +from agent_comms.recovery_states import RecoveryCondition from agent_comms.typed_table import ( Column, ForeignKey, @@ -45,7 +45,16 @@ class ACPClientConnectivity(StrEnum): @dataclass(frozen=True, slots=True) -class ConnectivityFacet(IncrementingColumn, MonotoneColumns, ExecutionReference, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="connectivity"): +class ConnectivityFacet( + IncrementingColumn, + MonotoneColumns, + ExecutionReference, + FrozenColumns, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="connectivity", +): column_orders = (NonDecreasing(('observed_at_ms',)),) diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index 089b82fa6e..af24423cfc 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -2,8 +2,8 @@ from __future__ import annotations -from typing import Annotated, ClassVar from dataclasses import dataclass +from typing import Annotated, ClassVar from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, @@ -12,7 +12,8 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.field_codec import projected, SnapshotExclude, WireName +from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition +from agent_comms.field_codec import SnapshotExclude, WireName, projected from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( @@ -32,31 +33,54 @@ TextStorage, TypedTable, ) -from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition @dataclass(frozen=True, slots=True) -class ResponseObligation(LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, ExecutionReference, RetryPartition, GuardedUpdateMembership, FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, CoordinatorTable, TypedTable, declared_name="obligations"): - column_orders = (NonDecreasing(('updated_at_ms',)),) - +class ResponseObligation( + LifecycleTransitions, + LifecycleChanges, + IncrementingColumn, + MonotoneColumns, + ExecutionReference, + RetryPartition, + GuardedUpdateMembership, + FrozenColumns, + GuardedColumns, + GuardedInserts, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="obligations", +): """One original reply-route obligation within an execution.""" - immutable_columns = ('execution_id', 'exact_target', 'created_at_ms') - update_membership_condition = """(json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS ( SELECT 1 - FROM publication_intents WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target )) OR - (json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( SELECT 1 FROM publication_receipts - WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target AND message_id = - json_extract(NEW.lifecycle, '$.message_id') AND seq = json_extract(NEW.lifecycle, '$.seq') )))""" - insert_condition = """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE - e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR - (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" - column_guards = (ColumnGuard(('lifecycle',), """(EXISTS (SELECT 1 FROM publication_receipts WHERE execution_id = OLD.execution_id AND exact_target = - OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') != 'published') OR (EXISTS (SELECT 1 FROM - publication_intents WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) AND - json_extract(NEW.lifecycle, '$.kind') NOT IN ({intent_response_names}))"""), ColumnGuard(('exact_target',), """(NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id=e.execution_id WHERE - e.execution_id=NEW.execution_id AND e.origin='wire' AND c.exact_target=NEW.exact_target )) OR (EXISTS - (SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id AND exact_target = - OLD.exact_target))"""),) + column_orders = (NonDecreasing(("updated_at_ms",)),) + immutable_columns = ("execution_id", "exact_target", "created_at_ms") + update_membership_condition = """(json_extract(NEW.lifecycle, '$.kind') + IN ({required_intent_response_names})) AND ((NOT EXISTS (SELECT 1 FROM publication_intents + WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target)) + OR (json_extract(NEW.lifecycle, '$.kind') = 'published' AND NOT EXISTS ( + SELECT 1 FROM publication_receipts WHERE execution_id = NEW.execution_id + AND exact_target = NEW.exact_target + AND message_id = json_extract(NEW.lifecycle, '$.message_id') + AND seq = json_extract(NEW.lifecycle, '$.seq'))))""" + insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e + JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id + AND e.origin='wire' AND c.exact_target=NEW.exact_target)) + OR (json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names}))""" + column_guards = ( + ColumnGuard(("lifecycle",), """(EXISTS (SELECT 1 FROM publication_receipts + WHERE execution_id = OLD.execution_id AND exact_target = OLD.exact_target) + AND json_extract(NEW.lifecycle, '$.kind') != 'published') OR (EXISTS ( + SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id + AND exact_target = OLD.exact_target) AND json_extract(NEW.lifecycle, '$.kind') + NOT IN ({intent_response_names}))"""), + ColumnGuard(("exact_target",), """(NOT EXISTS (SELECT 1 FROM executions e + JOIN wake_claims c ON c.execution_id=e.execution_id WHERE e.execution_id=NEW.execution_id + AND e.origin='wire' AND c.exact_target=NEW.exact_target)) OR (EXISTS ( + SELECT 1 FROM publication_intents WHERE execution_id = OLD.execution_id + AND exact_target = OLD.exact_target))"""), + ) @staticmethod diff --git a/src/agent_comms/native_pi.py b/src/agent_comms/native_pi.py index 4af06575f0..e399b6e43d 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -6,8 +6,6 @@ from __future__ import annotations -from agent_comms.participant_identity import ParticipantIdentity - import os import re import shutil @@ -17,16 +15,24 @@ from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Annotated +from typing import TYPE_CHECKING, Annotated, Any, Literal +from agent_comms.participant_identity import ParticipantIdentity from .coordination_database import CoordinationStore from .errors import RelationViolationError -from .field_codec import FieldCodec, Id128Text, MalformedRecord, Sha256Text, WireName, boundary_decoder -from .native_failures import NativeRequestFailure, NativePiUnavailable +from .field_codec import ( + FieldCodec, + Id128Text, + MalformedRecord, + Sha256Text, + WireName, + boundary_decoder, +) from .launch_root import LaunchRoot from .launch_runtime import LaunchRuntime, RestartEnvironment, ThreadLaunch from .native_arguments import NativeArguments, SelectedModel +from .native_failures import NativePiUnavailable, NativeRequestFailure from .native_input_record import NativeInputCommit, NativeInputLocation from .native_package import MANIFEST, UNIT_INSTRUCTIONS, VerifiedNativePackage from .native_session_files import NativeSessionFiles @@ -208,12 +214,16 @@ def at(self, session_file: Path, *, session_entry_start: int) -> NativeContextPr @dataclass(frozen=True, slots=True) -class NativeContextJournal(GuardedInserts, ImmutableRows, NativeContextRecord, TypedTable, DurableTables): +class NativeContextJournal( + GuardedInserts, ImmutableRows, NativeContextRecord, TypedTable, DurableTables +): """One proof declaration owns wire facts and the indexed durable journal.""" - insert_condition = """NEW.request_generation < COALESCE((SELECT MAX(request_generation) FROM native_context_journal), 0) OR - EXISTS(SELECT 1 FROM native_context_journal WHERE request_generation=NEW.request_generation AND - (llm_context_digest != NEW.llm_context_digest OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id + insert_condition = """NEW.request_generation < COALESCE((SELECT MAX(request_generation) + FROM native_context_journal), 0) OR EXISTS(SELECT 1 FROM native_context_journal + WHERE request_generation=NEW.request_generation + AND (llm_context_digest != NEW.llm_context_digest + OR committed_at_ms != NEW.committed_at_ms)) OR NEW.session_id != (SELECT session_id FROM native_context_journal ORDER BY session_id LIMIT 1)""" diff --git a/src/agent_comms/native_prompt_binding.py b/src/agent_comms/native_prompt_binding.py index 9372afc802..9a35c67369 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -13,7 +13,7 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Annotated, TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordination_tables.assignments import WakeAssignment @@ -21,14 +21,13 @@ from agent_comms.private_runtime_schema import PrivateRuntimeSchema from .cohort_schema import CohortTable -from agent_comms.native_runtime_input import NativeRuntimeTable from .field_codec import FieldCodec, Id128Text, MalformedRecord from .locked_store import DurableFile, SQLiteFile from .native_entries import NativeEvidenceRead from .native_input_owner import ParticipantOwner from .native_input_record import NativeColumn, NativeInputExecution, NativeInputRecord from .native_pi import read_tracked_input_commitment -from .native_runtime_input import NativeRuntimeInput +from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable from .private_send_rules import NativeIdentityCheck from .private_sidecar import create_sidecar_file, native_request_digest, sidecar_connection from .reservation_rules import ReservationViolationError @@ -52,7 +51,9 @@ def binding_store_path(store: Coordination) -> Path: @dataclass(frozen=True, slots=True) -class PromptBinding(ImmutableRows, DurableFile, SQLiteFile, NativeInputRecord, TypedTable, PrivateRuntimeSchema): +class PromptBinding( + ImmutableRows, DurableFile, SQLiteFile, NativeInputRecord, TypedTable, PrivateRuntimeSchema +): filename = "native_prompt_bindings.sqlite3" @classmethod diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index 4a946ba7d9..c199cf805b 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -19,13 +19,13 @@ from .field_codec import FieldCodec, MalformedRecord, NativeContext, WireName from .native_claim_epoch import NativeClaimEpoch, UnrecordedNativeClaim from .native_input_record import ( + NativeColumn, NativeInputContext, NativeInputExecution, NativeInputLocation, NativeInputPosition, - NativeStageStorage, NativeInputRecord, - NativeColumn, + NativeStageStorage, NumberNativeColumn, RecordedNativePosition, TextNativeColumn, @@ -40,19 +40,19 @@ from .typed_table import ( Column, DurableTables, - FrozenColumns, FinalizedColumn, - RequiredUpdateColumns, - StableReferenceColumns, - WriteOnceColumns, + FrozenColumns, IntegerStorage, MonotoneColumns, NoDelete, NonDecreasing, + RequiredUpdateColumns, SharedFileTables, + StableReferenceColumns, TextStorage, TypedRow, TypedTable, + WriteOnceColumns, ) if TYPE_CHECKING: @@ -89,8 +89,21 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) -class NativeRuntimeInput(WriteOnceColumns, FinalizedColumn, RequiredUpdateColumns, FrozenColumns, NoDelete, NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): - immutable_columns = ('input_id', 'stage', 'execution_id', 'attempt_ordinal', 'owner_lookup', 'owner_thread', 'owner_generation', 'owner_token_digest') +class NativeRuntimeInput( + WriteOnceColumns, + FinalizedColumn, + RequiredUpdateColumns, + FrozenColumns, + NoDelete, + NativeInputRecord, + NativeInputContext, + NativeRuntimeTable, + TypedTable, +): + immutable_columns = ( + "input_id", "stage", "execution_id", "attempt_ordinal", "owner_lookup", "owner_thread", + "owner_generation", "owner_token_digest", + ) write_once_columns = ('sent_owner_claimed_generation', 'session_id', 'session_file') finalized_column = 'session_entry_id' required_update_columns = ('sent_owner_claimed_generation',) @@ -315,10 +328,20 @@ def commit_context( raise StaleFence("native proof was previously committed") @dataclass(frozen=True) -class CurrentNativeCursor(MonotoneColumns, StableReferenceColumns, FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable): - column_orders = (NonDecreasing(('covered_seq', 'injected_seq')),) - - immutable_columns = ('wire_root_id', 'recipient_lookup', 'owner_thread', 'owner_generation', 'owner_claimed_generation') +class CurrentNativeCursor( + MonotoneColumns, + StableReferenceColumns, + FrozenColumns, + NoDelete, + NativeInputContext, + NativeRuntimeTable, + TypedTable, +): + column_orders = (NonDecreasing(("covered_seq", "injected_seq")),) + immutable_columns = ( + "wire_root_id", "recipient_lookup", "owner_thread", "owner_generation", + "owner_claimed_generation", + ) reference_column = 'injected_seq' stable_reference_columns = ('input_id',) diff --git a/src/agent_comms/native_source_cursor.py b/src/agent_comms/native_source_cursor.py index 99e60c6c40..33f349329e 100644 --- a/src/agent_comms/native_source_cursor.py +++ b/src/agent_comms/native_source_cursor.py @@ -4,7 +4,6 @@ from functools import partial from typing import TYPE_CHECKING -from agent_comms.native_runtime_input import NativeRuntimeTable from .coordination_database import CoordinationStore from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination @@ -12,7 +11,7 @@ from .field_codec import FieldCodec from .message_bus import MessageBus from .native_input_owner import RegistryOwner -from .native_runtime_input import CurrentNativeCursor +from .native_runtime_input import CurrentNativeCursor, NativeRuntimeTable from .proven_source_coverage import SourceCoverage from .store_files import StoreLockContention from .threads import Thread diff --git a/src/agent_comms/optional_awareness_projection.py b/src/agent_comms/optional_awareness_projection.py index cfa1976eae..87ab660a87 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -31,7 +31,14 @@ from .audience_manifest import FrozenRecipient from .bus_publication import CommittedDelivery from .child_process import ProcessIdentity -from .cohort_schema import AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, CohortDeliveryReceipts, CohortTable, assert_optional_awareness_schema +from .cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + ClaimBatchMembers, + CohortDeliveries, + CohortTable, + assert_optional_awareness_schema, +) from .context_segments.optional_awareness import ( CompleteAwareness, OmittedAwareness, @@ -346,7 +353,7 @@ def _selected_decisions( } deliveries = { row.wire_seq: row - for row in CohortDeliveryReceipts.select( + for row in CohortDeliveries.select( db, where=f"wire_root_id=? AND recipient_lookup=? AND wire_seq IN ({marks})", parameters=(root_id, lookup, *sequences), diff --git a/src/agent_comms/selected_tool_broker.py b/src/agent_comms/selected_tool_broker.py index c4893ae01e..7b0b4cf3d8 100644 --- a/src/agent_comms/selected_tool_broker.py +++ b/src/agent_comms/selected_tool_broker.py @@ -292,7 +292,11 @@ def _require_sent_full_input( raise SelectedToolDenied("Selected tool source was not included in this native input") expected = NativeInputIdentity( input_id, FullNativeExecution(claim_scope.execution_id, claim_scope.attempt_ordinal), - OwnerGenerations(owner_lookup=claim_scope.recipient_lookup, owner_thread=owner_name, generation=claim_scope.participant_generation), + OwnerGenerations( + owner_lookup=claim_scope.recipient_lookup, + owner_thread=owner_name, + generation=claim_scope.participant_generation, + ), ) if row.identity != expected: raise SelectedToolDenied("Selected tool does not match the exact sent FULL input") diff --git a/src/agent_comms/triage_native_sources.py b/src/agent_comms/triage_native_sources.py index 47a40784c6..4732af6952 100644 --- a/src/agent_comms/triage_native_sources.py +++ b/src/agent_comms/triage_native_sources.py @@ -4,8 +4,8 @@ only for triage, before an execution exists. It contains no handling state. """ -from typing import Annotated from dataclasses import dataclass +from typing import Annotated from .coordination_errors import IdentityConflict from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable @@ -18,16 +18,20 @@ @dataclass(frozen=True) -class TriageNativeSources(GuardedInserts, ImmutableRows, - NativeRuntimeTable, TypedTable, declared_name="native_runtime_triage_sources" +class TriageNativeSources( + GuardedInserts, + ImmutableRows, + NativeRuntimeTable, + TypedTable, + declared_name="native_runtime_triage_sources", ): - - insert_condition = """(NOT EXISTS (SELECT 1 FROM native_runtime_input n WHERE n.input_id=NEW.input_id AND n.stage='triage')) OR - (EXISTS (SELECT 1 FROM json_each(NEW.assignment_ids) member LEFT JOIN wake_claims c ON - c.assignment_id=member.value JOIN native_runtime_input n ON n.input_id=NEW.input_id WHERE c.assignment_id IS - NULL OR c.recipient_lookup<>n.owner_lookup) OR EXISTS (SELECT 1 FROM native_runtime_triage_sources s, - json_each(s.assignment_ids) previous JOIN json_each(NEW.assignment_ids) member ON - member.value=previous.value))""" + insert_condition = """(NOT EXISTS (SELECT 1 FROM native_runtime_input n + WHERE n.input_id=NEW.input_id AND n.stage='triage')) OR (EXISTS ( + SELECT 1 FROM json_each(NEW.assignment_ids) member LEFT JOIN wake_claims c + ON c.assignment_id=member.value JOIN native_runtime_input n ON n.input_id=NEW.input_id + WHERE c.assignment_id IS NULL OR c.recipient_lookup<>n.owner_lookup) OR EXISTS ( + SELECT 1 FROM native_runtime_triage_sources s, json_each(s.assignment_ids) previous + JOIN json_each(NEW.assignment_ids) member ON member.value=previous.value))""" input_id: Annotated[ str, Column(primary_key=True, references=(NativeRuntimeInput, "input_id")) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index e02f5a809c..48f803a472 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -48,8 +48,8 @@ WireValue, compile_decoder, ) -from .locked_store import DurableFile, FileStore, RuntimeFile, SQLiteFile from .lifecycle import LifecycleState +from .locked_store import DurableFile, FileStore, RuntimeFile, SQLiteFile if TYPE_CHECKING: from .execution_states import ExecutionState @@ -896,7 +896,8 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... f"{field.reference('NEW')} IS NOT {field.reference('OLD')}" for field in row._fields_named(row.stable_reference_columns)) return (RowMutation.UPDATE.abort_trigger( - row, when=f"{reference.reference('NEW')} IS {reference.reference('OLD')} AND ({changes})", + row, when=(f"{reference.reference('NEW')} IS {reference.reference('OLD')} " + f"AND ({changes})"), suffix="_reference", message="Values require a new reference"),) @@ -937,7 +938,9 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... edges = " OR ".join( f"({previous} = {sql_literal(name)} AND {following} IN " f"({','.join(sql_literal(target) for target in sorted(targets))}))" - for name, targets in field.lifecycle_family.transition_table().items() if targets) or "0" + for name, targets in field.lifecycle_family.transition_table().items() + if targets + ) or "0" return (RowMutation.UPDATE.abort_trigger( row, columns=("lifecycle",), when=f"{previous} != {following} AND NOT ({edges})", suffix="_edges", message="Lifecycle transition is not declared"),) @@ -1013,10 +1016,17 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... (state,) = dependent._fields_named(("lifecycle",)) closed = ",".join(sql_literal(name) for name, targets in state.lifecycle_family.transition_table().items() if not targets) - blocked.append(f"EXISTS (SELECT 1 FROM {_identifier(dependent.declared_name)} dependent " - f"WHERE {keys} AND {state.discriminator('dependent')} NOT IN ({closed}))") - condition = f"{counter.reference('NEW')} != {counter.reference('OLD')} AND (" + " OR ".join( - [f"{counter.reference('NEW')} != {counter.reference('OLD')} + 1", *blocked]) + ")" + blocked.append( + f"EXISTS (SELECT 1 FROM {_identifier(dependent.declared_name)} dependent " + f"WHERE {keys} AND {state.discriminator('dependent')} NOT IN ({closed}))" + ) + condition = ( + f"{counter.reference('NEW')} != {counter.reference('OLD')} AND (" + + " OR ".join( + [f"{counter.reference('NEW')} != {counter.reference('OLD')} + 1", *blocked] + ) + + ")" + ) return (RowMutation.UPDATE.abort_trigger( row, when=condition, suffix="_counter", message="Counter advance has live dependents"),) @@ -1195,7 +1205,9 @@ def _columns(cls) -> tuple[_Field, ...]: names = {item.name for item in fields} following: dict[str, list[_Field]] = {} for owner in cls.__mro__: - declared = tuple(annotationlib.get_annotations(owner, format=annotationlib.Format.STRING)) + declared = tuple( + annotationlib.get_annotations(owner, format=annotationlib.Format.STRING) + ) for position, name in enumerate(declared): match vars(owner).get(name): case Generated() as declaration: diff --git a/tests/test_cohort_schema.py b/tests/test_cohort_schema.py index 0e9ea97046..1697646d94 100644 --- a/tests/test_cohort_schema.py +++ b/tests/test_cohort_schema.py @@ -229,7 +229,7 @@ def test_seal_rejects_observer_with_preexisting_legacy_singleton_claim(tmp_path: def test_selected_and_observer_receipts_are_distinct_immutable_rows(tmp_path: Path) -> None: - from agent_comms.cohort_schema import CohortDeliveryReceipts + from agent_comms.cohort_schema import CohortDeliveries from agent_comms.field_codec import FieldCodec with Coordination(str(tmp_path / "coordination.sqlite3")) as store: @@ -259,11 +259,11 @@ def test_selected_and_observer_receipts_are_distinct_immutable_rows(tmp_path: Pa ) ] == [(0, "a", "selected", "claim-a"), (1, "b", "unmentioned_observer", None)] assert db.execute("SELECT COUNT(*) FROM claim_batch_members").fetchone()[0] == 1 - for row in CohortDeliveryReceipts.select(db, order_by=("ordinal",)): + for row in CohortDeliveries.select(db, order_by=("ordinal",)): encoded = FieldCodec.encode(row) - assert encoded["row"] == CohortDeliveryReceipts.declared_name + assert encoded["row"] == CohortDeliveries.declared_name assert encoded["kind"] == row.kind - assert FieldCodec.decode(CohortDeliveryReceipts, encoded) == row + assert FieldCodec.decode(CohortDeliveries, encoded) == row for statement in ( "UPDATE claim_batch_receipts SET accepted_at_ms=101", "DELETE FROM claim_batch_receipts", diff --git a/tests/test_coordination_cohort.py b/tests/test_coordination_cohort.py index 5671b98d06..62ab373c4a 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -21,7 +21,7 @@ FullPendingAssignment, ) from agent_comms.bus_publication import PRIVATE_WIRE_FIELD -from agent_comms.cohort_schema import CohortDeliveryReceipts, install_private_cohort_schema +from agent_comms.cohort_schema import CohortDeliveries, install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.coordination_cohort import ( accept_delivery_cohort, @@ -179,13 +179,13 @@ def test_sealed_receipts_follow_recipient_progress(tmp_path: Path) -> None: for message in (alice_first, bob, alice_second): accept_delivery_cohort(comms.bus, root_id, message.seq, store) - assert CohortDeliveryReceipts.sealed_sequences_after( + assert CohortDeliveries.sealed_sequences_after( store.session._connection, wire_root_id=root_id, recipient_lookup=lookups["Alice"], after_seq=alice_first.seq, ) == frozenset({alice_second.seq}) - assert CohortDeliveryReceipts.sealed_sequences_after( + assert CohortDeliveries.sealed_sequences_after( store.session._connection, wire_root_id=root_id, recipient_lookup=lookups["Bob"], diff --git a/tools/cutover/carry_table_triggers.py b/tools/cutover/carry_table_triggers.py index fafe217b7b..06a85a942d 100644 --- a/tools/cutover/carry_table_triggers.py +++ b/tools/cutover/carry_table_triggers.py @@ -4,6 +4,7 @@ transaction as its guards, after the old digest has been checked against the stored schema. No runtime reader accepts both schemas. Retire after installation. """ + from __future__ import annotations import hashlib @@ -27,8 +28,10 @@ def paths(cls, root: Path) -> tuple[Path, ...]: # Explicit offline discovery; ordinary TypedTable reads only imported members. tuple(TypedTable.__registry__.values()) paths = { - path for owner, directory in SQLiteFile.declarations_in(root) - if issubclass(owner, DurableFile) for path in owner.matching_in(directory) + path + for owner, directory in SQLiteFile.declarations_in(root) + if issubclass(owner, DurableFile) + for path in owner.matching_in(directory) } for path in paths: cls.require_confined(root, path) @@ -40,15 +43,24 @@ def rewrite(cls, path: Path, *, apply: bool) -> int: with closing(opener(path)) as db: transaction = SQLiteFile.write_transaction(db) if apply else nullcontext(db) with transaction: - rows = tuple(TypedTable.member_named(table.name) - for table in SQLiteTableName.stored(db)) + rows = tuple( + TypedTable.member_named(table.name) for table in SQLiteTableName.stored(db) + ) declared = {name: sql for row in rows for name, sql in row.schema_objects().items()} - structure = {name: sql for row in rows for name, sql in row.structure_objects().items()} - stored = SQLiteSchemaObject.read(db.execute( - "SELECT name,sql FROM sqlite_master WHERE sql IS NOT NULL " - "AND name NOT LIKE 'sqlite_%' ORDER BY rowid")) - actual_guards = SQLiteSchemaObject.read(db.execute( - "SELECT name,sql FROM sqlite_master WHERE type='trigger' ORDER BY rowid")) + structure = { + name: sql for row in rows for name, sql in row.structure_objects().items() + } + stored = SQLiteSchemaObject.read( + db.execute( + "SELECT name,sql FROM sqlite_master WHERE sql IS NOT NULL " + "AND name NOT LIKE 'sqlite_%' ORDER BY rowid" + ) + ) + actual_guards = SQLiteSchemaObject.read( + db.execute( + "SELECT name,sql FROM sqlite_master WHERE type='trigger' ORDER BY rowid" + ) + ) guard_names = {guard.name for guard in actual_guards} actual_structure = [item for item in stored if item.name not in guard_names] if differences := SQLiteSchemaObject.differences(actual_structure, structure): @@ -62,11 +74,14 @@ def rewrite(cls, path: Path, *, apply: bool) -> int: # Sidecar digests use declaration insertion order, which is also # the catalog's creation order before this first trigger carry. old_schema = {item.name: item.sql for item in stored} - old_digest = hashlib.sha256(json.dumps( - old_schema, separators=(",", ":")).encode()).hexdigest() + old_digest = hashlib.sha256( + json.dumps(old_schema, separators=(",", ":")).encode() + ).hexdigest() digest = _digest(row) complete = not SQLiteSchemaObject.differences(stored, declared) - if meta.ddl_digest != old_digest and not (meta.ddl_digest == digest and complete): + if meta.ddl_digest != old_digest and not ( + meta.ddl_digest == digest and complete + ): raise RelationViolationError(f"{path}: sidecar schema digest is unknown") digest_changed = meta.ddl_digest != digest differences = SQLiteSchemaObject.differences(actual_guards, expected) @@ -93,11 +108,19 @@ def rewrite(cls, path: Path, *, apply: bool) -> int: @classmethod def backup_files(cls, root: Path) -> tuple[Path, ...]: - return tuple(member for path in cls.paths(root) if cls.rewrite(path, apply=False) - for member in SQLiteFile.files(path) if member.exists()) + return tuple( + member + for path in cls.paths(root) + if cls.rewrite(path, apply=False) + for member in SQLiteFile.files(path) + if member.exists() + ) @classmethod def carry(cls, root: Path, *, apply: bool) -> Carried: changed = tuple(path for path in cls.paths(root) if cls.rewrite(path, apply=apply)) - return Carried(len(changed), {"files": len(changed)}, - tuple(str(path.relative_to(root)) for path in changed) if apply else ()) + return Carried( + len(changed), + {"files": len(changed)}, + tuple(str(path.relative_to(root)) for path in changed) if apply else (), + ) From f01ecab4473aa7b004edb16234a73cfcba5ee0f1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:50:04 -0400 Subject: [PATCH 11/18] Declare immutable SQL columns on their owning fields --- src/agent_comms/cohort_schema.py | 38 +++++----- src/agent_comms/coordination_cohort.py | 20 +---- .../coordination_tables/assignments.py | 60 +++++++++------ .../coordination_tables/attempts.py | 21 ++---- .../coordination_tables/executions.py | 39 +++++----- .../coordination_tables/participants.py | 8 +- .../coordination_tables/recovery.py | 7 +- .../coordination_tables/responses.py | 10 +-- src/agent_comms/native_runtime_input.py | 42 +++++------ src/agent_comms/typed_table.py | 25 ++++++- tests/test_table_mutation_capabilities.py | 73 +++++++++++++++---- 11 files changed, 187 insertions(+), 156 deletions(-) diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index 9c0692d45c..7b2efdfc41 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -20,7 +20,7 @@ ColumnGuard, DurableTables, ForeignKey, - FrozenColumns, + FrozenColumn, GuardedColumns, GuardedInserts, GuardedRelatedInserts, @@ -57,7 +57,6 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) class ClaimBatchAcknowledgements( InitialOneWayColumn, - FrozenColumns, GuardedColumns, NoDelete, CohortTable, @@ -67,11 +66,6 @@ class ClaimBatchAcknowledgements( transition_column = "sealed" transition_from = 0 transition_to = 1 - immutable_columns = ( - "wire_root_id", "wire_seq", "message_id", "exact_target", "envelope_digest", - "audience_digest", "decisions_digest", "member_count", "claim_count", "manifest_codec", - "resolver_version", "policy_version", "accepted_at_ms", - ) column_guards = ( ColumnGuard(('sealed',), """(((SELECT COUNT(*) FROM cohort_delivery_receipts d WHERE d.wire_root_id = OLD.wire_root_id AND d.wire_seq = OLD.wire_seq) @@ -100,7 +94,7 @@ class ClaimBatchAcknowledgements( wire_root_id: Annotated[ str, - Column( + FrozenColumn( primary_key=True, check=( "\n" @@ -109,20 +103,20 @@ class ClaimBatchAcknowledgements( ), ), ] - wire_seq: Annotated[int, Column(primary_key=True, check="wire_seq > 0")] - message_id: Annotated[str, Column(check="length(message_id) BETWEEN 1 AND 256")] - exact_target: Annotated[str, Column(check="length(exact_target) BETWEEN 1 AND 256")] - envelope_digest: Annotated[str, Column(check="length(envelope_digest) = 64")] - audience_digest: Annotated[str, Column(check="length(audience_digest) = 64")] - decisions_digest: Annotated[str, Column(check="length(decisions_digest) = 64")] - member_count: Annotated[int, Column(check="member_count BETWEEN 0 AND 4096")] - claim_count: Annotated[int, Column(check="claim_count BETWEEN 0 AND member_count")] - manifest_codec: Annotated[str, Column(check="length(manifest_codec) BETWEEN 1 AND 256")] + wire_seq: Annotated[int, FrozenColumn(primary_key=True, check="wire_seq > 0")] + message_id: Annotated[str, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] + exact_target: Annotated[str, FrozenColumn(check="length(exact_target) BETWEEN 1 AND 256")] + envelope_digest: Annotated[str, FrozenColumn(check="length(envelope_digest) = 64")] + audience_digest: Annotated[str, FrozenColumn(check="length(audience_digest) = 64")] + decisions_digest: Annotated[str, FrozenColumn(check="length(decisions_digest) = 64")] + member_count: Annotated[int, FrozenColumn(check="member_count BETWEEN 0 AND 4096")] + claim_count: Annotated[int, FrozenColumn(check="claim_count BETWEEN 0 AND member_count")] + manifest_codec: Annotated[str, FrozenColumn(check="length(manifest_codec) BETWEEN 1 AND 256")] resolver_version: Annotated[ - str, Column(check="length(resolver_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(resolver_version) BETWEEN 1 AND 256") ] - policy_version: Annotated[str, Column(check="length(policy_version) BETWEEN 1 AND 256")] - accepted_at_ms: Annotated[int, Column(check="accepted_at_ms >= 0")] + policy_version: Annotated[str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256")] + accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] sealed: bool = False without_rowid = True @@ -247,7 +241,9 @@ def references(cls): ) @dataclass(frozen=True, kw_only=True) -class AwarenessClaimGenerations(OwnerRecord, GuardedInserts, ImmutableRows, AwarenessTable, TypedTable): +class AwarenessClaimGenerations( + OwnerRecord, GuardedInserts, ImmutableRows, AwarenessTable, TypedTable +): insert_condition = """(NOT EXISTS (SELECT 1 FROM claim_batch_members m JOIN wake_claims c ON c.assignment_id=m.claim_id JOIN claim_batch_receipts r diff --git a/src/agent_comms/coordination_cohort.py b/src/agent_comms/coordination_cohort.py index 1f10178f2e..86d2c8af73 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -85,23 +85,6 @@ def _expected_assignments( return tuple(result) -def _immutable_assignment_matches(current: WakeAssignment, expected: WakeAssignment) -> bool: - return current.lifecycle.mode == expected.lifecycle.mode and all( - getattr(current, field) == getattr(expected, field) - for field in ( - "assignment_id", - "recipient", - "recipient_lookup", - "wire_seq", - "message_id", - "audience", - "resolver_version", - "policy_version", - "accepted_at_ms", - ) - ) - - def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> AcceptedCohort: message = initial.message audience = initial.audience @@ -169,8 +152,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte if db_row is None: raise IdentityConflict("sealed cohort claim is missing") actual = db_row - if not _immutable_assignment_matches(actual, accepted): - raise IdentityConflict("sealed cohort claim immutable facts conflict") + accepted.require_same_acceptance(actual) current.append(actual) # A singleton claim for an explicitly no-wake observer cannot be accepted # as a K member or hidden by a valid sealed N acknowledgement. diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index b21d6a965e..a2511bfc6f 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -2,8 +2,8 @@ from __future__ import annotations -from typing import Annotated, ClassVar from dataclasses import dataclass, replace +from typing import Annotated, ClassVar from agent_comms.assignment_states import AssignmentState, EngagedAssignment from agent_comms.coordination_contracts import ( @@ -17,13 +17,13 @@ ) from agent_comms.coordination_schema import CoordinatorTable from agent_comms.envelope_claim_transitions import AssignmentBinding -from agent_comms.field_codec import projected, SnapshotExclude, WireName +from agent_comms.field_codec import SnapshotExclude, WireName, projected from agent_comms.message_reference import MessageReference from agent_comms.typed_table import ( Column, FirstValueState, ForeignKey, - FrozenColumns, + FrozenColumn, Generated, GuardedInserts, ImmutableRows, @@ -42,7 +42,19 @@ @dataclass(frozen=True, slots=True) -class WakeAssignment(WriteOnceColumns, FirstValueState, RequiredColumn, LifecycleTransitions, LifecycleChanges, IncrementingColumn, MonotoneColumns, FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="wake_claims"): +class WakeAssignment( + WriteOnceColumns, + FirstValueState, + RequiredColumn, + LifecycleTransitions, + LifecycleChanges, + IncrementingColumn, + MonotoneColumns, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="wake_claims", +): write_once_columns = ('triage_verdict', 'execution_id', 'exact_target') initial_value_column = 'execution_id' initial_value_state = EngagedAssignment @@ -51,8 +63,6 @@ class WakeAssignment(WriteOnceColumns, FirstValueState, RequiredColumn, Lifecycl column_orders = (NonDecreasing(('updated_at_ms',)),) - immutable_columns = ('assignment_id', 'recipient_lookup', 'wire_seq', 'message_id', 'recipient', 'audience', 'resolver_version', 'policy_version', 'accepted_at_ms', 'wake_mode') - @property def recipient_identity(self): from agent_comms.audience_manifest import FrozenRecipient @@ -131,22 +141,22 @@ def require_engaged_binding(self) -> AssignmentBinding: assignment_id: Annotated[ str, WireName("claim_id"), - Column(primary_key=True, check="length(assignment_id) BETWEEN 1 AND 256"), + FrozenColumn(primary_key=True, check="length(assignment_id) BETWEEN 1 AND 256"), ] - recipient: Annotated[str, Column(check="length(recipient) BETWEEN 1 AND 256")] - recipient_lookup: str - wire_seq: Annotated[int, Column(check="wire_seq > 0")] - message_id: Annotated[str, Column(check="length(message_id) BETWEEN 1 AND 256")] - audience: MessageAudience + recipient: Annotated[str, FrozenColumn(check="length(recipient) BETWEEN 1 AND 256")] + recipient_lookup: Annotated[str, FrozenColumn()] + wire_seq: Annotated[int, FrozenColumn(check="wire_seq > 0")] + message_id: Annotated[str, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] + audience: Annotated[MessageAudience, FrozenColumn()] lifecycle: Annotated[AssignmentState, SnapshotExclude()] - accepted_at_ms: Annotated[int, Column(check="accepted_at_ms >= 0")] + accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= accepted_at_ms")] revision: Annotated[int, Column(check="revision > 0")] = 1 resolver_version: Annotated[ - str, Column(check="length(resolver_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(resolver_version) BETWEEN 1 AND 256") ] = RESOLVER_VERSION policy_version: Annotated[ - str, Column(check="length(policy_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256") ] = POLICY_VERSION @projected(view="snapshot", name="disposition") @@ -204,7 +214,7 @@ def durable_key(self) -> tuple[str, int]: ) wake_mode: ClassVar[Generated] = Generated( str, - Column(generated="({assignment_mode})", check="wake_mode IN ({wake_names})"), + FrozenColumn(generated="({assignment_mode})", check="wake_mode IN ({wake_names})"), ) triage_verdict: ClassVar[Generated] = Generated( str, @@ -264,13 +274,17 @@ def references(cls): ) @dataclass(frozen=True, slots=True) -class ExecutionAssignmentLink(GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="execution_claims"): - - insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id AND e.status IN - ({unstarted_execution_names})) OR NEW.ordinal != (SELECT count(*) FROM execution_claims WHERE execution_id = - NEW.execution_id)) OR (NOT EXISTS ( SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id = - e.execution_id WHERE e.execution_id = NEW.execution_id AND c.assignment_id = NEW.assignment_id AND - c.exact_target IS NOT NULL AND c.recipient_lookup = e.owner_lookup ))""" +class ExecutionAssignmentLink( + GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="execution_claims" +): + + insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e + WHERE e.execution_id = NEW.execution_id AND e.status IN ({unstarted_execution_names})) + OR NEW.ordinal != (SELECT count(*) FROM execution_claims + WHERE execution_id = NEW.execution_id)) OR (NOT EXISTS ( + SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id = e.execution_id + WHERE e.execution_id = NEW.execution_id AND c.assignment_id = NEW.assignment_id + AND c.exact_target IS NOT NULL AND c.recipient_lookup = e.owner_lookup ))""" execution_id: Annotated[str, Column(primary_key=True)] assignment_id: Annotated[str, WireName("claim_id"), Column(unique=True)] diff --git a/src/agent_comms/coordination_tables/attempts.py b/src/agent_comms/coordination_tables/attempts.py index 3df0172056..098e3231fb 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -25,7 +25,7 @@ CounterDependent, FinalizedLifecycle, ForeignKey, - FrozenColumns, + FrozenColumn, Generated, GuardedInserts, IncrementingColumn, @@ -92,7 +92,6 @@ class AttemptRecord( MonotoneColumns, OwnerRecord, GuardedInserts, - FrozenColumns, NoDelete, CoordinatorTable, TypedTable, @@ -129,10 +128,6 @@ class AttemptRecord( AND a.attempt_ordinal = NEW.attempt_ordinal - 1 AND a.phase IN ({retry_attempt_phases}) AND a.backend_done = 1 AND a.process_dead = 1 AND NEW.owner_generation > a.owner_generation AND NEW.owner_token_digest != a.owner_token_digest))""" - immutable_columns = ( - "execution_id", "attempt_ordinal", "owner_lookup", "owner_thread", "owner_generation", - "owner_token_digest", "created_at_ms", - ) def require_attempt(self) -> AttemptRecord: return self @@ -181,15 +176,15 @@ def authority(self) -> AttemptAuthority: self.owner_token_digest, ) - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] - attempt_ordinal: Annotated[int, Column(primary_key=True, check="attempt_ordinal > 0")] - owner_lookup: str - owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] - owner_generation: Annotated[int, Column(check="owner_generation > 0")] + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] + attempt_ordinal: Annotated[int, FrozenColumn(primary_key=True, check="attempt_ordinal > 0")] + owner_lookup: Annotated[str, FrozenColumn()] + owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] + owner_generation: Annotated[int, FrozenColumn(check="owner_generation > 0")] owner_token_digest: Annotated[ str, SnapshotExclude(), - Column(unique=True, check="length(owner_token_digest) BETWEEN 1 AND 256"), + FrozenColumn(unique=True, check="length(owner_token_digest) BETWEEN 1 AND 256"), ] lifecycle: Annotated[AttemptState, SnapshotExclude()] revision: Annotated[int, Column(check="revision > 0")] @@ -199,7 +194,7 @@ def authority(self) -> AttemptAuthority: Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] @projected(view="snapshot", name="phase") diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index afe9fe8f78..469b52ae27 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 from abc import abstractmethod from dataclasses import dataclass, replace from enum import StrEnum @@ -35,7 +36,7 @@ Column, ColumnGuard, ForeignKey, - FrozenColumns, + FrozenColumn, Generated, GuardedAfterWrites, GuardedColumns, @@ -113,12 +114,11 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) class CurrentExecutions( - IncrementingColumn, GuardedInserts, FrozenColumns, NoDelete, CoordinatorTable, TypedTable + IncrementingColumn, GuardedInserts, NoDelete, CoordinatorTable, TypedTable ): revision_column = "pointer_revision" insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations WHERE owner_lookup = NEW.owner_lookup)""" - immutable_columns = ('owner_lookup',) @property def unresolved(self) -> bool: @@ -132,7 +132,7 @@ def require_idle(self) -> None: "selected owner has an unresolved execution; new claims remain pending" ) - owner_lookup: Annotated[str, Column(primary_key=True)] + owner_lookup: Annotated[str, FrozenColumn(primary_key=True)] execution_id: Annotated[PointerExecution, Column(storage=TextStorage, nullable=True)] attempt_ordinal: Annotated[PointerAttempt, Column(storage=IntegerStorage, nullable=True)] pointer_revision: Annotated[int, Column(check="pointer_revision >= 0")] @@ -193,16 +193,12 @@ class ExecutionRecord( IncrementingColumn, MonotoneColumns, GuardedColumns, - FrozenColumns, NoDelete, CoordinatorTable, TypedTable, declared_name="executions", ): column_orders = (NonDecreasing(("updated_at_ms",)),) - immutable_columns = ( - "execution_id", "origin", "owner_lookup", "owner_thread", "max_attempts", "created_at_ms", - ) column_guards = ( ColumnGuard(("lifecycle",), """(json_extract(NEW.lifecycle, '$.kind') IN ({optional_attempt_execution_names}) AND json_extract(NEW.lifecycle, '$.ordinal') @@ -247,7 +243,7 @@ def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> boo execution_id: Annotated[ str, - Column( + FrozenColumn( primary_key=True, check=( "\n" @@ -257,18 +253,18 @@ def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> boo ), ), ] - origin: ExecutionOrigin + origin: Annotated[ExecutionOrigin, FrozenColumn()] lifecycle: Annotated[ExecutionState, SnapshotExclude()] - owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] - owner_lookup: str + owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] + owner_lookup: Annotated[str, FrozenColumn()] revision: Annotated[int, Column(check="revision > 0")] - max_attempts: Annotated[int, Column(check="max_attempts > 0")] + max_attempts: Annotated[int, FrozenColumn(check="max_attempts > 0")] reason_code: Annotated[ ReasonCode, Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] @projected(view="snapshot", name="status") @@ -463,7 +459,6 @@ class ReplayAssessments( AccumulatingBits, ExecutionReference, RetryPartition, - FrozenColumns, NoDelete, CoordinatorTable, TypedTable, @@ -473,20 +468,21 @@ class ReplayAssessments( NonIncreasing(("replay_safe",)), ) accumulating_columns = ("facts",) - immutable_columns = ("execution_id",) @property def allows_retry(self) -> bool: return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] facts: Annotated[ReplayFact, Column(check="facts >= 0 AND facts <= 1023")] replay_safe: Annotated[bool, Column()] side_effects_possible: Annotated[bool, Column()] revision: Annotated[int, Column(check="revision > 0")] @classmethod - def accumulate(cls, db, execution_id: str, observed: ReplayFact) -> None: + def accumulate( + cls, db: sqlite3.Connection, execution_id: str, observed: ReplayFact + ) -> None: """Join original observations under the caller's existing attempt fence. Missing replay evidence cannot establish safety. Only an existing @@ -520,14 +516,14 @@ def accumulate(cls, db, execution_id: str, observed: ReplayFact) -> None: ) @classmethod - def authorize_if_unobserved(cls, db, execution_id: str) -> None: + def authorize_if_unobserved(cls, db: sqlite3.Connection, execution_id: str) -> None: """Record provider-failure proof only while no output fact exists.""" before = cls.one(db, execution_id=execution_id) if before is None: cls(execution_id, ReplayFact.NONE, True, False, 1).record(db, None) @classmethod - def revoke(cls, db, execution_id: str) -> None: + def revoke(cls, db: sqlite3.Connection, execution_id: str) -> None: """A nonretryable failure consumes any earlier provider-failure proof.""" before = cls.one(db, execution_id=execution_id) if before is not None and before.replay_safe: @@ -543,7 +539,7 @@ def require_successor(self, after: ReplayAssessments) -> None: if accumulated != after: raise IdentityConflict("replay facts cannot be erased") - def record(self, db, before: ReplayAssessments | None) -> bool: + def record(self, db: sqlite3.Connection, before: ReplayAssessments | None) -> bool: """Persist monotonic replay evidence inside the checked transaction.""" if before is not None: @@ -567,7 +563,6 @@ def __post_init__(self) -> None: validate_execution_id(self.execution_id) if self.revision <= 0: raise ValueError("replay revision must be positive") - object.__setattr__(self, "facts", ReplayFact(self.facts)) if int(self.facts) < 0 or int(self.facts) & ~APPROVED_REPLAY_FACT_MASK: raise ValueError("unrecognized replay fact bits") if self.facts != ReplayFact.NONE and self.replay_safe: diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index f5d974f4a0..9c9e4951f0 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -13,7 +13,7 @@ ColumnGuard, CounterAdvance, ForeignKey, - FrozenColumns, + FrozenColumn, GuardedColumns, ImmutableRows, MonotoneColumns, @@ -69,14 +69,12 @@ def references(cls): @dataclass(frozen=True, kw_only=True) class OwnerGenerations( - CounterAdvance, NoDelete, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable + CounterAdvance, NoDelete, ParticipantIdentity, CoordinatorTable, TypedTable ): counter_column = 'generation' - immutable_columns = ('owner_lookup',) - - owner_lookup: Annotated[str, Column(primary_key=True)] + owner_lookup: Annotated[str, FrozenColumn(primary_key=True)] owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] generation: Annotated[int, Column(check="generation > 0")] diff --git a/src/agent_comms/coordination_tables/recovery.py b/src/agent_comms/coordination_tables/recovery.py index ef1189ed0c..d479a713ee 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -19,7 +19,7 @@ from agent_comms.typed_table import ( Column, ForeignKey, - FrozenColumns, + FrozenColumn, ImmutableRows, IncrementingColumn, Index, @@ -49,7 +49,6 @@ class ConnectivityFacet( IncrementingColumn, MonotoneColumns, ExecutionReference, - FrozenColumns, NoDelete, CoordinatorTable, TypedTable, @@ -58,9 +57,7 @@ class ConnectivityFacet( column_orders = (NonDecreasing(('observed_at_ms',)),) - immutable_columns = ('execution_id',) - - execution_id: Annotated[str, field_codec.SnapshotExclude(), Column(primary_key=True)] + execution_id: Annotated[str, field_codec.SnapshotExclude(), FrozenColumn(primary_key=True)] owner: OwnerConnectivity acp_client: ACPClientConnectivity revision: Annotated[int, Column(check="revision>0")] diff --git a/src/agent_comms/coordination_tables/responses.py b/src/agent_comms/coordination_tables/responses.py index af24423cfc..28fff5a566 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -19,7 +19,7 @@ from agent_comms.typed_table import ( Column, ColumnGuard, - FrozenColumns, + FrozenColumn, Generated, GuardedColumns, GuardedInserts, @@ -44,7 +44,6 @@ class ResponseObligation( ExecutionReference, RetryPartition, GuardedUpdateMembership, - FrozenColumns, GuardedColumns, GuardedInserts, NoDelete, @@ -55,7 +54,6 @@ class ResponseObligation( """One original reply-route obligation within an execution.""" column_orders = (NonDecreasing(("updated_at_ms",)),) - immutable_columns = ("execution_id", "exact_target", "created_at_ms") update_membership_condition = """(json_extract(NEW.lifecycle, '$.kind') IN ({required_intent_response_names})) AND ((NOT EXISTS (SELECT 1 FROM publication_intents WHERE execution_id = NEW.execution_id AND exact_target = NEW.exact_target)) @@ -87,9 +85,9 @@ class ResponseObligation( def response_record(records, exact_target): return next((row for row in records if row.exact_target == exact_target), None) - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] exact_target: Annotated[ - str, Column(primary_key=True, check="length(exact_target) BETWEEN 1 AND 256") + str, FrozenColumn(primary_key=True, check="length(exact_target) BETWEEN 1 AND 256") ] lifecycle: Annotated[ResponseState, SnapshotExclude()] reason_code: Annotated[ @@ -97,7 +95,7 @@ def response_record(records, exact_target): Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] revision: Annotated[int, Column(check="revision > 0")] diff --git a/src/agent_comms/native_runtime_input.py b/src/agent_comms/native_runtime_input.py index c199cf805b..3f767869f0 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -41,7 +41,7 @@ Column, DurableTables, FinalizedColumn, - FrozenColumns, + FrozenColumn, IntegerStorage, MonotoneColumns, NoDelete, @@ -93,17 +93,12 @@ class NativeRuntimeInput( WriteOnceColumns, FinalizedColumn, RequiredUpdateColumns, - FrozenColumns, NoDelete, NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable, ): - immutable_columns = ( - "input_id", "stage", "execution_id", "attempt_ordinal", "owner_lookup", "owner_thread", - "owner_generation", "owner_token_digest", - ) write_once_columns = ('sent_owner_claimed_generation', 'session_id', 'session_file') finalized_column = 'session_entry_id' required_update_columns = ('sent_owner_claimed_generation',) @@ -240,20 +235,24 @@ def published_replies(self, db, user, owner_lookup): input_id: Annotated[ str, - Column(primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'"), + FrozenColumn( + primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'" + ), ] stage: Annotated[ - type[NativeInputExecution], Column(check=NativeInputExecution.sql_constraint("stage")) + type[NativeInputExecution], FrozenColumn(check=NativeInputExecution.sql_constraint("stage")) ] execution_id: Annotated[ NativeColumn, - Column(references=(ExecutionRecord, "execution_id"), storage=TextStorage, nullable=True), + FrozenColumn( + references=(ExecutionRecord, "execution_id"), storage=TextStorage, nullable=True + ), ] - attempt_ordinal: Annotated[NativeColumn, Column(storage=IntegerStorage, nullable=True)] - owner_lookup: Annotated[str, Column(references=(Participants, "participant_lookup"))] - owner_thread: str - owner_generation: Annotated[int, Column(check="owner_generation>0")] - owner_token_digest: Annotated[str, Column(check="length(owner_token_digest)=64")] + attempt_ordinal: Annotated[NativeColumn, FrozenColumn(storage=IntegerStorage, nullable=True)] + owner_lookup: Annotated[str, FrozenColumn(references=(Participants, "participant_lookup"))] + owner_thread: Annotated[str, FrozenColumn()] + owner_generation: Annotated[int, FrozenColumn(check="owner_generation>0")] + owner_token_digest: Annotated[str, FrozenColumn(check="length(owner_token_digest)=64")] sent_owner_claimed_generation: Annotated[ NativeClaimEpoch, WireName("sent_owner_admission_generation"), @@ -331,17 +330,12 @@ def commit_context( class CurrentNativeCursor( MonotoneColumns, StableReferenceColumns, - FrozenColumns, NoDelete, NativeInputContext, NativeRuntimeTable, TypedTable, ): column_orders = (NonDecreasing(("covered_seq", "injected_seq")),) - immutable_columns = ( - "wire_root_id", "recipient_lookup", "owner_thread", "owner_generation", - "owner_claimed_generation", - ) reference_column = 'injected_seq' stable_reference_columns = ('input_id',) @@ -351,20 +345,20 @@ def context_anchor(self) -> NativeColumn: wire_root_id: Annotated[ str, - Column( + FrozenColumn( primary_key=True, check="length(wire_root_id)=32 AND wire_root_id NOT GLOB '*[^0-9a-f]*'", ), ] recipient_lookup: Annotated[ - str, Column(primary_key=True, references=(Participants, "participant_lookup")) + str, FrozenColumn(primary_key=True, references=(Participants, "participant_lookup")) ] - owner_thread: Annotated[str, Column(check="owner_thread<>''")] - owner_generation: Annotated[int, Column(primary_key=True, check="owner_generation>0")] + owner_thread: Annotated[str, FrozenColumn(check="owner_thread<>''")] + owner_generation: Annotated[int, FrozenColumn(primary_key=True, check="owner_generation>0")] owner_claimed_generation: Annotated[ int, WireName("owner_admission_generation"), - Column(record_key=True, primary_key=True, check="{owner_claimed_generation}>0"), + FrozenColumn(record_key=True, primary_key=True, check="{owner_claimed_generation}>0"), ] @property diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 48f803a472..e7f900d397 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -288,6 +288,9 @@ def declared(cls, annotation: object) -> Column: return column return cls() + def immutable_fields(self, field: _Field) -> tuple[_Field, ...]: + return () + @property def writable(self) -> bool: return self.generated is None @@ -297,6 +300,13 @@ def generated_sql(self) -> str: return "" if self.writable else f" GENERATED ALWAYS AS ({self.generated}) STORED" +class FrozenColumn(Column): + """Immutability is declared with the field's other SQL constraints.""" + + def immutable_fields(self, field: _Field) -> tuple[_Field, ...]: + return (field,) + + _NAME_SEPARATORS = re.compile(r"\W+") @@ -740,11 +750,18 @@ def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ... class FrozenColumns(MutationCapability): - immutable_columns: ClassVar[tuple[str, ...]] + @classmethod + def immutable_columns(cls, row: type[TypedTable]) -> tuple[_Field, ...]: + return tuple( + immutable for field in row._columns() + for immutable in field.column.immutable_fields(field) + ) @classmethod def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: - columns = row._fields_named(row.immutable_columns) + columns = cls.immutable_columns(row) + if not columns: + return () condition = " OR ".join( f'NEW."{column.wire_name}" IS NOT OLD."{column.wire_name}"' for column in columns) return (RowMutation.UPDATE.abort_trigger( @@ -1163,7 +1180,9 @@ class SQLiteUserVersion(TypedRow): user_version: int -class TypedTable(TypedRow, DeclaredFamily, affix="Row", discovery_package="agent_comms"): +class TypedTable( + FrozenColumns, TypedRow, DeclaredFamily, affix="Row", discovery_package="agent_comms" +): """The row declaration owns its table; no separate table/schema registry. Fields declare SQL capabilities with ``Annotated[T, Column(...)]``. Composite keys derive diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py index 77adc1dc15..a1d68bd634 100644 --- a/tests/test_table_mutation_capabilities.py +++ b/tests/test_table_mutation_capabilities.py @@ -1,4 +1,5 @@ """Raw SQLite mutations exercise the declaration-derived capability family.""" +import sqlite3 from contextlib import closing from dataclasses import dataclass from typing import Annotated, ClassVar @@ -7,34 +8,58 @@ from agent_comms.coordination_database import CoordinationStore from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import OwnerGenerations, Participants from agent_comms.coordination_tables.executions import ExecutionRecord +from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.declared_family import DeclaredFamily from agent_comms.execution_states import ( - ActiveExecution, CompletedExecution, DeferredExecution, ExecutionState, PendingExecution, + ActiveExecution, + CompletedExecution, + DeferredExecution, + ExecutionState, + PendingExecution, ) -from agent_comms.declared_family import DeclaredFamily from agent_comms.lifecycle import LifecycleState from agent_comms.typed_table import ( - AccumulatingBits, Column, ColumnGuard, FinalizedColumn, FinalizedLifecycle, FirstValueState, - FrozenColumns, Generated, GuardedAfterWrites, GuardedColumns, GuardedInserts, GuardedRelatedInserts, - GuardedUpdateMembership, InitialOneWayColumn, - ImmutableRows, IncrementingColumn, LifecycleChanges, - LifecycleTransitions, MonotoneColumns, NonDecreasing, NonDecreasingWhenPresent, - NonIncreasing, OrdinalTransitions, RequiredColumn, RequiredUpdateColumns, StableReferenceColumns, TypedTable, - ValidatedInserts, WriteOnceColumns, + AccumulatingBits, + Column, + ColumnGuard, + FinalizedColumn, + FinalizedLifecycle, + FirstValueState, + FrozenColumn, + Generated, + GuardedAfterWrites, + GuardedColumns, + GuardedInserts, + GuardedRelatedInserts, + GuardedUpdateMembership, + ImmutableRows, + IncrementingColumn, + InitialOneWayColumn, + LifecycleChanges, + LifecycleTransitions, + MonotoneColumns, + NonDecreasing, + NonDecreasingWhenPresent, + NonIncreasing, + OrdinalTransitions, + RequiredColumn, + RequiredUpdateColumns, + StableReferenceColumns, + TypedTable, + ValidatedInserts, + WriteOnceColumns, ) -import sqlite3 @dataclass(frozen=True) -class OrderedRow(IncrementingColumn, FrozenColumns, MonotoneColumns, AccumulatingBits, TypedTable): - identity: Annotated[int, Column(primary_key=True)] +class OrderedRow(IncrementingColumn, MonotoneColumns, AccumulatingBits, TypedTable): + identity: Annotated[int, FrozenColumn(primary_key=True)] revision: int rising: int falling: int bits: int optional: int | None - immutable_columns = ("identity",) accumulating_columns = ("bits",) column_orders = ( NonDecreasing(("rising",)), NonIncreasing(("falling",)), @@ -42,6 +67,14 @@ class OrderedRow(IncrementingColumn, FrozenColumns, MonotoneColumns, Accumulatin ) +@dataclass(frozen=True) +class ComputedRow(OrderedRow): + offset: int = 0 + parity: ClassVar[Generated] = Generated( + int, FrozenColumn(generated="(rising + offset) % 2") + ) + + @dataclass(frozen=True) class SealedRow(InitialOneWayColumn, TypedTable): sealed: int @@ -156,7 +189,7 @@ class ReferencedRow(WriteOnceColumns, FirstValueState, RequiredColumn, TypedTabl @pytest.fixture def db(): with closing(CoordinationStore.writer(":memory:", isolation_level=None)) as connection: - for row in (OrderedRow, SealedRow, FrozenRow, GuardedRow, PhasedRow, ChangingRow, + for row in (OrderedRow, ComputedRow, SealedRow, FrozenRow, GuardedRow, PhasedRow, ChangingRow, ReferencedRow, InsertCheckedRow, RelatedGuardRow, AfterWriteRow, ValidatedRow, OrdinalRow, ProofRow, PointerRow): row.create(connection) @@ -181,6 +214,16 @@ def test_ordered_columns_accept_progress_and_optional_retirement(db): assert OrderedRow.select(db) == [OrderedRow(1, 2, 11, 9, 3, None)] +def test_frozen_fields_include_inherited_and_generated_columns(db): + original = ComputedRow(1, 1, 10, 10, 1, 10) + original.insert(db) + for changes in ({"identity": 2}, {"rising": 11}, {"offset": 1}): + with pytest.raises(sqlite3.IntegrityError): + ComputedRow.update(db, where="identity=1", **({"revision": 2} | changes)) + ComputedRow.update(db, where="identity=1", revision=2, rising=12, offset=2) + assert ComputedRow.select(db) == [ComputedRow(1, 2, 12, 10, 1, 10, 2)] + + def test_one_way_column_consumes_the_declared_transition(db): with pytest.raises(sqlite3.IntegrityError): SealedRow(1).insert(db) From 821f3363b6c40762fe71ea234da4cd7a0a030dcd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 17:53:05 -0400 Subject: [PATCH 12/18] Refresh native schema pin after table and lifecycle integration --- stack/pi-native.sha256 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stack/pi-native.sha256 b/stack/pi-native.sha256 index 499333f7ce..39aceb56b7 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -22,7 +22,7 @@ fef47246f4ce18621bae844a940e7d009cefc7df7cdf00ccb98198d9c407a696 dist/core/sess 4fa4e9a0ba80f43b560eb827dbd0985ea56624170b51401b39398d76ffcbcefa dist/core/export-html/index.js d7ad93bcb341424fa79014ce2fec4226326cfab1e8d5fee27c1859138e120161 dist/core/sdk.js 37ae95f1f0ca7c88a75f3c83101e3d7b621c15afced8ac8ec287f958f150cb30 dist/core/native-proof-journal.js -57ea5e66bfa8452d4df018d067c92cdb0c58c068d8d7be1fafac66cff02bfd88 dist/core/native-proof-schema.js +14554a57b1c11f295b30298d3321ba14a399278e4e7191433fc88325bd27a7e4 dist/core/native-proof-schema.js aae7f609bfa65d29ed16e311456921a88221622e08cf6a1f5c7aab8e58bd69e6 node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js bd6cfc604781d55db4dc34a0463544c1f323611429ac8d5fc5ebde31642132fb agent-comms-extensions/global-agent-comms/index.mjs 7162c92d37ec9d070f918ce05c4be8c8c191cf3e1559351d56c4cd4e6e050dba dist/agent-comms-imports.json @@ -56,5 +56,5 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 8e8276bbabcd898136d431345611928626fad6b6f03ac572f1d8506e6ece326f dist/core/system-prompt.d.ts 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 -# agent-comms-native-tree-v1 e7e7518c79e83968156dd5ef33f4de320ab842d9950360800bd9d47061229db8 +# agent-comms-native-tree-v1 b9afdff3cb188915848d31359cd22e5cd6c81d9f7f973d707f40e915b15505ba # agent-comms-native-inputs-v1 fa2384dd95207ef775033d5dfa49ad8a9a6ff71d6e3e2c5fbd0f286be07e3d1a From d3869c1f9d8d1f53ee1f9a60a07c11318e5f639d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 18:54:38 -0400 Subject: [PATCH 13/18] Let replay states own missing and recorded assessments --- src/agent_comms/attempt_store.py | 18 +- src/agent_comms/coordination_snapshot.py | 5 +- .../coordination_tables/executions.py | 157 ++++++++++++++---- src/agent_comms/recovery_reader.py | 2 +- stack/pi-native.sha256 | 2 +- tests/test_coordination_store.py | 74 +++++++-- tests/test_lifecycle_subclasses.py | 2 +- tests/test_native_unknown_recovery.py | 17 +- 8 files changed, 210 insertions(+), 67 deletions(-) diff --git a/src/agent_comms/attempt_store.py b/src/agent_comms/attempt_store.py index caaa3a16f5..9d2a5eb989 100644 --- a/src/agent_comms/attempt_store.py +++ b/src/agent_comms/attempt_store.py @@ -19,7 +19,6 @@ from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact -from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.recovery import ( ACPClientConnectivity, ConnectivityFacet, @@ -180,18 +179,13 @@ def observe_replay( if snapshot.pointer_revision != expected_pointer_revision: raise StaleRevision("pointer revision changed") before = snapshot.replay - if (before.revision if before else None) != expected_replay_revision: - raise StaleRevision("replay assessment revision changed") - after = ReplayAssessments( - fence.execution_id, - facts, - replay_safe, - side_effects_possible, - 1 if before is None else before.revision + 1, - ) - if not after.record(self.session._connection, snapshot.replay): + before.require_revision(expected_replay_revision) + if not before.observe( + self.session._connection, fence.execution_id, facts=facts, + replay_safe=replay_safe, side_effects_possible=side_effects_possible, + ): return AlreadyApplied(snapshot) - return Applied(self.snapshots.get(after.execution_id)) + return Applied(self.snapshots.get(fence.execution_id)) def fail_unknown( self, diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index 2ea06c1a4d..f150e81463 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -29,6 +29,7 @@ NoPointedExecution, PointerAttempt, PointerExecution, + ReplayAssessment, ReplayAssessments, ) from agent_comms.coordination_tables.publications import PublicationEvidence, PublicationIntents @@ -52,7 +53,7 @@ class RecoverySnapshot: links: Annotated[ tuple[ExecutionAssignmentLink, ...], SnapshotName("execution_claims") ] - replay: ReplayAssessments | None + replay: ReplayAssessment obligations: tuple[ResponseObligation, ...] publication_intents: tuple[PublicationIntents, ...] publication_evidence: Annotated[ @@ -144,8 +145,8 @@ def __post_init__(self) -> None: def validate_membership(self) -> None: execution = self.execution execution_id = execution.execution_id + self.replay.require_execution(execution_id) related = ( - self.replay, *self.obligations, *self.publication_intents, *self.publication_evidence, diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 469b52ae27..2027da2355 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -3,7 +3,7 @@ from __future__ import annotations import sqlite3 -from abc import abstractmethod +from abc import ABC, abstractmethod from dataclasses import dataclass, replace from enum import StrEnum from typing import Annotated, ClassVar @@ -14,7 +14,7 @@ require_nonempty, validate_execution_id, ) -from agent_comms.coordination_errors import IdentityConflict, IntegrityViolationError +from agent_comms.coordination_errors import IdentityConflict, IntegrityViolationError, StaleRevision from agent_comms.coordination_schema import CoordinatorTable from agent_comms.coordination_tables.attempts import ( APPROVED_REPLAY_FACT_MASK, @@ -23,6 +23,7 @@ ) from agent_comms.execution_states import ExecutionState from agent_comms.field_codec import ( + MalformedRecord, NullMember, SingleFieldValue, SnapshotExclude, @@ -229,9 +230,7 @@ def schema_objects(cls, *, if_not_exists: bool = False): return super().schema_objects(if_not_exists=if_not_exists) | { cls.retry_view: f"CREATE VIEW {existence}{cls.retry_view} AS\n" + _RETRY_BASIS} - def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> bool: - if replay is None: - return False + def retry_authorized(self, replay: ReplayState, obligations) -> bool: return ( self.lifecycle.has_retry_budget(self.max_attempts) and replay.allows_retry @@ -452,8 +451,85 @@ class RetryPartition(GuardedAfterWrites): AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal IS NOT NULL)""" +class ReplayState(ABC): + """An original replay assessment, or the state before one has been recorded.""" + + @property + @abstractmethod + def allows_retry(self) -> bool: ... + + @property + @abstractmethod + def next_revision(self) -> int: ... + + @abstractmethod + def require_revision(self, expected: object) -> None: ... + + @abstractmethod + def require_execution(self, execution_id: str) -> None: ... + + @abstractmethod + def require_assessment(self) -> ReplayAssessments: ... + + @abstractmethod + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: ... + + @abstractmethod + def revoke_retry(self, db: sqlite3.Connection) -> None: ... + + @abstractmethod + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: ... + + def observe( + self, db: sqlite3.Connection, execution_id: str, *, facts: ReplayFact, + replay_safe: bool, side_effects_possible: bool, + ) -> bool: + self.require_execution(execution_id) + after = ReplayAssessments( + execution_id, facts, replay_safe, side_effects_possible, self.next_revision + ) + return self.record(db, after) + + +@dataclass(frozen=True, slots=True) +class NoReplayAssessment(NullMember, ReplayState): + @property + def allows_retry(self) -> bool: + return False + + @property + def next_revision(self) -> int: + return 1 + + def require_revision(self, expected: object) -> None: + try: + type(self).from_wire(expected) + except MalformedRecord as error: + raise StaleRevision("replay assessment revision changed") from error + + def require_execution(self, execution_id: str) -> None: + """No recorded assessment claims an execution identity.""" + + def require_assessment(self) -> ReplayAssessments: + raise IntegrityViolationError("replay assessment is absent") + + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: + self.observe( + db, execution_id, facts=ReplayFact.NONE, + replay_safe=True, side_effects_possible=False, + ) + + def revoke_retry(self, db: sqlite3.Connection) -> None: + """An absent assessment has granted nothing to revoke.""" + + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: + after.insert(db) + return True + + @dataclass(frozen=True, slots=True) class ReplayAssessments( + ReplayState, IncrementingColumn, MonotoneColumns, AccumulatingBits, @@ -515,19 +591,41 @@ def accumulate( values, ) + @classmethod + def for_execution(cls, db: sqlite3.Connection, execution_id: str) -> ReplayAssessment: + return next(iter(cls._keyed(db, dict(execution_id=execution_id))), NoReplayAssessment()) + @classmethod def authorize_if_unobserved(cls, db: sqlite3.Connection, execution_id: str) -> None: """Record provider-failure proof only while no output fact exists.""" - before = cls.one(db, execution_id=execution_id) - if before is None: - cls(execution_id, ReplayFact.NONE, True, False, 1).record(db, None) + cls.for_execution(db, execution_id).authorize_retry(db, execution_id) @classmethod def revoke(cls, db: sqlite3.Connection, execution_id: str) -> None: """A nonretryable failure consumes any earlier provider-failure proof.""" - before = cls.one(db, execution_id=execution_id) - if before is not None and before.replay_safe: - replace(before, replay_safe=False, revision=before.revision + 1).record(db, before) + cls.for_execution(db, execution_id).revoke_retry(db) + + @property + def next_revision(self) -> int: + return self.revision + 1 + + def require_revision(self, expected: object) -> None: + if expected != self.revision: + raise StaleRevision("replay assessment revision changed") + + def require_execution(self, execution_id: str) -> None: + if self.execution_id != execution_id: + raise IntegrityViolationError("snapshot records belong to another execution") + + def require_assessment(self) -> ReplayAssessments: + return self + + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: + """An existing assessment cannot be authorized by a later provider failure.""" + + def revoke_retry(self, db: sqlite3.Connection) -> None: + if self.replay_safe: + self.record(db, replace(self, replay_safe=False, revision=self.next_revision)) def require_successor(self, after: ReplayAssessments) -> None: accumulated = replace( @@ -539,24 +637,21 @@ def require_successor(self, after: ReplayAssessments) -> None: if accumulated != after: raise IdentityConflict("replay facts cannot be erased") - def record(self, db: sqlite3.Connection, before: ReplayAssessments | None) -> bool: - """Persist monotonic replay evidence inside the checked transaction.""" - - if before is not None: - if replace(before, revision=self.revision) == self: - return False - before.require_successor(self) - ReplayAssessments.update( - db, - where="execution_id=?", - parameters=(self.execution_id,), - facts=self.facts, - replay_safe=self.replay_safe, - side_effects_possible=self.side_effects_possible, - revision=before.revision + 1, - ) - else: - self.insert(db) + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: + """The original recorded state validates and persists its successor.""" + after.require_execution(self.execution_id) + if replace(self, revision=after.revision) == after: + return False + self.require_successor(after) + ReplayAssessments.update( + db, + where="execution_id=?", + parameters=(self.execution_id,), + facts=after.facts, + replay_safe=after.replay_safe, + side_effects_possible=after.side_effects_possible, + revision=self.next_revision, + ) return True def __post_init__(self) -> None: @@ -582,3 +677,7 @@ def __post_init__(self) -> None: checks = ("facts = 0 OR replay_safe = 0",) unique = (("execution_id", "retry_authorized"),) + + +# The fixed external snapshot format is still a row object or JSON null. +type ReplayAssessment = ReplayAssessments | NoReplayAssessment diff --git a/src/agent_comms/recovery_reader.py b/src/agent_comms/recovery_reader.py index a7b32f1543..6c018376d2 100644 --- a/src/agent_comms/recovery_reader.py +++ b/src/agent_comms/recovery_reader.py @@ -53,7 +53,7 @@ def get(self, execution_id: str) -> RecoverySnapshot: ) ) assignments = tuple(self.assignments.get(link.assignment_id) for link in links) - replay = ReplayAssessments.one(db, execution_id=execution_id) + replay = ReplayAssessments.for_execution(db, execution_id) obligations = tuple(ResponseObligation.select(db, where="execution_id=?", parameters=(execution_id,), order_by=("exact_target",))) intents = tuple(PublicationIntents.select(db, where="execution_id=?", diff --git a/stack/pi-native.sha256 b/stack/pi-native.sha256 index 913946f258..81add0946a 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -56,5 +56,5 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 8e8276bbabcd898136d431345611928626fad6b6f03ac572f1d8506e6ece326f dist/core/system-prompt.d.ts 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 -# agent-comms-native-tree-v1 06e079a85db1a882d017749041c3f2b28a366b1caa7f8a82ba2d307fc582d7d4 +# agent-comms-native-tree-v1 e0c899f88db25438981bb373aaa4861ea9aa4091a16a2bd8c8d61803a1c2569f # agent-comms-native-inputs-v1 6da7f1219c76045199dd98873e19b9bcbed4ee69085603a6c400f3ec136879fa diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index a62b4901f6..41e84dd2a6 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -36,6 +36,7 @@ from agent_comms.attempt_start import INITIAL_LEASE_DURATION_MS from agent_comms.coordination_errors import ( IdentityConflict, + IntegrityViolationError, PublicationUncertain, RecoveryBlocked, StaleFence, @@ -44,7 +45,11 @@ from agent_comms.coordination_results import AlreadyApplied, Applied from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordination_tables.attempts import ReplayFact -from agent_comms.coordination_tables.executions import ReplayAssessments +from agent_comms.coordination_tables.executions import ( + NoReplayAssessment, + ReplayAssessment, + ReplayAssessments, +) from agent_comms.coordination_tables.executions import ExecutionOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, @@ -629,9 +634,9 @@ def test_provider_failure_after_tool_start_is_terminal(db_path: Path) -> None: ) assert type(outcome.value.execution.lifecycle) is FailedExecution assert not outcome.value.can_retry - assert outcome.value.replay is not None - assert outcome.value.replay.facts == ReplayFact.TOOL_CALL_STARTED - assert outcome.value.replay.side_effects_possible + replay = outcome.value.replay.require_assessment() + assert replay.facts == ReplayFact.TOOL_CALL_STARTED + assert replay.side_effects_possible @pytest.mark.parametrize("first_fact", ["process_dead", "backend_done"]) @@ -700,8 +705,8 @@ def test_provider_proof_carries_until_later_attempt_records_output( reason_code=GivenReasonCode("provider_failed"), failure=ProviderRetryExhausted("controlled provider failure"), ) - proof = deferred.value.replay - assert deferred.value.can_retry and proof is not None and proof.allows_retry + proof = deferred.value.replay.require_assessment() + assert deferred.value.can_retry and proof.allows_retry db.participants.advance_generation("owner", "thread", expected_generation=1) second = db.attempts.start( AttemptStart( @@ -717,8 +722,7 @@ def test_provider_proof_carries_until_later_attempt_records_output( assert second.value.snapshot.replay == proof with db.session.writing() as connection: ReplayAssessments.accumulate(connection, "exec", fact) - revoked = db.snapshots.get("exec").replay - assert revoked is not None + revoked = db.snapshots.get("exec").replay.require_assessment() assert revoked.facts == fact and not revoked.allows_retry second_fence = final_evidence(db, second.value.fence, pointer_revision=3) @@ -734,18 +738,61 @@ def test_provider_proof_carries_until_later_attempt_records_output( assert outcome.value.replay == revoked +def test_replay_states_own_presence_revision_and_retry_authorization(db_path: Path) -> None: + with ready(db_path) as store, store.session.writing() as connection: + absent = store.snapshots.get("exec").replay + assert absent == NoReplayAssessment() + assert FieldCodec.encode(absent, ReplayAssessment) is None + assert FieldCodec.decode(ReplayAssessment, None) == absent + snapshot = store.snapshots.get("exec") + encoded = FieldCodec.encode(snapshot) + assert encoded["replay"] is None + assert FieldCodec.decode(RecoverySnapshot, encoded) == snapshot + absent.require_revision(None) + assert not absent.allows_retry + with pytest.raises(IntegrityViolationError): + absent.require_assessment() + with pytest.raises(StaleRevision): + absent.require_revision(1) + absent.revoke_retry(connection) + assert ReplayAssessments.count(connection) == 0 + + absent.authorize_retry(connection, "exec") + recorded = ReplayAssessments.for_execution(connection, "exec").require_assessment() + assert recorded.allows_retry and recorded.revision == 1 + assert FieldCodec.decode(ReplayAssessment, FieldCodec.encode(recorded)) == recorded + recorded.require_revision(1) + with pytest.raises(StaleRevision): + recorded.require_revision(None) + with pytest.raises(IntegrityViolationError): + recorded.require_execution("another-execution") + changes = connection.total_changes + ReplayAssessments.authorize_if_unobserved(connection, "exec") + assert not recorded.observe( + connection, "exec", facts=ReplayFact.NONE, + replay_safe=True, side_effects_possible=False, + ) + assert connection.total_changes == changes + ReplayAssessments.revoke(connection, "exec") + revoked = ReplayAssessments.for_execution(connection, "exec").require_assessment() + assert not revoked.allows_retry and revoked.revision == 2 + changes = connection.total_changes + ReplayAssessments.revoke(connection, "exec") + assert connection.total_changes == changes + + def test_repeated_replay_fact_does_not_rewrite_its_durable_assessment(db_path: Path) -> None: with ready(db_path) as db: connection = db.session._connection ReplayAssessments.accumulate(connection, "exec", ReplayFact.ASSISTANT_OUTPUT) - before = ReplayAssessments.one(connection, execution_id="exec") + before = ReplayAssessments.for_execution(connection, "exec").require_assessment() changes = connection.total_changes for _ in range(100): ReplayAssessments.accumulate(connection, "exec", ReplayFact.ASSISTANT_OUTPUT) assert connection.total_changes == changes - assert ReplayAssessments.one(connection, execution_id="exec") == before + assert ReplayAssessments.for_execution(connection, "exec") == before ReplayAssessments.accumulate(connection, "exec", ReplayFact.THINKING_OUTPUT) - after = ReplayAssessments.one(connection, execution_id="exec") + after = ReplayAssessments.for_execution(connection, "exec").require_assessment() assert after.facts == ReplayFact.ASSISTANT_OUTPUT | ReplayFact.THINKING_OUTPUT assert after.revision == before.revision + 1 assert not after.allows_retry @@ -821,8 +868,7 @@ def test_ambiguity_forces_failed_partition(db_path: Path) -> None: ) assert type(settled.value.execution.lifecycle) is FailedExecution assert not settled.value.can_retry - assert settled.value.replay is not None - assert settled.value.replay.facts == ReplayFact.TOOL_EXECUTED + assert settled.value.replay.require_assessment().facts == ReplayFact.TOOL_EXECUTED def test_fenced_connectivity_audit_and_monotone_replay(db_path: Path) -> None: @@ -881,7 +927,7 @@ def test_fenced_connectivity_audit_and_monotone_replay(db_path: Path) -> None: replay_safe=True, side_effects_possible=False, ) - assert db.snapshots.get("exec").replay.facts == ReplayFact.TOOL_EXECUTED + assert db.snapshots.get("exec").replay.require_assessment().facts == ReplayFact.TOOL_EXECUTED def test_recovered_audit_requires_same_attempt_new_incident_and_resumption(db_path: Path) -> None: diff --git a/tests/test_lifecycle_subclasses.py b/tests/test_lifecycle_subclasses.py index d3a0aa6c73..ede3a3664c 100644 --- a/tests/test_lifecycle_subclasses.py +++ b/tests/test_lifecycle_subclasses.py @@ -82,7 +82,7 @@ def test_unknown_settles_failed_in_current_store(tmp_path, authorized): outcome = store.attempts.fail_unknown(fence, expected_pointer_revision=1).value assert type(outcome.attempt.lifecycle) is UnknownOutcomeAttempt assert type(outcome.execution.lifecycle) is FailedExecution - assert outcome.replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert outcome.replay.require_assessment().facts & ReplayFact.UNKNOWN_EFFECTS assert not outcome.can_retry diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 99dc4ee6e1..04fdd63b2c 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -111,8 +111,9 @@ def recover_starting_owner(released_starting): after.attempt.lifecycle.require(Failed, ValueError("failure required")) assert not after.is_current assert after.attempt.reason_code == GivenReasonCode("released_native_unknown") - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not after.replay.replay_safe + replay = after.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert not CurrentExecutions.require(store.session._connection, owner_lookup=owner.incarnation.lookup).unresolved assert input_evidence(store) == inputs @@ -298,8 +299,9 @@ def test_stop_of_previously_released_dead_owner_settles_without_replay(released_ snapshot = store.snapshots.get(execution_id) assert not snapshot.is_current assert type(snapshot.execution.lifecycle) is FailedExecution - assert snapshot.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not snapshot.replay.replay_safe + replay = snapshot.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert input_id in str(before_input) @@ -382,8 +384,9 @@ def test_dead_original_settles_unknown_beside_a_live_successor(released_unknown) RecoveryMonitorCapability.abandon_released_native_attempt(store, execution_id) after = store.snapshots.get(execution_id) assert not after.is_current and isinstance(after.attempt.lifecycle, Failed) - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not after.replay.replay_safe + replay = after.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert comms.registry.require("beta") == successor @@ -410,4 +413,4 @@ def test_unknown_settlement_is_atomic_on_existing_store(tmp_path): fence, expected_pointer_revision=before.pointer_revision ).value assert not after.is_current and not after.can_retry - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert after.replay.require_assessment().facts & ReplayFact.UNKNOWN_EFFECTS From 6d76526852d6d2ffc3d79faed72f86652d4b44a3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:03:34 -0400 Subject: [PATCH 14/18] Use existing replay union encoding and current turn checks --- src/agent_comms/coordination_tables/executions.py | 2 +- tests/test_coordination.py | 4 ++-- tests/test_coordination_store.py | 2 +- tests/test_native_unknown_recovery.py | 9 ++++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/agent_comms/coordination_tables/executions.py b/src/agent_comms/coordination_tables/executions.py index 2027da2355..c9a63d5824 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -680,4 +680,4 @@ def __post_init__(self) -> None: # The fixed external snapshot format is still a row object or JSON null. -type ReplayAssessment = ReplayAssessments | NoReplayAssessment +ReplayAssessment = ReplayAssessments | NoReplayAssessment diff --git a/tests/test_coordination.py b/tests/test_coordination.py index e4c22e701a..05e1271a32 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -36,7 +36,7 @@ WakeAssignment, ) from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact -from agent_comms.coordination_tables.executions import ReplayAssessments +from agent_comms.coordination_tables.executions import NoReplayAssessment, ReplayAssessments from agent_comms.coordination_tables.executions import ( CurrentExecutions, ExecutionOrigin, @@ -946,7 +946,7 @@ def test_missing_replay_cannot_later_reauthorize_failed(db): attempt=attempt_record, assignments=(), links=(), - replay=None, + replay=NoReplayAssessment(), obligations=(), publication_intents=(), publication_evidence=(), diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 41e84dd2a6..3ab9cc6905 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -43,6 +43,7 @@ StaleRevision, ) from agent_comms.coordination_results import AlreadyApplied, Applied +from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import WakeAssignment from agent_comms.coordination_tables.attempts import ReplayFact from agent_comms.coordination_tables.executions import ( @@ -509,7 +510,6 @@ def test_token_digest_is_global_and_only_digest_persists(db_path: Path) -> None: def test_settled_snapshot_encodes_recovery_kind_separately_from_table(db_path: Path) -> None: - from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.recovery import RecoveryAudit with store(db_path) as db: diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 04fdd63b2c..465a5aeb5a 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -133,7 +133,8 @@ def running_successor(comms): new_owner=True, ) successor, _ = comms.registry.lease_local_turn("beta", "productive-successor") - assert successor.process_alive and successor.turn_record.read().executing + assert successor.process_alive + successor.turn_record.read().require_active() return successor @@ -142,7 +143,8 @@ def test_undispatched_original_recovers_beside_a_running_successor(released_star successor = running_successor(comms) recover_starting_owner(released_starting) assert comms.registry.require("beta") == successor - assert successor.process_alive and successor.turn_record.read().executing + assert successor.process_alive + successor.turn_record.read().require_active() comms.registry.release_turn(successor.require_turn_lease()) @@ -390,7 +392,8 @@ def test_dead_original_settles_unknown_beside_a_live_successor(released_unknown) assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert comms.registry.require("beta") == successor - assert successor.process_alive and successor.turn_record.read().executing + assert successor.process_alive + successor.turn_record.read().require_active() comms.registry.release_turn(successor.require_turn_lease()) From 2026ffaea1d3e01049951b7d07708b180181665e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:30:27 -0400 Subject: [PATCH 15/18] Open coordinator schemas through their declared owner --- src/agent_comms/coordination_database.py | 24 +---- tests/test_table_mutation_capabilities.py | 31 ++++++ tests/test_table_trigger_carry.py | 62 ----------- tools/cutover/carry_table_triggers.py | 126 ---------------------- 4 files changed, 33 insertions(+), 210 deletions(-) delete mode 100644 tests/test_table_trigger_carry.py delete mode 100644 tools/cutover/carry_table_triggers.py diff --git a/src/agent_comms/coordination_database.py b/src/agent_comms/coordination_database.py index 64c433c284..216fb042c1 100644 --- a/src/agent_comms/coordination_database.py +++ b/src/agent_comms/coordination_database.py @@ -1,4 +1,4 @@ -"""Private SQLite connection lifetime and versioned schema installation.""" +"""Private SQLite connections and declaration-owned schema installation.""" from __future__ import annotations @@ -259,7 +259,7 @@ def __init__( self._connection = self._open_connection(contention) try: self._configure_connection() - self._initialize_schema() + CoordinatorTable.reconcile_schema(self._connection) self._enforce_private_modes() except BaseException: self._connection.close() @@ -308,26 +308,6 @@ def _private_file(self) -> os.stat_result: raise IntegrityViolationError("coordination database must be a regular file") return info - def _initialize_schema(self) -> None: - # An installed schema needs only a consistent read. Opening an - # ordinary reader must never become a writer awaiting other readers' - # release, especially before the wire -> registry -> SQLite lock order. - self._connection.execute("BEGIN") - try: - if SQLiteTableName.stored(self._connection): - CoordinatorTable.require_schema(self._connection) - return - finally: - self._connection.execute("ROLLBACK") - - # Only installation owns a write transaction. Recheck after taking it: - # another initializer may have installed the schema since the read. - with self.writing(): - if SQLiteTableName.stored(self._connection): - CoordinatorTable.require_schema(self._connection) - else: - CoordinatorTable.create_schema(self._connection) - def writing(self) -> AbstractContextManager[sqlite3.Connection]: return StoreWriter.transaction(self, self._connection) diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py index a1d68bd634..8e3e5f5dc9 100644 --- a/tests/test_table_mutation_capabilities.py +++ b/tests/test_table_mutation_capabilities.py @@ -45,6 +45,7 @@ OrdinalTransitions, RequiredColumn, RequiredUpdateColumns, + RowMutation, StableReferenceColumns, TypedTable, ValidatedInserts, @@ -344,3 +345,33 @@ def test_owner_counter_refuses_skipping_a_generation(): with pytest.raises(sqlite3.IntegrityError): OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=3) OwnerGenerations.update(connection, where="owner_lookup='owner'", generation=2) + + +def test_normal_writer_replaces_old_guards_without_rewriting_rows(tmp_path): + path = tmp_path / CoordinationStore.filename + with closing(CoordinationStore(path)) as store: + connection = store._connection + Participants(participant_lookup="owner", display_name="owner", committed=True).insert( + connection + ) + before = Participants.select(connection) + delete_guard = RowMutation.DELETE.abort_trigger(Participants).name + connection.execute(f'DROP TRIGGER "{delete_guard}"') + connection.execute( + "CREATE TRIGGER previous_guard BEFORE DELETE ON participants " + "BEGIN SELECT RAISE(ABORT, 'frozen'); END" + ) + assert CoordinatorTable.schema_differs(connection) + with closing(CoordinationStore(path)) as store: + connection = store._connection + CoordinatorTable.require_installed(connection) + assert Participants.select(connection) == before + assert connection.total_changes == 0 + version = connection.execute("PRAGMA schema_version").fetchone()[0] + with pytest.raises(sqlite3.IntegrityError): + Participants.delete(connection) + with closing(CoordinationStore(path)) as store: + connection = store._connection + assert connection.execute("PRAGMA schema_version").fetchone()[0] == version + assert connection.total_changes == 0 + assert Participants.select(connection) == before diff --git a/tests/test_table_trigger_carry.py b/tests/test_table_trigger_carry.py deleted file mode 100644 index 5389ffff1f..0000000000 --- a/tests/test_table_trigger_carry.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The trigger carry preserves application rows and refuses an unknown sidecar.""" -import sqlite3 -from contextlib import closing - -import pytest - -from tools.cutover.carry_table_triggers import TableTriggers -from agent_comms.coordination_database import CoordinationStore -from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import Participants -from agent_comms.errors import RelationViolationError -from agent_comms.native_prompt_binding import PromptBinding -from agent_comms.private_sidecar import create_sidecar_file -from agent_comms.typed_table import SQLiteTableName, TableSchema, TypedTable - - -def saved_rows(db): - return {table.name: tuple(tuple(values) for values in db.execute(f'SELECT * FROM "{table.name}"')) - for table in SQLiteTableName.stored(db)} - - -def test_trigger_rewrite_preserves_rows_and_is_idempotent(tmp_path): - tuple(TypedTable.__registry__.values()) - path = tmp_path / "coordination.sqlite3" - with closing(CoordinationStore.writer(path)) as db: - CoordinatorTable.create_schema(db) - Participants(participant_lookup="owner", display_name="owner", committed=True).insert(db) - (delete_guard,) = (guard for guard in Participants.triggers() if "_delete_" in guard) - db.execute(f'DROP TRIGGER "{delete_guard}"') - db.execute('CREATE TRIGGER previous_guard BEFORE DELETE ON participants ' - "BEGIN SELECT RAISE(ABORT, 'frozen'); END") - db.commit() - before = saved_rows(db) - schema = TableSchema.stored_schema(db) - assert TableTriggers.rewrite(path, apply=False) == 1 - with closing(CoordinationStore.read_snapshot(path)) as db: - assert TableSchema.stored_schema(db) == schema - assert TableTriggers.rewrite(path, apply=True) == 1 - assert TableTriggers.rewrite(path, apply=True) == 0 - with closing(CoordinationStore.read_snapshot(path)) as db: - assert not CoordinatorTable.schema_differs(db) - assert saved_rows(db) == before - with closing(CoordinationStore.writer(path)) as db: - with pytest.raises(sqlite3.IntegrityError): - db.execute("DELETE FROM participants") - - -def test_unknown_sidecar_is_not_blessed(tmp_path): - tmp_path.chmod(0o700) - path = tmp_path / PromptBinding.filename - create_sidecar_file(path, PromptBinding) - with closing(CoordinationStore.writer(path)) as db: - db.execute('CREATE TRIGGER unknown_guard BEFORE INSERT ON prompt_binding BEGIN SELECT 1; END') - db.commit() - before = saved_rows(db) - schema = TableSchema.stored_schema(db) - with pytest.raises(RelationViolationError) as failure: - TableTriggers.rewrite(path, apply=True) - assert str(path) in str(failure.value) - with closing(CoordinationStore.read_snapshot(path)) as db: - assert saved_rows(db) == before - assert TableSchema.stored_schema(db) == schema diff --git a/tools/cutover/carry_table_triggers.py b/tools/cutover/carry_table_triggers.py deleted file mode 100644 index 06a85a942d..0000000000 --- a/tools/cutover/carry_table_triggers.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Rewrite declared mutation guards once, retaining every application row. - -SnapshotMeta is the sole exception: its existing schema digest changes in the same -transaction as its guards, after the old digest has been checked against the stored -schema. No runtime reader accepts both schemas. Retire after installation. -""" - -from __future__ import annotations - -import hashlib -import json -from contextlib import closing, nullcontext -from pathlib import Path - -from agent_comms.coordination_database import CoordinationStore -from agent_comms.errors import RelationViolationError -from agent_comms.installer.carry import Carried, Carry -from agent_comms.locked_store import DurableFile, SQLiteFile -from agent_comms.private_sidecar import SnapshotMeta, _digest -from agent_comms.typed_table import SQLiteSchemaObject, SQLiteTableName, TableSchema, TypedTable - - -class TableTriggers(Carry): - """One install writer for stored triggers and their sidecar schema digest.""" - - @classmethod - def paths(cls, root: Path) -> tuple[Path, ...]: - # Explicit offline discovery; ordinary TypedTable reads only imported members. - tuple(TypedTable.__registry__.values()) - paths = { - path - for owner, directory in SQLiteFile.declarations_in(root) - if issubclass(owner, DurableFile) - for path in owner.matching_in(directory) - } - for path in paths: - cls.require_confined(root, path) - return tuple(sorted(paths)) - - @classmethod - def rewrite(cls, path: Path, *, apply: bool) -> int: - opener = CoordinationStore.writer if apply else CoordinationStore.read_snapshot - with closing(opener(path)) as db: - transaction = SQLiteFile.write_transaction(db) if apply else nullcontext(db) - with transaction: - rows = tuple( - TypedTable.member_named(table.name) for table in SQLiteTableName.stored(db) - ) - declared = {name: sql for row in rows for name, sql in row.schema_objects().items()} - structure = { - name: sql for row in rows for name, sql in row.structure_objects().items() - } - stored = SQLiteSchemaObject.read( - db.execute( - "SELECT name,sql FROM sqlite_master WHERE sql IS NOT NULL " - "AND name NOT LIKE 'sqlite_%' ORDER BY rowid" - ) - ) - actual_guards = SQLiteSchemaObject.read( - db.execute( - "SELECT name,sql FROM sqlite_master WHERE type='trigger' ORDER BY rowid" - ) - ) - guard_names = {guard.name for guard in actual_guards} - actual_structure = [item for item in stored if item.name not in guard_names] - if differences := SQLiteSchemaObject.differences(actual_structure, structure): - raise RelationViolationError(f"{path}: table/index/view differs: {differences}") - expected = {name: sql for name, sql in declared.items() if name not in structure} - digest_changed = False - digest = "" - if SnapshotMeta in rows: - (row,) = (row for row in rows if row is not SnapshotMeta) - (meta,) = SnapshotMeta.select(db) - # Sidecar digests use declaration insertion order, which is also - # the catalog's creation order before this first trigger carry. - old_schema = {item.name: item.sql for item in stored} - old_digest = hashlib.sha256( - json.dumps(old_schema, separators=(",", ":")).encode() - ).hexdigest() - digest = _digest(row) - complete = not SQLiteSchemaObject.differences(stored, declared) - if meta.ddl_digest != old_digest and not ( - meta.ddl_digest == digest and complete - ): - raise RelationViolationError(f"{path}: sidecar schema digest is unknown") - digest_changed = meta.ddl_digest != digest - differences = SQLiteSchemaObject.differences(actual_guards, expected) - if not differences and not digest_changed: - return 0 - if not apply: - return 1 - remaining = {guard.name: guard.sql for guard in actual_guards} - metadata_guards = SnapshotMeta.triggers() if digest_changed else {} - for guard in actual_guards: - if expected.get(guard.name) != guard.sql or guard.name in metadata_guards: - db.execute('DROP TRIGGER "' + guard.name.replace('"', '""') + '"') - del remaining[guard.name] - if digest_changed: - SnapshotMeta.update(db, where="singleton=1", ddl_digest=digest) - for name, sql in expected.items(): - if remaining.get(name) != sql: - db.execute(sql) - if differences := SQLiteSchemaObject.differences( - TableSchema.stored_schema(db), declared - ): - raise RelationViolationError(f"{path}: schema still differs: {differences}") - return 1 - - @classmethod - def backup_files(cls, root: Path) -> tuple[Path, ...]: - return tuple( - member - for path in cls.paths(root) - if cls.rewrite(path, apply=False) - for member in SQLiteFile.files(path) - if member.exists() - ) - - @classmethod - def carry(cls, root: Path, *, apply: bool) -> Carried: - changed = tuple(path for path in cls.paths(root) if cls.rewrite(path, apply=apply)) - return Carried( - len(changed), - {"files": len(changed)}, - tuple(str(path.relative_to(root)) for path in changed) if apply else (), - ) From c92b99f5a1f609199e559c3cfa58d9017ed1fa9d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 19:37:59 -0400 Subject: [PATCH 16/18] Wait for recovery owner announcements and reject unexplained columns --- tests/test_coordination.py | 4 +- tests/test_native_unknown_recovery.py | 59 ++++++++++++++------------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 05e1271a32..93b804a9f6 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -1426,12 +1426,12 @@ def test_all_durable_tables_reject_delete_even_when_empty(db, table): assert triggers, f"{table} lacks fail-loud delete authority" -def test_schema_drift_and_symlink_rejected(tmp_path): +def test_unexplained_columns_and_symlinks_rejected(tmp_path): path = tmp_path / "db" with CoordinationStore(path): pass with sqlite3.connect(path) as c: - c.execute("CREATE INDEX stray ON participants(participant_lookup)") + c.execute("ALTER TABLE participants ADD COLUMN unexplained TEXT") with pytest.raises(SchemaVersionError): CoordinationStore(path) link = tmp_path / "link" diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index 465a5aeb5a..9fdcec2bc8 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -67,7 +67,8 @@ async def release_starting_attempt(directory): def starting_owner(directory, output, exit_allowed): - output.put(str(asyncio.run(release_starting_attempt(directory)))) + with closing(output): + output.send(str(asyncio.run(release_starting_attempt(directory)))) if not exit_allowed.wait(15): raise TimeoutError("test must release starting owner") @@ -75,21 +76,20 @@ def starting_owner(directory, output, exit_allowed): @pytest.fixture def released_starting(tmp_path): context = multiprocessing.get_context("spawn") - output, exit_allowed = context.Queue(), context.Event() - process = context.Process(target=starting_owner, args=(str(tmp_path), output, exit_allowed)) + output, announcement = context.Pipe(duplex=False) + exit_allowed = context.Event() + process = context.Process( + target=starting_owner, args=(str(tmp_path), announcement, exit_allowed) + ) process.start() - try: - root = Path(output.get(timeout=10)) - leave(process, exit_allowed) - yield root - finally: - exit_allowed.set() - process.join(10) - if process.is_alive(): - process.terminate() - process.join(5) - output.close() - assert process.exitcode == 0 + announcement.close() + with closing(output): + try: + root = Path(output.recv()) + leave(process, exit_allowed) + yield root + finally: + leave(process, exit_allowed) def recover_starting_owner(released_starting): @@ -249,7 +249,8 @@ def test_recover_owner_refuses_a_conflicting_input_identity(released_starting, c def unknown_owner(directory, history, output, exit_allowed): source = asyncio.run(release_native_input(Path(directory), history)) - output.put((str(source.root), source.root_id, source.execution_id, source.input_id)) + with closing(output): + output.send((str(source.root), source.root_id, source.execution_id, source.input_id)) if not exit_allowed.wait(15): raise TimeoutError("test must release fixture process") @@ -257,27 +258,27 @@ def unknown_owner(directory, history, output, exit_allowed): @pytest.fixture(params=NativeSendHistory.members_with(UnknownHistory), ids=lambda history: history.declared_name) def released_unknown(tmp_path, request): context = multiprocessing.get_context("spawn") - output, exit_allowed = context.Queue(), context.Event() + output, announcement = context.Pipe(duplex=False) + exit_allowed = context.Event() process = context.Process( - target=unknown_owner, args=(str(tmp_path), request.param, output, exit_allowed) + target=unknown_owner, args=(str(tmp_path), request.param, announcement, exit_allowed) ) process.start() - try: - root, root_id, execution_id, input_id = output.get(timeout=10) - yield process, exit_allowed, Path(root), root_id, execution_id, input_id - finally: - exit_allowed.set() - process.join(10) - if process.is_alive(): - process.terminate() - process.join(5) - output.close() - assert process.exitcode == 0 + announcement.close() + with closing(output): + try: + root, root_id, execution_id, input_id = output.recv() + yield process, exit_allowed, Path(root), root_id, execution_id, input_id + finally: + leave(process, exit_allowed) def leave(process, exit_allowed): exit_allowed.set() process.join(5) + if process.is_alive(): + process.terminate() + process.join(5) assert process.exitcode == 0 From fe4b7e985a5d969980ba7950f33eef349dc95a5c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:05:25 -0400 Subject: [PATCH 17/18] Derive corruption-test guards from table declarations --- tests/conftest.py | 15 ++++++++ tests/test_native_pi.py | 3 +- tests/test_optional_awareness_projection.py | 38 +++++++++------------ tests/test_private_sidecar.py | 2 +- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 583c1416b1..d4c52384f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import asyncio import os +import sqlite3 import subprocess import sys import tempfile @@ -27,6 +28,7 @@ from agent_comms.messages import ConversationRole, InfoKind, Message from agent_comms.store_files import _store_lock from agent_comms.thread_text import ForkedParent, GivenThreadText +from agent_comms.typed_table import TypedTable pytest_plugins = (goal_owner_fixture.__name__,) @@ -209,6 +211,19 @@ def _managed_runtime_environment_is_explicit(monkeypatch: pytest.MonkeyPatch) -> yield +@contextmanager +def without_mutation_guards(db: sqlite3.Connection, table: type[TypedTable]) -> Iterator[None]: + """Damage disposable rows, restoring their declaration before a checked read.""" + guards = table.triggers() + for name in guards: + db.execute(f'DROP TRIGGER "{name}"') + try: + yield + finally: + for statement in guards.values(): + db.execute(statement) + + @contextmanager def _root_lifetime(root: Path) -> Iterator[Path]: """Keep each directory's bus workers inside the directory's actual lifetime.""" diff --git a/tests/test_native_pi.py b/tests/test_native_pi.py index 5accb61bfa..4d1404b62d 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -137,7 +137,8 @@ def link(self, original, selected): class RemovedConstraintDamage(JournalDamage): def alter(self, files): with sqlite3.connect(files.input_proof) as db: - db.execute(f'DROP TRIGGER {NativeContextJournal.declared_name}_append') + for name in NativeContextJournal.triggers(): + db.execute(f'DROP TRIGGER "{name}"') @pytest.mark.parametrize('damage', JournalDamage.members_with(JournalDamage), ids=lambda member: member.declared_name) diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index bc5d4d1940..5e64ba9775 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -4,7 +4,7 @@ from agent_comms.lifecycle_capability import Executing -from conftest import publish_initial +from conftest import publish_initial, without_mutation_guards from agent_comms.messages import NoticeRole import os from dataclasses import replace @@ -15,7 +15,11 @@ from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_publication import CommittedDelivery from agent_comms.child_process import ProcessIdentity -from agent_comms.cohort_schema import install_private_cohort_schema +from agent_comms.cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + install_private_cohort_schema, +) from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordination_errors import IdentityConflict @@ -288,18 +292,14 @@ def test_older_receipt_message_id_mismatch_omits_entire_context(tmp_path: Path) owner = _owner(comms, "member000") builder = _projection(index, store, owner, 0, initial.message.seq) assert builder(initial, assignment, owner).context()["selected"] - # Disposable legacy/disk inconsistency only: supported writes freeze - # acknowledgement facts. Restore the original trigger before checking schema. + # Disposable disk inconsistency only: supported writes freeze + # acknowledgement facts. Restore declared guards before checking schema. db = store.session._connection - trigger = db.execute( - "SELECT sql FROM sqlite_master WHERE name='cohort_receipt_update_guard'" - ).fetchone()[0] - db.execute("DROP TRIGGER cohort_receipt_update_guard") - db.execute( - "UPDATE claim_batch_receipts SET message_id=? WHERE wire_root_id=? AND wire_seq=?", - ("forged-older-id", root_id, prior.message.seq), - ) - db.execute(trigger) + with without_mutation_guards(db, ClaimBatchAcknowledgements): + db.execute( + "UPDATE claim_batch_receipts SET message_id=? WHERE wire_root_id=? AND wire_seq=?", + ("forged-older-id", root_id, prior.message.seq), + ) stale = builder(initial, assignment, owner) assert 'Optional awareness omitted' in stale.render(32768) finally: @@ -459,14 +459,10 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete # The passive row remains a completeness requirement, not an inner-join # casualty silently omitted while the original is called complete. db = store.session._connection - trigger = db.execute( - "SELECT sql FROM sqlite_master WHERE name='awareness_generation_delete_guard'" - ).fetchone()[0] - db.execute("DROP TRIGGER awareness_generation_delete_guard") - db.execute( - "DELETE FROM awareness_claim_generations WHERE claim_id=?", (passive.assignment_id,) - ) - db.execute(trigger) + with without_mutation_guards(db, AwarenessClaimGenerations): + db.execute( + "DELETE FROM awareness_claim_generations WHERE claim_id=?", (passive.assignment_id,) + ) missing = builder(initial, assignment, owner) assert 'Optional awareness omitted' in missing.render(32768) assert WakeAssignment.select(db) == assignments_before diff --git a/tests/test_private_sidecar.py b/tests/test_private_sidecar.py index 48189b6532..da00559603 100644 --- a/tests/test_private_sidecar.py +++ b/tests/test_private_sidecar.py @@ -115,7 +115,7 @@ def test_symlink_ancestor_refused(path): "CREATE INDEX unrelated_index ON prompt_binding(owner_thread)", "CREATE VIEW unrelated_view AS SELECT * FROM prompt_binding", "CREATE TABLE unrelated_table (x)", - "DROP TRIGGER prompt_binding_delete_guard", + *(f'DROP TRIGGER "{name}"' for name in PromptBinding.triggers()), ], ) def test_all_schema_objects_checked_independent_of_name(path, sql): From d5921a7ba726477adb920a642f80ce765f2e6ddb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 21 Oct 2026 20:12:39 -0400 Subject: [PATCH 18/18] Finish declaration consumers and preserve fixture lifetimes --- src/agent_comms/typed_table.py | 4 +--- tests/test_acp_private_nk_delivery.py | 2 +- tests/test_claim_bus_read.py | 7 +++++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index bb4a9c46e3..099e597423 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -1313,9 +1313,7 @@ def _columns(cls) -> tuple[_Field, ...]: names = {item.name for item in fields} following: dict[str, list[_Field]] = {} for owner in cls.__mro__: - declared = tuple( - annotationlib.get_annotations(owner, format=annotationlib.Format.STRING) - ) + declared = tuple(annotationlib.get_annotations(owner, format=annotationlib.Format.STRING)) for position, name in enumerate(declared): match vars(owner).get(name): case Generated() as declaration: diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index ce848fdee8..e6dcb0f167 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -94,7 +94,7 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag assert comms.registry.require("beta").name == "gamma" with Coordination(str(comms.root / "coordination.sqlite3")) as store: after = store.participants.get(lookup) - assert after.owner_thread == "gamma" + assert after.owner_identity.owner_thread == "gamma" assert after.owner_identity.generation == before.owner_identity.generation assert after.pointer == active.pointer with pytest.raises(StaleRevision): diff --git a/tests/test_claim_bus_read.py b/tests/test_claim_bus_read.py index 9863ee699a..911fc8fb24 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -9,7 +9,7 @@ from __future__ import annotations from agent_comms.context_store import UNREAD_CONTEXT -from conftest import publish_initial +from conftest import _root_lifetime, publish_initial import hashlib import os import sys @@ -76,7 +76,10 @@ def record_context(comms: Comms, name: str, generation: int) -> None: @contextmanager def selected_turn(): """Alice runs a selected turn for one message; context rows surround it.""" - with TemporaryDirectory(prefix="ac-claim-read-") as dirname: + with ( + TemporaryDirectory(prefix="ac-claim-read-") as dirname, + _root_lifetime(Path(dirname)), + ): root = Path(dirname) / "wire" root.mkdir(mode=0o700) worktree = Path(dirname) / "work"