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..f53ec6b874 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -10,19 +10,26 @@ 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, + ImmutableRows, Index, - SharedFileTables, - SQLiteForeignKeys, + NoDelete, + InitialOneWayColumn, SQLiteJournalMode, + SharedFileTables, TypedTable, ) @@ -48,7 +55,32 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(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 + + 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' )))"""), + ) + wire_root_id: Annotated[ str, Column( @@ -84,116 +116,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 +148,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 +219,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 +244,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 +275,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 +286,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 +294,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 +311,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..4c027993fa 100644 --- a/src/agent_comms/coordinated_runtime_schema.py +++ b/src/agent_comms/coordinated_runtime_schema.py @@ -2,22 +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 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: @@ -25,4 +13,4 @@ def install_native_runtime_schema(store: Coordination) -> None: 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..c99ef3dd09 100644 --- a/src/agent_comms/coordination_response.py +++ b/src/agent_comms/coordination_response.py @@ -15,14 +15,13 @@ from __future__ import annotations -import sqlite3 import time from dataclasses import dataclass from functools import partial 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 +51,8 @@ Column, DurableTables, ForeignKey, + ImmutableRows, SharedFileTables, - SQLiteForeignKeys, SqlValue, TypedRow, TypedTable, @@ -62,23 +61,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 +226,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 +333,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 +537,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 +557,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_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/__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..b21d6a965e 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,16 +21,38 @@ from agent_comms.message_reference import MessageReference from agent_comms.typed_table import ( Column, + FirstValueState, ForeignKey, + FrozenColumns, Generated, + GuardedInserts, + 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(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', 'wake_mode') + @property def recipient_identity(self): from agent_comms.audience_manifest import FrozenRecipient @@ -241,58 +263,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")] @@ -315,47 +294,3 @@ def references(cls): ("assignment_id", "execution_id"), WakeAssignment, ("assignment_id", "execution_id") ), ) - - @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..768289e220 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -21,17 +21,30 @@ StaleFence, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import OwnerGenerations +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 ( - TextStorage, + AccumulatingBits, Column, + CounterDependent, + FinalizedLifecycle, ForeignKey, + FrozenColumns, Generated, + GuardedInserts, + IncrementingColumn, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + NonDecreasingWhenPresent, + NonIncreasing, + TextStorage, TypedTable, ) +from agent_comms.coordination_tables import ExecutionReference, RetryPartition if TYPE_CHECKING: from agent_comms.coordination_snapshot import RecoverySnapshot @@ -79,7 +92,31 @@ def require_retry(self, snapshot: RecoverySnapshot) -> None: @dataclass(frozen=True, slots=True) -class AttemptRecord(CoordinatorTable, TypedTable, PreviousAttempt, declared_name="attempts"): +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 @@ -127,14 +164,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 +289,13 @@ 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(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',) -@dataclass(frozen=True, slots=True) -class ReplayAssessments(CoordinatorTable, TypedTable): @property def allows_retry(self) -> bool: return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible @@ -448,58 +409,3 @@ def __post_init__(self) -> None: checks = ("facts = 0 OR replay_safe = 0",) 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..1620f9a5d7 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -21,12 +21,22 @@ 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, + IncrementingColumn, Index, + IntegerStorage, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + OrdinalTransitions, + TextStorage, TypedTable, ) @@ -89,7 +99,12 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) -class CurrentExecutions(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',) + @property def unresolved(self) -> bool: return self.execution_id.pointed @@ -144,30 +159,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 +172,25 @@ def trigger_bodies(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(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') + 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" @classmethod @@ -395,78 +404,3 @@ def references(cls): deferred=True, ), ) - - @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..ceb17cfd87 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -10,13 +10,43 @@ from agent_comms.coordination_schema import CoordinatorTable from agent_comms.typed_table import ( Column, + ColumnGuard, + CounterAdvance, ForeignKey, + FrozenColumns, + GuardedColumns, + ImmutableRows, + MonotoneColumns, + NoDelete, + NonDecreasing, 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(MonotoneColumns, GuardedColumns, NoDelete, CoordinatorTable, TypedTable): + + column_orders = (NonDecreasing(('committed',)),) + + column_guards = ( + ColumnGuard(('participant_lookup',), """1"""), + ) + participant_lookup: Annotated[ str, Column( @@ -27,36 +57,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 +67,13 @@ 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(CounterAdvance, NoDelete, FrozenColumns, ParticipantIdentity, CoordinatorTable, TypedTable): + counter_column = 'generation' + + immutable_columns = ('owner_lookup',) -@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")] @@ -105,25 +92,3 @@ def references(cls): @property 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..f4c2629981 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) @@ -234,41 +208,3 @@ def references(cls): deferred=True, ), ) - - @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..6cf9e9af0f 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -13,14 +13,21 @@ 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, + ImmutableRows, + IncrementingColumn, Index, + MonotoneColumns, + NoDelete, + NonDecreasing, RowId, + TextStorage, TypedTable, UnallocatedRowId, ) @@ -38,7 +45,12 @@ class ACPClientConnectivity(StrEnum): @dataclass(frozen=True, slots=True) -class ConnectivityFacet(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',) + execution_id: Annotated[str, field_codec.SnapshotExclude(), Column(primary_key=True)] owner: OwnerConnectivity acp_client: ACPClientConnectivity @@ -52,37 +64,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 +104,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..7f22feb333 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -16,18 +16,49 @@ from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - TextStorage, Column, - ForeignKey, + ColumnGuard, + FrozenColumns, Generated, + GuardedColumns, + GuardedInserts, + GuardedUpdateMembership, + IncrementingColumn, + LifecycleChanges, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + TextStorage, TypedTable, ) +from agent_comms.coordination_tables import ExecutionReference, RetryPartition @dataclass(frozen=True, slots=True) -class ResponseObligation(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_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 def response_record(records, exact_target): return next((row for row in records if row.exact_target == exact_target), None) @@ -140,142 +171,3 @@ def __post_init__(self) -> None: ("execution_id", "exact_target", "intent_settled"), ("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_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/execution_store.py b/src/agent_comms/execution_store.py index 3e1027172b..deb12ad4f0 100644 --- a/src/agent_comms/execution_store.py +++ b/src/agent_comms/execution_store.py @@ -110,7 +110,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..4a946ba7d9 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -40,7 +40,15 @@ from .typed_table import ( Column, DurableTables, + FrozenColumns, + FinalizedColumn, + RequiredUpdateColumns, + StableReferenceColumns, + WriteOnceColumns, IntegerStorage, + MonotoneColumns, + NoDelete, + NonDecreasing, SharedFileTables, TextStorage, TypedRow, @@ -81,7 +89,12 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) -class NativeRuntimeInput(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') + 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: return " UNION ALL ".join( @@ -301,36 +314,14 @@ 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(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',) -@dataclass(frozen=True) -class CurrentNativeCursor(NativeInputContext, NativeRuntimeTable, TypedTable): @property def context_anchor(self) -> NativeColumn: return self.session_id @@ -386,20 +377,3 @@ def owner_identity(self): "AND stage IS NOT NULL " "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..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) @@ -41,17 +40,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 +71,7 @@ def get(self, lookup: str) -> ParticipantSnapshot: person.display_name, person.committed, aliases, - generation.owner_thread, - generation.generation, + generation, pointer, ) @@ -155,7 +145,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 +206,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 83a51f14b0..afd85dd79e 100644 --- a/src/agent_comms/thread_management.py +++ b/src/agent_comms/thread_management.py @@ -631,10 +631,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..47a40784c6 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")) ] @@ -27,31 +40,3 @@ class TriageNativeSources( 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..941638245b 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) @@ -641,6 +654,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 +675,411 @@ 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, when=condition, suffix="_frozen", 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 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" + + @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 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"({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, + message="Column ordering is monotone") + + +class NonDecreasing(ColumnOrder): + comparison = "<" + suffix = "_increasing" + + +class NonIncreasing(ColumnOrder): + comparison = ">" + 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, ...]] + + @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 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 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] + + @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 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_membership_condition, suffix="_membership", + message="Update requires published membership"),) + + +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, + suffix=f"_related_{row.declared_name}", + 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 +1284,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_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py new file mode 100644 index 0000000000..77adc1dc15 --- /dev/null +++ b/tests/test_table_mutation_capabilities.py @@ -0,0 +1,303 @@ +"""Raw SQLite mutations exercise the declaration-derived capability family.""" +from contextlib import closing +from dataclasses import dataclass +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, 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, +) +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(InitialOneWayColumn, 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",)),) + + +@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 + + +@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 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 + 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, InsertCheckedRow, RelatedGuardRow, AfterWriteRow, ValidatedRow, + OrdinalRow, ProofRow, PointerRow): + row.create(connection) + connection.create_function("test_positive", 1, lambda value: int(value > 0)) + 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): + 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): + 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) + + +@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) + 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..5389ffff1f --- /dev/null +++ b/tests/test_table_trigger_carry.py @@ -0,0 +1,62 @@ +"""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/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 ())