diff --git a/src/agent_comms/attempt_recovery.py b/src/agent_comms/attempt_recovery.py index 47a3c9de92..3c09f0e6ae 100644 --- a/src/agent_comms/attempt_recovery.py +++ b/src/agent_comms/attempt_recovery.py @@ -28,7 +28,8 @@ ) from agent_comms.coordination_results import Applied from agent_comms.coordination_snapshot import RecoverySnapshot -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import CurrentExecutions from agent_comms.coordination_tables.recovery import ( RecoveryAudit, @@ -79,14 +80,14 @@ def observe_native_release( input still settles UNKNOWN, never unsent or safe to replay. """ 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 b8716bdb36..9d2a5eb989 100644 --- a/src/agent_comms/attempt_store.py +++ b/src/agent_comms/attempt_store.py @@ -18,7 +18,7 @@ from agent_comms.coordination_session import CoordinationSession, NativeBoundary from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact from agent_comms.coordination_tables.recovery import ( ACPClientConnectivity, ConnectivityFacet, @@ -57,7 +57,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") @@ -179,18 +179,13 @@ def observe_replay( if snapshot.pointer_revision != expected_pointer_revision: raise StaleRevision("pointer revision changed") before = snapshot.replay - if (before.revision if before else None) != expected_replay_revision: - raise StaleRevision("replay assessment revision changed") - after = ReplayAssessments( - fence.execution_id, - facts, - replay_safe, - side_effects_possible, - 1 if before is None else before.revision + 1, - ) - if not after.record(self.session._connection, snapshot.replay): + before.require_revision(expected_replay_revision) + if not before.observe( + self.session._connection, fence.execution_id, facts=facts, + replay_safe=replay_safe, side_effects_possible=side_effects_possible, + ): return AlreadyApplied(snapshot) - return Applied(self.snapshots.get(after.execution_id)) + return Applied(self.snapshots.get(fence.execution_id)) def fail_unknown( self, diff --git a/src/agent_comms/cohort_foreground.py b/src/agent_comms/cohort_foreground.py index 143c45f127..3c4324293b 100644 --- a/src/agent_comms/cohort_foreground.py +++ b/src/agent_comms/cohort_foreground.py @@ -8,7 +8,7 @@ from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordinator import Coordination -from .cohort_schema import CohortDeliveryReceipts, assert_cohort_schema +from .cohort_schema import CohortDeliveries, CohortTable from .coordination_cohort import accept_delivery_cohort from .errors import RelationViolationError from .message_bus import MessageBus @@ -39,8 +39,8 @@ 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) - sealed = CohortDeliveryReceipts.sealed_sequences_after( + CohortTable.require_installed(resource.session._connection) + sealed = CohortDeliveries.sealed_sequences_after( resource.session._connection, wire_root_id=root_id, recipient_lookup=lookup, diff --git a/src/agent_comms/cohort_schema.py b/src/agent_comms/cohort_schema.py index 62bfb1edc4..7b2efdfc41 100644 --- a/src/agent_comms/cohort_schema.py +++ b/src/agent_comms/cohort_schema.py @@ -10,18 +10,25 @@ 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, + FrozenColumn, + GuardedColumns, + GuardedInserts, + GuardedRelatedInserts, + ImmutableRows, Index, + InitialOneWayColumn, + NoDelete, SharedFileTables, - SQLiteForeignKeys, SQLiteJournalMode, TypedTable, ) @@ -48,10 +55,46 @@ class AwarenessTable(SharedFileTables, DurableTables): @dataclass(frozen=True, kw_only=True) -class ClaimBatchAcknowledgements(CohortTable, TypedTable, declared_name="claim_batch_receipts"): +class ClaimBatchAcknowledgements( + InitialOneWayColumn, + GuardedColumns, + NoDelete, + CohortTable, + TypedTable, + declared_name="claim_batch_receipts", +): + transition_column = "sealed" + transition_from = 0 + transition_to = 1 + 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( + FrozenColumn( primary_key=True, check=( "\n" @@ -60,20 +103,20 @@ class ClaimBatchAcknowledgements(CohortTable, TypedTable, declared_name="claim_b ), ), ] - wire_seq: Annotated[int, Column(primary_key=True, check="wire_seq > 0")] - message_id: Annotated[str, Column(check="length(message_id) BETWEEN 1 AND 256")] - exact_target: Annotated[str, Column(check="length(exact_target) BETWEEN 1 AND 256")] - envelope_digest: Annotated[str, Column(check="length(envelope_digest) = 64")] - audience_digest: Annotated[str, Column(check="length(audience_digest) = 64")] - decisions_digest: Annotated[str, Column(check="length(decisions_digest) = 64")] - member_count: Annotated[int, Column(check="member_count BETWEEN 0 AND 4096")] - claim_count: Annotated[int, Column(check="claim_count BETWEEN 0 AND member_count")] - manifest_codec: Annotated[str, Column(check="length(manifest_codec) BETWEEN 1 AND 256")] + wire_seq: Annotated[int, FrozenColumn(primary_key=True, check="wire_seq > 0")] + message_id: Annotated[str, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] + exact_target: Annotated[str, FrozenColumn(check="length(exact_target) BETWEEN 1 AND 256")] + envelope_digest: Annotated[str, FrozenColumn(check="length(envelope_digest) = 64")] + audience_digest: Annotated[str, FrozenColumn(check="length(audience_digest) = 64")] + decisions_digest: Annotated[str, FrozenColumn(check="length(decisions_digest) = 64")] + member_count: Annotated[int, FrozenColumn(check="member_count BETWEEN 0 AND 4096")] + claim_count: Annotated[int, FrozenColumn(check="claim_count BETWEEN 0 AND member_count")] + manifest_codec: Annotated[str, FrozenColumn(check="length(manifest_codec) BETWEEN 1 AND 256")] resolver_version: Annotated[ - str, Column(check="length(resolver_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(resolver_version) BETWEEN 1 AND 256") ] - policy_version: Annotated[str, Column(check="length(policy_version) BETWEEN 1 AND 256")] - accepted_at_ms: Annotated[int, Column(check="accepted_at_ms >= 0")] + policy_version: Annotated[str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256")] + accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] sealed: bool = False without_rowid = True @@ -84,116 +127,18 @@ 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,52 +161,28 @@ 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): - # Delivery selection kind is distinct from this table's nominal identity. +class CohortDeliveries( + GuardedRelatedInserts, + GuardedInserts, + ImmutableRows, + CohortTable, + TypedTable, + declared_name="cohort_delivery_receipts", +): + """Sealed deliveries, including observers that receive no execution claim.""" + + related_insert_target = WakeAssignment + related_insert_condition = """EXISTS (SELECT 1 FROM cohort_delivery_receipts d + JOIN claim_batch_receipts r ON r.wire_root_id=d.wire_root_id AND r.wire_seq=d.wire_seq + AND r.sealed=1 WHERE d.recipient_lookup=NEW.recipient_lookup AND d.wire_seq=NEW.wire_seq + AND d.kind='unmentioned_observer')""" + insert_condition = """((SELECT sealed FROM claim_batch_receipts r + WHERE r.wire_root_id = NEW.wire_root_id AND r.wire_seq = NEW.wire_seq) != 0) + OR (NEW.kind = 'selected' AND NOT EXISTS (SELECT 1 FROM claim_batch_members m + JOIN wake_claims c ON c.assignment_id = m.claim_id WHERE m.wire_root_id = NEW.wire_root_id + AND m.wire_seq = NEW.wire_seq AND m.claim_id = NEW.claim_id + AND m.recipient_lookup = NEW.recipient_lookup AND c.recipient = NEW.canonical_thread))""" wire_root_id: Annotated[str, Column(primary_key=True)] wire_seq: Annotated[int, Column(primary_key=True)] @@ -319,67 +240,28 @@ 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(AwarenessTable, TypedTable): +class AwarenessClaimGenerations( + OwnerRecord, GuardedInserts, ImmutableRows, AwarenessTable, TypedTable +): + + insert_condition = """(NOT EXISTS (SELECT 1 FROM claim_batch_members m + JOIN wake_claims c ON c.assignment_id=m.claim_id JOIN claim_batch_receipts r + ON r.wire_root_id=m.wire_root_id AND r.wire_seq=m.wire_seq JOIN owner_generations g + ON g.owner_lookup=m.recipient_lookup WHERE m.claim_id=NEW.claim_id + AND m.wire_root_id=NEW.wire_root_id AND m.wire_seq=NEW.wire_seq + AND m.recipient_lookup=NEW.recipient_lookup AND c.recipient=NEW.canonical_thread + AND c.message_id=r.message_id AND r.sealed=0 AND g.owner_thread=NEW.canonical_thread + AND g.generation=NEW.owner_generation))""" + claim_id: Annotated[str, Column(primary_key=True)] wire_root_id: str wire_seq: Annotated[int, Column(check="wire_seq > 0")] - 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 +271,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 +302,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 +313,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 +321,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 +338,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/context_segments/optional_awareness.py b/src/agent_comms/context_segments/optional_awareness.py index 8f9d170793..14157ae34a 100644 --- a/src/agent_comms/context_segments/optional_awareness.py +++ b/src/agent_comms/context_segments/optional_awareness.py @@ -19,7 +19,12 @@ from agent_comms.coordination_tables.executions import ExecutionRecord from agent_comms.coordination_tables.responses import ResponseObligation -from ..cohort_schema import AwarenessClaimGenerations, ClaimBatchMembers, ClaimBatchAcknowledgements, CohortDeliveryReceipts +from ..cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + ClaimBatchMembers, + CohortDeliveries, +) from ..field_codec import FieldCodec from ..turn_context import ContextSegment, InstructionFile, InstructionSegment, TurnContext from ..wake_candidate_index import ProjectionUnavailableError @@ -37,7 +42,7 @@ def capture( assignment: WakeAssignment, acknowledgement: ClaimBatchAcknowledgements, member: ClaimBatchMembers, - delivery: CohortDeliveryReceipts, + delivery: CohortDeliveries, generation: AwarenessClaimGenerations, ) -> _SelectedDecision: acknowledgement.require_selected_claim(assignment) @@ -50,7 +55,7 @@ def capture( ) if member != expected_member: raise ProjectionUnavailableError("selected source is not the recorded batch member") - expected_delivery = CohortDeliveryReceipts( + expected_delivery = CohortDeliveries( wire_root_id=acknowledgement.wire_root_id, wire_seq=assignment.wire_seq, ordinal=delivery.ordinal, diff --git a/src/agent_comms/coordinated_runtime_schema.py b/src/agent_comms/coordinated_runtime_schema.py index 850084656a..35ff34b862 100644 --- a/src/agent_comms/coordinated_runtime_schema.py +++ b/src/agent_comms/coordinated_runtime_schema.py @@ -2,22 +2,9 @@ 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 +12,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..86d2c8af73 100644 --- a/src/agent_comms/coordination_cohort.py +++ b/src/agent_comms/coordination_cohort.py @@ -10,8 +10,8 @@ import hashlib import logging import sqlite3 -from typing import Annotated from dataclasses import dataclass +from typing import Annotated from agent_comms.assignment_states import AssignmentState, PendingNotification from agent_comms.bus_publication import CommittedDelivery @@ -19,8 +19,8 @@ AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, - CohortDeliveryReceipts, - assert_cohort_schema, + CohortDeliveries, + CohortTable, assert_optional_awareness_schema, ) from agent_comms.coordination_contracts import POLICY_VERSION, RESOLVER_VERSION @@ -85,23 +85,6 @@ def _expected_assignments( return tuple(result) -def _immutable_assignment_matches(current: WakeAssignment, expected: WakeAssignment) -> bool: - return current.lifecycle.mode == expected.lifecycle.mode and all( - getattr(current, field) == getattr(expected, field) - for field in ( - "assignment_id", - "recipient", - "recipient_lookup", - "wire_seq", - "message_id", - "audience", - "resolver_version", - "policy_version", - "accepted_at_ms", - ) - ) - - def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> AcceptedCohort: message = initial.message audience = initial.audience @@ -143,7 +126,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte for ordinal, assignment in enumerate(expected) ]: raise IdentityConflict("sealed K claim members conflict with committed bus") - deliveries = CohortDeliveryReceipts.select( + deliveries = CohortDeliveries.select( db, where="wire_root_id=? AND wire_seq=?", parameters=(initial.wire_root_id, message.seq), @@ -151,7 +134,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte ) selected = {assignment.recipient_lookup: assignment.assignment_id for assignment in expected} if deliveries != [ - CohortDeliveryReceipts( + CohortDeliveries( wire_root_id=initial.wire_root_id, wire_seq=message.seq, ordinal=ordinal, @@ -169,8 +152,7 @@ def _claim_record(db: sqlite3.Connection, initial: CommittedDelivery) -> Accepte if db_row is None: raise IdentityConflict("sealed cohort claim is missing") actual = db_row - if not _immutable_assignment_matches(actual, accepted): - raise IdentityConflict("sealed cohort claim immutable facts conflict") + accepted.require_same_acceptance(actual) current.append(actual) # A singleton claim for an explicitly no-wake observer cannot be accepted # as a K member or hidden by a valid sealed N acknowledgement. @@ -214,8 +196,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 +227,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)) @@ -287,7 +269,7 @@ def accept_delivery_cohort( } for ordinal, recipient in enumerate(initial.audience.recipients): assignment_id = selected.get(recipient.recipient_lookup) - CohortDeliveryReceipts( + CohortDeliveries( wire_root_id=wire_root_id, wire_seq=wire_seq, ordinal=ordinal, @@ -360,7 +342,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_database.py b/src/agent_comms/coordination_database.py index ee35540771..912146c94a 100644 --- a/src/agent_comms/coordination_database.py +++ b/src/agent_comms/coordination_database.py @@ -1,4 +1,4 @@ -"""Private SQLite connection lifetime and versioned schema installation.""" +"""Private SQLite connections and declaration-owned schema installation.""" from __future__ import annotations @@ -259,7 +259,7 @@ def __init__( self._connection = self._open_connection(contention) try: self._configure_connection() - self._initialize_schema() + CoordinatorTable.reconcile_schema(self._connection) self._enforce_private_modes() except BaseException: self._connection.close() @@ -308,26 +308,6 @@ def _private_file(self) -> os.stat_result: raise IntegrityViolationError("coordination database must be a regular file") return info - def _initialize_schema(self) -> None: - # An installed schema needs only a consistent read. Opening an - # ordinary reader must never become a writer awaiting other readers' - # release, especially before the wire -> registry -> SQLite lock order. - self._connection.execute("BEGIN") - try: - if SQLiteTableName.stored(self._connection): - CoordinatorTable.require_schema(self._connection) - return - finally: - self._connection.execute("ROLLBACK") - - # Only installation owns a write transaction. Recheck after taking it: - # another initializer may have installed the schema since the read. - with self.writing(): - if SQLiteTableName.stored(self._connection): - CoordinatorTable.require_schema(self._connection) - else: - CoordinatorTable.create_schema(self._connection) - def writing(self) -> AbstractContextManager[sqlite3.Connection]: return StoreWriter.transaction(self, self._connection) diff --git a/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 c4a09f6cb5..3f75f0d083 100644 --- a/src/agent_comms/coordination_schema.py +++ b/src/agent_comms/coordination_schema.py @@ -69,18 +69,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, ) @@ -96,10 +84,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_values( member.declared_name for member in AttemptState.members_with(Terminal) ), diff --git a/src/agent_comms/coordination_snapshot.py b/src/agent_comms/coordination_snapshot.py index a00808be86..f150e81463 100644 --- a/src/agent_comms/coordination_snapshot.py +++ b/src/agent_comms/coordination_snapshot.py @@ -20,10 +20,17 @@ AttemptRecord, NoPreviousAttempt, PreviousAttempt, - ReplayAssessments, ) from agent_comms.coordination_tables.executions import ( - ExecutionOrigin, ExecutionRecord, NoPointedAttempt, NoPointedExecution, PointerAttempt, PointerExecution, + CurrentExecutions, + ExecutionOrigin, + ExecutionRecord, + NoPointedAttempt, + NoPointedExecution, + PointerAttempt, + PointerExecution, + ReplayAssessment, + ReplayAssessments, ) from agent_comms.coordination_tables.publications import PublicationEvidence, PublicationIntents from agent_comms.coordination_tables.recovery import ConnectivityFacet, RecoveryAudit @@ -46,7 +53,7 @@ class RecoverySnapshot: links: Annotated[ tuple[ExecutionAssignmentLink, ...], SnapshotName("execution_claims") ] - replay: ReplayAssessments | None + replay: ReplayAssessment obligations: tuple[ResponseObligation, ...] publication_intents: tuple[PublicationIntents, ...] publication_evidence: Annotated[ @@ -138,8 +145,8 @@ def __post_init__(self) -> None: def validate_membership(self) -> None: execution = self.execution execution_id = execution.execution_id + self.replay.require_execution(execution_id) related = ( - self.replay, *self.obligations, *self.publication_intents, *self.publication_evidence, @@ -234,11 +241,6 @@ def settle( self, session, outcome, reason_code: ReasonCode, boundary: NativeBoundary | None = None, ) -> None: - from .coordination_tables.assignments import WakeAssignment - from .coordination_tables.attempts import AttemptRecord, ReplayAssessments - from .coordination_tables.executions import CurrentExecutions, ExecutionRecord - from .coordination_tables.responses import ResponseObligation - db = session._connection execution, attempt = self.execution, self.attempt if self.publication_intents: diff --git a/src/agent_comms/coordination_tables/assignments.py b/src/agent_comms/coordination_tables/assignments.py index da29a5e500..a2511bfc6f 100644 --- a/src/agent_comms/coordination_tables/assignments.py +++ b/src/agent_comms/coordination_tables/assignments.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import Annotated, ClassVar from dataclasses import dataclass, replace +from typing import Annotated, ClassVar -from agent_comms.assignment_states import AssignmentState +from agent_comms.assignment_states import AssignmentState, EngagedAssignment from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, POLICY_VERSION, @@ -17,20 +17,52 @@ ) from agent_comms.coordination_schema import CoordinatorTable from agent_comms.envelope_claim_transitions import AssignmentBinding -from agent_comms.field_codec import projected, SnapshotExclude, WireName +from agent_comms.field_codec import SnapshotExclude, WireName, projected from agent_comms.message_reference import MessageReference from agent_comms.typed_table import ( Column, + FirstValueState, ForeignKey, + FrozenColumn, 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, + 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',)),) + @property def recipient_identity(self): from agent_comms.audience_manifest import FrozenRecipient @@ -109,22 +141,22 @@ def require_engaged_binding(self) -> AssignmentBinding: assignment_id: Annotated[ str, WireName("claim_id"), - Column(primary_key=True, check="length(assignment_id) BETWEEN 1 AND 256"), + FrozenColumn(primary_key=True, check="length(assignment_id) BETWEEN 1 AND 256"), ] - recipient: Annotated[str, Column(check="length(recipient) BETWEEN 1 AND 256")] - recipient_lookup: str - wire_seq: Annotated[int, Column(check="wire_seq > 0")] - message_id: Annotated[str, Column(check="length(message_id) BETWEEN 1 AND 256")] - audience: MessageAudience + recipient: Annotated[str, FrozenColumn(check="length(recipient) BETWEEN 1 AND 256")] + recipient_lookup: Annotated[str, FrozenColumn()] + wire_seq: Annotated[int, FrozenColumn(check="wire_seq > 0")] + message_id: Annotated[str, FrozenColumn(check="length(message_id) BETWEEN 1 AND 256")] + audience: Annotated[MessageAudience, FrozenColumn()] lifecycle: Annotated[AssignmentState, SnapshotExclude()] - accepted_at_ms: Annotated[int, Column(check="accepted_at_ms >= 0")] + accepted_at_ms: Annotated[int, FrozenColumn(check="accepted_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= accepted_at_ms")] revision: Annotated[int, Column(check="revision > 0")] = 1 resolver_version: Annotated[ - str, Column(check="length(resolver_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(resolver_version) BETWEEN 1 AND 256") ] = RESOLVER_VERSION policy_version: Annotated[ - str, Column(check="length(policy_version) BETWEEN 1 AND 256") + str, FrozenColumn(check="length(policy_version) BETWEEN 1 AND 256") ] = POLICY_VERSION @projected(view="snapshot", name="disposition") @@ -182,7 +214,7 @@ def durable_key(self) -> tuple[str, int]: ) wake_mode: ClassVar[Generated] = Generated( str, - Column(generated="({assignment_mode})", check="wake_mode IN ({wake_names})"), + FrozenColumn(generated="({assignment_mode})", check="wake_mode IN ({wake_names})"), ) triage_verdict: ClassVar[Generated] = Generated( str, @@ -241,58 +273,19 @@ 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(CoordinatorTable, TypedTable, declared_name="execution_claims"): +class ExecutionAssignmentLink( + GuardedInserts, ImmutableRows, CoordinatorTable, TypedTable, declared_name="execution_claims" +): + + insert_condition = """(NOT EXISTS (SELECT 1 FROM executions e + WHERE e.execution_id = NEW.execution_id AND e.status IN ({unstarted_execution_names})) + OR NEW.ordinal != (SELECT count(*) FROM execution_claims + WHERE execution_id = NEW.execution_id)) OR (NOT EXISTS ( + SELECT 1 FROM executions e JOIN wake_claims c ON c.execution_id = e.execution_id + WHERE e.execution_id = NEW.execution_id AND c.assignment_id = NEW.assignment_id + AND c.exact_target IS NOT NULL AND c.recipient_lookup = e.owner_lookup ))""" + execution_id: Annotated[str, Column(primary_key=True)] assignment_id: Annotated[str, WireName("claim_id"), Column(unique=True)] ordinal: Annotated[int, Column(primary_key=True, check="ordinal>=0")] @@ -315,47 +308,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..098e3231fb 100644 --- a/src/agent_comms/coordination_tables/attempts.py +++ b/src/agent_comms/coordination_tables/attempts.py @@ -3,9 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, replace +from dataclasses import dataclass from enum import IntFlag -from typing import TYPE_CHECKING, Final, Annotated, ClassVar +from typing import TYPE_CHECKING, Annotated, ClassVar, Final from agent_comms.attempt_states import AttemptState from agent_comms.coordination_contracts import ( @@ -14,22 +14,27 @@ require_nonempty, validate_execution_id, ) -from agent_comms.coordination_errors import ( - IdentityConflict, - IntegrityViolationError, - RecoveryBlocked, - StaleFence, -) +from agent_comms.coordination_errors import RecoveryBlocked, StaleFence from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.participants import OwnerGenerations -from agent_comms.field_codec import projected, SnapshotExclude +from agent_comms.coordination_tables.participants import OwnerGenerations, OwnerRecord +from agent_comms.field_codec import SnapshotExclude, projected from agent_comms.owner_fence import AttemptAuthority from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - TextStorage, Column, + CounterDependent, + FinalizedLifecycle, ForeignKey, + FrozenColumn, Generated, + GuardedInserts, + IncrementingColumn, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + NonDecreasingWhenPresent, + TextStorage, TypedTable, ) @@ -79,7 +84,51 @@ 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, + 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))""" + def require_attempt(self) -> AttemptRecord: return self @@ -127,23 +176,15 @@ 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 - owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] - owner_generation: Annotated[int, Column(check="owner_generation > 0")] + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] + attempt_ordinal: Annotated[int, FrozenColumn(primary_key=True, check="attempt_ordinal > 0")] + owner_lookup: Annotated[str, FrozenColumn()] + owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] + owner_generation: Annotated[int, FrozenColumn(check="owner_generation > 0")] owner_token_digest: Annotated[ str, SnapshotExclude(), - Column(unique=True, check="length(owner_token_digest) BETWEEN 1 AND 256"), + FrozenColumn(unique=True, check="length(owner_token_digest) BETWEEN 1 AND 256"), ] lifecycle: Annotated[AttemptState, SnapshotExclude()] revision: Annotated[int, Column(check="revision > 0")] @@ -153,7 +194,7 @@ def owner_identity(self) -> OwnerGenerations: Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] @projected(view="snapshot", name="phase") @@ -259,247 +300,3 @@ def references(cls): deferred=True, ), ) - - @classmethod - def trigger_bodies(cls): - return { - "attempt_insert_authorized": ( - """ BEFORE INSERT ON attempts BEGIN - SELECT RAISE(ABORT, 'attempt must be contiguous and within execution budget') - WHERE NOT EXISTS ( - SELECT 1 FROM executions e WHERE e.execution_id = NEW.execution_id - AND e.owner_lookup = NEW.owner_lookup AND NEW.attempt_ordinal <= e.max_attempts - AND ((NEW.attempt_ordinal = 1 AND - (e.status = 'pending' OR - (e.status = 'active' AND e.current_attempt_ordinal = 1))) - OR (NEW.attempt_ordinal > 1 AND - (e.status = 'deferred' OR - (e.status = 'active' AND e.current_attempt_ordinal = NEW.attempt_ordinal)))) - AND NEW.attempt_ordinal = 1 + coalesce( - (SELECT max(a.attempt_ordinal) FROM attempts a - WHERE a.execution_id = e.execution_id), 0)); - SELECT RAISE(ABORT, 'attempt generation must match owner counter') - WHERE NOT EXISTS (SELECT 1 FROM owner_generations g - WHERE g.owner_lookup = NEW.owner_lookup AND g.owner_thread = NEW.owner_thread - AND g.generation = NEW.owner_generation); - SELECT RAISE(ABORT, 'new attempt requires a fresh active fence') - WHERE (json_extract(NEW.lifecycle, '$.kind')) != 'prompt_starting' OR NEW.revision != 1 - OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) != 0 OR (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) != 0; - SELECT RAISE(ABORT, 'retry requires derived authorization and no current owner') - WHERE NEW.attempt_ordinal > 1 AND ( - NOT EXISTS (SELECT 1 FROM retry_disposition_basis b - WHERE b.execution_id = NEW.execution_id AND b.authorized = 1) - OR EXISTS (SELECT 1 FROM current_executions p - WHERE p.owner_lookup = NEW.owner_lookup AND p.execution_id IS NOT NULL)); - SELECT RAISE(ABORT, 'previous attempt must be dead with an older distinct fence') - WHERE NEW.attempt_ordinal > 1 AND NOT EXISTS ( - SELECT 1 FROM attempts a WHERE a.execution_id = NEW.execution_id - AND a.attempt_ordinal = NEW.attempt_ordinal - 1 - AND a.phase IN ({retry_attempt_phases}) - AND a.backend_done = 1 AND a.process_dead = 1 - AND NEW.owner_generation > a.owner_generation - AND NEW.owner_token_digest != a.owner_token_digest); -END""" - ), - "attempt_phase_edge": ( - """ BEFORE UPDATE OF lifecycle ON attempts -WHEN (json_extract(NEW.lifecycle, '$.kind')) != (json_extract(OLD.lifecycle, '$.kind')) AND NOT ({attempt_edges}) -BEGIN SELECT RAISE(ABORT, 'attempt phase transition is not declared'); END""" - ), - "attempt_frozen_facts": ( - """ BEFORE UPDATE ON attempts -WHEN NEW.execution_id IS NOT OLD.execution_id - OR NEW.attempt_ordinal != OLD.attempt_ordinal - OR NEW.owner_lookup IS NOT OLD.owner_lookup OR NEW.owner_thread IS NOT OLD.owner_thread - OR NEW.owner_generation != OLD.owner_generation - OR NEW.owner_token_digest IS NOT OLD.owner_token_digest - OR NEW.created_at_ms != OLD.created_at_ms - OR NEW.revision != OLD.revision + 1 - OR NEW.updated_at_ms < OLD.updated_at_ms - OR ((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, '$.backend_done') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.backend_done') END) = 0) - OR ((CASE WHEN json_extract(OLD.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(OLD.lifecycle, '$.process_dead') END) = 1 AND (CASE WHEN json_extract(NEW.lifecycle, '$.kind') IN ({terminal_attempt_names}) THEN 1 ELSE json_extract(NEW.lifecycle, '$.process_dead') END) = 0) - OR (OLD.last_progress_at_ms IS NOT NULL AND - (NEW.last_progress_at_ms IS NULL OR NEW.last_progress_at_ms < OLD.last_progress_at_ms)) - OR ((json_extract(OLD.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) IS NOT NULL - AND (json_extract(NEW.lifecycle, '$.lease_expires_at_ms')) < (json_extract(OLD.lifecycle, '$.lease_expires_at_ms'))) - OR ((json_extract(OLD.lifecycle, '$.kind')) IN ({terminal_attempt_names}) AND (json_extract(NEW.lifecycle, '$.kind')) = (json_extract(OLD.lifecycle, '$.kind'))) -BEGIN SELECT RAISE(ABORT, 'attempt transition rewrites frozen facts'); END""" - ), - "attempt_delete_frozen": ( - """ BEFORE DELETE ON attempts BEGIN - SELECT RAISE(ABORT, 'attempt cannot be deleted'); -END""" - ), - } - - -@dataclass(frozen=True, slots=True) -class ReplayAssessments(CoordinatorTable, TypedTable): - @property - def allows_retry(self) -> bool: - return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible - - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] - facts: Annotated[ReplayFact, Column(check="facts >= 0 AND facts <= 1023")] - replay_safe: Annotated[bool, Column()] - side_effects_possible: Annotated[bool, Column()] - revision: Annotated[int, Column(check="revision > 0")] - - @classmethod - def accumulate(cls, db, execution_id: str, observed: ReplayFact) -> None: - """Join original observations under the caller's existing attempt fence. - - Missing replay evidence cannot establish safety. Only an existing - assessment may retain that fact, and every incoming observation can - revoke it. Output facts remain monotonic across all attempts. - """ - if not observed or cls.exists( - db, - where=( - "execution_id=? AND (facts & ?) = ? AND replay_safe=0 AND side_effects_possible=1" - ), - parameters=(execution_id, int(observed), int(observed)), - ): - return - # Most streamed deltas repeat a fact already durably joined. Read only - # that answer, not a decoded row or another write-lock acquisition. - # A new fact joins atomically: another connection may have accumulated - # different evidence since the existence check, even in autocommit use. - statement, values = cls(execution_id, observed, False, True, 1)._insertion() - db.execute( - statement + """ - ON CONFLICT (execution_id) DO UPDATE SET - facts = facts | excluded.facts, - replay_safe = 0, - side_effects_possible = 1, - revision = revision + 1 - WHERE (facts | excluded.facts) != facts - OR replay_safe != 0 OR side_effects_possible != 1 - """, - values, - ) - - @classmethod - def authorize_if_unobserved(cls, db, execution_id: str) -> None: - """Record provider-failure proof only while no output fact exists.""" - before = cls.one(db, execution_id=execution_id) - if before is None: - cls(execution_id, ReplayFact.NONE, True, False, 1).record(db, None) - - @classmethod - def revoke(cls, db, execution_id: str) -> None: - """A nonretryable failure consumes any earlier provider-failure proof.""" - before = cls.one(db, execution_id=execution_id) - if before is not None and before.replay_safe: - replace(before, replay_safe=False, revision=before.revision + 1).record(db, before) - - def require_successor(self, after: ReplayAssessments) -> None: - accumulated = replace( - after, - facts=self.facts | after.facts, - replay_safe=self.replay_safe and after.replay_safe, - side_effects_possible=self.side_effects_possible or after.side_effects_possible, - ) - if accumulated != after: - raise IdentityConflict("replay facts cannot be erased") - - def record(self, db, before: ReplayAssessments | None) -> bool: - """Persist monotonic replay evidence inside the checked transaction.""" - - if before is not None: - if replace(before, revision=self.revision) == self: - return False - before.require_successor(self) - ReplayAssessments.update( - db, - where="execution_id=?", - parameters=(self.execution_id,), - facts=self.facts, - replay_safe=self.replay_safe, - side_effects_possible=self.side_effects_possible, - revision=before.revision + 1, - ) - else: - self.insert(db) - return True - - def __post_init__(self) -> None: - validate_execution_id(self.execution_id) - if self.revision <= 0: - raise ValueError("replay revision must be positive") - object.__setattr__(self, "facts", ReplayFact(self.facts)) - if int(self.facts) < 0 or int(self.facts) & ~APPROVED_REPLAY_FACT_MASK: - raise ValueError("unrecognized replay fact bits") - if self.facts != ReplayFact.NONE and self.replay_safe: - raise IntegrityViolationError("an execution with ambiguity facts cannot be replay-safe") - - retry_authorized: ClassVar[Generated] = Generated( - int, - Column( - generated=( - "CASE WHEN facts = 0 AND replay_safe = 1 AND side_effects_possibl" - "e = 0\n" - " THEN 1 ELSE 0 END" - ), - nullable=True, - ), - ) - - checks = ("facts = 0 OR replay_safe = 0",) - - unique = (("execution_id", "retry_authorized"),) - - @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..c9a63d5824 100644 --- a/src/agent_comms/coordination_tables/executions.py +++ b/src/agent_comms/coordination_tables/executions.py @@ -2,10 +2,11 @@ from __future__ import annotations -from abc import abstractmethod -from typing import Annotated, ClassVar -from dataclasses import dataclass +import sqlite3 +from abc import ABC, abstractmethod +from dataclasses import dataclass, replace from enum import StrEnum +from typing import Annotated, ClassVar from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, @@ -13,20 +14,44 @@ require_nonempty, validate_execution_id, ) -from agent_comms.coordination_errors import IntegrityViolationError +from agent_comms.coordination_errors import IdentityConflict, IntegrityViolationError, StaleRevision from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments +from agent_comms.coordination_tables.attempts import ( + APPROVED_REPLAY_FACT_MASK, + AttemptRecord, + ReplayFact, +) from agent_comms.execution_states import ExecutionState -from agent_comms.field_codec import NullMember, SingleFieldValue, SnapshotExclude, SubclassUnion, projected +from agent_comms.field_codec import ( + MalformedRecord, + NullMember, + SingleFieldValue, + SnapshotExclude, + SubclassUnion, + projected, +) from agent_comms.reason_codes import ReasonCode from agent_comms.thread_text import AbsentText, GivenText, TextChoice from agent_comms.typed_table import ( - IntegerStorage, - TextStorage, + AccumulatingBits, Column, + ColumnGuard, ForeignKey, + FrozenColumn, Generated, + GuardedAfterWrites, + GuardedColumns, + GuardedInserts, + IncrementingColumn, Index, + IntegerStorage, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + NonIncreasing, + OrdinalTransitions, + TextStorage, TypedTable, ) @@ -89,7 +114,13 @@ def otherwise[Default](self, default: Default) -> Default: @dataclass(frozen=True, slots=True) -class CurrentExecutions(CoordinatorTable, TypedTable): +class CurrentExecutions( + IncrementingColumn, GuardedInserts, NoDelete, CoordinatorTable, TypedTable +): + revision_column = "pointer_revision" + insert_condition = """NOT EXISTS (SELECT 1 FROM owner_generations + WHERE owner_lookup = NEW.owner_lookup)""" + @property def unresolved(self) -> bool: return self.execution_id.pointed @@ -102,7 +133,7 @@ def require_idle(self) -> None: "selected owner has an unresolved execution; new claims remain pending" ) - owner_lookup: Annotated[str, Column(primary_key=True)] + owner_lookup: Annotated[str, FrozenColumn(primary_key=True)] execution_id: Annotated[PointerExecution, Column(storage=TextStorage, nullable=True)] attempt_ordinal: Annotated[PointerAttempt, Column(storage=IntegerStorage, nullable=True)] pointer_revision: Annotated[int, Column(check="pointer_revision >= 0")] @@ -144,30 +175,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 +188,39 @@ def trigger_bodies(cls): @dataclass(frozen=True, slots=True) -class ExecutionRecord(CoordinatorTable, TypedTable, declared_name="executions"): +class ExecutionRecord( + LifecycleTransitions, + OrdinalTransitions, + IncrementingColumn, + MonotoneColumns, + GuardedColumns, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="executions", +): + column_orders = (NonDecreasing(("updated_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 @@ -191,9 +230,7 @@ def schema_objects(cls, *, if_not_exists: bool = False): return super().schema_objects(if_not_exists=if_not_exists) | { cls.retry_view: f"CREATE VIEW {existence}{cls.retry_view} AS\n" + _RETRY_BASIS} - def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> bool: - if replay is None: - return False + def retry_authorized(self, replay: ReplayState, obligations) -> bool: return ( self.lifecycle.has_retry_budget(self.max_attempts) and replay.allows_retry @@ -205,7 +242,7 @@ def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> boo execution_id: Annotated[ str, - Column( + FrozenColumn( primary_key=True, check=( "\n" @@ -215,18 +252,18 @@ def retry_authorized(self, replay: ReplayAssessments | None, obligations) -> boo ), ), ] - origin: ExecutionOrigin + origin: Annotated[ExecutionOrigin, FrozenColumn()] lifecycle: Annotated[ExecutionState, SnapshotExclude()] - owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] - owner_lookup: str + owner_thread: Annotated[str, FrozenColumn(check="length(owner_thread) BETWEEN 1 AND 256")] + owner_lookup: Annotated[str, FrozenColumn()] revision: Annotated[int, Column(check="revision > 0")] - max_attempts: Annotated[int, Column(check="max_attempts > 0")] + max_attempts: Annotated[int, FrozenColumn(check="max_attempts > 0")] reason_code: Annotated[ ReasonCode, Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] @projected(view="snapshot", name="status") @@ -360,7 +397,6 @@ def __post_init__(self) -> None: @classmethod def references(cls): from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink - from agent_comms.coordination_tables.attempts import ReplayAssessments from agent_comms.coordination_tables.participants import Participants return ( @@ -396,77 +432,252 @@ def references(cls): ), ) + +class ExecutionReference: + """An execution-owned row cannot outlive its original execution.""" + @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""" + def references(cls) -> tuple[ForeignKey, ...]: + return ( + ForeignKey(("execution_id",), ExecutionRecord, ("execution_id",), on_delete="RESTRICT"), + ) + + +class RetryPartition(GuardedAfterWrites): + """A failed execution cannot retain authorization to retry.""" + + after_write_condition = """EXISTS (SELECT 1 FROM retry_disposition_basis b JOIN executions e + ON e.execution_id = b.execution_id WHERE b.execution_id = NEW.execution_id + AND b.authorized = 1 AND e.status = 'failed' AND e.current_attempt_ordinal IS NOT NULL)""" + + +class ReplayState(ABC): + """An original replay assessment, or the state before one has been recorded.""" + + @property + @abstractmethod + def allows_retry(self) -> bool: ... + + @property + @abstractmethod + def next_revision(self) -> int: ... + + @abstractmethod + def require_revision(self, expected: object) -> None: ... + + @abstractmethod + def require_execution(self, execution_id: str) -> None: ... + + @abstractmethod + def require_assessment(self) -> ReplayAssessments: ... + + @abstractmethod + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: ... + + @abstractmethod + def revoke_retry(self, db: sqlite3.Connection) -> None: ... + + @abstractmethod + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: ... + + def observe( + self, db: sqlite3.Connection, execution_id: str, *, facts: ReplayFact, + replay_safe: bool, side_effects_possible: bool, + ) -> bool: + self.require_execution(execution_id) + after = ReplayAssessments( + execution_id, facts, replay_safe, side_effects_possible, self.next_revision + ) + return self.record(db, after) + + +@dataclass(frozen=True, slots=True) +class NoReplayAssessment(NullMember, ReplayState): + @property + def allows_retry(self) -> bool: + return False + + @property + def next_revision(self) -> int: + return 1 + + def require_revision(self, expected: object) -> None: + try: + type(self).from_wire(expected) + except MalformedRecord as error: + raise StaleRevision("replay assessment revision changed") from error + + def require_execution(self, execution_id: str) -> None: + """No recorded assessment claims an execution identity.""" + + def require_assessment(self) -> ReplayAssessments: + raise IntegrityViolationError("replay assessment is absent") + + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: + self.observe( + db, execution_id, facts=ReplayFact.NONE, + replay_safe=True, side_effects_possible=False, + ) + + def revoke_retry(self, db: sqlite3.Connection) -> None: + """An absent assessment has granted nothing to revoke.""" + + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: + after.insert(db) + return True + + +@dataclass(frozen=True, slots=True) +class ReplayAssessments( + ReplayState, + IncrementingColumn, + MonotoneColumns, + AccumulatingBits, + ExecutionReference, + RetryPartition, + NoDelete, + CoordinatorTable, + TypedTable, +): + column_orders = ( + NonDecreasing(("side_effects_possible",)), + NonIncreasing(("replay_safe",)), + ) + accumulating_columns = ("facts",) + + @property + def allows_retry(self) -> bool: + return self.replay_safe and self.facts == ReplayFact.NONE and not self.side_effects_possible + + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] + facts: Annotated[ReplayFact, Column(check="facts >= 0 AND facts <= 1023")] + replay_safe: Annotated[bool, Column()] + side_effects_possible: Annotated[bool, Column()] + revision: Annotated[int, Column(check="revision > 0")] + + @classmethod + def accumulate( + cls, db: sqlite3.Connection, execution_id: str, observed: ReplayFact + ) -> None: + """Join original observations under the caller's existing attempt fence. + + Missing replay evidence cannot establish safety. Only an existing + assessment may retain that fact, and every incoming observation can + revoke it. Output facts remain monotonic across all attempts. + """ + if not observed or cls.exists( + db, + where=( + "execution_id=? AND (facts & ?) = ? AND replay_safe=0 AND side_effects_possible=1" ), - "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""" + parameters=(execution_id, int(observed), int(observed)), + ): + return + # Most streamed deltas repeat a fact already durably joined. Read only + # that answer, not a decoded row or another write-lock acquisition. + # A new fact joins atomically: another connection may have accumulated + # different evidence since the existence check, even in autocommit use. + statement, values = cls(execution_id, observed, False, True, 1)._insertion() + db.execute( + statement + """ + ON CONFLICT (execution_id) DO UPDATE SET + facts = facts | excluded.facts, + replay_safe = 0, + side_effects_possible = 1, + revision = revision + 1 + WHERE (facts | excluded.facts) != facts + OR replay_safe != 0 OR side_effects_possible != 1 + """, + values, + ) + + @classmethod + def for_execution(cls, db: sqlite3.Connection, execution_id: str) -> ReplayAssessment: + return next(iter(cls._keyed(db, dict(execution_id=execution_id))), NoReplayAssessment()) + + @classmethod + def authorize_if_unobserved(cls, db: sqlite3.Connection, execution_id: str) -> None: + """Record provider-failure proof only while no output fact exists.""" + cls.for_execution(db, execution_id).authorize_retry(db, execution_id) + + @classmethod + def revoke(cls, db: sqlite3.Connection, execution_id: str) -> None: + """A nonretryable failure consumes any earlier provider-failure proof.""" + cls.for_execution(db, execution_id).revoke_retry(db) + + @property + def next_revision(self) -> int: + return self.revision + 1 + + def require_revision(self, expected: object) -> None: + if expected != self.revision: + raise StaleRevision("replay assessment revision changed") + + def require_execution(self, execution_id: str) -> None: + if self.execution_id != execution_id: + raise IntegrityViolationError("snapshot records belong to another execution") + + def require_assessment(self) -> ReplayAssessments: + return self + + def authorize_retry(self, db: sqlite3.Connection, execution_id: str) -> None: + """An existing assessment cannot be authorized by a later provider failure.""" + + def revoke_retry(self, db: sqlite3.Connection) -> None: + if self.replay_safe: + self.record(db, replace(self, replay_safe=False, revision=self.next_revision)) + + def require_successor(self, after: ReplayAssessments) -> None: + accumulated = replace( + after, + facts=self.facts | after.facts, + replay_safe=self.replay_safe and after.replay_safe, + side_effects_possible=self.side_effects_possible or after.side_effects_possible, + ) + if accumulated != after: + raise IdentityConflict("replay facts cannot be erased") + + def record(self, db: sqlite3.Connection, after: ReplayAssessments) -> bool: + """The original recorded state validates and persists its successor.""" + after.require_execution(self.execution_id) + if replace(self, revision=after.revision) == after: + return False + self.require_successor(after) + ReplayAssessments.update( + db, + where="execution_id=?", + parameters=(self.execution_id,), + facts=after.facts, + replay_safe=after.replay_safe, + side_effects_possible=after.side_effects_possible, + revision=self.next_revision, + ) + return True + + def __post_init__(self) -> None: + validate_execution_id(self.execution_id) + if self.revision <= 0: + raise ValueError("replay revision must be positive") + if int(self.facts) < 0 or int(self.facts) & ~APPROVED_REPLAY_FACT_MASK: + raise ValueError("unrecognized replay fact bits") + if self.facts != ReplayFact.NONE and self.replay_safe: + raise IntegrityViolationError("an execution with ambiguity facts cannot be replay-safe") + + retry_authorized: ClassVar[Generated] = Generated( + int, + Column( + generated=( + "CASE WHEN facts = 0 AND replay_safe = 1 AND side_effects_possibl" + "e = 0\n" + " THEN 1 ELSE 0 END" ), - } + nullable=True, + ), + ) + + checks = ("facts = 0 OR replay_safe = 0",) + unique = (("execution_id", "retry_authorized"),) + + +# The fixed external snapshot format is still a row object or JSON null. +ReplayAssessment = ReplayAssessments | NoReplayAssessment diff --git a/src/agent_comms/coordination_tables/participants.py b/src/agent_comms/coordination_tables/participants.py index d74f763eac..9c9e4951f0 100644 --- a/src/agent_comms/coordination_tables/participants.py +++ b/src/agent_comms/coordination_tables/participants.py @@ -2,21 +2,51 @@ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass +from typing import Annotated -from agent_comms.participant_identity import ParticipantIdentity from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordination_schema import CoordinatorTable +from agent_comms.participant_identity import ParticipantIdentity from agent_comms.typed_table import ( Column, + ColumnGuard, + CounterAdvance, ForeignKey, + FrozenColumn, + 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,29 +67,14 @@ class ParticipantAliases(CoordinatorTable, TypedTable): def references(cls): return (ForeignKey(("participant_lookup",), Participants, ("participant_lookup",)),) - @classmethod - def trigger_bodies(cls): - return { - "participant_alias_identity_immutable": ( - """ -BEFORE UPDATE ON participant_aliases -BEGIN - SELECT RAISE(ABORT, 'participant alias identity is immutable'); -END""" - ), - "participant_alias_delete_frozen": ( - """ -BEFORE DELETE ON participant_aliases -BEGIN - SELECT RAISE(ABORT, 'participant alias cannot be deleted'); -END""" - ), - } +@dataclass(frozen=True, kw_only=True) +class OwnerGenerations( + CounterAdvance, NoDelete, ParticipantIdentity, CoordinatorTable, TypedTable +): + counter_column = 'generation' -@dataclass(frozen=True, kw_only=True) -class OwnerGenerations(ParticipantIdentity, CoordinatorTable, TypedTable): - owner_lookup: Annotated[str, Column(primary_key=True)] + owner_lookup: Annotated[str, FrozenColumn(primary_key=True)] owner_thread: Annotated[str, Column(check="length(owner_thread) BETWEEN 1 AND 256")] generation: Annotated[int, Column(check="generation > 0")] @@ -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..dbe409674a 100644 --- a/src/agent_comms/coordination_tables/publications.py +++ b/src/agent_comms/coordination_tables/publications.py @@ -23,14 +23,24 @@ 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 +135,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 +151,20 @@ 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 +219,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..d479a713ee 100644 --- a/src/agent_comms/coordination_tables/recovery.py +++ b/src/agent_comms/coordination_tables/recovery.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import Annotated from dataclasses import dataclass from dataclasses import field as dataclass_field from enum import StrEnum +from typing import Annotated from agent_comms import field_codec from agent_comms.coordination_contracts import ( @@ -13,14 +13,21 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.recovery_states import RecoveryCondition +from agent_comms.coordination_tables.executions import ExecutionReference from agent_comms.reason_codes import GivenReasonCode, RecoveryDetail +from agent_comms.recovery_states import RecoveryCondition from agent_comms.typed_table import ( - TextStorage, Column, ForeignKey, + FrozenColumn, + ImmutableRows, + IncrementingColumn, Index, + MonotoneColumns, + NoDelete, + NonDecreasing, RowId, + TextStorage, TypedTable, UnallocatedRowId, ) @@ -38,8 +45,19 @@ class ACPClientConnectivity(StrEnum): @dataclass(frozen=True, slots=True) -class ConnectivityFacet(CoordinatorTable, TypedTable, declared_name="connectivity"): - execution_id: Annotated[str, field_codec.SnapshotExclude(), Column(primary_key=True)] +class ConnectivityFacet( + IncrementingColumn, + MonotoneColumns, + ExecutionReference, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="connectivity", +): + + column_orders = (NonDecreasing(('observed_at_ms',)),) + + execution_id: Annotated[str, field_codec.SnapshotExclude(), FrozenColumn(primary_key=True)] owner: OwnerConnectivity acp_client: ACPClientConnectivity revision: Annotated[int, Column(check="revision>0")] @@ -52,37 +70,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 +110,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..28fff5a566 100644 --- a/src/agent_comms/coordination_tables/responses.py +++ b/src/agent_comms/coordination_tables/responses.py @@ -2,8 +2,8 @@ from __future__ import annotations -from typing import Annotated, ClassVar from dataclasses import dataclass +from typing import Annotated, ClassVar from agent_comms.coordination_contracts import ( MAX_IDENTIFIER_CHARS, @@ -12,29 +12,82 @@ validate_execution_id, ) from agent_comms.coordination_schema import CoordinatorTable -from agent_comms.field_codec import projected, SnapshotExclude, WireName +from agent_comms.coordination_tables.executions import ExecutionReference, RetryPartition +from agent_comms.field_codec import SnapshotExclude, WireName, projected from agent_comms.obligation_states import ResponseState from agent_comms.reason_codes import ReasonCode from agent_comms.typed_table import ( - TextStorage, Column, - ForeignKey, + ColumnGuard, + FrozenColumn, Generated, + GuardedColumns, + GuardedInserts, + GuardedUpdateMembership, + IncrementingColumn, + LifecycleChanges, + LifecycleTransitions, + MonotoneColumns, + NoDelete, + NonDecreasing, + TextStorage, TypedTable, ) @dataclass(frozen=True, slots=True) -class ResponseObligation(CoordinatorTable, TypedTable, declared_name="obligations"): +class ResponseObligation( + LifecycleTransitions, + LifecycleChanges, + IncrementingColumn, + MonotoneColumns, + ExecutionReference, + RetryPartition, + GuardedUpdateMembership, + GuardedColumns, + GuardedInserts, + NoDelete, + CoordinatorTable, + TypedTable, + declared_name="obligations", +): """One original reply-route obligation within an execution.""" + column_orders = (NonDecreasing(("updated_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) - execution_id: Annotated[str, SnapshotExclude(), Column(primary_key=True)] + execution_id: Annotated[str, SnapshotExclude(), FrozenColumn(primary_key=True)] exact_target: Annotated[ - str, Column(primary_key=True, check="length(exact_target) BETWEEN 1 AND 256") + str, FrozenColumn(primary_key=True, check="length(exact_target) BETWEEN 1 AND 256") ] lifecycle: Annotated[ResponseState, SnapshotExclude()] reason_code: Annotated[ @@ -42,7 +95,7 @@ def response_record(records, exact_target): Column(storage=TextStorage, nullable=True, check="reason_code IS NULL OR length(reason_code) BETWEEN 1 AND 64"), ] - created_at_ms: Annotated[int, Column(check="created_at_ms >= 0")] + created_at_ms: Annotated[int, FrozenColumn(check="created_at_ms >= 0")] updated_at_ms: Annotated[int, Column(check="updated_at_ms >= created_at_ms")] revision: Annotated[int, Column(check="revision > 0")] @@ -140,142 +193,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/durable_turn.py b/src/agent_comms/durable_turn.py index ec758e1381..8278fecc17 100644 --- a/src/agent_comms/durable_turn.py +++ b/src/agent_comms/durable_turn.py @@ -23,7 +23,8 @@ ) from .attempt_store import AttemptStore from .coordination_errors import IdentityConflict -from .coordination_tables.attempts import ReplayAssessments, ReplayFact +from .coordination_tables.attempts import ReplayFact +from .coordination_tables.executions import ReplayAssessments from .coordination_session import NativeBoundary from .coordinator import Coordination from .mro_dispatch import MroDispatch, handles diff --git a/src/agent_comms/execution_states.py b/src/agent_comms/execution_states.py index 927f02634c..289c808e19 100644 --- a/src/agent_comms/execution_states.py +++ b/src/agent_comms/execution_states.py @@ -24,6 +24,28 @@ from .coordination_snapshot import RecoverySnapshot +class OrdinalRule: + increment: int = 0 + predecessors: type = LifecycleState + + def next_ordinal(self, previous: ExecutionState) -> int: + return (previous.current_attempt_ordinal or 0) + self.increment + + def accepts(self, following: ExecutionState, previous: ExecutionState) -> bool: + return (isinstance(previous, self.predecessors) + and following.current_attempt_ordinal == (self.next_ordinal(previous) or None)) + + +class PreservedOrdinal(OrdinalRule): + """Keep the previous attempt, including its absence before the first start.""" + + +@dataclass(frozen=True) +class AdvancedOrdinal(OrdinalRule): + predecessors: type + increment: int = 1 + + @dataclass(frozen=True) class ExecutionState(DeclaredFamily, LifecycleState, affix="Execution"): starts_attempt: ClassVar[bool] = False @@ -37,7 +59,7 @@ def current_attempt_ordinal(self) -> int | None: @property def next_attempt_ordinal(self) -> int: - return (self.current_attempt_ordinal or 0) + 1 + return ActiveExecution.ordinal_rule.next_ordinal(self) def require_attempt_start(self) -> None: raise IdentityConflict("execution cannot start an attempt") @@ -71,8 +93,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, previous) def validate_snapshot(self, snapshot, authorized: bool) -> None: """State-specific cross-lifecycle rules, after common identities agree.""" @@ -140,6 +164,7 @@ def __post_init__(self): class ActiveExecution(Active, EngagedExecution, AttemptExecution): + ordinal_rule = AdvancedOrdinal(StartsAttempt) def require_final_response(self) -> None: pass @@ -151,11 +176,6 @@ def successors(cls): def accepts_attempt(self, phase): return phase.accepts_active_execution() - 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 2aab787d5a..de12f27cfe 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 efdf15f99c..ec5af7c4e2 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..e399b6e43d 100644 --- a/src/agent_comms/native_pi.py +++ b/src/agent_comms/native_pi.py @@ -6,8 +6,6 @@ from __future__ import annotations -from agent_comms.participant_identity import ParticipantIdentity - import os import re import shutil @@ -17,16 +15,24 @@ from contextlib import ExitStack, closing, contextmanager from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Annotated +from typing import TYPE_CHECKING, Annotated, Any, Literal +from agent_comms.participant_identity import ParticipantIdentity from .coordination_database import CoordinationStore from .errors import RelationViolationError -from .field_codec import FieldCodec, Id128Text, MalformedRecord, Sha256Text, WireName, boundary_decoder -from .native_failures import NativeRequestFailure, NativePiUnavailable +from .field_codec import ( + FieldCodec, + Id128Text, + MalformedRecord, + Sha256Text, + WireName, + boundary_decoder, +) from .launch_root import LaunchRoot from .launch_runtime import LaunchRuntime, RestartEnvironment, ThreadLaunch from .native_arguments import NativeArguments, SelectedModel +from .native_failures import NativePiUnavailable, NativeRequestFailure from .native_input_record import NativeInputCommit, NativeInputLocation from .native_package import MANIFEST, UNIT_INSTRUCTIONS, VerifiedNativePackage from .native_session_files import NativeSessionFiles @@ -38,7 +44,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 +214,25 @@ 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..9a35c67369 100644 --- a/src/agent_comms/native_prompt_binding.py +++ b/src/agent_comms/native_prompt_binding.py @@ -13,27 +13,33 @@ from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Annotated, TYPE_CHECKING +from typing import TYPE_CHECKING, Annotated from agent_comms.coordination_errors import IdentityConflict from agent_comms.coordination_tables.assignments import WakeAssignment 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 .field_codec import FieldCodec, Id128Text, MalformedRecord from .locked_store import DurableFile, SQLiteFile from .native_entries import NativeEvidenceRead from .native_input_owner import ParticipantOwner from .native_input_record import NativeColumn, NativeInputExecution, NativeInputRecord from .native_pi import read_tracked_input_commitment -from .native_runtime_input import NativeRuntimeInput +from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable from .private_send_rules import NativeIdentityCheck from .private_sidecar import create_sidecar_file, native_request_digest, sidecar_connection from .reservation_rules import ReservationViolationError 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 +51,9 @@ 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 +94,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 +143,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..3f767869f0 100644 --- a/src/agent_comms/native_runtime_input.py +++ b/src/agent_comms/native_runtime_input.py @@ -19,13 +19,13 @@ from .field_codec import FieldCodec, MalformedRecord, NativeContext, WireName from .native_claim_epoch import NativeClaimEpoch, UnrecordedNativeClaim from .native_input_record import ( + NativeColumn, NativeInputContext, NativeInputExecution, NativeInputLocation, NativeInputPosition, - NativeStageStorage, NativeInputRecord, - NativeColumn, + NativeStageStorage, NumberNativeColumn, RecordedNativePosition, TextNativeColumn, @@ -40,11 +40,19 @@ from .typed_table import ( Column, DurableTables, + FinalizedColumn, + FrozenColumn, IntegerStorage, + MonotoneColumns, + NoDelete, + NonDecreasing, + RequiredUpdateColumns, SharedFileTables, + StableReferenceColumns, TextStorage, TypedRow, TypedTable, + WriteOnceColumns, ) if TYPE_CHECKING: @@ -81,7 +89,20 @@ class PublishedReplyRevision(TypedRow): @dataclass(frozen=True) -class NativeRuntimeInput(NativeInputRecord, NativeInputContext, NativeRuntimeTable, TypedTable): +class NativeRuntimeInput( + WriteOnceColumns, + FinalizedColumn, + RequiredUpdateColumns, + NoDelete, + NativeInputRecord, + NativeInputContext, + NativeRuntimeTable, + TypedTable, +): + 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( @@ -214,20 +235,24 @@ def published_replies(self, db, user, owner_lookup): input_id: Annotated[ str, - Column(primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'"), + FrozenColumn( + primary_key=True, check="length(input_id)=32 AND input_id NOT GLOB '*[^0-9a-f]*'" + ), ] stage: Annotated[ - type[NativeInputExecution], Column(check=NativeInputExecution.sql_constraint("stage")) + type[NativeInputExecution], FrozenColumn(check=NativeInputExecution.sql_constraint("stage")) ] execution_id: Annotated[ NativeColumn, - Column(references=(ExecutionRecord, "execution_id"), storage=TextStorage, nullable=True), + FrozenColumn( + references=(ExecutionRecord, "execution_id"), storage=TextStorage, nullable=True + ), ] - attempt_ordinal: Annotated[NativeColumn, Column(storage=IntegerStorage, nullable=True)] - owner_lookup: Annotated[str, Column(references=(Participants, "participant_lookup"))] - owner_thread: str - owner_generation: Annotated[int, Column(check="owner_generation>0")] - owner_token_digest: Annotated[str, Column(check="length(owner_token_digest)=64")] + attempt_ordinal: Annotated[NativeColumn, FrozenColumn(storage=IntegerStorage, nullable=True)] + owner_lookup: Annotated[str, FrozenColumn(references=(Participants, "participant_lookup"))] + owner_thread: Annotated[str, FrozenColumn()] + owner_generation: Annotated[int, FrozenColumn(check="owner_generation>0")] + owner_token_digest: Annotated[str, FrozenColumn(check="length(owner_token_digest)=64")] sent_owner_claimed_generation: Annotated[ NativeClaimEpoch, WireName("sent_owner_admission_generation"), @@ -301,56 +326,39 @@ def commit_context( if updated.rowcount != 1: raise StaleFence("native proof was previously committed") - @classmethod - def trigger_bodies(cls) -> dict[str, str]: - name = cls.declared_name - return { - f"{name}_identity_guard": f""" - BEFORE UPDATE ON {name} - WHEN NEW.input_id IS NOT OLD.input_id OR NEW.stage IS NOT OLD.stage - OR NEW.execution_id IS NOT OLD.execution_id - OR NEW.attempt_ordinal IS NOT OLD.attempt_ordinal - OR NEW.owner_lookup IS NOT OLD.owner_lookup - OR NEW.owner_thread IS NOT OLD.owner_thread - OR NEW.owner_generation IS NOT OLD.owner_generation - OR NEW.owner_token_digest IS NOT OLD.owner_token_digest - OR (OLD.{{sent_owner_claimed_generation}} - IS NOT NULL - AND NEW.{{sent_owner_claimed_generation}} - IS NOT OLD.{{sent_owner_claimed_generation}}) - OR (NEW.{{sent_owner_claimed_generation}} IS NULL AND NEW.session_id IS NOT NULL) - OR (OLD.session_id IS NOT NULL AND - (NEW.session_id IS NOT OLD.session_id OR NEW.session_file IS NOT OLD.session_file)) - OR OLD.session_entry_id IS NOT NULL - OR (NEW.session_id IS NULL AND NEW.{{sent_owner_claimed_generation}} IS NULL) - BEGIN SELECT RAISE(ABORT,'native runtime input identity is frozen'); END""", - f"{name}_delete_guard": f" BEFORE DELETE ON {name} " - "BEGIN SELECT RAISE(ABORT,'native runtime input cannot be deleted'); END", - } - - @dataclass(frozen=True) -class CurrentNativeCursor(NativeInputContext, NativeRuntimeTable, TypedTable): +class CurrentNativeCursor( + MonotoneColumns, + StableReferenceColumns, + NoDelete, + NativeInputContext, + NativeRuntimeTable, + TypedTable, +): + column_orders = (NonDecreasing(("covered_seq", "injected_seq")),) + reference_column = 'injected_seq' + stable_reference_columns = ('input_id',) + @property def context_anchor(self) -> NativeColumn: return self.session_id wire_root_id: Annotated[ str, - Column( + FrozenColumn( primary_key=True, check="length(wire_root_id)=32 AND wire_root_id NOT GLOB '*[^0-9a-f]*'", ), ] recipient_lookup: Annotated[ - str, Column(primary_key=True, references=(Participants, "participant_lookup")) + str, FrozenColumn(primary_key=True, references=(Participants, "participant_lookup")) ] - owner_thread: Annotated[str, Column(check="owner_thread<>''")] - owner_generation: Annotated[int, Column(primary_key=True, check="owner_generation>0")] + owner_thread: Annotated[str, FrozenColumn(check="owner_thread<>''")] + owner_generation: Annotated[int, FrozenColumn(primary_key=True, check="owner_generation>0")] owner_claimed_generation: Annotated[ int, WireName("owner_admission_generation"), - Column(record_key=True, primary_key=True, check="{owner_claimed_generation}>0"), + FrozenColumn(record_key=True, primary_key=True, check="{owner_claimed_generation}>0"), ] @property @@ -386,20 +394,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 94d0e2ba26..33f349329e 100644 --- a/src/agent_comms/native_source_cursor.py +++ b/src/agent_comms/native_source_cursor.py @@ -4,7 +4,6 @@ from functools import partial from typing import TYPE_CHECKING -from .coordinated_runtime_schema import assert_native_runtime_schema from .coordination_database import CoordinationStore from .coordination_errors import IdentityConflict, StaleFence from .coordinator import Coordination @@ -12,7 +11,7 @@ from .field_codec import FieldCodec from .message_bus import MessageBus from .native_input_owner import RegistryOwner -from .native_runtime_input import CurrentNativeCursor +from .native_runtime_input import CurrentNativeCursor, NativeRuntimeTable from .proven_source_coverage import SourceCoverage from .store_files import StoreLockContention from .threads import Thread @@ -63,7 +62,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): @@ -112,12 +111,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..87ab660a87 100644 --- a/src/agent_comms/optional_awareness_projection.py +++ b/src/agent_comms/optional_awareness_projection.py @@ -35,8 +35,8 @@ AwarenessClaimGenerations, ClaimBatchAcknowledgements, ClaimBatchMembers, - CohortDeliveryReceipts, - assert_cohort_schema, + CohortDeliveries, + CohortTable, assert_optional_awareness_schema, ) from .context_segments.optional_awareness import ( @@ -301,7 +301,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( @@ -353,7 +353,7 @@ def _selected_decisions( } deliveries = { row.wire_seq: row - for row in CohortDeliveryReceipts.select( + for row in CohortDeliveries.select( db, where=f"wire_root_id=? AND recipient_lookup=? AND wire_seq IN ({marks})", parameters=(root_id, lookup, *sequences), diff --git a/src/agent_comms/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 f81d8b841e..0f0e5afbcf 100644 --- a/src/agent_comms/private_send_stage.py +++ b/src/agent_comms/private_send_stage.py @@ -15,7 +15,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 @@ -94,7 +94,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( @@ -119,7 +119,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 b20cffdcbb..1352efb187 100644 --- a/src/agent_comms/proven_source_coverage.py +++ b/src/agent_comms/proven_source_coverage.py @@ -24,8 +24,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 @@ -293,8 +293,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/recovery_reader.py b/src/agent_comms/recovery_reader.py index 22e0f89661..6c018376d2 100644 --- a/src/agent_comms/recovery_reader.py +++ b/src/agent_comms/recovery_reader.py @@ -11,7 +11,8 @@ from agent_comms.coordination_session import CoordinationSession from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import ExecutionAssignmentLink -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments +from agent_comms.coordination_tables.attempts import AttemptRecord +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ( ExecutionRecord, ) @@ -52,7 +53,7 @@ def get(self, execution_id: str) -> RecoverySnapshot: ) ) assignments = tuple(self.assignments.get(link.assignment_id) for link in links) - replay = ReplayAssessments.one(db, execution_id=execution_id) + replay = ReplayAssessments.for_execution(db, execution_id) obligations = tuple(ResponseObligation.select(db, where="execution_id=?", parameters=(execution_id,), order_by=("exact_target",))) intents = tuple(PublicationIntents.select(db, where="execution_id=?", diff --git a/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 836f98fceb..e04f327327 100644 --- a/src/agent_comms/selected_participant.py +++ b/src/agent_comms/selected_participant.py @@ -11,13 +11,13 @@ 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 agent_comms.native_runtime_input import NativeRuntimeTable from .context_store import UNREAD_CONTEXT, ContextPrior -from .coordinated_runtime_schema import assert_native_runtime_schema 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 @@ -107,7 +107,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() @@ -161,7 +161,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) @@ -241,11 +241,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 @@ -313,7 +313,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..7b0b4cf3d8 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") @@ -292,7 +292,11 @@ def _require_sent_full_input( raise SelectedToolDenied("Selected tool source was not included in this native input") expected = NativeInputIdentity( input_id, FullNativeExecution(claim_scope.execution_id, claim_scope.attempt_ordinal), - OwnerGenerations(owner_lookup=claim_scope.recipient_lookup, owner_thread=owner_name, generation=claim_scope.participant_generation), + OwnerGenerations( + owner_lookup=claim_scope.recipient_lookup, + owner_thread=owner_name, + generation=claim_scope.participant_generation, + ), ) if row.identity != expected: raise SelectedToolDenied("Selected tool does not match the exact sent FULL input") diff --git a/src/agent_comms/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..4732af6952 100644 --- a/src/agent_comms/triage_native_sources.py +++ b/src/agent_comms/triage_native_sources.py @@ -4,18 +4,35 @@ only for triage, before an execution exists. It contains no handling state. """ -from typing import Annotated from dataclasses import dataclass +from typing import Annotated from .coordination_errors import IdentityConflict from .native_runtime_input import NativeRuntimeInput, NativeRuntimeTable -from .typed_table import Column, TypedTable +from .typed_table import ( + Column, + GuardedInserts, + ImmutableRows, + TypedTable, +) @dataclass(frozen=True) class TriageNativeSources( - NativeRuntimeTable, TypedTable, declared_name="native_runtime_triage_sources" + 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 +44,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/turn_failure.py b/src/agent_comms/turn_failure.py index 3aaddcc921..7086ebf53f 100644 --- a/src/agent_comms/turn_failure.py +++ b/src/agent_comms/turn_failure.py @@ -34,7 +34,7 @@ def supersedes(self, previous: TurnFailure | None) -> bool: def prepare_execution_retry(self, db, execution_id: str) -> None: """Consume any earlier provider proof for this nonretryable failure.""" - from .coordination_tables.attempts import ReplayAssessments + from .coordination_tables.executions import ReplayAssessments ReplayAssessments.revoke(db, execution_id) @@ -144,7 +144,7 @@ class RetryableProviderFailure: def prepare_execution_retry(self, db, execution_id: str) -> None: """A provider refusal may retry only while durable output facts are absent.""" - from .coordination_tables.attempts import ReplayAssessments + from .coordination_tables.executions import ReplayAssessments ReplayAssessments.authorize_if_unobserved(db, execution_id) diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index 0be87f8d80..ff2c3ab2a3 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -22,6 +22,7 @@ from functools import lru_cache from pathlib import Path from typing import ( + TYPE_CHECKING, Annotated, ClassVar, Literal, @@ -29,6 +30,7 @@ Self, TypeVar, Union, + cast, get_args, get_origin, ) @@ -46,9 +48,13 @@ WireValue, compile_decoder, ) +from .lifecycle import LifecycleState from .locked_store import DurableFile, FileStore, RegistryFile, RuntimeFile, SQLiteFile from .maintenance_barrier import MaintenanceBarrier +if TYPE_CHECKING: + from .execution_states import ExecutionState + RelatedRow = TypeVar("RelatedRow", bound="TypedRow") @@ -283,6 +289,9 @@ def declared(cls, annotation: object) -> Column: return column return cls() + def immutable_fields(self, field: _Field) -> tuple[_Field, ...]: + return () + @property def writable(self) -> bool: return self.generated is None @@ -292,6 +301,13 @@ def generated_sql(self) -> str: return "" if self.writable else f" GENERATED ALWAYS AS ({self.generated}) STORED" +class FrozenColumn(Column): + """Immutability is declared with the field's other SQL constraints.""" + + def immutable_fields(self, field: _Field) -> tuple[_Field, ...]: + return (field,) + + _NAME_SEPARATORS = re.compile(r"\W+") @@ -430,6 +446,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) @@ -711,6 +738,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(): @@ -738,36 +772,424 @@ 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): + @classmethod + def immutable_columns(cls, row: type[TypedTable]) -> tuple[_Field, ...]: + return tuple( + immutable for field in row._columns() + for immutable in field.column.immutable_fields(field) + ) + + @classmethod + def mutation_guards(cls, row: type[TypedTable]) -> tuple[SQLiteSchemaObject, ...]: + columns = cls.immutable_columns(row) + if not columns: + return () + condition = " OR ".join( + f'NEW."{column.wire_name}" IS NOT OLD."{column.wire_name}"' for column in columns) + return (RowMutation.UPDATE.abort_trigger( + 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')} " + f"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, ...]: + (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 + expected = f"NULLIF(COALESCE({ordinal.reference('OLD')}, 0) + {rule.increment}, 0)" + allowed = ",".join(sql_literal(previous.declared_name) + for previous in family.members_with(rule.predecessors)) + violations.append( + 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): @@ -845,7 +1267,9 @@ class SQLiteUserVersion(TypedRow): user_version: int -class TypedTable(TypedRow, DeclaredFamily, affix="Row", discovery_package="agent_comms"): +class TypedTable( + FrozenColumns, TypedRow, DeclaredFamily, affix="Row", discovery_package="agent_comms" +): """The row declaration owns its table; no separate table/schema registry. Fields declare SQL capabilities with ``Annotated[T, Column(...)]``. Composite keys derive @@ -979,15 +1403,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/stack/pi-native.sha256 b/stack/pi-native.sha256 index b3f195bc60..81add0946a 100644 --- a/stack/pi-native.sha256 +++ b/stack/pi-native.sha256 @@ -22,7 +22,7 @@ fef47246f4ce18621bae844a940e7d009cefc7df7cdf00ccb98198d9c407a696 dist/core/sess 4fa4e9a0ba80f43b560eb827dbd0985ea56624170b51401b39398d76ffcbcefa dist/core/export-html/index.js d7ad93bcb341424fa79014ce2fec4226326cfab1e8d5fee27c1859138e120161 dist/core/sdk.js 37ae95f1f0ca7c88a75f3c83101e3d7b621c15afced8ac8ec287f958f150cb30 dist/core/native-proof-journal.js -b382dd789961aa8d9954239a1ce869ee8baa1388bf4d37c4d812ac9a2dbb76e7 dist/core/native-proof-schema.js +14554a57b1c11f295b30298d3321ba14a399278e4e7191433fc88325bd27a7e4 dist/core/native-proof-schema.js aae7f609bfa65d29ed16e311456921a88221622e08cf6a1f5c7aab8e58bd69e6 node_modules/@earendil-works/pi-ai/dist/api/openai-completions.js bd6cfc604781d55db4dc34a0463544c1f323611429ac8d5fc5ebde31642132fb agent-comms-extensions/global-agent-comms/index.mjs 7162c92d37ec9d070f918ce05c4be8c8c191cf3e1559351d56c4cd4e6e050dba dist/agent-comms-imports.json @@ -56,5 +56,5 @@ d64e8c05c7932d377034820b1db0df45612afe64885c0765f8f348a12e1bc955 dist/core/skil 8e8276bbabcd898136d431345611928626fad6b6f03ac572f1d8506e6ece326f dist/core/system-prompt.d.ts 0391543cf703a93a0163b37b3ccebe53e0f12bff6f6162b86d56fb017fe49011 dist/core/system-prompt.js # agent-comms-native-stock-v1 @earendil-works/pi-coding-agent@0.85.1 -# agent-comms-native-tree-v1 06e079a85db1a882d017749041c3f2b28a366b1caa7f8a82ba2d307fc582d7d4 +# agent-comms-native-tree-v1 e0c899f88db25438981bb373aaa4861ea9aa4091a16a2bd8c8d61803a1c2569f # agent-comms-native-inputs-v1 6da7f1219c76045199dd98873e19b9bcbed4ee69085603a6c400f3ec136879fa diff --git a/tests/conftest.py b/tests/conftest.py index 583c1416b1..d4c52384f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import asyncio import os +import sqlite3 import subprocess import sys import tempfile @@ -27,6 +28,7 @@ from agent_comms.messages import ConversationRole, InfoKind, Message from agent_comms.store_files import _store_lock from agent_comms.thread_text import ForkedParent, GivenThreadText +from agent_comms.typed_table import TypedTable pytest_plugins = (goal_owner_fixture.__name__,) @@ -209,6 +211,19 @@ def _managed_runtime_environment_is_explicit(monkeypatch: pytest.MonkeyPatch) -> yield +@contextmanager +def without_mutation_guards(db: sqlite3.Connection, table: type[TypedTable]) -> Iterator[None]: + """Damage disposable rows, restoring their declaration before a checked read.""" + guards = table.triggers() + for name in guards: + db.execute(f'DROP TRIGGER "{name}"') + try: + yield + finally: + for statement in guards.values(): + db.execute(statement) + + @contextmanager def _root_lifetime(root: Path) -> Iterator[Path]: """Keep each directory's bus workers inside the directory's actual lifetime.""" diff --git a/tests/test_acp_private_nk_delivery.py b/tests/test_acp_private_nk_delivery.py index 5994148a86..e6dcb0f167 100644 --- a/tests/test_acp_private_nk_delivery.py +++ b/tests/test_acp_private_nk_delivery.py @@ -94,8 +94,8 @@ def test_private_owner_rename_during_active_attempt_can_complete(tmp_path, manag assert comms.registry.require("beta").name == "gamma" with Coordination(str(comms.root / "coordination.sqlite3")) as store: after = store.participants.get(lookup) - assert after.owner_thread == "gamma" - assert after.participant_generation == before.participant_generation + assert after.owner_identity.owner_thread == "gamma" + assert after.owner_identity.generation == before.owner_identity.generation assert after.pointer == active.pointer with pytest.raises(StaleRevision): 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 e85808a9dc..911fc8fb24 100644 --- a/tests/test_claim_bus_read.py +++ b/tests/test_claim_bus_read.py @@ -9,7 +9,7 @@ from __future__ import annotations from agent_comms.context_store import UNREAD_CONTEXT -from conftest import publish_initial +from conftest import _root_lifetime, publish_initial import hashlib import os import sys @@ -76,7 +76,10 @@ def record_context(comms: Comms, name: str, generation: int) -> None: @contextmanager def selected_turn(): """Alice runs a selected turn for one message; context rows surround it.""" - with TemporaryDirectory(prefix="ac-claim-read-") as dirname: + with ( + TemporaryDirectory(prefix="ac-claim-read-") as dirname, + _root_lifetime(Path(dirname)), + ): root = Path(dirname) / "wire" root.mkdir(mode=0o700) worktree = Path(dirname) / "work" @@ -115,7 +118,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, )) @@ -130,7 +133,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_cohort_schema.py b/tests/test_cohort_schema.py index 0e9ea97046..1697646d94 100644 --- a/tests/test_cohort_schema.py +++ b/tests/test_cohort_schema.py @@ -229,7 +229,7 @@ def test_seal_rejects_observer_with_preexisting_legacy_singleton_claim(tmp_path: def test_selected_and_observer_receipts_are_distinct_immutable_rows(tmp_path: Path) -> None: - from agent_comms.cohort_schema import CohortDeliveryReceipts + from agent_comms.cohort_schema import CohortDeliveries from agent_comms.field_codec import FieldCodec with Coordination(str(tmp_path / "coordination.sqlite3")) as store: @@ -259,11 +259,11 @@ def test_selected_and_observer_receipts_are_distinct_immutable_rows(tmp_path: Pa ) ] == [(0, "a", "selected", "claim-a"), (1, "b", "unmentioned_observer", None)] assert db.execute("SELECT COUNT(*) FROM claim_batch_members").fetchone()[0] == 1 - for row in CohortDeliveryReceipts.select(db, order_by=("ordinal",)): + for row in CohortDeliveries.select(db, order_by=("ordinal",)): encoded = FieldCodec.encode(row) - assert encoded["row"] == CohortDeliveryReceipts.declared_name + assert encoded["row"] == CohortDeliveries.declared_name assert encoded["kind"] == row.kind - assert FieldCodec.decode(CohortDeliveryReceipts, encoded) == row + assert FieldCodec.decode(CohortDeliveries, encoded) == row for statement in ( "UPDATE claim_batch_receipts SET accepted_at_ms=101", "DELETE FROM claim_batch_receipts", diff --git a/tests/test_coordinated_runtime.py b/tests/test_coordinated_runtime.py index 006790c221..8228867033 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 NativeRuntimeInput, NativeRuntimeTable from agent_comms.coordination_cohort import accept_delivery_cohort, sealed_cohort_assignments from agent_comms.coordination_errors import ( IdentityConflict, @@ -170,10 +168,11 @@ 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) - store.session._connection.execute("DROP TRIGGER native_runtime_input_delete_guard") + NativeRuntimeTable.require_installed(store.session._connection) + trigger = next(iter(NativeRuntimeInput.triggers())) + store.session._connection.execute(f'DROP TRIGGER "{trigger}"') 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.py b/tests/test_coordination.py index 5525c0baeb..93b804a9f6 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -35,7 +35,8 @@ MessageAudience, WakeAssignment, ) -from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import AttemptRecord, ReplayFact +from agent_comms.coordination_tables.executions import NoReplayAssessment, ReplayAssessments from agent_comms.coordination_tables.executions import ( CurrentExecutions, ExecutionOrigin, @@ -945,7 +946,7 @@ def test_missing_replay_cannot_later_reauthorize_failed(db): attempt=attempt_record, assignments=(), links=(), - replay=None, + replay=NoReplayAssessment(), obligations=(), publication_intents=(), publication_evidence=(), @@ -1425,12 +1426,12 @@ def test_all_durable_tables_reject_delete_even_when_empty(db, table): assert triggers, f"{table} lacks fail-loud delete authority" -def test_schema_drift_and_symlink_rejected(tmp_path): +def test_unexplained_columns_and_symlinks_rejected(tmp_path): path = tmp_path / "db" with CoordinationStore(path): pass with sqlite3.connect(path) as c: - c.execute("CREATE INDEX stray ON participants(participant_lookup)") + c.execute("ALTER TABLE participants ADD COLUMN unexplained TEXT") with pytest.raises(SchemaVersionError): CoordinationStore(path) link = tmp_path / "link" diff --git a/tests/test_coordination_cohort.py b/tests/test_coordination_cohort.py index 5671b98d06..62ab373c4a 100644 --- a/tests/test_coordination_cohort.py +++ b/tests/test_coordination_cohort.py @@ -21,7 +21,7 @@ FullPendingAssignment, ) from agent_comms.bus_publication import PRIVATE_WIRE_FIELD -from agent_comms.cohort_schema import CohortDeliveryReceipts, install_private_cohort_schema +from agent_comms.cohort_schema import CohortDeliveries, install_private_cohort_schema from agent_comms.comms import Comms from agent_comms.coordination_cohort import ( accept_delivery_cohort, @@ -179,13 +179,13 @@ def test_sealed_receipts_follow_recipient_progress(tmp_path: Path) -> None: for message in (alice_first, bob, alice_second): accept_delivery_cohort(comms.bus, root_id, message.seq, store) - assert CohortDeliveryReceipts.sealed_sequences_after( + assert CohortDeliveries.sealed_sequences_after( store.session._connection, wire_root_id=root_id, recipient_lookup=lookups["Alice"], after_seq=alice_first.seq, ) == frozenset({alice_second.seq}) - assert CohortDeliveryReceipts.sealed_sequences_after( + assert CohortDeliveries.sealed_sequences_after( store.session._connection, wire_root_id=root_id, recipient_lookup=lookups["Bob"], diff --git a/tests/test_coordination_store.py b/tests/test_coordination_store.py index 9a0e6a8341..3ab9cc6905 100644 --- a/tests/test_coordination_store.py +++ b/tests/test_coordination_store.py @@ -36,14 +36,21 @@ from agent_comms.attempt_start import INITIAL_LEASE_DURATION_MS from agent_comms.coordination_errors import ( IdentityConflict, + IntegrityViolationError, PublicationUncertain, RecoveryBlocked, StaleFence, StaleRevision, ) from agent_comms.coordination_results import AlreadyApplied, Applied +from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.assignments import WakeAssignment -from agent_comms.coordination_tables.attempts import ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import ReplayFact +from agent_comms.coordination_tables.executions import ( + NoReplayAssessment, + ReplayAssessment, + ReplayAssessments, +) from agent_comms.coordination_tables.executions import ExecutionOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, @@ -219,12 +226,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" @@ -503,7 +510,6 @@ def test_token_digest_is_global_and_only_digest_persists(db_path: Path) -> None: def test_settled_snapshot_encodes_recovery_kind_separately_from_table(db_path: Path) -> None: - from agent_comms.coordination_snapshot import RecoverySnapshot from agent_comms.coordination_tables.recovery import RecoveryAudit with store(db_path) as db: @@ -628,9 +634,9 @@ def test_provider_failure_after_tool_start_is_terminal(db_path: Path) -> None: ) assert type(outcome.value.execution.lifecycle) is FailedExecution assert not outcome.value.can_retry - assert outcome.value.replay is not None - assert outcome.value.replay.facts == ReplayFact.TOOL_CALL_STARTED - assert outcome.value.replay.side_effects_possible + replay = outcome.value.replay.require_assessment() + assert replay.facts == ReplayFact.TOOL_CALL_STARTED + assert replay.side_effects_possible @pytest.mark.parametrize("first_fact", ["process_dead", "backend_done"]) @@ -699,8 +705,8 @@ def test_provider_proof_carries_until_later_attempt_records_output( reason_code=GivenReasonCode("provider_failed"), failure=ProviderRetryExhausted("controlled provider failure"), ) - proof = deferred.value.replay - assert deferred.value.can_retry and proof is not None and proof.allows_retry + proof = deferred.value.replay.require_assessment() + assert deferred.value.can_retry and proof.allows_retry db.participants.advance_generation("owner", "thread", expected_generation=1) second = db.attempts.start( AttemptStart( @@ -716,8 +722,7 @@ def test_provider_proof_carries_until_later_attempt_records_output( assert second.value.snapshot.replay == proof with db.session.writing() as connection: ReplayAssessments.accumulate(connection, "exec", fact) - revoked = db.snapshots.get("exec").replay - assert revoked is not None + revoked = db.snapshots.get("exec").replay.require_assessment() assert revoked.facts == fact and not revoked.allows_retry second_fence = final_evidence(db, second.value.fence, pointer_revision=3) @@ -733,18 +738,61 @@ def test_provider_proof_carries_until_later_attempt_records_output( assert outcome.value.replay == revoked +def test_replay_states_own_presence_revision_and_retry_authorization(db_path: Path) -> None: + with ready(db_path) as store, store.session.writing() as connection: + absent = store.snapshots.get("exec").replay + assert absent == NoReplayAssessment() + assert FieldCodec.encode(absent, ReplayAssessment) is None + assert FieldCodec.decode(ReplayAssessment, None) == absent + snapshot = store.snapshots.get("exec") + encoded = FieldCodec.encode(snapshot) + assert encoded["replay"] is None + assert FieldCodec.decode(RecoverySnapshot, encoded) == snapshot + absent.require_revision(None) + assert not absent.allows_retry + with pytest.raises(IntegrityViolationError): + absent.require_assessment() + with pytest.raises(StaleRevision): + absent.require_revision(1) + absent.revoke_retry(connection) + assert ReplayAssessments.count(connection) == 0 + + absent.authorize_retry(connection, "exec") + recorded = ReplayAssessments.for_execution(connection, "exec").require_assessment() + assert recorded.allows_retry and recorded.revision == 1 + assert FieldCodec.decode(ReplayAssessment, FieldCodec.encode(recorded)) == recorded + recorded.require_revision(1) + with pytest.raises(StaleRevision): + recorded.require_revision(None) + with pytest.raises(IntegrityViolationError): + recorded.require_execution("another-execution") + changes = connection.total_changes + ReplayAssessments.authorize_if_unobserved(connection, "exec") + assert not recorded.observe( + connection, "exec", facts=ReplayFact.NONE, + replay_safe=True, side_effects_possible=False, + ) + assert connection.total_changes == changes + ReplayAssessments.revoke(connection, "exec") + revoked = ReplayAssessments.for_execution(connection, "exec").require_assessment() + assert not revoked.allows_retry and revoked.revision == 2 + changes = connection.total_changes + ReplayAssessments.revoke(connection, "exec") + assert connection.total_changes == changes + + def test_repeated_replay_fact_does_not_rewrite_its_durable_assessment(db_path: Path) -> None: with ready(db_path) as db: connection = db.session._connection ReplayAssessments.accumulate(connection, "exec", ReplayFact.ASSISTANT_OUTPUT) - before = ReplayAssessments.one(connection, execution_id="exec") + before = ReplayAssessments.for_execution(connection, "exec").require_assessment() changes = connection.total_changes for _ in range(100): ReplayAssessments.accumulate(connection, "exec", ReplayFact.ASSISTANT_OUTPUT) assert connection.total_changes == changes - assert ReplayAssessments.one(connection, execution_id="exec") == before + assert ReplayAssessments.for_execution(connection, "exec") == before ReplayAssessments.accumulate(connection, "exec", ReplayFact.THINKING_OUTPUT) - after = ReplayAssessments.one(connection, execution_id="exec") + after = ReplayAssessments.for_execution(connection, "exec").require_assessment() assert after.facts == ReplayFact.ASSISTANT_OUTPUT | ReplayFact.THINKING_OUTPUT assert after.revision == before.revision + 1 assert not after.allows_retry @@ -820,8 +868,7 @@ def test_ambiguity_forces_failed_partition(db_path: Path) -> None: ) assert type(settled.value.execution.lifecycle) is FailedExecution assert not settled.value.can_retry - assert settled.value.replay is not None - assert settled.value.replay.facts == ReplayFact.TOOL_EXECUTED + assert settled.value.replay.require_assessment().facts == ReplayFact.TOOL_EXECUTED def test_fenced_connectivity_audit_and_monotone_replay(db_path: Path) -> None: @@ -880,7 +927,7 @@ def test_fenced_connectivity_audit_and_monotone_replay(db_path: Path) -> None: replay_safe=True, side_effects_possible=False, ) - assert db.snapshots.get("exec").replay.facts == ReplayFact.TOOL_EXECUTED + assert db.snapshots.get("exec").replay.require_assessment().facts == ReplayFact.TOOL_EXECUTED def test_recovered_audit_requires_same_attempt_new_incident_and_resumption(db_path: Path) -> None: @@ -1296,7 +1343,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_lifecycle_subclasses.py b/tests/test_lifecycle_subclasses.py index d3a0aa6c73..ede3a3664c 100644 --- a/tests/test_lifecycle_subclasses.py +++ b/tests/test_lifecycle_subclasses.py @@ -82,7 +82,7 @@ def test_unknown_settles_failed_in_current_store(tmp_path, authorized): outcome = store.attempts.fail_unknown(fence, expected_pointer_revision=1).value assert type(outcome.attempt.lifecycle) is UnknownOutcomeAttempt assert type(outcome.execution.lifecycle) is FailedExecution - assert outcome.replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert outcome.replay.require_assessment().facts & ReplayFact.UNKNOWN_EFFECTS assert not outcome.can_retry diff --git a/tests/test_native_pi.py b/tests/test_native_pi.py index 5accb61bfa..4d1404b62d 100644 --- a/tests/test_native_pi.py +++ b/tests/test_native_pi.py @@ -137,7 +137,8 @@ def link(self, original, selected): class RemovedConstraintDamage(JournalDamage): def alter(self, files): with sqlite3.connect(files.input_proof) as db: - db.execute(f'DROP TRIGGER {NativeContextJournal.declared_name}_append') + for name in NativeContextJournal.triggers(): + db.execute(f'DROP TRIGGER "{name}"') @pytest.mark.parametrize('damage', JournalDamage.members_with(JournalDamage), ids=lambda member: member.declared_name) diff --git a/tests/test_native_unknown_recovery.py b/tests/test_native_unknown_recovery.py index dc988ab9e6..4e7d80bedf 100644 --- a/tests/test_native_unknown_recovery.py +++ b/tests/test_native_unknown_recovery.py @@ -67,7 +67,8 @@ async def release_starting_attempt(directory): def starting_owner(directory, output, exit_allowed): - output.put(str(asyncio.run(release_starting_attempt(directory)))) + with closing(output): + output.send(str(asyncio.run(release_starting_attempt(directory)))) if not exit_allowed.wait(15): raise TimeoutError("test must release starting owner") @@ -75,21 +76,20 @@ def starting_owner(directory, output, exit_allowed): @pytest.fixture def released_starting(tmp_path): context = multiprocessing.get_context("spawn") - output, exit_allowed = context.Queue(), context.Event() - process = context.Process(target=starting_owner, args=(str(tmp_path), output, exit_allowed)) + output, announcement = context.Pipe(duplex=False) + exit_allowed = context.Event() + process = context.Process( + target=starting_owner, args=(str(tmp_path), announcement, exit_allowed) + ) process.start() - try: - root = Path(output.get(timeout=10)) - leave(process, exit_allowed) - yield root - finally: - exit_allowed.set() - process.join(10) - if process.is_alive(): - process.terminate() - process.join(5) - output.close() - assert process.exitcode == 0 + announcement.close() + with closing(output): + try: + root = Path(output.recv()) + leave(process, exit_allowed) + yield root + finally: + leave(process, exit_allowed) def recover_starting_owner(released_starting): @@ -111,8 +111,9 @@ def recover_starting_owner(released_starting): after.attempt.lifecycle.require(Failed, ValueError("failure required")) assert not after.is_current assert after.attempt.reason_code == GivenReasonCode("released_native_unknown") - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not after.replay.replay_safe + replay = after.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert not CurrentExecutions.require(store.session._connection, owner_lookup=owner.incarnation.lookup).unresolved assert input_evidence(store) == inputs @@ -246,7 +247,8 @@ def test_recover_owner_refuses_a_conflicting_input_identity(released_starting, c def unknown_owner(directory, history, output, exit_allowed): source = asyncio.run(release_native_input(Path(directory), history)) - output.put((str(source.root), source.root_id, source.execution_id, source.input_id)) + with closing(output): + output.send((str(source.root), source.root_id, source.execution_id, source.input_id)) if not exit_allowed.wait(15): raise TimeoutError("test must release fixture process") @@ -254,27 +256,27 @@ def unknown_owner(directory, history, output, exit_allowed): @pytest.fixture(params=NativeSendHistory.members_with(UnknownHistory), ids=lambda history: history.declared_name) def released_unknown(tmp_path, request): context = multiprocessing.get_context("spawn") - output, exit_allowed = context.Queue(), context.Event() + output, announcement = context.Pipe(duplex=False) + exit_allowed = context.Event() process = context.Process( - target=unknown_owner, args=(str(tmp_path), request.param, output, exit_allowed) + target=unknown_owner, args=(str(tmp_path), request.param, announcement, exit_allowed) ) process.start() - try: - root, root_id, execution_id, input_id = output.get(timeout=10) - yield process, exit_allowed, Path(root), root_id, execution_id, input_id - finally: - exit_allowed.set() - process.join(10) - if process.is_alive(): - process.terminate() - process.join(5) - output.close() - assert process.exitcode == 0 + announcement.close() + with closing(output): + try: + root, root_id, execution_id, input_id = output.recv() + yield process, exit_allowed, Path(root), root_id, execution_id, input_id + finally: + leave(process, exit_allowed) def leave(process, exit_allowed): exit_allowed.set() process.join(5) + if process.is_alive(): + process.terminate() + process.join(5) assert process.exitcode == 0 @@ -298,8 +300,9 @@ def test_stop_of_previously_released_dead_owner_settles_without_replay(released_ snapshot = store.snapshots.get(execution_id) assert not snapshot.is_current assert type(snapshot.execution.lifecycle) is FailedExecution - assert snapshot.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not snapshot.replay.replay_safe + replay = snapshot.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert input_id in str(before_input) @@ -382,8 +385,9 @@ def test_dead_original_settles_unknown_beside_a_live_successor(released_unknown) RecoveryMonitorCapability.abandon_released_native_attempt(store, execution_id) after = store.snapshots.get(execution_id) assert not after.is_current and isinstance(after.attempt.lifecycle, Failed) - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS - assert not after.replay.replay_safe + replay = after.replay.require_assessment() + assert replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert not replay.replay_safe assert input_evidence(store) == before_input assert (root / "bus.jsonl").read_bytes() == before_bus assert comms.registry.require("beta") == successor @@ -410,4 +414,4 @@ def test_unknown_settlement_is_atomic_on_existing_store(tmp_path): fence, expected_pointer_revision=before.pointer_revision ).value assert not after.is_current and not after.can_retry - assert after.replay.facts & ReplayFact.UNKNOWN_EFFECTS + assert after.replay.require_assessment().facts & ReplayFact.UNKNOWN_EFFECTS diff --git a/tests/test_optional_awareness_projection.py b/tests/test_optional_awareness_projection.py index e83bf39b90..5e64ba9775 100644 --- a/tests/test_optional_awareness_projection.py +++ b/tests/test_optional_awareness_projection.py @@ -4,7 +4,7 @@ from agent_comms.lifecycle_capability import Executing -from conftest import publish_initial +from conftest import publish_initial, without_mutation_guards from agent_comms.messages import NoticeRole import os from dataclasses import replace @@ -15,7 +15,11 @@ from agent_comms.thread_identity import RecordedThreadIncarnation from agent_comms.bus_publication import CommittedDelivery from agent_comms.child_process import ProcessIdentity -from agent_comms.cohort_schema import install_private_cohort_schema +from agent_comms.cohort_schema import ( + AwarenessClaimGenerations, + ClaimBatchAcknowledgements, + install_private_cohort_schema, +) from agent_comms.comms import Comms from agent_comms.coordination_cohort import accept_delivery_cohort from agent_comms.coordination_errors import IdentityConflict @@ -99,7 +103,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, ) @@ -288,18 +292,14 @@ def test_older_receipt_message_id_mismatch_omits_entire_context(tmp_path: Path) owner = _owner(comms, "member000") builder = _projection(index, store, owner, 0, initial.message.seq) assert builder(initial, assignment, owner).context()["selected"] - # Disposable legacy/disk inconsistency only: supported writes freeze - # acknowledgement facts. Restore the original trigger before checking schema. + # Disposable disk inconsistency only: supported writes freeze + # acknowledgement facts. Restore declared guards before checking schema. db = store.session._connection - trigger = db.execute( - "SELECT sql FROM sqlite_master WHERE name='cohort_receipt_update_guard'" - ).fetchone()[0] - db.execute("DROP TRIGGER cohort_receipt_update_guard") - db.execute( - "UPDATE claim_batch_receipts SET message_id=? WHERE wire_root_id=? AND wire_seq=?", - ("forged-older-id", root_id, prior.message.seq), - ) - db.execute(trigger) + with without_mutation_guards(db, ClaimBatchAcknowledgements): + db.execute( + "UPDATE claim_batch_receipts SET message_id=? WHERE wire_root_id=? AND wire_seq=?", + ("forged-older-id", root_id, prior.message.seq), + ) stale = builder(initial, assignment, owner) assert 'Optional awareness omitted' in stale.render(32768) finally: @@ -459,14 +459,10 @@ def test_saved_wire_awareness_preserves_passive_authority_and_rejects_incomplete # The passive row remains a completeness requirement, not an inner-join # casualty silently omitted while the original is called complete. db = store.session._connection - trigger = db.execute( - "SELECT sql FROM sqlite_master WHERE name='awareness_generation_delete_guard'" - ).fetchone()[0] - db.execute("DROP TRIGGER awareness_generation_delete_guard") - db.execute( - "DELETE FROM awareness_claim_generations WHERE claim_id=?", (passive.assignment_id,) - ) - db.execute(trigger) + with without_mutation_guards(db, AwarenessClaimGenerations): + db.execute( + "DELETE FROM awareness_claim_generations WHERE claim_id=?", (passive.assignment_id,) + ) missing = builder(initial, assignment, owner) assert 'Optional awareness omitted' in missing.render(32768) assert WakeAssignment.select(db) == assignments_before diff --git a/tests/test_private_sidecar.py b/tests/test_private_sidecar.py index 48189b6532..da00559603 100644 --- a/tests/test_private_sidecar.py +++ b/tests/test_private_sidecar.py @@ -115,7 +115,7 @@ def test_symlink_ancestor_refused(path): "CREATE INDEX unrelated_index ON prompt_binding(owner_thread)", "CREATE VIEW unrelated_view AS SELECT * FROM prompt_binding", "CREATE TABLE unrelated_table (x)", - "DROP TRIGGER prompt_binding_delete_guard", + *(f'DROP TRIGGER "{name}"' for name in PromptBinding.triggers()), ], ) def test_all_schema_objects_checked_independent_of_name(path, sql): diff --git a/tests/test_s3_legality_behavior.py b/tests/test_s3_legality_behavior.py index 77bf6ebe5f..60dca936e7 100644 --- a/tests/test_s3_legality_behavior.py +++ b/tests/test_s3_legality_behavior.py @@ -11,7 +11,8 @@ from agent_comms.lifecycle_capability import Complete, Failed, Successful, Terminal, Active from agent_comms.coordination_errors import IntegrityViolationError from agent_comms.coordination_snapshot import RecoverySnapshot -from agent_comms.coordination_tables.attempts import ReplayAssessments, ReplayFact +from agent_comms.coordination_tables.attempts import ReplayFact +from agent_comms.coordination_tables.executions import ReplayAssessments from agent_comms.coordination_tables.executions import ExecutionOrigin from agent_comms.coordination_tables.publications import ( PublicationIntents, diff --git a/tests/test_table_mutation_capabilities.py b/tests/test_table_mutation_capabilities.py new file mode 100644 index 0000000000..8e3e5f5dc9 --- /dev/null +++ b/tests/test_table_mutation_capabilities.py @@ -0,0 +1,377 @@ +"""Raw SQLite mutations exercise the declaration-derived capability family.""" +import sqlite3 +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.executions import ExecutionRecord +from agent_comms.coordination_tables.participants import OwnerGenerations, Participants +from agent_comms.declared_family import DeclaredFamily +from agent_comms.execution_states import ( + ActiveExecution, + CompletedExecution, + DeferredExecution, + ExecutionState, + PendingExecution, +) +from agent_comms.lifecycle import LifecycleState +from agent_comms.typed_table import ( + AccumulatingBits, + Column, + ColumnGuard, + FinalizedColumn, + FinalizedLifecycle, + FirstValueState, + FrozenColumn, + Generated, + GuardedAfterWrites, + GuardedColumns, + GuardedInserts, + GuardedRelatedInserts, + GuardedUpdateMembership, + ImmutableRows, + IncrementingColumn, + InitialOneWayColumn, + LifecycleChanges, + LifecycleTransitions, + MonotoneColumns, + NonDecreasing, + NonDecreasingWhenPresent, + NonIncreasing, + OrdinalTransitions, + RequiredColumn, + RequiredUpdateColumns, + RowMutation, + StableReferenceColumns, + TypedTable, + ValidatedInserts, + WriteOnceColumns, +) + + +@dataclass(frozen=True) +class OrderedRow(IncrementingColumn, MonotoneColumns, AccumulatingBits, TypedTable): + identity: Annotated[int, FrozenColumn(primary_key=True)] + revision: int + rising: int + falling: int + bits: int + optional: int | None + accumulating_columns = ("bits",) + column_orders = ( + NonDecreasing(("rising",)), NonIncreasing(("falling",)), + NonDecreasingWhenPresent(("optional",)), + ) + + +@dataclass(frozen=True) +class ComputedRow(OrderedRow): + offset: int = 0 + parity: ClassVar[Generated] = Generated( + int, FrozenColumn(generated="(rising + offset) % 2") + ) + + +@dataclass(frozen=True) +class SealedRow(InitialOneWayColumn, TypedTable): + sealed: int + 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, ComputedRow, 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_frozen_fields_include_inherited_and_generated_columns(db): + original = ComputedRow(1, 1, 10, 10, 1, 10) + original.insert(db) + for changes in ({"identity": 2}, {"rising": 11}, {"offset": 1}): + with pytest.raises(sqlite3.IntegrityError): + ComputedRow.update(db, where="identity=1", **({"revision": 2} | changes)) + ComputedRow.update(db, where="identity=1", revision=2, rising=12, offset=2) + assert ComputedRow.select(db) == [ComputedRow(1, 2, 12, 10, 1, 10, 2)] + + +def test_one_way_column_consumes_the_declared_transition(db): + with pytest.raises(sqlite3.IntegrityError): + SealedRow(1).insert(db) + 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) + + +def test_normal_writer_replaces_old_guards_without_rewriting_rows(tmp_path): + path = tmp_path / CoordinationStore.filename + with closing(CoordinationStore(path)) as store: + connection = store._connection + Participants(participant_lookup="owner", display_name="owner", committed=True).insert( + connection + ) + before = Participants.select(connection) + delete_guard = RowMutation.DELETE.abort_trigger(Participants).name + connection.execute(f'DROP TRIGGER "{delete_guard}"') + connection.execute( + "CREATE TRIGGER previous_guard BEFORE DELETE ON participants " + "BEGIN SELECT RAISE(ABORT, 'frozen'); END" + ) + assert CoordinatorTable.schema_differs(connection) + with closing(CoordinationStore(path)) as store: + connection = store._connection + CoordinatorTable.require_installed(connection) + assert Participants.select(connection) == before + assert connection.total_changes == 0 + version = connection.execute("PRAGMA schema_version").fetchone()[0] + with pytest.raises(sqlite3.IntegrityError): + Participants.delete(connection) + with closing(CoordinationStore(path)) as store: + connection = store._connection + assert connection.execute("PRAGMA schema_version").fetchone()[0] == version + assert connection.total_changes == 0 + assert Participants.select(connection) == before diff --git a/tests/test_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):