agent-protocols 0.11.2__tar.gz → 0.11.3__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 (38) hide show
  1. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/PKG-INFO +6 -6
  2. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/README.md +5 -5
  3. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/pyproject.toml +1 -1
  4. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/__init__.py +2 -2
  5. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/delegation.py +30 -50
  6. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/discourse.py +10 -19
  7. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/http_client.py +4 -4
  8. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/knowledge.py +4 -8
  9. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/knowledge.schema.json +0 -7
  10. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/mail.py +18 -18
  11. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/mail_state.py +4 -4
  12. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols.egg-info/PKG-INFO +6 -6
  13. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_delegation.py +1 -1
  14. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_delegation_conformance.py +23 -19
  15. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_discourse.py +7 -8
  16. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_discourse_coverage.py +4 -4
  17. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_knowledge.py +4 -4
  18. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_knowledge_http.py +2 -2
  19. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_mail.py +3 -3
  20. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/setup.cfg +0 -0
  21. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/errors.py +0 -0
  22. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/identity.py +0 -0
  23. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/mail.schema.json +0 -0
  24. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols/profile.py +0 -0
  25. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  26. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  27. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols.egg-info/requires.txt +0 -0
  28. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/src/agent_protocols.egg-info/top_level.txt +0 -0
  29. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_http_client.py +0 -0
  30. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_identity.py +0 -0
  31. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_identity_coverage.py +0 -0
  32. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_identity_vectors.py +0 -0
  33. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_knowledge_vectors.py +0 -0
  34. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_mail_http.py +0 -0
  35. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_mail_vectors.py +0 -0
  36. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_profile.py +0 -0
  37. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_profile_coverage.py +0 -0
  38. {agent_protocols-0.11.2 → agent_protocols-0.11.3}/tests/test_profile_vectors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.11.2
3
+ Version: 0.11.3
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, Agent Discourse, Agent Knowledge, and Agent Mail protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -25,7 +25,7 @@ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, Agent
25
25
 
26
26
  - `agent_protocols.identity`: `did:agent:` encoding, strict JSON parsing (`parse_strict_json`, `parse_envelope_json`), JCS canonicalization, event hashes, Ed25519 signing and strict verification (`verify_ed25519_strict`), closed event objects (`validate_event_fields`), clock-derived nonces with bounded `Max-Seen-Nonce` resynchronization, live-write and exact-resubmission checks (`verify_submission`), request JWT helpers.
27
27
  - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, succession checks, materialization.
28
- - `agent_protocols.delegation`: principal documents and resolution, Controller records with `supersedes` lineage, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
28
+ - `agent_protocols.delegation`: principal documents and resolution, Controller records, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
29
29
  - `agent_protocols.discourse`: the ADP kernel — twelve built-in event types, freshness classes, room policy (`invites`, `open_roles`), signed join requests and reviews, the type system with the portable type schema profile, redacted records, server records, and archive verification.
30
30
  - `agent_protocols.knowledge`: signed research contributions, known-set lifecycle views, evidence and profile bindings, in-memory storage, exact queries with checkpoint-bound pagination, single-page ranked search, discovery, and response validation.
31
31
  - `agent_protocols.mail` and `agent_protocols.mail_state`: signed mailbox cards, sender-signed PyHPKE packets, historical key retention, card pins, inbox deduplication, and synchronized relay state with snapshots.
@@ -55,11 +55,11 @@ ADP room writes carry a signed `base_seq` / `base_hash`. Message and control wri
55
55
 
56
56
  ## Delegation
57
57
 
58
- Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. `supersedes` lets a successor key manage its predecessors' credentials. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes `delegation_query_url`.
58
+ Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. A restricted key manages (replaces, revokes, enumerates) only credentials whose scopes and audiences fall within its own ceiling. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes its `delegation_service` origin.
59
59
 
60
60
  Controller registration (Section 4.3) proves key possession with a fixed-shape challenge: `signer.sign_controller_challenge(challenge)` signs its exact UTF-8 bytes and refuses any other string, and `verify_controller_challenge(agent_id, challenge, signature)` applies the strict Ed25519 rules. Issuing challenges, binding them to the owner-approved fields, and expiry stay with the provider.
61
61
 
62
- Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject` and `owner_controller`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
62
+ Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
63
63
 
64
64
  The validation layers have different responsibilities:
65
65
 
@@ -99,7 +99,7 @@ The suite includes both `unittest.TestCase` classes and pytest functions. Runnin
99
99
  `agent_protocols.knowledge` implements Agent Knowledge 1.0: signed publications,
100
100
  assessments and retractions; strict payload and dependency validation; deterministic
101
101
  known-set lifecycle views; evidence byte verification; and exact query, batch,
102
- ranked-search and discovery contracts. The JSON Schema ships inside the installed
102
+ ranked-search (with portable lexical matching) and discovery contracts. The JSON Schema ships inside the installed
103
103
  package; no repository files are needed at runtime.
104
104
 
105
105
  ```python
@@ -128,7 +128,7 @@ event = knowledge_publish_event(signer.agent_id(), now, nonces.next_nonce(now),
128
128
  envelope = signer.sign_event(event)
129
129
  store = KnowledgeStore("https://knowledge.example.com")
130
130
  record = store.submit(envelope)
131
- page = store.query({"q": "cache language", "kind": "observation"})
131
+ page = store.query({"kind": "observation", "tag": "caching"})
132
132
  assert page["result"][0]["envelope"]["hash"] == envelope["hash"]
133
133
  assert materialize_knowledge(store.known_envelopes())[envelope["hash"]]["status"] == "active"
134
134
  ```
@@ -6,7 +6,7 @@ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, Agent
6
6
 
7
7
  - `agent_protocols.identity`: `did:agent:` encoding, strict JSON parsing (`parse_strict_json`, `parse_envelope_json`), JCS canonicalization, event hashes, Ed25519 signing and strict verification (`verify_ed25519_strict`), closed event objects (`validate_event_fields`), clock-derived nonces with bounded `Max-Seen-Nonce` resynchronization, live-write and exact-resubmission checks (`verify_submission`), request JWT helpers.
8
8
  - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, succession checks, materialization.
9
- - `agent_protocols.delegation`: principal documents and resolution, Controller records with `supersedes` lineage, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
9
+ - `agent_protocols.delegation`: principal documents and resolution, Controller records, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
10
10
  - `agent_protocols.discourse`: the ADP kernel — twelve built-in event types, freshness classes, room policy (`invites`, `open_roles`), signed join requests and reviews, the type system with the portable type schema profile, redacted records, server records, and archive verification.
11
11
  - `agent_protocols.knowledge`: signed research contributions, known-set lifecycle views, evidence and profile bindings, in-memory storage, exact queries with checkpoint-bound pagination, single-page ranked search, discovery, and response validation.
12
12
  - `agent_protocols.mail` and `agent_protocols.mail_state`: signed mailbox cards, sender-signed PyHPKE packets, historical key retention, card pins, inbox deduplication, and synchronized relay state with snapshots.
@@ -36,11 +36,11 @@ ADP room writes carry a signed `base_seq` / `base_hash`. Message and control wri
36
36
 
37
37
  ## Delegation
38
38
 
39
- Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. `supersedes` lets a successor key manage its predecessors' credentials. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes `delegation_query_url`.
39
+ Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. A restricted key manages (replaces, revokes, enumerates) only credentials whose scopes and audiences fall within its own ceiling. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes its `delegation_service` origin.
40
40
 
41
41
  Controller registration (Section 4.3) proves key possession with a fixed-shape challenge: `signer.sign_controller_challenge(challenge)` signs its exact UTF-8 bytes and refuses any other string, and `verify_controller_challenge(agent_id, challenge, signature)` applies the strict Ed25519 rules. Issuing challenges, binding them to the owner-approved fields, and expiry stay with the provider.
42
42
 
43
- Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject` and `owner_controller`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
43
+ Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
44
44
 
45
45
  The validation layers have different responsibilities:
46
46
 
@@ -80,7 +80,7 @@ The suite includes both `unittest.TestCase` classes and pytest functions. Runnin
80
80
  `agent_protocols.knowledge` implements Agent Knowledge 1.0: signed publications,
81
81
  assessments and retractions; strict payload and dependency validation; deterministic
82
82
  known-set lifecycle views; evidence byte verification; and exact query, batch,
83
- ranked-search and discovery contracts. The JSON Schema ships inside the installed
83
+ ranked-search (with portable lexical matching) and discovery contracts. The JSON Schema ships inside the installed
84
84
  package; no repository files are needed at runtime.
85
85
 
86
86
  ```python
@@ -109,7 +109,7 @@ event = knowledge_publish_event(signer.agent_id(), now, nonces.next_nonce(now),
109
109
  envelope = signer.sign_event(event)
110
110
  store = KnowledgeStore("https://knowledge.example.com")
111
111
  record = store.submit(envelope)
112
- page = store.query({"q": "cache language", "kind": "observation"})
112
+ page = store.query({"kind": "observation", "tag": "caching"})
113
113
  assert page["result"][0]["envelope"]["hash"] == envelope["hash"]
114
114
  assert materialize_knowledge(store.known_envelopes())[envelope["hash"]]["status"] == "active"
115
115
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.11.2"
3
+ version = "0.11.3"
4
4
  description = "Python SDK for Agent Identity, Agent Profile, Agent Delegation, Agent Discourse, Agent Knowledge, and Agent Mail protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -67,7 +67,7 @@ from .delegation import (
67
67
  DelegationRecord,
68
68
  DelegationVerdict,
69
69
  audit_delegation_history,
70
- controller_lineage,
70
+ controller_covers_credential,
71
71
  delegation_grant_event,
72
72
  delegation_revoke_event,
73
73
  is_principal_alias,
@@ -115,7 +115,7 @@ __all__ = [
115
115
  "agent_id_from_public_key",
116
116
  "audit_delegation_history",
117
117
  "canonical_event_bytes",
118
- "controller_lineage",
118
+ "controller_covers_credential",
119
119
  "create_event",
120
120
  "create_request_jwt_claims",
121
121
  "delegation_grant_event",
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, TypedDict, Literal
3
+ from typing import Any, Mapping, TypedDict, Literal
4
4
  from copy import deepcopy
5
5
  from urllib.parse import urlparse
6
6
  import re
@@ -34,7 +34,6 @@ DELEGATION_ERROR_CODES = (
34
34
  "controller_not_current",
35
35
  "delegation_not_permitted",
36
36
  "delegation_ceiling_exceeded",
37
- "not_owner_controller",
38
37
  "credential_not_found",
39
38
  "credential_identity_mismatch",
40
39
  "grant_expired",
@@ -59,8 +58,6 @@ class _ControllerBinding(TypedDict):
59
58
  class Controller(_ControllerBinding, total=False):
60
59
  name: str
61
60
  delegation: Literal["*"] | DelegationPolicy
62
- # Earlier controllers of this principal whose credentials this key may manage.
63
- supersedes: list[AgentId]
64
61
  retired_at: int
65
62
  invalid_from: int
66
63
 
@@ -122,10 +119,6 @@ def validate_controller(controller: Controller, retired: bool = False) -> None:
122
119
  _strings(policy["audiences"], "audiences")
123
120
  for audience in policy["audiences"]:
124
121
  validate_audience(audience)
125
- if "supersedes" in controller:
126
- _strings(controller["supersedes"], "supersedes")
127
- for agent_id in controller["supersedes"]:
128
- validate_agent_id(agent_id)
129
122
  if retired:
130
123
  _timestamp(controller.get("retired_at"), "retired_at")
131
124
  if controller["retired_at"] < controller["valid_from"]:
@@ -158,22 +151,16 @@ def validate_principal_document(document: PrincipalDocument) -> None:
158
151
  delegates = delegates or "delegation" in record
159
152
  if record["valid_from"] > document["updated_at"] or record.get("retired_at", 0) > document["updated_at"]:
160
153
  _fail("controller timestamp exceeds document update")
161
- # Succession (Section 5.1): each entry names another, earlier record.
162
- for record in records.values():
163
- for agent_id in record.get("supersedes", []):
164
- predecessor = records.get(agent_id)
165
- if predecessor is None or agent_id == record["id"] or predecessor["valid_from"] >= record["valid_from"]:
166
- _fail("invalid supersedes entry")
167
154
  if "aliases" in document:
168
155
  _strings(document["aliases"], "aliases", empty=True)
169
156
  for alias in document["aliases"]:
170
157
  _validate_https_url(alias, "alias")
171
158
  if "avatar_url" in document:
172
159
  _validate_https_url(document["avatar_url"], "avatar_url")
173
- if "delegation_query_url" in document:
174
- _validate_https_url(document["delegation_query_url"], "delegation_query_url")
160
+ if "delegation_service" in document:
161
+ validate_origin(document["delegation_service"])
175
162
  elif delegates:
176
- _fail("delegation_query_url is required when a controller carries delegation")
163
+ _fail("delegation_service is required when a controller carries delegation")
177
164
 
178
165
 
179
166
  def validate_principal_resolution(document: PrincipalDocument, resolved_url: str) -> None:
@@ -196,23 +183,17 @@ def is_principal_alias(document: PrincipalDocument, url: str) -> bool:
196
183
  return isinstance(aliases, list) and url in aliases
197
184
 
198
185
 
199
- def controller_lineage(document: PrincipalDocument, controller_id: AgentId) -> set[AgentId]:
200
- """The lineage of a controller (Section 5.1): its own ID plus, transitively,
201
- every record it supersedes. A restricted controller owns a credential whose
202
- `owner_controller` is in its lineage."""
203
- records = {
204
- record["id"]: record
205
- for record in [*document.get("controllers", []), *document.get("retired_controllers", [])]
206
- }
207
- lineage: set[AgentId] = set()
208
- pending = [controller_id]
209
- while pending:
210
- agent_id = pending.pop()
211
- if agent_id in lineage:
212
- continue
213
- lineage.add(agent_id)
214
- pending.extend(records.get(agent_id, {}).get("supersedes", []))
215
- return lineage
186
+ def controller_covers_credential(controller: Controller, credential: Mapping[str, Any]) -> bool:
187
+ """The Section 5 management rule: a restricted controller may replace, revoke,
188
+ or enumerate a credential only when every scope and audience of its latest
189
+ grant is within the controller's ceiling; an unrestricted controller covers
190
+ every credential of the principal."""
191
+ policy = controller.get("delegation")
192
+ if policy is None:
193
+ return False
194
+ if policy == "*":
195
+ return True
196
+ return set(credential["scopes"]) <= set(policy["scopes"]) and set(credential["audiences"]) <= set(policy["audiences"])
216
197
 
217
198
 
218
199
  def validate_delegation_grant_payload(
@@ -352,7 +333,6 @@ def materialize_delegation_credential(
352
333
  **deepcopy(payload),
353
334
  "protocol": DELEGATION_PROTOCOL,
354
335
  "controller": event["actor"],
355
- "owner_controller": previous["owner_controller"] if previous else event["actor"],
356
336
  "status": status,
357
337
  "event_id": envelope["hash"],
358
338
  "grant_event_id": envelope["hash"],
@@ -386,18 +366,20 @@ def validate_historical_delegation(record: DelegationRecord, document: Principal
386
366
  _authority(record["envelope"]["event"], document, record.get("accepted_at"), previous, True)
387
367
 
388
368
 
389
- def validate_controller_enumeration(document: PrincipalDocument, actor: AgentId, now: int, owner: AgentId) -> None:
369
+ def validate_controller_enumeration(document: PrincipalDocument, actor: AgentId, now: int,
370
+ credential: Mapping[str, Any]) -> None:
390
371
  """Per-result authorization; caller authenticates actor and resolves document."""
391
372
  validate_principal_document(document)
392
373
  _timestamp(now, "now")
393
- validate_agent_id(owner)
374
+ _strings(credential.get("scopes"), "scopes")
375
+ _strings(credential.get("audiences"), "audiences")
394
376
  controller = next((c for c in document["controllers"] if c["id"] == actor), None)
395
377
  if not controller or now < controller["valid_from"]:
396
378
  raise AgentProtocolError("controller_not_current", "controller cannot enumerate")
397
379
  if "delegation" not in controller:
398
380
  raise AgentProtocolError("delegation_not_permitted", "controller cannot enumerate")
399
- if controller["delegation"] != "*" and owner not in controller_lineage(document, actor):
400
- raise AgentProtocolError("not_owner_controller", "controller does not own credential")
381
+ if not controller_covers_credential(controller, credential):
382
+ raise AgentProtocolError("delegation_ceiling_exceeded", "credential is outside the controller's ceiling")
401
383
 
402
384
 
403
385
  def validate_delegation_use(credential: DelegationCredential, audience: str, now: int) -> None:
@@ -425,8 +407,8 @@ def verify_delegation_credential(
425
407
  service-trusting evidence policy: checks the accepted record of its latest
426
408
  grant — signature, Controller binding, ceiling, and authority interval —
427
409
  confirms that the grant matches the credential, and then checks use for
428
- `audience` at `now`. Ownership and `supersedes` lineage govern management,
429
- which the service enforced at acceptance; auditors replay them with
410
+ `audience` at `now`. The Section 5 management rule governs management,
411
+ which the service enforced at acceptance; auditors replay it with
430
412
  :func:`audit_delegation_history`. Relying parties still enforce scopes and
431
413
  constraints and authenticate the subject."""
432
414
  reasons: list[str] = []
@@ -473,7 +455,8 @@ def _previous(event: Event, previous: DelegationCredential | None) -> None:
473
455
  if event["type"] == DELEGATION_REVOKE:
474
456
  raise AgentProtocolError("credential_not_found", "revocation requires previous credential")
475
457
  return
476
- validate_agent_id(previous.get("owner_controller"))
458
+ _strings(previous.get("scopes"), "previous.scopes")
459
+ _strings(previous.get("audiences"), "previous.audiences")
477
460
  _timestamp(previous.get("accepted_at"), "previous.accepted_at")
478
461
  delegation_id, principal_id = _event_identity(event)
479
462
  same_subject = event["type"] != DELEGATION_GRANT or event["payload"]["subject"] == previous.get("subject")
@@ -515,15 +498,12 @@ def _authority(event: Event, document: PrincipalDocument, accepted_at: int,
515
498
  _previous(event, previous)
516
499
  if previous and accepted_at < previous["accepted_at"]:
517
500
  _fail("acceptance order reversed")
518
- if (previous and controller["delegation"] != "*"
519
- and previous["owner_controller"] not in controller_lineage(document, event["actor"])):
520
- raise AgentProtocolError("not_owner_controller", "controller does not own credential")
501
+ if previous and not controller_covers_credential(controller, previous):
502
+ raise AgentProtocolError("delegation_ceiling_exceeded", "credential is outside the controller's ceiling")
521
503
  if grant:
522
504
  if "expires_at" in payload and payload["expires_at"] <= accepted_at:
523
505
  raise AgentProtocolError("grant_expired", "grant expired at acceptance")
524
- policy = controller["delegation"]
525
- if policy != "*" and (not set(payload["scopes"]) <= set(policy["scopes"])
526
- or not set(payload["audiences"]) <= set(policy["audiences"])):
506
+ if not controller_covers_credential(controller, payload):
527
507
  raise AgentProtocolError("delegation_ceiling_exceeded", "grant exceeds controller delegation policy")
528
508
 
529
509
 
@@ -542,7 +522,7 @@ def audit_delegation_history(
542
522
  ) -> None:
543
523
  """Auditor check (Section 8): replays every accepted record of a credential
544
524
  against the authoritative principal document — signatures, controller
545
- intervals, ceilings, and ownership lineage — and confirms that the replay
525
+ intervals, and ceilings — and confirms that the replay
546
526
  matches the credential. Relying parties use :func:`verify_delegation_credential`."""
547
527
  if not records:
548
528
  _fail("no accepted records")
@@ -554,7 +534,7 @@ def audit_delegation_history(
554
534
  )
555
535
  assert replayed is not None
556
536
  # Only status and the service's check time may differ from event replay.
557
- fields = (*_GRANT_FIELDS, "protocol", "event_id", "grant_event_id", "owner_controller", "controller", "accepted_at")
537
+ fields = (*_GRANT_FIELDS, "protocol", "event_id", "grant_event_id", "controller", "accepted_at")
558
538
  expected = {field: replayed[field] for field in fields if field in replayed}
559
539
  actual = {field: credential[field] for field in fields if field in credential}
560
540
  if rfc8785.dumps(expected) != rfc8785.dumps(actual) or (
@@ -89,7 +89,6 @@ DISCOURSE_ERROR_CODES = (
89
89
  "join_request_not_found",
90
90
  "join_request_not_pending",
91
91
  "member_banned",
92
- "role_not_allowed",
93
92
  "max_speakers_exceeded",
94
93
  "membership_required",
95
94
  "room_head_mismatch",
@@ -109,8 +108,8 @@ MAX_MENTIONS = 32
109
108
  # Room IDs are host-assigned and URL-safe (Section 6.1).
110
109
  ROOM_ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}")
111
110
 
112
- # `<algorithm>:<base64url-digest>` content digests (Section 12.5).
113
- CONTENT_DIGEST_PATTERN = re.compile(r"(sha256|sha3-256):[A-Za-z0-9_-]{43}")
111
+ # Content digests (Section 12.5): the SHA3-256 of the bytes, encoded like an event hash.
112
+ CONTENT_DIGEST_PATTERN = re.compile(r"[A-Za-z0-9_-]{43}")
114
113
 
115
114
  # Custom event types must not use these prefixes.
116
115
  RESERVED_TYPE_PREFIXES = ("room.", "type.", "message.")
@@ -457,7 +456,7 @@ def validate_pack_import(declaration: dict[str, Any]) -> None:
457
456
  raise AgentProtocolError("invalid_event", "external pack must be an HTTPS URL")
458
457
  if not isinstance(declaration["digest"], str) or not CONTENT_DIGEST_PATTERN.fullmatch(declaration["digest"]):
459
458
  raise AgentProtocolError(
460
- "invalid_event", "external pack digest must be <sha256|sha3-256>:<base64url-digest>"
459
+ "invalid_event", "external pack digest must be the base64url SHA3-256 of the pack bytes"
461
460
  )
462
461
  else:
463
462
  raise AgentProtocolError("invalid_event", "pack import requires either use, or pack with digest")
@@ -658,7 +657,7 @@ def _strip_annotations(schema: dict[str, Any]) -> dict[str, Any]:
658
657
  return stripped
659
658
 
660
659
 
661
- _ROOM_POLICY_FIELDS = frozenset({"invites", "open_roles", "max_speakers", "observer_allowed", "extra"})
660
+ _ROOM_POLICY_FIELDS = frozenset({"invites", "open_roles", "max_speakers", "extra"})
662
661
 
663
662
 
664
663
  def validate_room_policy(policy: dict[str, Any] | None) -> None:
@@ -673,7 +672,6 @@ def validate_room_policy(policy: dict[str, Any] | None) -> None:
673
672
  max_speakers = policy.get("max_speakers")
674
673
  if max_speakers is not None and (type(max_speakers) is not int or max_speakers < 1):
675
674
  raise AgentProtocolError("invalid_event", "max_speakers must be a positive integer")
676
- observer_allowed = policy.get("observer_allowed", True)
677
675
  invites = policy.get("invites")
678
676
  if invites is not None:
679
677
  if not isinstance(invites, dict):
@@ -682,8 +680,6 @@ def validate_room_policy(policy: dict[str, Any] | None) -> None:
682
680
  validate_agent_id(agent_id)
683
681
  if role not in ROLES:
684
682
  raise AgentProtocolError("invalid_event", f"invalid invited role: {role}")
685
- if role == "observer" and not observer_allowed:
686
- raise AgentProtocolError("role_not_allowed", "observers are not allowed")
687
683
  open_roles = policy.get("open_roles")
688
684
  if open_roles is not None:
689
685
  if not isinstance(open_roles, list) or len(set(open_roles)) != len(open_roles):
@@ -691,8 +687,6 @@ def validate_room_policy(policy: dict[str, Any] | None) -> None:
691
687
  for role in open_roles:
692
688
  if role not in ("speaker", "observer"):
693
689
  raise AgentProtocolError("invalid_event", f"open_roles cannot contain {role}")
694
- if role == "observer" and not observer_allowed:
695
- raise AgentProtocolError("role_not_allowed", "observers are not allowed")
696
690
 
697
691
 
698
692
  def validate_room_visibility_policy(visibility: Visibility, policy: dict[str, Any] | None) -> None:
@@ -705,11 +699,11 @@ def validate_room_visibility_policy(visibility: Visibility, policy: dict[str, An
705
699
 
706
700
  def effective_open_roles(visibility: Visibility, policy: dict[str, Any] | None) -> list[Role]:
707
701
  """The roles any agent may take by direct `room.join` (Section 8.3): the
708
- explicit `open_roles`, or by default `observer` in a public room when
709
- observers are allowed, and none otherwise."""
702
+ explicit `open_roles`, or by default `observer` in a public room and none
703
+ otherwise."""
710
704
  if policy is not None and policy.get("open_roles") is not None:
711
705
  return list(policy["open_roles"])
712
- if visibility == "public" and (policy is None or policy.get("observer_allowed") is not False):
706
+ if visibility == "public":
713
707
  return ["observer"]
714
708
  return []
715
709
 
@@ -953,14 +947,11 @@ def _compile_schema(schema: dict[str, Any]) -> Draft202012Validator:
953
947
 
954
948
 
955
949
  def verify_pack_digest(data: bytes, digest: str) -> None:
956
- """Verifies a `<algorithm>:<base64url-digest>` content digest over raw
957
- bytes. Supports `sha256` and `sha3-256`."""
950
+ """Verifies a content digest (the base64url SHA3-256 of the bytes) over raw bytes."""
958
951
  if not isinstance(digest, str) or not CONTENT_DIGEST_PATTERN.fullmatch(digest):
959
952
  raise AgentProtocolError("pack_unavailable", f"invalid digest format: {digest}")
960
- algorithm, _, expected = digest.partition(":")
961
- raw = hashlib.sha256(data).digest() if algorithm == "sha256" else hashlib.sha3_256(data).digest()
962
- actual = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
963
- if actual != expected:
953
+ actual = base64.urlsafe_b64encode(hashlib.sha3_256(data).digest()).rstrip(b"=").decode()
954
+ if actual != digest:
964
955
  raise AgentProtocolError("pack_unavailable", "pack digest mismatch")
965
956
 
966
957
 
@@ -297,10 +297,10 @@ class DelegationClient:
297
297
  request: dict[str, Any],
298
298
  jwt: str | None = None,
299
299
  ) -> dict[str, Any]:
300
- """Queries the endpoint a principal document names in its
301
- `delegation_query_url`. That is how a relying party reaches the
302
- authoritative service for a principal without trusting a URL supplied
303
- by whoever presented the credential."""
300
+ """Queries an explicit endpoint, such as the `query` endpoint discovered
301
+ at a principal's `delegation_service` origin. A relying party reaches a
302
+ principal's authoritative service through that origin, never through a
303
+ URL supplied by whoever presented the credential."""
304
304
  validate_delegation_query_request(request, allow_enumeration=jwt is not None)
305
305
  return _read_json(self.session.post(query_url, json=request, headers=_auth_headers(jwt)))
306
306
 
@@ -46,7 +46,7 @@ SCHEMA = parse_strict_json(files(__package__).joinpath("knowledge.schema.json").
46
46
  ASCII_FOLD = str.maketrans('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')
47
47
  # Relationships that may point at an assessment as well as a publication.
48
48
  _ASSESSMENT_RELATIONS = ("derived_from", "supports", "contradicts")
49
- _READ_ONLY = ("q", "cursor", "limit", "after_seq")
49
+ _READ_ONLY = ("cursor", "limit", "after_seq")
50
50
  _VALIDATORS = {name: Draft202012Validator({"$ref": "#/$defs/" + name, "$defs": SCHEMA["$defs"]}, format_checker=FormatChecker()) for name in SCHEMA["$defs"]}
51
51
 
52
52
 
@@ -145,7 +145,8 @@ def validate_knowledge_envelope(item: Envelope) -> None:
145
145
  verify_envelope(_identity_envelope(item))
146
146
  payload = item["event"]["payload"]
147
147
  validate_knowledge_id(item["hash"])
148
- _https_url(payload["license"])
148
+ if item["event"]["type"] != KNOWLEDGE_RETRACT:
149
+ _https_url(payload["license"])
149
150
  for evidence in payload.get("evidence", []):
150
151
  _https_url(evidence["url"])
151
152
  if "digest" in evidence:
@@ -293,8 +294,6 @@ def validate_knowledge_query(value: Mapping[str, Any]) -> dict[str, Any]:
293
294
  """Validate a parsed query and return a normalized copy."""
294
295
  validate_knowledge_schema(value, 'queryRequest', 'invalid_request')
295
296
  _validate_filters({k: v for k, v in value.items() if k not in _READ_ONLY})
296
- if 'q' in value:
297
- knowledge_text_terms(value['q'])
298
297
  return _normalize_read_numbers(value)
299
298
 
300
299
 
@@ -317,14 +316,12 @@ def parse_knowledge_query(parameters: Iterable[tuple[str, str]]) -> dict[str, An
317
316
 
318
317
 
319
318
  def knowledge_query_matches(item: Envelope, filters: Mapping[str, Any]) -> bool:
320
- """Exact filter and text predicate; `after_seq`, `limit` and `cursor` are not payload filters."""
319
+ """Exact filter predicate; `after_seq`, `limit` and `cursor` are not payload filters."""
321
320
  event, payload = item['event'], item['event']['payload']
322
321
  is_publication = event['type'] == KNOWLEDGE_PUBLISH
323
322
  for key in ('actor', 'type'):
324
323
  if key in filters and event[key] != filters[key]:
325
324
  return False
326
- if 'q' in filters and not knowledge_text_matches(item, filters['q']):
327
- return False
328
325
  if 'language' in filters and (not is_publication or _ascii_fold(payload['language']) != _ascii_fold(filters['language'])):
329
326
  return False
330
327
  if 'verdict' in filters and (event['type'] != KNOWLEDGE_ASSESS or payload['verdict'] != filters['verdict']):
@@ -779,7 +776,6 @@ class KnowledgeAssessPayload(KnowledgeAssessRequired, total=False):
779
776
 
780
777
 
781
778
  class KnowledgeRetractRequired(TypedDict):
782
- license: str
783
779
  target: str
784
780
  reason: str
785
781
 
@@ -473,14 +473,10 @@
473
473
  "retractPayload": {
474
474
  "type": "object",
475
475
  "required": [
476
- "license",
477
476
  "target",
478
477
  "reason"
479
478
  ],
480
479
  "properties": {
481
- "license": {
482
- "$ref": "#/$defs/httpsUrl"
483
- },
484
480
  "target": {
485
481
  "$ref": "#/$defs/eventHash"
486
482
  },
@@ -893,9 +889,6 @@
893
889
  "after_seq": {
894
890
  "$ref": "#/$defs/nonNegativeInteger"
895
891
  },
896
- "q": {
897
- "$ref": "#/$defs/queryText"
898
- },
899
892
  "limit": {
900
893
  "type": "integer",
901
894
  "minimum": 1,
@@ -227,11 +227,11 @@ def validate_mail_envelope(envelope: Envelope, definition: str = 'envelope', *,
227
227
  validate_origin(route)
228
228
  _public_key(payload['public_key'])
229
229
  else:
230
- _decode(payload['enc'], 32, 'invalid_packet')
231
- ct = _decode(payload['ciphertext'], code='invalid_packet')
232
- _require(len(ct) >= 1040 and len(ct) % 1024 == 16, 'invalid_packet', 'invalid ciphertext block length')
230
+ _decode(payload['enc'], 32, 'invalid_event')
231
+ ct = _decode(payload['ciphertext'], code='invalid_event')
232
+ _require(len(ct) >= 1040 and len(ct) % 1024 == 16, 'invalid_event', 'invalid ciphertext block length')
233
233
  _require(event['created_at'] < payload['header']['expires_at'] <= event['created_at'] + MAIL_MAX_TTL_MS + MAIL_FUTURE_SKEW_MS,
234
- 'invalid_packet', 'invalid packet lifetime')
234
+ 'invalid_event', 'invalid packet lifetime')
235
235
 
236
236
 
237
237
  def parse_mail_envelope(raw: str | bytes, *, max_bytes: int = MAIL_MAX_PACKET_BYTES) -> Envelope:
@@ -284,14 +284,14 @@ def encode_mail_plaintext(message: dict[str, Any]) -> bytes:
284
284
 
285
285
  def decode_mail_plaintext(plaintext: bytes) -> dict[str, Any]:
286
286
  _require(isinstance(plaintext, bytes) and 1024 <= len(plaintext) <= MAIL_MAX_PACKET_BYTES
287
- and len(plaintext) % 1024 == 0, 'invalid_packet', 'invalid plaintext block length')
287
+ and len(plaintext) % 1024 == 0, 'invalid_event', 'invalid plaintext block length')
288
288
  n = int.from_bytes(plaintext[:4], 'big')
289
- _require(0 < n <= len(plaintext) - 4, 'invalid_packet', 'invalid plaintext JSON length')
290
- _require(len(plaintext) == 1024 * ((4 + n + 1023) // 1024), 'invalid_packet', 'nonminimal padding')
291
- _require(not any(plaintext[4+n:]), 'invalid_packet', 'nonzero padding')
289
+ _require(0 < n <= len(plaintext) - 4, 'invalid_event', 'invalid plaintext JSON length')
290
+ _require(len(plaintext) == 1024 * ((4 + n + 1023) // 1024), 'invalid_event', 'nonminimal padding')
291
+ _require(not any(plaintext[4+n:]), 'invalid_event', 'nonzero padding')
292
292
  data = plaintext[4:4+n]
293
- message = _parse_bounded(data, MAIL_MAX_PACKET_BYTES, 'invalid_packet')
294
- _require(_jcs(message) == data, 'invalid_packet', 'plaintext JSON is not JCS')
293
+ message = _parse_bounded(data, MAIL_MAX_PACKET_BYTES, 'invalid_event')
294
+ _require(_jcs(message) == data, 'invalid_event', 'plaintext JSON is not JCS')
295
295
  validate_mail_message(message)
296
296
  return message
297
297
 
@@ -307,7 +307,7 @@ def _packet_bytes(packet: dict[str, Any], packet_id: str | None = None) -> bytes
307
307
  raw = _jcs(packet)
308
308
  _require(len(raw) <= MAIL_MAX_PACKET_BYTES, 'payload_too_large', 'packet exceeds limit')
309
309
  if packet_id is not None:
310
- _require(packet['hash'] == packet_id, 'invalid_packet', 'packet ID mismatch')
310
+ _require(packet['hash'] == packet_id, 'invalid_event', 'packet ID mismatch')
311
311
  return raw
312
312
 
313
313
 
@@ -322,7 +322,7 @@ def validate_mail_packet(packet: dict[str, Any], *, packet_id: str | None = None
322
322
 
323
323
 
324
324
  def parse_mail_packet(raw: str | bytes, *, max_body_bytes: int = MAIL_MAX_PACKET_BYTES + 65536) -> dict[str, Any]:
325
- packet = _parse_bounded(raw, max_body_bytes, 'invalid_packet')
325
+ packet = _parse_bounded(raw, max_body_bytes, 'invalid_event')
326
326
  validate_mail_packet(packet)
327
327
  return packet
328
328
 
@@ -342,7 +342,7 @@ def encrypt_mail(message: dict[str, Any], card: Envelope, signer: AgentSigner, n
342
342
  c = card['event']['payload']
343
343
  _require(message['created_at'] <= now + MAIL_FUTURE_SKEW_MS and now < message['expires_at'],
344
344
  'packet_expired', 'message is expired or future-dated')
345
- _require(message['expires_at'] <= c['receive_until'], 'invalid_packet', 'message exceeds receive_until')
345
+ _require(message['expires_at'] <= c['receive_until'], 'invalid_event', 'message exceeds receive_until')
346
346
  header = {'mailbox_id': c['mailbox_id'], 'card_hash': card['hash'], 'expires_at': message['expires_at']}
347
347
  event = create_event(MAIL_PROTOCOL, MAIL_SUBMIT, signer.agent_id(), now, nonce, {'header': header})
348
348
  plaintext = encode_mail_plaintext(message)
@@ -356,7 +356,7 @@ def encrypt_mail(message: dict[str, Any], card: Envelope, signer: AgentSigner, n
356
356
  finally:
357
357
  del context
358
358
  except (PyHPKEError, ValueError) as exc:
359
- raise AgentProtocolError('invalid_packet', 'HPKE encryption failed') from exc
359
+ raise AgentProtocolError('invalid_event', 'HPKE encryption failed') from exc
360
360
  event['payload'].update(enc=_b64(enc), ciphertext=_b64(ciphertext))
361
361
  packet = signer.sign_event(event)
362
362
  _require(len(_packet_bytes(packet)) <= c['max_packet_bytes'], 'payload_too_large', 'packet exceeds card limit')
@@ -378,9 +378,9 @@ def _open_verified(packet: dict[str, Any], raw: bytes, card: Envelope, key: Mail
378
378
  c, h = card['event']['payload'], packet['event']['payload']['header']
379
379
  _require(packet['event']['created_at'] <= now + MAIL_FUTURE_SKEW_MS, 'timestamp_out_of_window', 'packet is future-dated')
380
380
  _require(h['card_hash'] == card['hash'] and h['mailbox_id'] == c['mailbox_id'],
381
- 'invalid_packet', 'packet/card binding mismatch')
381
+ 'invalid_event', 'packet/card binding mismatch')
382
382
  _require(len(raw) <= c['max_packet_bytes'], 'payload_too_large', 'packet exceeds card limit')
383
- _require(h['expires_at'] <= c['receive_until'], 'invalid_packet', 'packet exceeds key retention deadline')
383
+ _require(h['expires_at'] <= c['receive_until'], 'invalid_event', 'packet exceeds key retention deadline')
384
384
  _require(isinstance(key, MailEncryptionKey) and key.public_key() == c['public_key'],
385
385
  'invalid_private_key', 'recipient secret does not match card')
386
386
  try:
@@ -390,11 +390,11 @@ def _open_verified(packet: dict[str, Any], raw: bytes, card: Envelope, key: Mail
390
390
  finally:
391
391
  del context
392
392
  except (PyHPKEError, ValueError) as exc:
393
- raise AgentProtocolError('invalid_packet', 'HPKE decryption failed') from exc
393
+ raise AgentProtocolError('invalid_event', 'HPKE decryption failed') from exc
394
394
  message = decode_mail_plaintext(plaintext)
395
395
  e = packet['event']
396
396
  _require(message['from'] == e['actor'] and message['to'] == owner and message['expires_at'] == h['expires_at'],
397
- 'invalid_packet', 'sender, recipient or expiration mismatch')
397
+ 'invalid_event', 'sender, recipient or expiration mismatch')
398
398
  _require(message['created_at'] <= min(now, e['created_at']) + MAIL_FUTURE_SKEW_MS,
399
399
  'timestamp_out_of_window', 'message timestamp too far in future')
400
400
  return message
@@ -102,7 +102,7 @@ class MailKeyring:
102
102
  """Open a packet already checked by ``_packet_bytes``; used by MailInbox."""
103
103
  with self._lock:
104
104
  entry = self._entries.get(packet['event']['payload']['header']['card_hash'])
105
- _require(entry is not None, 'invalid_packet', 'unknown retained mailbox card')
105
+ _require(entry is not None, 'invalid_event', 'unknown retained mailbox card')
106
106
  return _open_verified(packet, raw, entry[0], entry[1], self.owner, now)
107
107
 
108
108
  def prune(self, *, now_ms: int | None = None) -> int:
@@ -269,10 +269,10 @@ class MailRelayStore:
269
269
  """Packets are asynchronous signed objects: no live-write window or nonce maximum applies."""
270
270
  now = _now(now_ms)
271
271
  validate_mail_id(mailbox_id, size=16)
272
- packet = _parse_bounded(packet, self.max_body_bytes, 'invalid_packet') if isinstance(packet, (str, bytes)) else copy.deepcopy(packet)
272
+ packet = _parse_bounded(packet, self.max_body_bytes, 'invalid_event') if isinstance(packet, (str, bytes)) else copy.deepcopy(packet)
273
273
  raw = _packet_bytes(packet)
274
274
  h = packet['event']['payload']['header']
275
- _require(h['mailbox_id'] == mailbox_id, 'invalid_packet', 'path/header mailbox mismatch')
275
+ _require(h['mailbox_id'] == mailbox_id, 'invalid_event', 'path/header mailbox mismatch')
276
276
  pid, size = packet['hash'], len(raw)
277
277
  with self._lock:
278
278
  box = self._boxes.get(mailbox_id)
@@ -286,7 +286,7 @@ class MailRelayStore:
286
286
  _require(card['hash'] == h['card_hash'] and now < c['expires_at'], 'stale_card', 'packet needs the current unexpired card')
287
287
  _require(now < h['expires_at'], 'packet_expired', 'packet expired')
288
288
  _require(h['expires_at'] <= min(c['receive_until'], now + MAIL_MAX_TTL_MS + MAIL_FUTURE_SKEW_MS),
289
- 'invalid_packet', 'packet exceeds accepted lifetime')
289
+ 'invalid_event', 'packet exceeds accepted lifetime')
290
290
  _require(packet['event']['created_at'] <= now + MAIL_FUTURE_SKEW_MS, 'timestamp_out_of_window', 'packet is future-dated')
291
291
  _require(size <= c['max_packet_bytes'], 'payload_too_large', 'packet exceeds card limit')
292
292
  box.drop_expired(now)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.11.2
3
+ Version: 0.11.3
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, Agent Delegation, Agent Discourse, Agent Knowledge, and Agent Mail protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -25,7 +25,7 @@ Python SDK for the draft Agent Identity, Agent Profile, Agent Delegation, Agent
25
25
 
26
26
  - `agent_protocols.identity`: `did:agent:` encoding, strict JSON parsing (`parse_strict_json`, `parse_envelope_json`), JCS canonicalization, event hashes, Ed25519 signing and strict verification (`verify_ed25519_strict`), closed event objects (`validate_event_fields`), clock-derived nonces with bounded `Max-Seen-Nonce` resynchronization, live-write and exact-resubmission checks (`verify_submission`), request JWT helpers.
27
27
  - `agent_protocols.profile`: `profile.update` payload helpers, delegation discovery hints, validation, succession checks, materialization.
28
- - `agent_protocols.delegation`: principal documents and resolution, Controller records with `supersedes` lineage, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
28
+ - `agent_protocols.delegation`: principal documents and resolution, Controller records, grant/revoke payloads, credentials, authority, acceptance, historical, and use checks, `verify_delegation_credential` over the latest grant record, and `audit_delegation_history` for auditors.
29
29
  - `agent_protocols.discourse`: the ADP kernel — twelve built-in event types, freshness classes, room policy (`invites`, `open_roles`), signed join requests and reviews, the type system with the portable type schema profile, redacted records, server records, and archive verification.
30
30
  - `agent_protocols.knowledge`: signed research contributions, known-set lifecycle views, evidence and profile bindings, in-memory storage, exact queries with checkpoint-bound pagination, single-page ranked search, discovery, and response validation.
31
31
  - `agent_protocols.mail` and `agent_protocols.mail_state`: signed mailbox cards, sender-signed PyHPKE packets, historical key retention, card pins, inbox deduplication, and synchronized relay state with snapshots.
@@ -55,11 +55,11 @@ ADP room writes carry a signed `base_seq` / `base_hash`. Message and control wri
55
55
 
56
56
  ## Delegation
57
57
 
58
- Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. `supersedes` lets a successor key manage its predecessors' credentials. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes `delegation_query_url`.
58
+ Controllers are records shared by `controllers` and `retired_controllers`: `id` is the Agent ID, `source` is an HTTPS origin or `local`, and `valid_from` starts the binding. Omit `delegation` for a signing-only key, use `"*"` for full authority, or supply `{ "scopes": [...], "audiences": [...] }` for restricted authority. A restricted key manages (replaces, revokes, enumerates) only credentials whose scopes and audiences fall within its own ceiling. Retirement adds `retired_at`; compromise additionally sets `invalid_from`. A principal that grants delegations publishes its `delegation_service` origin.
59
59
 
60
60
  Controller registration (Section 4.3) proves key possession with a fixed-shape challenge: `signer.sign_controller_challenge(challenge)` signs its exact UTF-8 bytes and refuses any other string, and `verify_controller_challenge(agent_id, challenge, signature)` applies the strict Ed25519 rules. Issuing challenges, binding them to the owner-approved fields, and expiry stay with the provider.
61
61
 
62
- Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject` and `owner_controller`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
62
+ Grants name the canonical `principal_id`. Credentials carry `principal_id`, an immutable `subject`, the latest `grant_event_id`, the service `accepted_at`, and `checked_at`. Materialization requires an explicit acceptance time and trusted previous state; it never derives acceptance time from `created_at`.
63
63
 
64
64
  The validation layers have different responsibilities:
65
65
 
@@ -99,7 +99,7 @@ The suite includes both `unittest.TestCase` classes and pytest functions. Runnin
99
99
  `agent_protocols.knowledge` implements Agent Knowledge 1.0: signed publications,
100
100
  assessments and retractions; strict payload and dependency validation; deterministic
101
101
  known-set lifecycle views; evidence byte verification; and exact query, batch,
102
- ranked-search and discovery contracts. The JSON Schema ships inside the installed
102
+ ranked-search (with portable lexical matching) and discovery contracts. The JSON Schema ships inside the installed
103
103
  package; no repository files are needed at runtime.
104
104
 
105
105
  ```python
@@ -128,7 +128,7 @@ event = knowledge_publish_event(signer.agent_id(), now, nonces.next_nonce(now),
128
128
  envelope = signer.sign_event(event)
129
129
  store = KnowledgeStore("https://knowledge.example.com")
130
130
  record = store.submit(envelope)
131
- page = store.query({"q": "cache language", "kind": "observation"})
131
+ page = store.query({"kind": "observation", "tag": "caching"})
132
132
  assert page["result"][0]["envelope"]["hash"] == envelope["hash"]
133
133
  assert materialize_knowledge(store.known_envelopes())[envelope["hash"]]["status"] == "active"
134
134
  ```
@@ -78,7 +78,7 @@ class DelegationTests(unittest.TestCase):
78
78
  "id": "https://profiles.example.com/org/acme",
79
79
  "protocol": DELEGATION_PROTOCOL, "updated_at": 1000, "controllers": [{"id": controller.agent_id(), "source": "local", "valid_from": 0}],
80
80
  "aliases": ["https://profiles.example.com/acme"],
81
- "delegation_query_url": "https://profiles.example.com/v1/delegations/query",
81
+ "delegation_service": "https://profiles.example.com",
82
82
  }
83
83
  )
84
84
  with self.assertRaises(Exception):
@@ -74,7 +74,7 @@ ORIGIN = "https://dmsg.net"
74
74
 
75
75
 
76
76
  def document():
77
- return {"protocol": d.DELEGATION_PROTOCOL, "id": ID, "updated_at": 2000, "delegation_query_url": f"{ID}/query", "controllers": [
77
+ return {"protocol": d.DELEGATION_PROTOCOL, "id": ID, "updated_at": 2000, "delegation_service": "https://example.com", "controllers": [
78
78
  {"id": SIGNER.agent_id(), "source": ORIGIN, "valid_from": 100, "delegation": {"scopes": ["draft"], "audiences": [ORIGIN]}},
79
79
  {"id": ROOT.agent_id(), "source": "local", "valid_from": 100, "delegation": "*"},
80
80
  ]}
@@ -98,7 +98,6 @@ def test_authority_ownership_and_materialization():
98
98
  assert credential["accepted_at"] == 250
99
99
  assert credential["checked_at"] == 250
100
100
  assert credential["principal_id"] == ID
101
- assert credential["owner_controller"] == SIGNER.agent_id()
102
101
  assert credential["grant_event_id"] == envelope["hash"]
103
102
  d.validate_delegation_use(credential, ORIGIN, 300)
104
103
  with pytest.raises(AgentProtocolError):
@@ -120,18 +119,21 @@ def test_authority_ownership_and_materialization():
120
119
  with pytest.raises(AgentProtocolError) as not_permitted:
121
120
  d.validate_delegation_acceptance(envelope, only, ID, 250)
122
121
  assert not_permitted.value.code == "delegation_not_permitted"
122
+ # A restricted key manages another key's credential only within its ceiling.
123
123
  root_credential = d.materialize_delegation_credential(grant(ROOT), accepted_at=250)
124
- with pytest.raises(AgentProtocolError) as not_owner:
125
- d.validate_delegation_acceptance(envelope, doc, ID, 300, root_credential)
126
- assert not_owner.value.code == "not_owner_controller"
124
+ d.validate_delegation_acceptance(envelope, doc, ID, 300, root_credential)
125
+ wide = d.materialize_delegation_credential(grant(ROOT, 2, scopes=["draft", "admin"]), accepted_at=250)
126
+ with pytest.raises(AgentProtocolError) as outside:
127
+ d.validate_delegation_acceptance(envelope, doc, ID, 300, wide)
128
+ assert outside.value.code == "delegation_ceiling_exceeded"
127
129
  with pytest.raises(AgentProtocolError):
128
- d.validate_controller_enumeration(doc, SIGNER.agent_id(), 300, ROOT.agent_id())
129
- d.validate_controller_enumeration(doc, ROOT.agent_id(), 300, SIGNER.agent_id())
130
+ d.validate_controller_enumeration(doc, SIGNER.agent_id(), 300, wide)
131
+ d.validate_controller_enumeration(doc, SIGNER.agent_id(), 300, credential)
132
+ d.validate_controller_enumeration(doc, ROOT.agent_id(), 300, wide)
130
133
  revocation = revoke()
131
134
  d.validate_delegation_acceptance(revocation, doc, ID, 450, credential)
132
135
  revoked = d.materialize_delegation_credential(revocation, accepted_at=450, previous=credential)
133
136
  assert revoked["status"] == "revoked"
134
- assert revoked["owner_controller"] == SIGNER.agent_id()
135
137
  assert revoked["controller"] == ROOT.agent_id()
136
138
  assert revoked["grant_event_id"] == envelope["hash"]
137
139
  assert revoked["audiences"] == credential["audiences"]
@@ -140,7 +142,7 @@ def test_authority_ownership_and_materialization():
140
142
  assert missing.value.code == "credential_not_found"
141
143
  replacement = grant(ROOT, 3)
142
144
  d.validate_delegation_acceptance(replacement, doc, ID, 500, revoked)
143
- assert d.materialize_delegation_credential(replacement, accepted_at=500, previous=revoked)["owner_controller"] == SIGNER.agent_id()
145
+ assert d.materialize_delegation_credential(replacement, accepted_at=500, previous=revoked)["controller"] == ROOT.agent_id()
144
146
 
145
147
 
146
148
  def test_a_replacement_cannot_move_a_credential_to_another_subject():
@@ -172,32 +174,34 @@ def test_retired_history_needs_accepted_records_and_the_original_ceiling():
172
174
  d.validate_historical_delegation({"envelope": grant(nonce=2, scopes=["admin"]), "accepted_at": 250}, doc, ID)
173
175
 
174
176
 
175
- def test_a_restricted_successor_manages_its_predecessors_credentials():
177
+ def test_a_restricted_controller_manages_credentials_within_its_ceiling():
176
178
  doc = document()
177
179
  retired = doc["controllers"].pop(0)
178
180
  retired["retired_at"] = 600
179
181
  doc["retired_controllers"] = [retired]
180
182
  doc["controllers"].append({
181
183
  "id": SUCCESSOR.agent_id(), "source": ORIGIN, "valid_from": 600,
182
- "delegation": {"scopes": ["draft", "inbox"], "audiences": [ORIGIN]}, "supersedes": [SIGNER.agent_id()],
184
+ "delegation": {"scopes": ["draft", "inbox"], "audiences": [ORIGIN]},
183
185
  })
184
186
  d.validate_principal_document(doc)
185
- assert d.controller_lineage(doc, SUCCESSOR.agent_id()) == {SIGNER.agent_id(), SUCCESSOR.agent_id()}
186
187
  credential = d.materialize_delegation_credential(grant(), accepted_at=250)
188
+ assert d.controller_covers_credential(doc["controllers"][1], credential)
187
189
  revocation = revoke(SUCCESSOR, 700, 1)
188
190
  d.validate_delegation_acceptance(revocation, doc, ID, 700, credential)
189
- d.validate_controller_enumeration(doc, SUCCESSOR.agent_id(), 700, SIGNER.agent_id())
191
+ d.validate_controller_enumeration(doc, SUCCESSOR.agent_id(), 700, credential)
190
192
  widened = SUCCESSOR.sign_event(d.delegation_grant_event(SUCCESSOR.agent_id(), 700, 2, {
191
193
  "id": "del", "principal_id": ID, "subject": ROOT.agent_id(), "scopes": ["draft", "inbox"],
192
194
  "audiences": [ORIGIN], "expires_at": 1900,
193
195
  }))
194
196
  d.validate_delegation_acceptance(widened, doc, ID, 700, credential)
195
- assert d.materialize_delegation_credential(widened, accepted_at=700, previous=credential)["owner_controller"] == SIGNER.agent_id()
196
- # Without the supersedes link the successor owns nothing.
197
- unlinked = deepcopy(doc)
198
- del unlinked["controllers"][1]["supersedes"]
199
- with pytest.raises(AgentProtocolError, match="own"):
200
- d.validate_delegation_acceptance(revocation, unlinked, ID, 700, credential)
197
+ assert d.materialize_delegation_credential(widened, accepted_at=700, previous=credential)["controller"] == SUCCESSOR.agent_id()
198
+ # A credential outside the ceiling cannot be revoked, replaced, or enumerated by the restricted key.
199
+ wide = d.materialize_delegation_credential(grant(ROOT, 3, scopes=["draft", "admin"]), accepted_at=250)
200
+ assert not d.controller_covers_credential(doc["controllers"][1], wide)
201
+ with pytest.raises(AgentProtocolError, match="ceiling"):
202
+ d.validate_delegation_acceptance(revocation, doc, ID, 700, wide)
203
+ with pytest.raises(AgentProtocolError, match="ceiling"):
204
+ d.validate_controller_enumeration(doc, SUCCESSOR.agent_id(), 700, wide)
201
205
 
202
206
 
203
207
  def test_verify_delegation_credential_checks_the_latest_grant():
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  from base64 import urlsafe_b64encode
3
3
  from copy import deepcopy
4
- from hashlib import sha256, sha3_256
4
+ from hashlib import sha3_256
5
5
  from pathlib import Path
6
6
 
7
7
  import pytest
@@ -240,10 +240,8 @@ def test_direct_join_follows_invites_and_open_roles():
240
240
  assert not d.can_join_directly("private", None, stranger, "observer")
241
241
  assert d.effective_open_roles("public", None) == ["observer"]
242
242
  assert d.effective_open_roles("restricted", None) == []
243
- assert d.effective_open_roles("public", {"observer_allowed": False}) == []
244
243
  for bad in (
245
244
  {"open_roles": ["moderator"]},
246
- {"observer_allowed": False, "open_roles": ["observer"]},
247
245
  {"moderator_agent_ids": []},
248
246
  {"invites": {invited: "owner"}},
249
247
  {"max_speakers": 0},
@@ -314,11 +312,12 @@ def test_registry_imports_packs_and_rejects_conflicts():
314
312
 
315
313
  def test_verifies_pack_digests():
316
314
  data = b"pack document bytes"
317
- for algorithm, digest in (("sha256", sha256(data).digest()), ("sha3-256", sha3_256(data).digest())):
318
- value = f"{algorithm}:" + urlsafe_b64encode(digest).rstrip(b"=").decode()
319
- d.verify_pack_digest(data, value)
320
- with pytest.raises(AgentProtocolError):
321
- d.verify_pack_digest(b"tampered", value)
315
+ value = urlsafe_b64encode(sha3_256(data).digest()).rstrip(b"=").decode()
316
+ d.verify_pack_digest(data, value)
317
+ with pytest.raises(AgentProtocolError):
318
+ d.verify_pack_digest(b"tampered", value)
319
+ with pytest.raises(AgentProtocolError):
320
+ d.verify_pack_digest(data, f"sha3-256:{value}")
322
321
  with pytest.raises(AgentProtocolError):
323
322
  d.verify_pack_digest(data, "md5:abc")
324
323
 
@@ -90,11 +90,11 @@ class ValidationTests(unittest.TestCase):
90
90
 
91
91
  def test_validate_pack_import_covers_each_arm(self):
92
92
  validate_pack_import({"use": PACK_REACTIONS})
93
- validate_pack_import({"pack": "https://example.com/p.json", "digest": "sha256:" + "A" * 43})
93
+ validate_pack_import({"pack": "https://example.com/p.json", "digest": "A" * 43})
94
94
  with self.assertRaises(AgentProtocolError):
95
95
  validate_pack_import({"pack": "https://example.com/p.json", "digest": "sha256:abc"})
96
96
  with self.assertRaises(AgentProtocolError):
97
- validate_pack_import({"pack": "http://example.com/p.json", "digest": "sha256:" + "A" * 43})
97
+ validate_pack_import({"pack": "http://example.com/p.json", "digest": "A" * 43})
98
98
  with self.assertRaises(AgentProtocolError):
99
99
  validate_pack_import({"pack": "https://example.com/p.json", "digest": " "})
100
100
  with self.assertRaises(AgentProtocolError):
@@ -117,9 +117,9 @@ class ValidationTests(unittest.TestCase):
117
117
  with self.assertRaises(AgentProtocolError):
118
118
  validate_message_create_payload({"content_type": " ", "content": "hi"})
119
119
 
120
- def test_verify_pack_digest_supports_sha3_256(self):
120
+ def test_verify_pack_digest_is_sha3_256(self):
121
121
  data = b"pack document bytes"
122
- digest = "sha3-256:" + urlsafe_b64encode(sha3_256(data).digest()).rstrip(b"=").decode()
122
+ digest = urlsafe_b64encode(sha3_256(data).digest()).rstrip(b"=").decode()
123
123
  verify_pack_digest(data, digest)
124
124
  with self.assertRaises(AgentProtocolError):
125
125
  verify_pack_digest(b"tampered", digest)
@@ -98,7 +98,7 @@ def test_withheld_dependencies_resolve_and_exact_receipts_bypass_admission():
98
98
  second = signed(2, relations=[{'relation': 'derived_from', 'target': first['hash']}])
99
99
  record = store.submit(second)
100
100
  retraction = SIGNER.sign_event(k.knowledge_retract_event(SIGNER.agent_id(), NOW, 3, {
101
- 'license': 'https://example.com/license', 'target': first['hash'], 'reason': 'mistake'}))
101
+ 'target': first['hash'], 'reason': 'mistake'}))
102
102
  assert store.submit(retraction)['seq'] == 3
103
103
  store.admit = lambda _: (_ for _ in ()).throw(AgentProtocolError('permission_denied', 'closed'))
104
104
  assert store.submit(second) == record
@@ -122,7 +122,7 @@ def test_relationships_may_cite_or_dispute_assessments():
122
122
  def test_known_set_validation_rejects_unresolved_forged_and_wrong_keys():
123
123
  item = signed()
124
124
  withdrawal = SIGNER.sign_event(k.knowledge_retract_event(SIGNER.agent_id(), NOW, 2, {
125
- 'license': 'https://example.com/license', 'target': item['hash'], 'reason': 'mistake'}))
125
+ 'target': item['hash'], 'reason': 'mistake'}))
126
126
  assert code(lambda: k.materialize_knowledge({withdrawal['hash']: withdrawal})) == 'missing_dependency'
127
127
  with pytest.raises(AgentProtocolError):
128
128
  k.materialize_knowledge({'wrong': item})
@@ -162,7 +162,7 @@ def test_page_tracker_rejects_cross_page_drift(mutation):
162
162
  store = k.KnowledgeStore(ORIGIN, clock=lambda: NOW)
163
163
  for n in range(1, 4):
164
164
  store.submit(signed(n))
165
- request = {'q': 'cache', 'limit': 1}
165
+ request = {'type': 'knowledge.publish', 'limit': 1}
166
166
  first = store.query(request)
167
167
  tracker = k.KnowledgePageTracker(ORIGIN)
168
168
  tracker.accept(request, first)
@@ -173,7 +173,7 @@ def test_page_tracker_rejects_cross_page_drift(mutation):
173
173
  elif mutation == 'duplicate':
174
174
  second['result'] = first['result']
175
175
  elif mutation == 'request':
176
- request['q'] = 'Cache'
176
+ request['type'] = 'knowledge.assess'
177
177
  elif mutation == 'cursor':
178
178
  request['cursor'] = 'other'
179
179
  with pytest.raises(AgentProtocolError) as exc:
@@ -155,13 +155,13 @@ def test_http_verifies_read_envelopes_ids_scope_filters_and_order(mutation):
155
155
 
156
156
  def test_query_pages_check_scope_and_repeat_parameters():
157
157
  store, _, _, _, _ = populated()
158
- request = {'q': 'cache', 'limit': 1}
158
+ request = {'type': 'knowledge.publish', 'limit': 1}
159
159
  first = store.query(request)
160
160
  second = store.query({**request, 'cursor': first['next_cursor']})
161
161
  session = Session(Response(first), Response(second))
162
162
  assert len(list(KnowledgeClient(ORIGIN, session).query_pages(request))) == 2
163
163
  params = parse_qs(urlsplit(session.calls[1][1]).query)
164
- assert params == {'q': ['cache'], 'limit': ['1'], 'cursor': [first['next_cursor']]}
164
+ assert params == {'type': ['knowledge.publish'], 'limit': ['1'], 'cursor': [first['next_cursor']]}
165
165
  second['as_of'] += 1
166
166
  with pytest.raises(AgentProtocolError) as exc:
167
167
  list(KnowledgeClient(ORIGIN, Session(Response(first), Response(second))).query_pages(request))
@@ -86,7 +86,7 @@ def test_encryption_checks_happen_before_hpke(monkeypatch):
86
86
  large = signed_change(ENVS['message'], SENDER, parts=[mail_part('application/octet-stream', bytes(5000))])
87
87
  assert code_of(lambda: encrypt_mail(large, ENVS['small_limit_card'], now_ms=NOW)) == 'payload_too_large'
88
88
  long = signed_change(ENVS['message'], SENDER, expires_at=NOW+3*DAY)
89
- assert code_of(lambda: encrypt_mail(long, ENVS['card'], now_ms=NOW)) == 'invalid_packet'
89
+ assert code_of(lambda: encrypt_mail(long, ENVS['card'], now_ms=NOW)) == 'invalid_event'
90
90
  assert code_of(lambda: encrypt_mail(ENVS['message'], ENVS['card'], now_ms=NOW+DAY+1000)) == 'stale_card'
91
91
 
92
92
 
@@ -122,7 +122,7 @@ def test_keyring_old_secret_retention_and_prune():
122
122
  assert keys.open(packet('original'), now_ms=NOW+DAY+500) == ENVS['message']
123
123
  restored = MailKeyring.from_snapshot(keys.snapshot())
124
124
  assert restored.prune(now_ms=NOW+2*DAY) == 3
125
- assert code_of(lambda: restored.open(packet('original'), now_ms=NOW)) == 'invalid_packet'
125
+ assert code_of(lambda: restored.open(packet('original'), now_ms=NOW)) == 'invalid_event'
126
126
  assert code_of(lambda: MailKeyring(OWNER).add(ENVS['card'], key_for(ENVS['rotated_card']))) == 'invalid_private_key'
127
127
 
128
128
 
@@ -213,7 +213,7 @@ def test_relay_raw_bounds_strict_json_route_change_and_path_binding():
213
213
  relay.deliver(MAILBOX, raw + ' ' * relay.max_body_bytes, now_ms=NOW)
214
214
  with pytest.raises(AgentProtocolError):
215
215
  relay.deliver(MAILBOX, raw[:-1] + ',"enc":"bad"}', now_ms=NOW)
216
- assert code_of(lambda: relay.deliver(new_mail_id(), raw, now_ms=NOW)) == 'invalid_packet'
216
+ assert code_of(lambda: relay.deliver(new_mail_id(), raw, now_ms=NOW)) == 'invalid_event'
217
217
  # A valid sender signature still cannot prove ciphertext decryptability.
218
218
  assert code_of(lambda: relay.deliver(MAILBOX, V['recipient_rejections'][0]['packet'], now_ms=NOW)) == 'invalid_event_hash'
219
219
  bad = signed_change(next(c['packet'] for c in V['recipient_rejections'] if c['name'] == 'wrong HPKE AAD'), SENDER, nonce=900)