agent-protocols 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/PKG-INFO +8 -7
  2. agent_protocols-0.5.0/README.md +32 -0
  3. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/pyproject.toml +2 -2
  4. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/__init__.py +35 -1
  5. agent_protocols-0.5.0/src/agent_protocols/delegation.py +174 -0
  6. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/discourse.py +170 -4
  7. agent_protocols-0.5.0/src/agent_protocols/errors.py +6 -0
  8. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/http_client.py +76 -4
  9. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/identity.py +44 -9
  10. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols/profile.py +11 -1
  11. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/PKG-INFO +8 -7
  12. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/SOURCES.txt +2 -0
  13. agent_protocols-0.5.0/tests/test_delegation.py +166 -0
  14. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_discourse.py +114 -0
  15. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_http_client.py +38 -0
  16. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_identity.py +83 -0
  17. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_profile.py +37 -4
  18. agent_protocols-0.4.0/README.md +0 -31
  19. agent_protocols-0.4.0/src/agent_protocols/errors.py +0 -4
  20. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/setup.cfg +0 -0
  21. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  22. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/requires.txt +0 -0
  23. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
  24. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_discourse_coverage.py +0 -0
  25. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_identity_coverage.py +0 -0
  26. {agent_protocols-0.4.0 → agent_protocols-0.5.0}/tests/test_profile_coverage.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.4.0
4
- Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
3
+ Version: 0.5.0
4
+ Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
7
7
  Keywords: agent,protocol,ed25519,sdk
@@ -15,14 +15,15 @@ Requires-Dist: requests<3,>=2.31; extra == "http"
15
15
 
16
16
  # agent-protocols Python SDK
17
17
 
18
- Python SDK for the draft Agent Identity, Agent Profile, and Agent Discourse protocols.
18
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
19
19
 
20
20
  ## Modules
21
21
 
22
22
  - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
23
- - `agent_protocols.profile`: `profile.update` payload helpers, validation, materialization.
23
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
24
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
24
25
  - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
25
- - `agent_protocols.http_client`: optional requests-based Profile and Discourse clients. Install with `agent-protocols[http]`.
26
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
26
27
 
27
28
  ## Example
28
29
 
@@ -41,6 +42,6 @@ envelope = signer.sign_event(event)
41
42
  profile = materialize_profile(envelope)
42
43
  ```
43
44
 
44
- `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
45
+ Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
45
46
 
46
- ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
47
+ ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -0,0 +1,32 @@
1
+ # agent-protocols Python SDK
2
+
3
+ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols.
4
+
5
+ ## Modules
6
+
7
+ - `agent_protocols.identity`: `did:agent:` encoding, JCS canonicalization, event hashes, Ed25519 signing and verification, live-write nonce checks, request JWT helpers.
8
+ - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, materialization.
9
+ - `agent_protocols.delegation`: Agent Delegation principal documents, grant/revoke payloads, credential documents, validation, and materialization.
10
+ - `agent_protocols.discourse`: ADP kernel event constants, the room type system (type definitions, pack imports, type registry, JSON Schema payload validation), join request helpers, room-path checks, kind-based permission and state helpers.
11
+ - `agent_protocols.http_client`: optional requests-based Profile, Delegation, and Discourse clients. Install with `agent-protocols[http]`.
12
+
13
+ ## Example
14
+
15
+ ```python
16
+ from agent_protocols import AgentSigner, ClientNonceManager, materialize_profile, profile_update_event, unix_ms
17
+
18
+ signer = AgentSigner.generate()
19
+ nonces = ClientNonceManager()
20
+ event = profile_update_event(
21
+ signer.agent_id(),
22
+ unix_ms(),
23
+ nonces.next_nonce(),
24
+ {"id": signer.agent_id(), "name": "ResearchAgent-v3"},
25
+ )
26
+ envelope = signer.sign_event(event)
27
+ profile = materialize_profile(envelope)
28
+ ```
29
+
30
+ Agent Profile has no `username` field: the Agent ID is the identity key, and the latest profile is the accepted `profile.update` with the greatest `nonce`.
31
+
32
+ ADP room writes declare a signed `base_seq` / `base_hash`: discussion and contract writes must match the current room head, while `signal`-kind writes — including the built-in membership events — only anchor to an accepted record and never contend for the head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.4.0"
4
- description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
3
+ version = "0.5.0"
4
+ description = "Python SDK for Agent Identity, Agent Profile, Agent Delegation, and Agent Discourse protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = { text = "MIT" }
@@ -13,6 +13,7 @@ from .identity import (
13
13
  event_hash,
14
14
  event_hash_bytes,
15
15
  public_key_bytes,
16
+ service_origin,
16
17
  sign_event,
17
18
  sign_event_hash,
18
19
  unix_ms,
@@ -31,10 +32,33 @@ from .identity import (
31
32
  with_room_head,
32
33
  with_room_id,
33
34
  )
34
- from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
35
+ from .profile import (
36
+ PROFILE_PROTOCOL,
37
+ PROFILE_UPDATE,
38
+ latest_profile_update,
39
+ materialize_profile,
40
+ profile_update_event,
41
+ validate_profile_update,
42
+ )
43
+ from .delegation import (
44
+ DELEGATION_GRANT,
45
+ DELEGATION_PROTOCOL,
46
+ DELEGATION_REVOKE,
47
+ delegation_grant_event,
48
+ delegation_revoke_event,
49
+ materialize_delegation_credential,
50
+ validate_delegation_envelope,
51
+ validate_delegation_grant_payload,
52
+ validate_delegation_query_request,
53
+ validate_delegation_revoke_payload,
54
+ validate_principal_document,
55
+ )
35
56
 
36
57
  __all__ = [
37
58
  "AGENT_ID_PREFIX",
59
+ "DELEGATION_GRANT",
60
+ "DELEGATION_PROTOCOL",
61
+ "DELEGATION_REVOKE",
38
62
  "MAX_NONCE_HEADER",
39
63
  "PROFILE_PROTOCOL",
40
64
  "PROFILE_UPDATE",
@@ -46,18 +70,28 @@ __all__ = [
46
70
  "agent_id_from_public_key",
47
71
  "canonical_event_bytes",
48
72
  "create_event",
73
+ "delegation_grant_event",
74
+ "delegation_revoke_event",
49
75
  "create_request_jwt_claims",
50
76
  "event_hash",
51
77
  "event_hash_bytes",
78
+ "latest_profile_update",
52
79
  "materialize_profile",
80
+ "materialize_delegation_credential",
53
81
  "profile_update_event",
54
82
  "public_key_bytes",
83
+ "service_origin",
55
84
  "sign_event",
56
85
  "sign_event_hash",
57
86
  "unix_ms",
58
87
  "unix_secs",
59
88
  "validate_agent_id",
89
+ "validate_delegation_envelope",
90
+ "validate_delegation_grant_payload",
91
+ "validate_delegation_query_request",
92
+ "validate_delegation_revoke_payload",
60
93
  "validate_nonce",
94
+ "validate_principal_document",
61
95
  "validate_profile_update",
62
96
  "verify_envelope",
63
97
  "verify_event_hash",
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import urlparse
5
+
6
+ from .errors import AgentProtocolError
7
+ from .identity import AgentId, Envelope, Event, create_event, validate_agent_id, verify_envelope
8
+
9
+ DELEGATION_PROTOCOL = "agent-delegation/1.0"
10
+ DELEGATION_GRANT = "delegation.grant"
11
+ DELEGATION_REVOKE = "delegation.revoke"
12
+
13
+ DelegationGrantPayload = dict[str, Any]
14
+ DelegationRevokePayload = dict[str, Any]
15
+ DelegationCredential = dict[str, Any]
16
+ PrincipalDocument = dict[str, Any]
17
+
18
+
19
+ def delegation_grant_event(
20
+ actor: AgentId,
21
+ created_at: int,
22
+ nonce: int,
23
+ payload: DelegationGrantPayload,
24
+ ) -> Event:
25
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_GRANT, actor, created_at, nonce, payload)
26
+
27
+
28
+ def delegation_revoke_event(
29
+ actor: AgentId,
30
+ created_at: int,
31
+ nonce: int,
32
+ payload: DelegationRevokePayload,
33
+ ) -> Event:
34
+ return create_event(DELEGATION_PROTOCOL, DELEGATION_REVOKE, actor, created_at, nonce, payload)
35
+
36
+
37
+ def validate_principal_document(document: PrincipalDocument) -> None:
38
+ _validate_https_url(document.get("id"), "principal.id")
39
+ controllers = document.get("controllers")
40
+ if not isinstance(controllers, list) or not controllers:
41
+ raise AgentProtocolError(
42
+ "invalid_principal",
43
+ "principal document controllers must be non-empty",
44
+ )
45
+ for controller in controllers:
46
+ validate_agent_id(controller)
47
+ if document.get("delegations_url") is not None:
48
+ _validate_https_url(document["delegations_url"], "delegations_url")
49
+
50
+
51
+ def validate_delegation_grant_payload(
52
+ payload: DelegationGrantPayload,
53
+ created_at: int | None = None,
54
+ ) -> None:
55
+ _validate_non_empty(payload.get("id"), "payload.id")
56
+ _validate_principal_descriptor(payload.get("principal"))
57
+ validate_agent_id(payload.get("subject"))
58
+ scopes = payload.get("scopes")
59
+ if not isinstance(scopes, list) or not scopes:
60
+ raise AgentProtocolError("invalid_delegation", "delegation scopes must be non-empty")
61
+ for scope in scopes:
62
+ _validate_non_empty(scope, "scope")
63
+ constraints = payload.get("constraints")
64
+ if constraints is not None and not isinstance(constraints, dict):
65
+ raise AgentProtocolError("invalid_delegation", "constraints must be an object")
66
+ expires_at = payload.get("expires_at")
67
+ if expires_at is not None:
68
+ not_before = payload.get("not_before")
69
+ if not_before is not None and expires_at <= not_before:
70
+ raise AgentProtocolError(
71
+ "invalid_delegation",
72
+ "expires_at must be greater than not_before",
73
+ )
74
+ if created_at is not None and expires_at <= created_at:
75
+ raise AgentProtocolError(
76
+ "invalid_delegation",
77
+ "expires_at must be greater than created_at",
78
+ )
79
+
80
+
81
+ def validate_delegation_query_request(request: dict[str, Any]) -> None:
82
+ """A delegation query MUST include at least one of `subject` or
83
+ `principal_id`. `limit` defaults to 20; services SHOULD cap it at 100."""
84
+ subject = request.get("subject")
85
+ principal_id = request.get("principal_id")
86
+ if subject is None and principal_id is None:
87
+ raise AgentProtocolError(
88
+ "invalid_delegation",
89
+ "query must include at least one of subject or principal_id",
90
+ )
91
+ if subject is not None:
92
+ validate_agent_id(subject)
93
+ if principal_id is not None:
94
+ _validate_https_url(principal_id, "principal_id")
95
+ limit = request.get("limit")
96
+ if limit is not None and (not isinstance(limit, int) or limit < 1):
97
+ raise AgentProtocolError("invalid_delegation", "limit must be a positive integer")
98
+
99
+
100
+ def validate_delegation_revoke_payload(payload: DelegationRevokePayload) -> None:
101
+ _validate_non_empty(payload.get("id"), "payload.id")
102
+ _validate_https_url(payload.get("principal_id"), "principal_id")
103
+
104
+
105
+ def validate_delegation_envelope(envelope: Envelope) -> None:
106
+ verify_envelope(envelope)
107
+ event = envelope["event"]
108
+ if event["protocol"] != DELEGATION_PROTOCOL:
109
+ raise AgentProtocolError(
110
+ "invalid_event_protocol",
111
+ f"expected {DELEGATION_PROTOCOL}, got {event['protocol']}",
112
+ )
113
+ if event["type"] == DELEGATION_GRANT:
114
+ validate_delegation_grant_payload(event["payload"], event["created_at"])
115
+ elif event["type"] == DELEGATION_REVOKE:
116
+ validate_delegation_revoke_payload(event["payload"])
117
+ else:
118
+ raise AgentProtocolError(
119
+ "invalid_event_type",
120
+ f"expected {DELEGATION_GRANT} or {DELEGATION_REVOKE}, got {event['type']}",
121
+ )
122
+
123
+
124
+ def materialize_delegation_credential(
125
+ envelope: Envelope,
126
+ *,
127
+ status: str = "active",
128
+ status_url: str | None = None,
129
+ updated_at: int | None = None,
130
+ ) -> DelegationCredential:
131
+ validate_delegation_envelope(envelope)
132
+ event = envelope["event"]
133
+ if event["type"] != DELEGATION_GRANT:
134
+ raise AgentProtocolError(
135
+ "invalid_event_type",
136
+ "delegation credential materialization requires a grant event",
137
+ )
138
+ payload = event["payload"]
139
+ credential = {
140
+ "id": payload["id"],
141
+ "protocol": DELEGATION_PROTOCOL,
142
+ "principal": payload["principal"],
143
+ "controller": event["actor"],
144
+ "subject": payload["subject"],
145
+ "relationship": payload.get("relationship"),
146
+ "scopes": payload["scopes"],
147
+ "constraints": payload.get("constraints"),
148
+ "not_before": payload.get("not_before"),
149
+ "expires_at": payload.get("expires_at"),
150
+ "status": status,
151
+ "status_url": status_url,
152
+ "updated_at": updated_at if updated_at is not None else event["created_at"],
153
+ "event_id": envelope["hash"],
154
+ }
155
+ return credential
156
+
157
+
158
+ def _validate_principal_descriptor(value: Any) -> None:
159
+ if not isinstance(value, dict):
160
+ raise AgentProtocolError("invalid_principal", "principal must be an object")
161
+ _validate_https_url(value.get("id"), "principal.id")
162
+
163
+
164
+ def _validate_https_url(value: Any, field: str) -> None:
165
+ if not isinstance(value, str):
166
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
167
+ parsed = urlparse(value)
168
+ if parsed.scheme != "https" or not parsed.netloc:
169
+ raise AgentProtocolError("invalid_url", f"{field} must be an HTTPS URL")
170
+
171
+
172
+ def _validate_non_empty(value: Any, field: str) -> None:
173
+ if not isinstance(value, str) or not value.strip():
174
+ raise AgentProtocolError("invalid_delegation", f"{field} must not be empty")
@@ -1,7 +1,7 @@
1
1
  """Agent Discourse Protocol 1.0: kernel types, the room type system, and
2
2
  verification helpers.
3
3
 
4
- The protocol defines nine built-in event types. Every other event type is
4
+ The protocol defines eleven built-in event types. Every other event type is
5
5
  declared per room as a schema-validated type definition, either inline or
6
6
  imported from a type pack. Hosts validate structure and permissions; they
7
7
  never need to understand application semantics.
@@ -32,12 +32,14 @@ from .identity import (
32
32
 
33
33
  DISCOURSE_PROTOCOL = "agent-discourse/1.0"
34
34
 
35
- # The nine built-in event types. All other types are room-defined.
35
+ # The eleven built-in event types. All other types are room-defined.
36
36
  ROOM_CREATE = "room.create"
37
+ ROOM_UPDATE = "room.update"
37
38
  ROOM_JOIN = "room.join"
38
39
  ROOM_JOIN_REVIEW = "room.join.review"
39
40
  ROOM_LEAVE = "room.leave"
40
41
  ROOM_MEMBER_ROLE_UPDATE = "room.member.role.update"
42
+ ROOM_MEMBER_REMOVE = "room.member.remove"
41
43
  ROOM_CLOSE = "room.close"
42
44
  ROOM_CANCEL = "room.cancel"
43
45
  TYPE_DEFINE = "type.define"
@@ -45,16 +47,67 @@ MESSAGE_CREATE = "message.create"
45
47
 
46
48
  BUILTIN_EVENT_TYPES = {
47
49
  ROOM_CREATE,
50
+ ROOM_UPDATE,
48
51
  ROOM_JOIN,
49
52
  ROOM_JOIN_REVIEW,
50
53
  ROOM_LEAVE,
51
54
  ROOM_MEMBER_ROLE_UPDATE,
55
+ ROOM_MEMBER_REMOVE,
52
56
  ROOM_CLOSE,
53
57
  ROOM_CANCEL,
54
58
  TYPE_DEFINE,
55
59
  MESSAGE_CREATE,
56
60
  }
57
61
 
62
+ # Built-in membership events. They carry the `signal` class: they anchor to
63
+ # an accepted record but never contend for or advance the room head, so busy
64
+ # rooms cannot starve joins, reviews, or other membership writes.
65
+ MEMBERSHIP_EVENT_TYPES = (
66
+ ROOM_JOIN,
67
+ ROOM_JOIN_REVIEW,
68
+ ROOM_LEAVE,
69
+ ROOM_MEMBER_ROLE_UPDATE,
70
+ ROOM_MEMBER_REMOVE,
71
+ )
72
+
73
+ # Standard ADP error codes from the Section 20 table.
74
+ DISCOURSE_ERROR_CODES = frozenset(
75
+ {
76
+ "invalid_event",
77
+ "invalid_event_hash",
78
+ "invalid_signature",
79
+ "invalid_actor",
80
+ "timestamp_out_of_window",
81
+ "nonce_not_greater",
82
+ "room_not_found",
83
+ "room_not_active",
84
+ "room_ended",
85
+ "permission_denied",
86
+ "approval_required",
87
+ "join_request_not_found",
88
+ "join_request_not_approved",
89
+ "join_request_role_mismatch",
90
+ "join_request_expired",
91
+ "member_banned",
92
+ "role_not_allowed",
93
+ "max_speakers_exceeded",
94
+ "membership_required",
95
+ "invalid_token",
96
+ "room_head_mismatch",
97
+ "base_record_mismatch",
98
+ "agent_status_not_found",
99
+ "rate_limited",
100
+ "payload_too_large",
101
+ "type_not_defined",
102
+ "type_disabled",
103
+ "payload_schema_violation",
104
+ "pack_unavailable",
105
+ }
106
+ )
107
+
108
+ # Hosts MUST reject events with more than this many `mentions` entries.
109
+ MAX_MENTIONS = 32
110
+
58
111
  # Custom event types must not use these prefixes.
59
112
  RESERVED_TYPE_PREFIXES = ("room.", "type.")
60
113
 
@@ -94,6 +147,7 @@ class PermissionContext(TypedDict, total=False):
94
147
 
95
148
  role: Role
96
149
  is_creator: bool
150
+ public_join_allowed: bool
97
151
  join_request_approved: bool
98
152
 
99
153
 
@@ -170,6 +224,42 @@ def event_requires_room_id(event_type: str) -> bool:
170
224
  return event_type != ROOM_CREATE
171
225
 
172
226
 
227
+ BuiltinEventClass = Literal["lifecycle", "signal", "control", "message"]
228
+
229
+ _BUILTIN_EVENT_CLASSES: dict[str, BuiltinEventClass] = {
230
+ ROOM_CREATE: "lifecycle",
231
+ ROOM_UPDATE: "lifecycle",
232
+ ROOM_CLOSE: "lifecycle",
233
+ ROOM_CANCEL: "lifecycle",
234
+ ROOM_JOIN: "signal",
235
+ ROOM_JOIN_REVIEW: "signal",
236
+ ROOM_LEAVE: "signal",
237
+ ROOM_MEMBER_ROLE_UPDATE: "signal",
238
+ ROOM_MEMBER_REMOVE: "signal",
239
+ TYPE_DEFINE: "control",
240
+ MESSAGE_CREATE: "message",
241
+ }
242
+
243
+
244
+ def builtin_event_class(event_type: str) -> BuiltinEventClass | None:
245
+ """Section 13.2 class of a built-in type; ``None`` for room-defined types."""
246
+ return _BUILTIN_EVENT_CLASSES.get(event_type)
247
+
248
+
249
+ def event_advances_room_head(event_type: str, registry: "TypeRegistry | None" = None) -> bool:
250
+ """Whether an accepted record of this type advances the room head and must
251
+ therefore match the current head when written (Section 6.1). Room
252
+ lifecycle, `message`-kind, and `control`-kind records advance the head;
253
+ `signal`-kind records — including the built-in membership events — only
254
+ anchor to an accepted record. Unknown custom types default to
255
+ head-advancing."""
256
+ builtin_class = builtin_event_class(event_type)
257
+ if builtin_class is not None:
258
+ return builtin_class != "signal"
259
+ definition = registry.get(event_type) if registry is not None else None
260
+ return definition is None or definition.get("kind") != "signal"
261
+
262
+
173
263
  def validate_discourse_envelope(envelope: Envelope) -> None:
174
264
  verify_envelope(envelope)
175
265
  event = envelope["event"]
@@ -221,8 +311,17 @@ def _validate_mentions(mentions: Any) -> None:
221
311
  return
222
312
  if not isinstance(mentions, list):
223
313
  raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
314
+ if len(mentions) > MAX_MENTIONS:
315
+ raise AgentProtocolError("invalid_event", f"mentions must not exceed {MAX_MENTIONS} entries")
316
+ # Validate each entry before testing uniqueness: a non-string mention would
317
+ # otherwise raise a raw TypeError from set() instead of a clean protocol
318
+ # error.
319
+ seen: set[str] = set()
224
320
  for mention in mentions:
225
321
  validate_agent_id(mention)
322
+ if mention in seen:
323
+ raise AgentProtocolError("invalid_event", "mentions must be unique")
324
+ seen.add(mention)
226
325
 
227
326
 
228
327
  def validate_custom_event_type_name(name: str) -> None:
@@ -333,6 +432,12 @@ class TypeRegistry:
333
432
 
334
433
  def define(self, definition: dict[str, Any]) -> None:
335
434
  validate_type_def(definition)
435
+ existing = self._types.get(definition["type"])
436
+ if existing is not None and existing.get("kind") != definition.get("kind"):
437
+ raise AgentProtocolError(
438
+ "invalid_event",
439
+ f"type {definition['type']} cannot change kind on redefinition",
440
+ )
336
441
  self._types[definition["type"]] = definition
337
442
 
338
443
  def _import(self, declaration: dict[str, Any], packs: dict[str, dict[str, Any]]) -> None:
@@ -438,6 +543,57 @@ def validate_message_create_payload(payload: dict[str, Any]) -> None:
438
543
  raise AgentProtocolError("invalid_event", "content_type must not be empty")
439
544
 
440
545
 
546
+ def validate_room_join_payload(payload: dict[str, Any]) -> None:
547
+ if payload.get("role") not in ROLES:
548
+ raise AgentProtocolError("invalid_event", f"invalid room role: {payload.get('role')}")
549
+ request_id = payload.get("request_id")
550
+ if request_id is not None and not str(request_id).strip():
551
+ raise AgentProtocolError("invalid_event", "request_id must not be empty")
552
+ if request_id is not None and "perspective" in payload:
553
+ raise AgentProtocolError(
554
+ "invalid_event",
555
+ "room.join payload cannot include both request_id and perspective",
556
+ )
557
+
558
+
559
+ _ROOM_UPDATE_FIELDS = frozenset(
560
+ {"topic", "agenda", "guidance", "tags", "language", "policy", "start_time", "end_time"}
561
+ )
562
+
563
+
564
+ def validate_room_update_payload(payload: dict[str, Any]) -> None:
565
+ """Shape checks for a `room.update` payload. State-dependent rules — room
566
+ status, effective time ordering against the current contract — remain
567
+ host-side."""
568
+ if not payload:
569
+ raise AgentProtocolError("invalid_event", "room.update payload must not be empty")
570
+ for field in payload:
571
+ if field not in _ROOM_UPDATE_FIELDS:
572
+ raise AgentProtocolError(
573
+ "invalid_event", f"room.update payload field {field} is not updatable"
574
+ )
575
+ topic = payload.get("topic")
576
+ if topic is not None and not str(topic).strip():
577
+ raise AgentProtocolError("invalid_event", "room topic must not be empty")
578
+ start_time = payload.get("start_time")
579
+ end_time = payload.get("end_time")
580
+ if start_time is not None and end_time is not None and start_time >= end_time:
581
+ raise AgentProtocolError("invalid_event", "start_time must be before end_time")
582
+ policy = payload.get("policy") or {}
583
+ max_speakers = policy.get("max_speakers")
584
+ if max_speakers is not None and (not isinstance(max_speakers, int) or max_speakers < 1):
585
+ raise AgentProtocolError("invalid_event", "max_speakers must be a positive integer")
586
+
587
+
588
+ def validate_room_member_remove_payload(payload: dict[str, Any]) -> None:
589
+ """Shape checks for a `room.member.remove` payload. Creator, self, and
590
+ membership checks remain host-side."""
591
+ validate_agent_id(payload.get("member"))
592
+ ban = payload.get("ban")
593
+ if ban is not None and not isinstance(ban, bool):
594
+ raise AgentProtocolError("invalid_event", "ban must be a boolean")
595
+
596
+
441
597
  def server_record_hash_payload(
442
598
  room_id: str,
443
599
  seq: int,
@@ -537,10 +693,18 @@ def can_submit_event(
537
693
  if event_type == ROOM_CREATE:
538
694
  return True
539
695
  if event_type == ROOM_JOIN:
540
- return bool(context.get("join_request_approved"))
696
+ return bool(context.get("public_join_allowed") or context.get("join_request_approved"))
541
697
  if event_type == ROOM_LEAVE:
542
698
  return is_creator or role is not None
543
- if event_type in {ROOM_JOIN_REVIEW, ROOM_MEMBER_ROLE_UPDATE, ROOM_CLOSE, ROOM_CANCEL, TYPE_DEFINE}:
699
+ if event_type in {
700
+ ROOM_UPDATE,
701
+ ROOM_JOIN_REVIEW,
702
+ ROOM_MEMBER_ROLE_UPDATE,
703
+ ROOM_MEMBER_REMOVE,
704
+ ROOM_CLOSE,
705
+ ROOM_CANCEL,
706
+ TYPE_DEFINE,
707
+ }:
544
708
  return is_creator or role == "moderator"
545
709
  if event_type == MESSAGE_CREATE:
546
710
  return is_creator or role in ("moderator", "speaker")
@@ -562,7 +726,9 @@ def can_write_in_state(event_type: str, state: RoomState) -> bool:
562
726
  ROOM_JOIN,
563
727
  ROOM_JOIN_REVIEW,
564
728
  ROOM_MEMBER_ROLE_UPDATE,
729
+ ROOM_MEMBER_REMOVE,
565
730
  ROOM_LEAVE,
731
+ ROOM_UPDATE,
566
732
  TYPE_DEFINE,
567
733
  ROOM_CANCEL,
568
734
  }
@@ -0,0 +1,6 @@
1
+ class AgentProtocolError(ValueError):
2
+ def __init__(self, code: str, message: str, data: dict | None = None):
3
+ super().__init__(message)
4
+ self.code = code
5
+ # Structured error details, e.g. {"max_nonce": ...} on nonce_not_greater.
6
+ self.data = data