diff --git a/src/agent_comms/activity_clocks.py b/src/agent_comms/activity_clocks.py index edec2e274b..0ffc61ae8b 100644 --- a/src/agent_comms/activity_clocks.py +++ b/src/agent_comms/activity_clocks.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Self from .field_codec import FieldCodec +from .projection_source import ProjectionSource from .typed_table import TypedRow if TYPE_CHECKING: @@ -14,7 +15,7 @@ @dataclass(frozen=True, slots=True) -class ChannelActivity(TypedRow): +class ChannelActivity(TypedRow, ProjectionSource): last_message: float = 0 last_user_input: float = 0 diff --git a/src/agent_comms/audience_manifest.py b/src/agent_comms/audience_manifest.py index d16b2ff225..4139bf4f4c 100644 --- a/src/agent_comms/audience_manifest.py +++ b/src/agent_comms/audience_manifest.py @@ -20,6 +20,7 @@ from .channel_targets import exact_stored_target from .field_codec import FieldCodec, MalformedRecord, Sha256Text from .messages import Message +from .projection_source import ProjectionSource AUDIENCE_VERSION: Final = 1 MAX_RECIPIENTS: Final = 4096 @@ -57,7 +58,7 @@ def _digest(domain: bytes, payload: bytes) -> str: @dataclass(frozen=True, slots=True) -class FrozenRecipient: +class FrozenRecipient(ProjectionSource): """Stable coordination lookup paired with its send-time canonical name.""" recipient_lookup: str @@ -69,7 +70,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class FrozenAudience: +class FrozenAudience(ProjectionSource): """Immutable precommit snapshot of the full delivered audience, not a acknowledgement. Neither the digest nor direct construction attests a bus append. At recovery, diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index b853676832..7ebe16713c 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -11,6 +11,7 @@ from .errors import HumanSendRefusedError, RelationViolationError, UnregisteredThreadError from .field_codec import NullMember, SingleFieldValue, SubclassUnion from .history_result import DmHistoryPageResult, HistoryPageResult +from .projection_source import ProjectionSource from .response_policy import DirectPolicy, MentionedOnlyPolicy from .wake_policy import BoundedTriageWake, FullWake, MessageAudience, UnmentionedWake @@ -103,7 +104,7 @@ def exact_stored_target(name: str) -> bool: if name in BuiltinChannelView.addresses() else True) -class CommandTarget(ABC): +class CommandTarget(ABC, ProjectionSource): """A command destination owns its binding and catalog participation.""" needs_channel_state: ClassVar[bool] = False @@ -573,7 +574,7 @@ def bindings[C: CliCommand]( @dataclass(frozen=True, slots=True) -class Tag: +class Tag(ProjectionSource): name: str def __post_init__(self) -> None: diff --git a/src/agent_comms/channels.py b/src/agent_comms/channels.py index 935a9776e2..24637c7af8 100644 --- a/src/agent_comms/channels.py +++ b/src/agent_comms/channels.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, ClassVar, Annotated +from .projection_source import ProjectionSource from .channel_targets import ChannelTarget, Tag from .cli_result import CliResult from .declared_family import DeclaredFamily @@ -47,7 +48,7 @@ def __missing__(self, tag: str) -> tuple[Thread, ...]: @dataclass(frozen=True, slots=True) -class Channel(DeclaredFamily, CliResult, ToolResult, affix="Channel"): +class Channel(DeclaredFamily, CliResult, ToolResult, ProjectionSource, affix="Channel"): """A captured channel presentation with behavior supplied by its declaration.""" name: str diff --git a/src/agent_comms/declared_family.py b/src/agent_comms/declared_family.py index 2c19507ebb..2cbe52dda0 100644 --- a/src/agent_comms/declared_family.py +++ b/src/agent_comms/declared_family.py @@ -217,12 +217,12 @@ def _member_declarations(cls) -> tuple[type[DeclaredFamily], ...]: @classmethod def discover_members(cls, package: ModuleType, capability: type) -> tuple[type[Self], ...]: """Acquire a producer package before deriving its declared capability members.""" - DeclaredFamily._discover_package(package) + DeclaredFamily.discover_package(package) return cls.members_with(capability) @staticmethod @cache - def _discover_package(package: ModuleType) -> None: + def discover_package(package: ModuleType) -> None: """Import each module of the package once, so its members register themselves.""" for module in pkgutil.iter_modules(package.__path__, package.__name__ + "."): if not module.ispkg: diff --git a/src/agent_comms/envelope_claim_transitions.py b/src/agent_comms/envelope_claim_transitions.py index 999c9d8579..fff16a8d7c 100644 --- a/src/agent_comms/envelope_claim_transitions.py +++ b/src/agent_comms/envelope_claim_transitions.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Annotated, ClassVar, Literal +from .projection_source import ProjectionSource from .coordination_errors import IdentityConflict from .field_codec import ( BoundedText, @@ -115,7 +116,7 @@ def validate_leaf(self, path: Path) -> None: @dataclass(frozen=True, slots=True) -class AssignmentBinding: +class AssignmentBinding(ProjectionSource): """The selected claim's revision and source, used for permission checks and its SQL row.""" assignment_id: Annotated[str, BoundedText] @@ -135,7 +136,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) -class ClaimScope(SubclassUnion, ABC, affix=""): +class ClaimScope(SubclassUnion, ABC, ProjectionSource, affix=""): """What one acquisition belongs to; its member answers reuse, liveness and operation use.""" def reuses(self, other: RecordedScope) -> bool: @@ -253,7 +254,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class ClaimRelease: +class ClaimRelease(ProjectionSource): resource: Annotated[str, ResourcePathText] generation: Annotated[str, Id128Text] @@ -276,7 +277,7 @@ class AcquiringClaim(ClaimIdentity): @dataclass(frozen=True) -class ClaimTransition(ClaimIdentity, SubclassUnion, ABC, affix=""): +class ClaimTransition(ClaimIdentity, SubclassUnion, ABC, ProjectionSource, affix=""): """One original resource transition; its member declares what it changes.""" max_resources: ClassVar[int] = 32 @@ -459,7 +460,7 @@ def live(self, registry: RegistrySnapshot) -> bool: @dataclass(frozen=True, slots=True) -class ClaimProjection(Mapping[str, ClaimOwner]): +class ClaimProjection(Mapping[str, ClaimOwner], ProjectionSource): """Immutable sorted claim table plus the last processed envelope sequence.""" last_seq: Annotated[int, ProjectionSequence] = 0 diff --git a/src/agent_comms/messages.py b/src/agent_comms/messages.py index 23ea32a61a..9630245a6f 100644 --- a/src/agent_comms/messages.py +++ b/src/agent_comms/messages.py @@ -3,12 +3,14 @@ from __future__ import annotations import agent_comms +from .projection_source import ProjectionSource from .turn_origin import TurnOrigin import builtins import hashlib import re import sqlite3 import time +from types import ModuleType from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Mapping, Sequence from contextlib import closing @@ -72,16 +74,8 @@ class BusProjectionTables(ProducedTable, SharedFileTables, DerivedTables, declar """Reverse lookups are derived from the record, in its append transaction.""" @classmethod - def producer_capabilities(cls) -> tuple[tuple[type[DeclaredFamily], type], ...]: - return ((BusRecord, BusCapability), (ClaimTransition, ClaimTransition)) - - @classmethod - def producer_classes(cls) -> tuple[type[DeclaredFamily], ...]: - return tuple( - member - for family, capability in cls.producer_capabilities() - for member in family.discover_members(agent_comms, capability) - ) + def producer_package(cls) -> ModuleType: + return agent_comms @classmethod def create_schema(cls, db: sqlite3.Connection) -> None: @@ -204,7 +198,7 @@ def apply( holders.advance(removed, projected) -class ProjectionPass(ABC): +class ProjectionPass(ABC, ProjectionSource): """How one projection pass writes the rows its records derive: an append writes its few rows at once.""" @@ -274,7 +268,7 @@ class CapabilityRow(BusTables, declared_name=None): seq: Annotated[int, Column(primary_key=True, references=TableReference(BusRow, ("seq",)))] -class _CapabilityTable: +class _CapabilityTable(ProjectionSource): """A class's table is derived once, after its dataclass decorator has run.""" def __get__(self, instance: BusCapability | None, owner: type[BusCapability]) -> type[CapabilityRow]: @@ -295,7 +289,7 @@ def for_class(self, owner: type[BusCapability]) -> type[CapabilityRow]: @dataclass(frozen=True) -class BusCapability(TypedRow): +class BusCapability(TypedRow, ProjectionSource): """The hierarchy and each dataclass declaration own their table union.""" table = _CapabilityTable() @@ -374,7 +368,7 @@ class Addressed(BusCapability): indexes: ClassVar[tuple[Index, ...]] = (Index(("sender", "target", "seq")),) -class MessageKind(DeclaredFamily, affix="Kind"): +class MessageKind(DeclaredFamily, ProjectionSource, affix="Kind"): """A declared message kind; each member owns the validation it requires. Members are the kind themselves, never instances: the wire carries the @@ -488,7 +482,7 @@ def require_publishable(cls, message: Message, worktree: Path) -> None: message.report.require_references_exist(worktree) -class MembershipTransition(DeclaredFamily, affix="Membership"): +class MembershipTransition(DeclaredFamily, ProjectionSource, affix="Membership"): @classmethod def conversation_role(cls) -> MembershipConversationRole: return MembershipConversationRole(cls) @@ -515,13 +509,13 @@ def applies(previous: bool, current: bool) -> bool: @dataclass(frozen=True, slots=True) -class NoMembershipChange(NullMember): +class NoMembershipChange(NullMember, ProjectionSource): def conversation_role(self): return ConversationRole() @dataclass(frozen=True, slots=True) -class MessageRoleWire: +class MessageRoleWire(ProjectionSource): """The unchanged external fields, decoded once into a message role.""" change: Annotated[ @@ -536,7 +530,7 @@ def role(self): return role.as_notice() if self.notice else role -class MessageRole(InlineRecord): +class MessageRole(InlineRecord, ProjectionSource): category: ClassVar[type[MessageCategory]] @abstractmethod @@ -576,7 +570,7 @@ class SendableRole(MessageRole, DeclaredFamily, affix="Role"): pass -class InformationalMessage: +class InformationalMessage(ProjectionSource): def response_policy(self, message): return InformationalPolicy.instance() diff --git a/src/agent_comms/projection_source.py b/src/agent_comms/projection_source.py new file mode 100644 index 0000000000..4c913fa383 --- /dev/null +++ b/src/agent_comms/projection_source.py @@ -0,0 +1,25 @@ +"""The declared capability of code that produces derived projection rows.""" + +from __future__ import annotations + +from typing import ClassVar + + +class ProjectionSource: + """A class whose code decides projection row content. + + Its source, and every subclass's source, belongs to the digest that tells a + stored projection it is still fresh. A class reached while rows are produced + that does not inherit this capability is a hole in that digest. + """ + + __slots__ = () + _sources: ClassVar[list[type[ProjectionSource]]] = [] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + ProjectionSource._sources.append(cls) + + @staticmethod + def sources() -> tuple[type[ProjectionSource], ...]: + return tuple(ProjectionSource._sources) diff --git a/src/agent_comms/read_basis.py b/src/agent_comms/read_basis.py index cded4de71e..15e6780e89 100644 --- a/src/agent_comms/read_basis.py +++ b/src/agent_comms/read_basis.py @@ -28,6 +28,7 @@ from .mentions import ThreadMention from .message_reference import MessageReference from .private_path import FileObservation, MissingFile +from .projection_source import ProjectionSource from .recorded_bus import RecordedBus, RecordedBusIdentity from .store_files import SharedLock from .thread_identity import RecordedThreadIncarnation, ThreadIncarnation @@ -817,7 +818,7 @@ def validate_identity( self.displayed.validate(self.viewer, snapshot, bus_identity) -class MessageDisplayScope(DeclaredFamily, ABC, affix="DisplayScope"): +class MessageDisplayScope(DeclaredFamily, ABC, ProjectionSource, affix="DisplayScope"): """Captured inclusion and its exact predicate on the bus capability tables.""" def read_key(self, sender: str, target: str, snapshot: RegistryProvenance) -> Conversation: diff --git a/src/agent_comms/response_policy.py b/src/agent_comms/response_policy.py index 82ef826e5b..0eb4f0acf2 100644 --- a/src/agent_comms/response_policy.py +++ b/src/agent_comms/response_policy.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from .declared_family import DeclaredFamily +from .projection_source import ProjectionSource if TYPE_CHECKING: from .messages import Message @@ -16,7 +17,7 @@ @dataclass(frozen=True) -class ResponsePolicy(DeclaredFamily, affix="Policy"): +class ResponsePolicy(DeclaredFamily, ProjectionSource, affix="Policy"): starts_turn = False separate_turn = False @@ -52,7 +53,7 @@ def disposition_key(self, message: Message, owner: Thread) -> str: return f"bus:{message.seq}:owner:{float(owner.created_at).hex()}" -class NoChannelRecipients: +class NoChannelRecipients(ProjectionSource): def recipients( self, message: Message, audience: Sequence[str], *, aliases: Mapping[str, str] | None = None ) -> tuple[str, ...]: @@ -119,7 +120,7 @@ def guidance(self, message: Message, *, aliases: Mapping[str, str] | None = None @dataclass(frozen=True, slots=True) -class ResponseEligibility: +class ResponseEligibility(ProjectionSource): policy: ResponsePolicy recipients: tuple[str, ...] = () diff --git a/src/agent_comms/task_sources.py b/src/agent_comms/task_sources.py index d4f89c512c..db46bcce06 100644 --- a/src/agent_comms/task_sources.py +++ b/src/agent_comms/task_sources.py @@ -13,6 +13,7 @@ from .goals import GoalDeclaration, GoalRevision from .message_reference import MessageReference from .owner_requests import OwnerRequestId, RequestDirective +from .projection_source import ProjectionSource from .thread_identity import ( RecordedThreadIncarnation, ThreadIncarnation, @@ -185,7 +186,7 @@ def root_source(self, message: Message, originals: dict[MessageReference, Messag raise RelationViolationError("Retained correction lacks its original source") from error -class TaskAttachment(DeclaredFamily, affix="TaskAttachment"): +class TaskAttachment(DeclaredFamily, ProjectionSource, affix="TaskAttachment"): """Absent attachments have no publication, fact or author effects.""" retains_authored_task = False diff --git a/src/agent_comms/thread_identity.py b/src/agent_comms/thread_identity.py index 4ad62bcb8d..3c97744df6 100644 --- a/src/agent_comms/thread_identity.py +++ b/src/agent_comms/thread_identity.py @@ -16,6 +16,7 @@ from agent_comms.participant_identity import ParticipantIdentity +from .projection_source import ProjectionSource from .cli_options import JsonFlagRecord from .declared_family import DeclaredFamily, UniqueExternalSelector, UnknownFamilyMember, declared_words from .errors import RelationViolationError @@ -421,7 +422,7 @@ def decode(cls, name: str, birth: float) -> ThreadIncarnation: @dataclass(frozen=True, slots=True) -class TurnId: +class TurnId(ProjectionSource): value: str @classmethod @@ -525,7 +526,7 @@ def __eq__(self, other: object) -> bool: @dataclass(slots=True) -class GenerationCounter: +class GenerationCounter(ProjectionSource): """One monotonic allocation domain, including deleted-name tombstones.""" counter: int = 0 @@ -558,7 +559,7 @@ def rename(self, old: str, new: str) -> None: self.generations[new] = self.generations.pop(old) -class ThreadRole(DeclaredFamily, affix="ThreadRole"): +class ThreadRole(DeclaredFamily, ProjectionSource, affix="ThreadRole"): """Whether a thread executes turns is the member's type; its wire name is its spelling.""" origin_label: ClassVar[str] diff --git a/src/agent_comms/typed_table.py b/src/agent_comms/typed_table.py index bc0f9276a7..5013ef6ec7 100644 --- a/src/agent_comms/typed_table.py +++ b/src/agent_comms/typed_table.py @@ -64,6 +64,7 @@ ) from .maintenance_barrier import HOLDER_NONCE, MaintenanceBarrier from .package_stamp import STAMP +from .projection_source import ProjectionSource from .store_files import _store_lock if TYPE_CHECKING: @@ -1424,17 +1425,12 @@ class ProjectionProducerTables(SharedFileTables, DerivedTables): class ProducedRow(ABC): - """Rows rebuilt when their declared producing modules change.""" + """Rows rebuilt when the source of a class producing them changes.""" @classmethod @abstractmethod - def producer_capabilities(cls) -> tuple[tuple[type[DeclaredFamily], type], ...]: - """Each record family and the capability whose declarations produce these rows.""" - - @classmethod - @abstractmethod - def producer_classes(cls) -> tuple[type[DeclaredFamily], ...]: - """The record family's declarations that implement this projection's production.""" + def producer_package(cls) -> types.ModuleType: + """The package whose ProjectionSource classes produce these rows.""" @classmethod @abstractmethod @@ -1974,34 +1970,31 @@ class ProducedTable(ProducedRow, TypedTable, declared_name=None): @classmethod def producer_digest(cls) -> str: for family in cls.__mro__: - if issubclass(family, ProducedTable) and "producer_classes" in vars(family): + if issubclass(family, ProducedTable) and "producer_package" in vars(family): return family._producer_digest() raise TypeError(f"{cls.__name__} has no producing family declaration") @classmethod @cache def _producer_digest(cls) -> str: - producers = cls.producer_classes() + source = "\0".join( + f"{owner.__module__}.{owner.__qualname__}\0{getsource(owner)}" + for owner in cls.producer_owners() + ) + return hashlib.sha256(source.encode()).hexdigest() + + @classmethod + def producer_owners(cls) -> tuple[type, ...]: + """Every class whose source the produced rows depend on, in a stable order.""" + DeclaredFamily.discover_package(cls.producer_package()) owners: set[type] = { owner for row in (cls, *cls.members_with(cls)) for owner in row.__mro__ if issubclass(owner, cls) } - # The capability's classes outside the record family (Message, Claimed) hold the - # production code itself, so they belong to the digest beside the family. - capabilities = tuple(capability for _, capability in cls.producer_capabilities()) - for producer in producers: - owners.update( - owner - for owner in producer.__mro__ - if issubclass(owner, (cls, producer.family_root(), *capabilities)) - ) - source = "\0".join( - f"{owner.__module__}.{owner.__qualname__}\0{getsource(owner)}" - for owner in sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__)) - ) - return hashlib.sha256(source.encode()).hexdigest() + owners.update(ProjectionSource.sources()) + return tuple(sorted(owners, key=lambda owner: (owner.__module__, owner.__qualname__))) @dataclass(frozen=True) diff --git a/tests/test_bus.py b/tests/test_bus.py index 1cdc58038d..0016220969 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -155,6 +155,26 @@ def test_projection_digest_ignores_infrastructure_source( "HeldResources.apply(db, self.claim_transition, projected, holders)", "HeldResources.apply(db, self.claim_transition, projected, holders)\n del projected", ), + ( + "messages.py", + "return (SentActivity(message.sender, message.timestamp),)", + "return (SentActivity(message.target, message.timestamp),)", + ), + ( + "messages.py", + "return (BusRouteCounts.from_message(message),)", + "return (BusRouteCounts.from_message(message), BusRouteCounts.from_message(message))", + ), + ( + "channel_targets.py", + " def direct_names(self) -> tuple[str, ...]:\n return self.names\n", + " def direct_names(self) -> tuple[str, ...]:\n return self.names[:1]\n", + ), + ( + "activity_clocks.py", + "message.sender_role.last_user_input(0.0, message.timestamp),", + "message.sender_role.last_user_input(1.0, message.timestamp),", + ), ), ) def test_projection_digest_tracks_producing_source( @@ -171,6 +191,66 @@ def test_projection_digest_tracks_producing_source( assert projection_source_digest(projection_source) != before +# Mechanisms that decode, store and dispatch rows; their edits never change row content. +PROJECTION_MECHANISM_MODULES = frozenset({ + "agent_comms.core_stamp", + "agent_comms.declared_family", + "agent_comms.field_codec", + "agent_comms.mro_dispatch", + "agent_comms.projection_source", + "agent_comms.typed_table", +}) + + +def projection_rebuild_classes(bus: Bus) -> set[type]: + """Every agent_comms class whose own code ran while the projections were rebuilt.""" + codes = set() + + def profile(frame, event, argument): + if event == "call": + codes.add(frame.f_code) + + with bus.writing() as db: + BusProjectionTables.reset_schema(db) # imports and caches outside the profile + sys.setprofile(profile) + try: + BusProjectionTables.reset_schema(db) + finally: + sys.setprofile(None) + modules = { + module.__file__: module for name, module in tuple(sys.modules.items()) + if name.startswith("agent_comms.") and getattr(module, "__file__", None) + } + classes = set() + for code in codes: + if code.co_filename not in modules: + continue + owner = modules[code.co_filename] + for part in code.co_qualname.split(".")[:-1]: + if part == "": + break + owner = getattr(owner, part) + if isinstance(owner, type): + classes.add(owner) + return classes + + +def test_every_class_that_produces_projection_rows_is_in_the_digest(bus): + first = bus.append(lambda seq, db: PublicRecord.from_message( + Message("author", "worker", "direct", InfoKind, seq=seq), + )) + bus.append(lambda seq, db: PublicRecord.from_message( + Message("worker", "author", "reply", InfoKind, seq=seq, answers=(first.reference,)), + )) + owners = set(BusProjectionTables.producer_owners()) + missing = sorted( + f"{owner.__module__}.{owner.__qualname__}" + for owner in projection_rebuild_classes(bus) + if owner.__module__ not in PROJECTION_MECHANISM_MODULES and owner not in owners + ) + assert missing == [] + + def test_bus_attachment_and_failed_write_do_not_install_a_schema(tmp_path): bus = Bus(Bus.path_in(tmp_path / "not-installed")) assert not bus.path.parent.exists()