ms-tau-sdk 2.0.15.dev62__tar.gz → 2.0.16.dev64__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 (87) hide show
  1. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/CHANGELOG.md +30 -0
  2. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/PKG-INFO +1 -1
  3. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/pyproject.toml +1 -1
  4. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/agent_skills/tau_project_customization/SKILL.md +7 -4
  5. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/agent_skills/tau_security_and_access/SKILL.md +27 -0
  6. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/request_identity.py +41 -3
  7. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/assertions.py +66 -14
  8. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/client.py +28 -0
  9. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/routes.py +4 -0
  10. ms_tau_sdk-2.0.16.dev64/src/ms_tau_sdk/backend/team_memberships.py +229 -0
  11. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/manager.py +2 -2
  12. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/requester.py +38 -4
  13. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/settings.py +8 -0
  14. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/.gitignore +0 -0
  15. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/README.md +0 -0
  16. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/__init__.py +0 -0
  17. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/app.py +0 -0
  18. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/cli.py +0 -0
  19. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/config.py +0 -0
  20. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/env_file.py +0 -0
  21. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/logs.py +0 -0
  22. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/proxy.py +0 -0
  23. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/state.py +0 -0
  24. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/static/BULMA-LICENSE.txt +0 -0
  25. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/static/app.js +0 -0
  26. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/static/board.css +0 -0
  27. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/static/bulma.min.css +0 -0
  28. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/packages/tau-board/src/ms_tau_board/static/index.html +0 -0
  29. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/__init__.py +0 -0
  30. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/agent_skills/tau_a2a_runtime_adapter/SKILL.md +0 -0
  31. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/agent_skills/tau_local_development/SKILL.md +0 -0
  32. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/agent_skills/tau_repository_integration/SKILL.md +0 -0
  33. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/__init__.py +0 -0
  34. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/a2a.py +0 -0
  35. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/chat.py +0 -0
  36. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/conversations.py +0 -0
  37. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/dependencies.py +0 -0
  38. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/health.py +0 -0
  39. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/inspection.py +0 -0
  40. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/local_chat.py +0 -0
  41. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/models.py +0 -0
  42. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/api/sessions.py +0 -0
  43. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/app.py +0 -0
  44. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/application.py +0 -0
  45. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/__init__.py +0 -0
  46. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/auth.py +0 -0
  47. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/local.py +0 -0
  48. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/mcp.py +0 -0
  49. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/backend/models.py +0 -0
  50. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/cli.py +0 -0
  51. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/errors.py +0 -0
  52. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/logging.py +0 -0
  53. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/__init__.py +0 -0
  54. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/a2a_failure.py +0 -0
  55. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/a2a_message.py +0 -0
  56. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/a2a_roles.py +0 -0
  57. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/assistant_ui.py +0 -0
  58. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/chat_history.py +0 -0
  59. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/protocols/strict_json.py +0 -0
  60. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/providers/__init__.py +0 -0
  61. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/providers/definitions.py +0 -0
  62. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/providers/factory.py +0 -0
  63. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/providers/tau_compat.py +0 -0
  64. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/resources/SYSTEM.md +0 -0
  65. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/resources/__init__.py +0 -0
  66. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/resources/loader.py +0 -0
  67. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/resources/prompts/review-code-repository.md +0 -0
  68. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/__init__.py +0 -0
  69. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/deployment_health.py +0 -0
  70. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/events.py +0 -0
  71. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/extensions.py +0 -0
  72. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/failures.py +0 -0
  73. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/live_turns.py +0 -0
  74. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/observability.py +0 -0
  75. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/provenance.py +0 -0
  76. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/session.py +0 -0
  77. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/snapshots.py +0 -0
  78. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/runtime/task_context.py +0 -0
  79. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/sessions/__init__.py +0 -0
  80. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/sessions/storage.py +0 -0
  81. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/skills.py +0 -0
  82. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/__init__.py +0 -0
  83. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/mainsequence_mcp.py +0 -0
  84. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/mcp_applications.py +0 -0
  85. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/mcp_connection.py +0 -0
  86. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/skill_read.py +0 -0
  87. {ms_tau_sdk-2.0.15.dev62 → ms_tau_sdk-2.0.16.dev64}/src/ms_tau_sdk/tools/task_control.py +0 -0
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - **The Tau skills say when and how a tool checks the person's teams
6
+ ([#103](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/103)).** `tau_security_and_access`
7
+ has a section "Checking the person's teams". A delegated call already reaches what the person's
8
+ teams share, so a tool rarely checks teams itself; a tool whose own policy names teams asks with
9
+ `in_teams()`. That answers only while the person is the request's verified caller, and an error
10
+ is never a membership answer. `tau_project_customization` points to the section.
11
+
12
+ ## 2.0.15 — 2026-10-15
13
+
14
+ - **Team membership on demand; the caller assertion's team list is deprecated
15
+ ([#103](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/103), ADR 0019).** A caller
16
+ assertion named every team of its caller, so it grew with the Organization, and an Organization
17
+ admin is a member of every team. The platform is replacing that list with a membership check,
18
+ and the runtime now asks it instead of reading the list:
19
+ - the verifier accepts a caller assertion with or without `team_uids`. A list that is present is
20
+ still checked, and `null` or any other claim is refused;
21
+ - `await in_teams(team_uids, *, max_age_seconds=None)` on the verified caller
22
+ (`request.state.user`, `current_caller()`) returns which of the given teams the person belongs
23
+ to, as a `frozenset`. It asks the platform with the runtime's credential and the request's
24
+ assertion, in calls of at most 100 teams. A turn's requester has the same method while it is
25
+ the verified caller of the request, and raises otherwise;
26
+ - `TAU_TEAM_MEMBERSHIP_CACHE_SECONDS` (`TauSDKSettings.team_membership_cache_seconds`, default
27
+ `0`) sets how long an answer is reused across requests. Within a request the same question is
28
+ asked once, and `max_age_seconds=0` always asks and stores the new answer. Answers are keyed by
29
+ release, Organization Environment, person and team, at most 10,000 per process. A failed call
30
+ raises; it never answers "not a member";
31
+ - `team_uids` on the verified caller and on the requester is deprecated and warns when read. It
32
+ raises when the assertion carried no list. `signed_team_uids` holds the list, or `None`, and is
33
+ the name `dataclasses.asdict()` and the repr show.
34
+
5
35
  - **Tau reads skills nested in folders
6
36
  ([#99](https://github.com/mainsequence-sdk/ms-tau-sdk/issues/99)).** Tau read only the skill
7
37
  folders directly in `.tau/skills` and `.agents/skills`, so a skill one folder deeper, such as
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ms-tau-sdk
3
- Version: 2.0.15.dev62
3
+ Version: 2.0.16.dev64
4
4
  Summary: Workspace-bound Tau application primitives for Main Sequence projects
5
5
  Project-URL: Changelog, https://github.com/mainsequence-sdk/ms-tau-sdk/blob/development/CHANGELOG.md
6
6
  Project-URL: Documentation, https://github.com/mainsequence-sdk/ms-tau-sdk/tree/development/docs
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ms-tau-sdk"
7
- version = "2.0.15.dev62"
7
+ version = "2.0.16.dev64"
8
8
  description = "Workspace-bound Tau application primitives for Main Sequence projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -158,10 +158,13 @@ async def revenue_by_region(tool_call_id, arguments, signal=None, on_update=None
158
158
  return AgentToolResult(content=[TextContent(text=json.dumps(answer.json()["rows"]))])
159
159
  ```
160
160
 
161
- - `current_requester()` returns the person the turn serves, with `uid` and `team_uids`, or
162
- `None`. The platform names that person when the turn starts, whoever called; it is `None` when
163
- the platform names nobody, in local mode, and in code outside a turn. Only it names the
164
- requester: never take a person's UID from tool arguments, the prompt, history, or a header.
161
+ - `current_requester()` returns the person the turn serves, with `uid` and
162
+ `await in_teams(team_uids, *, max_age_seconds=None)`, or `None`. `in_teams()` answers while the
163
+ person is the verified caller of the turn's request (see `tau_security_and_access`, "Checking the
164
+ person's teams"); `team_uids` is deprecated. The platform names that person when the turn
165
+ starts, whoever called; it is `None` when the platform names nobody, in local mode, and in code
166
+ outside a turn. Only it names the requester: never take a person's UID from tool arguments, the
167
+ prompt, history, or a header.
165
168
  - `platform_client()` returns a client. `await client.request("GET", "/api/v1/...")` calls a
166
169
  platform API path, and `await client.call_release(release_uid, method, path, ...)` calls another
167
170
  platform application. Both return an `httpx.Response`.
@@ -207,6 +207,33 @@ How sharing works:
207
207
  tool can use them when its design needs to, for example to refuse a team key where it expects a
208
208
  personal one. That is a choice of the tool, not a platform rule.
209
209
 
210
+ ## Checking the person's teams
211
+
212
+ A delegated call already reaches exactly what the person can, including what is shared with their
213
+ teams, so a tool rarely checks teams itself. When the tool's own policy names teams, ask the
214
+ platform with `in_teams()`:
215
+
216
+ ```python
217
+ person = current_requester()
218
+ if person is None or REPORTS_TEAM_UID not in await person.in_teams([REPORTS_TEAM_UID]):
219
+ return AgentToolResult(content=[TextContent(text="These reports are for the team's members.")])
220
+ ```
221
+
222
+ - `await in_teams(team_uids, *, max_age_seconds=None)` returns the subset of `team_uids` the
223
+ person is an active member of, as a `frozenset`. Unknown and inactive teams are left out.
224
+ - On the turn's requester it answers only while that person is the verified caller of the request
225
+ the turn runs in, such as a chat or A2A Message they sent. A turn with no such request raises
226
+ `TauSDKError`: a Task attempt the platform dispatches, a turn that resumes a caller delivery, or
227
+ work another Agent asked for. Refuse the action then; never read the error as a membership
228
+ answer. An HTTP handler asks for its caller with `await request.state.user.in_teams(...)`.
229
+ - The process reuses an answer across requests for `TAU_TEAM_MEMBERSHIP_CACHE_SECONDS` (default
230
+ `0`: only within its request); set it in `harness_agent.spec.env_vars`. `max_age_seconds=0`
231
+ always asks the platform: use it before a write that must see a removal at once.
232
+ - A check the platform cannot answer raises `BackendError`; it never answers "not a member".
233
+ - `team_uids` on the requester is deprecated and warns when read. It is empty when the turn does
234
+ not know the person's teams, and raises when the caller's assertion carried no team list, as
235
+ every assertion will once the platform stops signing them. Use `in_teams()`.
236
+
210
237
  ## What never reaches the model
211
238
 
212
239
  Tool results, their `details`, and errors can reach the model provider and the session history.
@@ -41,6 +41,12 @@ from ms_tau_sdk.backend.assertions import (
41
41
  )
42
42
  from ms_tau_sdk.backend.client import MainSequenceClient
43
43
  from ms_tau_sdk.backend.models import AgentSession, DirectoryUser
44
+ from ms_tau_sdk.backend.team_memberships import (
45
+ TeamMembershipCache,
46
+ TeamMembershipScope,
47
+ bind_team_membership_scope,
48
+ unbind_team_membership_scope,
49
+ )
44
50
  from ms_tau_sdk.errors import SessionNotFoundError, TauSDKError
45
51
  from ms_tau_sdk.logging import bind_request_identity_log_fields
46
52
  from ms_tau_sdk.runtime.provenance import CallerIdentityError
@@ -255,6 +261,12 @@ def required_assertion(scope: Scope) -> AssertionKind:
255
261
  return "platform" if internal else "caller"
256
262
 
257
263
 
264
+ def _backend_of(scope: Scope) -> MainSequenceClient | None:
265
+ """Return the application's platform client once its lifespan has created it."""
266
+
267
+ return getattr(getattr(scope.get("app"), "state", None), "backend", None)
268
+
269
+
258
270
  def _assertion(scope: Scope) -> str:
259
271
  values = [
260
272
  value for name, value in scope.get("headers", ()) if name.lower() == _ASSERTION_HEADER_NAME
@@ -268,11 +280,24 @@ def _assertion(scope: Scope) -> str:
268
280
 
269
281
 
270
282
  class RequestIdentityMiddleware:
271
- """Admit a hosted runtime's requests only with the platform's signed assertion."""
283
+ """Admit a hosted runtime's requests only with the platform's signed assertion.
284
+
285
+ It also gives each request with a verified caller what ``in_teams()`` needs: the deployment
286
+ the verifier trusts, the request's caller assertion, and the process's cache of answers, which
287
+ an answer may be reused from for ``team_membership_cache_seconds``.
288
+ """
272
289
 
273
- def __init__(self, app: ASGIApp, *, verifier: AssertionVerifier) -> None:
290
+ def __init__(
291
+ self,
292
+ app: ASGIApp,
293
+ *,
294
+ verifier: AssertionVerifier,
295
+ team_membership_cache_seconds: float = 0,
296
+ ) -> None:
274
297
  self.app = app
275
298
  self.verifier = verifier
299
+ self.team_membership_cache_seconds = team_membership_cache_seconds
300
+ self.team_memberships = TeamMembershipCache()
276
301
 
277
302
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
278
303
  if scope["type"] not in {"http", "websocket"}:
@@ -304,17 +329,29 @@ class RequestIdentityMiddleware:
304
329
  organization_environment_uid=self.verifier.organization_environment_uid,
305
330
  )
306
331
  bind_request_identity_log_fields(scope, user_uid=user_uid, auth_outcome="authenticated")
332
+ membership: TeamMembershipScope | None = None
307
333
  if caller is not None:
308
- scope[_CALLER_ASSERTION_SCOPE_KEY] = CallerAssertion(
334
+ caller_assertion = CallerAssertion(
309
335
  caller=caller,
310
336
  expires_at=verified.expires_at,
311
337
  token=token,
312
338
  )
339
+ scope[_CALLER_ASSERTION_SCOPE_KEY] = caller_assertion
340
+ membership = TeamMembershipScope(
341
+ release_uid=self.verifier.resource_release_uid,
342
+ environment_uid=self.verifier.organization_environment_uid,
343
+ cache=self.team_memberships,
344
+ cache_seconds=self.team_membership_cache_seconds,
345
+ assertion=caller_assertion,
346
+ client=_backend_of(scope),
347
+ )
313
348
  caller_token = _current_caller.set(caller)
314
349
  lookups_token = _workload_lookups.set({})
350
+ membership_token = bind_team_membership_scope(membership)
315
351
  try:
316
352
  await self.app(scope, receive, send)
317
353
  finally:
354
+ unbind_team_membership_scope(membership, membership_token)
318
355
  _workload_lookups.reset(lookups_token)
319
356
  _current_caller.reset(caller_token)
320
357
  scope.pop(_CALLER_ASSERTION_SCOPE_KEY, None)
@@ -363,6 +400,7 @@ def install_request_identity(app: FastAPI, settings: TauSDKSettings) -> None:
363
400
  app.add_middleware(
364
401
  RequestIdentityMiddleware,
365
402
  verifier=AssertionVerifier.from_settings(settings),
403
+ team_membership_cache_seconds=settings.team_membership_cache_seconds,
366
404
  )
367
405
  setattr(
368
406
  app.state,
@@ -4,9 +4,9 @@ The platform signs two kinds of short-lived JWS with one Ed25519 key set and sen
4
4
  ``X-MainSequence-Caller-Assertion`` header:
5
5
 
6
6
  - a caller assertion, for a request a person or a workload sends to the runtime through the
7
- platform. It names the caller (``sub``), the caller's teams in the Organization
8
- (``team_uids``), and whether the caller administers the Organization
9
- (``is_organization_admin``);
7
+ platform. It names the caller (``sub``) and whether the caller administers the Organization
8
+ (``is_organization_admin``). While the platform still signs it, it also carries the caller's
9
+ teams (``team_uids``), which is deprecated: ``in_teams()`` asks the platform instead (#103);
10
10
  - a platform assertion, for the platform's own background calls. It names no caller.
11
11
 
12
12
  Both are bound to this runtime's release (``aud``, ``resource_release_uid``) and Organization
@@ -24,7 +24,7 @@ import json
24
24
  import re
25
25
  import time
26
26
  import uuid
27
- from collections.abc import Callable, Mapping
27
+ from collections.abc import Callable, Iterable, Mapping
28
28
  from dataclasses import dataclass, field
29
29
  from typing import Any, Literal
30
30
  from urllib.parse import urlsplit
@@ -33,6 +33,7 @@ import httpx
33
33
  import jwt
34
34
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
35
35
 
36
+ from ms_tau_sdk.backend.team_memberships import deprecated_team_uids, member_team_uids
36
37
  from ms_tau_sdk.errors import ConfigurationError
37
38
  from ms_tau_sdk.settings import (
38
39
  CALLER_ASSERTION_ISSUER_ENV,
@@ -59,7 +60,7 @@ PLATFORM_ASSERTION_CLAIMS = frozenset(
59
60
  "exp",
60
61
  }
61
62
  )
62
- CALLER_ASSERTION_CLAIMS = PLATFORM_ASSERTION_CLAIMS | {"sub", "team_uids", "is_organization_admin"}
63
+ CALLER_ASSERTION_CLAIMS = PLATFORM_ASSERTION_CLAIMS | {"sub", "is_organization_admin"}
63
64
 
64
65
  type AssertionKind = Literal["caller", "platform"]
65
66
 
@@ -82,13 +83,55 @@ class AssertionKeysUnavailableError(Exception):
82
83
  """The platform's verification keys could not be obtained."""
83
84
 
84
85
 
85
- @dataclass(frozen=True, slots=True)
86
+ @dataclass(frozen=True, slots=True, init=False)
86
87
  class VerifiedCaller:
87
- """The caller that a verified caller assertion names."""
88
+ """The caller that a verified caller assertion names.
89
+
90
+ ``in_teams()`` asks the platform which teams the caller belongs to. ``team_uids`` is the
91
+ deprecated list earlier assertions carried; ``signed_team_uids`` is None when the assertion
92
+ carried none (#103).
93
+ """
88
94
 
89
95
  uid: str
90
- team_uids: tuple[str, ...]
91
96
  is_organization_admin: bool
97
+ signed_team_uids: tuple[str, ...] | None
98
+
99
+ def __init__(
100
+ self,
101
+ uid: str,
102
+ team_uids: tuple[str, ...] | None = None,
103
+ is_organization_admin: bool = False,
104
+ ) -> None:
105
+ object.__setattr__(self, "uid", uid)
106
+ object.__setattr__(self, "is_organization_admin", is_organization_admin)
107
+ object.__setattr__(
108
+ self, "signed_team_uids", None if team_uids is None else tuple(team_uids)
109
+ )
110
+
111
+ @property
112
+ def team_uids(self) -> tuple[str, ...]:
113
+ """Deprecated: the team UIDs the caller's assertion carried. Use ``in_teams()``.
114
+
115
+ Warns when read, and raises ``TauSDKError`` when the assertion carried no team list; it
116
+ never answers an empty list for that.
117
+ """
118
+
119
+ return deprecated_team_uids(self.signed_team_uids)
120
+
121
+ async def in_teams(
122
+ self,
123
+ team_uids: Iterable[str | uuid.UUID],
124
+ *,
125
+ max_age_seconds: float | None = None,
126
+ ) -> frozenset[str]:
127
+ """Return which of ``team_uids`` the caller is an active member of.
128
+
129
+ It asks the platform with the request's caller assertion and reuses answers as the
130
+ runtime's ``team_membership_cache_seconds`` allows; ``max_age_seconds=0`` is a fresh
131
+ check. See ``ms_tau_sdk.backend.team_memberships.member_team_uids``.
132
+ """
133
+
134
+ return await member_team_uids(self.uid, team_uids, max_age_seconds=max_age_seconds)
92
135
 
93
136
 
94
137
  @dataclass(frozen=True, slots=True)
@@ -210,19 +253,26 @@ def _fresh_until(cache_control: str | None, now: float) -> float | None:
210
253
 
211
254
  def _verified_caller(payload: Mapping[str, Any]) -> VerifiedCaller:
212
255
  uid = payload["sub"]
213
- team_uids = payload["team_uids"]
256
+ # The deprecated team list, checked while the platform still signs it (#103).
257
+ team_uids = payload.get("team_uids")
214
258
  is_organization_admin = payload["is_organization_admin"]
215
259
  if (
216
260
  not _is_canonical_uid(uid)
217
- or not isinstance(team_uids, list)
218
- or not all(_is_canonical_uid(team_uid) for team_uid in team_uids)
219
- or team_uids != sorted(set(team_uids))
261
+ or (
262
+ team_uids is not None
263
+ and (
264
+ not isinstance(team_uids, list)
265
+ or not all(_is_canonical_uid(team_uid) for team_uid in team_uids)
266
+ or team_uids != sorted(set(team_uids))
267
+ )
268
+ )
269
+ or ("team_uids" in payload and team_uids is None)
220
270
  or type(is_organization_admin) is not bool
221
271
  ):
222
272
  raise InvalidAssertionError("The caller assertion does not name a valid caller")
223
273
  return VerifiedCaller(
224
274
  uid=uid,
225
- team_uids=tuple(team_uids),
275
+ team_uids=None if team_uids is None else tuple(team_uids),
226
276
  is_organization_admin=is_organization_admin,
227
277
  )
228
278
 
@@ -332,7 +382,9 @@ class AssertionVerifier:
332
382
  )
333
383
  except (jwt.PyJWTError, TypeError, ValueError) as error:
334
384
  raise InvalidAssertionError(f"The {kind} assertion did not verify") from error
335
- if set(payload) != claims:
385
+ # A caller assertion may still carry the deprecated team list (#103).
386
+ optional = {"team_uids"} if kind == "caller" else set()
387
+ if set(payload) - optional != claims:
336
388
  raise InvalidAssertionError(f"The {kind} assertion does not have the exact claim set")
337
389
  if (
338
390
  payload["iss"] != self.issuer
@@ -80,6 +80,7 @@ from .routes import (
80
80
  model_provider_credentials,
81
81
  resource_release_runtime_access,
82
82
  user,
83
+ user_team_memberships,
83
84
  )
84
85
 
85
86
  T = TypeVar("T")
@@ -427,6 +428,33 @@ class MainSequenceClient:
427
428
  data = await self._request("GET", user(user_uid), idempotent=True)
428
429
  return DirectoryUser.model_validate(data)
429
430
 
431
+ async def team_memberships(
432
+ self, user_uid: str, team_uids: list[str], *, caller_assertion: str
433
+ ) -> set[str]:
434
+ """Return which of ``team_uids`` the person ``user_uid`` is an active member of (#103).
435
+
436
+ ``caller_assertion`` is the verified assertion of the request that asks, sent only with
437
+ this call; ``user_uid`` must be the person it names. The platform answers at most 100
438
+ teams per call and leaves out the ones it does not count.
439
+ """
440
+
441
+ query = urlencode({"team_uid__in": ",".join(team_uids)})
442
+ try:
443
+ data = await self._request(
444
+ "GET",
445
+ f"{user_team_memberships(user_uid)}?{query}",
446
+ idempotent=True,
447
+ headers={ASSERTION_HEADER: caller_assertion},
448
+ )
449
+ except SessionNotFoundError as error:
450
+ raise BackendError(
451
+ "The platform has no team membership check for this person.", status_code=404
452
+ ) from error
453
+ answered = data.get("team_uids") if isinstance(data, dict) else None
454
+ if not isinstance(answered, list) or not all(isinstance(uid, str) for uid in answered):
455
+ raise BackendError("The platform's team membership answer is invalid.")
456
+ return set(answered) & set(team_uids)
457
+
430
458
  async def list_model_providers(
431
459
  self, *, organization_environment_uid: str | None = None
432
460
  ) -> dict[str, Any]:
@@ -97,3 +97,7 @@ def resource_release_runtime_access(release_uid: str) -> str:
97
97
 
98
98
  def user(user_uid: str) -> str:
99
99
  return f"{USERS}{user_uid}/"
100
+
101
+
102
+ def user_team_memberships(user_uid: str) -> str:
103
+ return f"{USERS}{user_uid}/team-memberships/"
@@ -0,0 +1,229 @@
1
+ """Team membership checks a hosted runtime makes on demand (ADR-0019, #103).
2
+
3
+ The platform answers which of the given teams a person is an active member of. The runtime asks
4
+ only about the verified caller of the current request, with that request's caller assertion. It
5
+ keeps the answers for the rest of the request and, for as long as the application chose, for
6
+ later requests in the process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import threading
13
+ import time
14
+ import uuid
15
+ import warnings
16
+ from collections import OrderedDict
17
+ from collections.abc import Iterable
18
+ from contextvars import ContextVar, Token
19
+ from dataclasses import dataclass, field
20
+ from typing import TYPE_CHECKING
21
+
22
+ from ms_tau_sdk.errors import TauSDKError
23
+
24
+ if TYPE_CHECKING:
25
+ from ms_tau_sdk.backend.assertions import CallerAssertion
26
+ from ms_tau_sdk.backend.client import MainSequenceClient
27
+
28
+ # At most this many answers per process, and at most this many teams in one call to the platform,
29
+ # which answers no more in one call.
30
+ MAX_CACHED_ANSWERS = 10_000
31
+ MAX_TEAMS_PER_CALL = 100
32
+
33
+ # The release UID, Organization Environment UID, person UID and team UID.
34
+ type AnswerKey = tuple[str, str, str, str]
35
+
36
+
37
+ def _now() -> float:
38
+ return time.monotonic()
39
+
40
+
41
+ class TeamMembershipCache:
42
+ """Yes-or-no answers across requests, oldest dropped first.
43
+
44
+ The key starts with the release and Organization Environment the runtime is deployed for, so
45
+ an answer obtained for one never answers another.
46
+ """
47
+
48
+ def __init__(self, max_answers: int = MAX_CACHED_ANSWERS) -> None:
49
+ self._answers: OrderedDict[AnswerKey, tuple[bool, float]] = OrderedDict()
50
+ self._max_answers = max_answers
51
+ self._lock = threading.Lock()
52
+
53
+ def get(self, key: AnswerKey, *, max_age: float) -> bool | None:
54
+ """Return the stored answer when it is younger than ``max_age`` seconds, else None."""
55
+
56
+ if max_age <= 0:
57
+ return None
58
+ with self._lock:
59
+ entry = self._answers.get(key)
60
+ if entry is None:
61
+ return None
62
+ answer, stored_at = entry
63
+ return answer if _now() - stored_at < max_age else None
64
+
65
+ def put(self, key: AnswerKey, answer: bool) -> None:
66
+ with self._lock:
67
+ self._answers[key] = (answer, _now())
68
+ self._answers.move_to_end(key)
69
+ while len(self._answers) > self._max_answers:
70
+ self._answers.popitem(last=False)
71
+
72
+ def __len__(self) -> int:
73
+ with self._lock:
74
+ return len(self._answers)
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class TeamMembershipScope:
79
+ """What one hosted request may use to answer ``in_teams()``.
80
+
81
+ The release and Organization Environment are the ones the runtime's verifier reads from its
82
+ deployment configuration, never from the request, and every answer's key starts with them.
83
+ The request's caller assertion is presented only to ask the platform about that caller. The
84
+ scope ends with its request, also in the tasks that copied the request's context.
85
+ """
86
+
87
+ release_uid: str
88
+ environment_uid: str
89
+ cache: TeamMembershipCache = field(repr=False)
90
+ cache_seconds: float
91
+ assertion: CallerAssertion | None = field(default=None, repr=False)
92
+ client: MainSequenceClient | None = field(default=None, repr=False)
93
+ # The answers of this request, by key; the same question is asked once per request.
94
+ answers: dict[AnswerKey, bool] = field(default_factory=dict, repr=False)
95
+ active: bool = True
96
+
97
+
98
+ _current_scope: ContextVar[TeamMembershipScope | None] = ContextVar(
99
+ "ms_tau_sdk_team_membership_scope",
100
+ default=None,
101
+ )
102
+
103
+
104
+ def bind_team_membership_scope(
105
+ scope: TeamMembershipScope | None,
106
+ ) -> Token[TeamMembershipScope | None]:
107
+ """Make ``scope`` the current request's; None for a request without a verified caller."""
108
+
109
+ return _current_scope.set(scope)
110
+
111
+
112
+ def unbind_team_membership_scope(
113
+ scope: TeamMembershipScope | None,
114
+ token: Token[TeamMembershipScope | None],
115
+ ) -> None:
116
+ """End ``scope`` when its request ends, and restore the previous one."""
117
+
118
+ if scope is not None:
119
+ # Tasks that copied the request's context hold this same scope; it ends for them too.
120
+ scope.active = False
121
+ scope.assertion = None
122
+ scope.client = None
123
+ scope.answers = {}
124
+ _current_scope.reset(token)
125
+
126
+
127
+ def canonical_team_uids(team_uids: Iterable[str | uuid.UUID]) -> list[str]:
128
+ """Return the distinct canonical UIDs of ``team_uids``, sorted."""
129
+
130
+ if isinstance(team_uids, str | bytes | uuid.UUID):
131
+ raise TypeError("in_teams() takes a collection of team UIDs, not a single UID")
132
+ try:
133
+ return sorted({str(uuid.UUID(str(team_uid).strip())) for team_uid in team_uids})
134
+ except (AttributeError, TypeError, ValueError) as error:
135
+ raise ValueError("Team UIDs must be valid UUIDs") from error
136
+
137
+
138
+ async def member_team_uids(
139
+ person_uid: str,
140
+ team_uids: Iterable[str | uuid.UUID],
141
+ *,
142
+ max_age_seconds: float | None = None,
143
+ ) -> frozenset[str]:
144
+ """Return which of ``team_uids`` the person ``person_uid`` is an active member of.
145
+
146
+ ``VerifiedCaller.in_teams()`` and ``Requester.in_teams()`` call it, and it answers only about
147
+ the verified caller of the current hosted request:
148
+
149
+ - within the request, the same question is asked once;
150
+ - across requests, an answer is reused while it is younger than the runtime's
151
+ ``team_membership_cache_seconds``, or than ``max_age_seconds`` when the call gives one;
152
+ - ``max_age_seconds=0`` always asks the platform, skips both kinds of reuse, and stores the
153
+ new answer.
154
+
155
+ A longer list is asked in calls of at most 100 teams. A failed call raises; it never answers
156
+ "not a member".
157
+ """
158
+
159
+ asked = canonical_team_uids(team_uids)
160
+ if max_age_seconds is not None and (
161
+ isinstance(max_age_seconds, bool)
162
+ or not isinstance(max_age_seconds, int | float)
163
+ or not math.isfinite(max_age_seconds)
164
+ or max_age_seconds < 0
165
+ ):
166
+ raise ValueError("max_age_seconds must be None or a non-negative number of seconds")
167
+ scope = _current_scope.get()
168
+ if scope is None or not scope.active or scope.assertion is None:
169
+ raise TauSDKError("in_teams() needs a hosted request whose caller assertion was verified")
170
+ if person_uid != scope.assertion.caller.uid:
171
+ raise TauSDKError("in_teams() answers only about the verified caller of the request")
172
+ fresh = max_age_seconds == 0
173
+ lifetime = scope.cache_seconds if max_age_seconds is None else max_age_seconds
174
+ answers: dict[str, bool] = {}
175
+ unanswered: list[str] = []
176
+ for team_uid in asked:
177
+ key = (scope.release_uid, scope.environment_uid, person_uid, team_uid)
178
+ reused = None
179
+ if not fresh:
180
+ reused = scope.answers.get(key)
181
+ if reused is None:
182
+ reused = scope.cache.get(key, max_age=lifetime)
183
+ if reused is None:
184
+ unanswered.append(team_uid)
185
+ else:
186
+ answers[team_uid] = scope.answers[key] = reused
187
+ for start in range(0, len(unanswered), MAX_TEAMS_PER_CALL):
188
+ batch = unanswered[start : start + MAX_TEAMS_PER_CALL]
189
+ members = await _platform_members(scope, person_uid, batch)
190
+ for team_uid in batch:
191
+ key = (scope.release_uid, scope.environment_uid, person_uid, team_uid)
192
+ answers[team_uid] = scope.answers[key] = team_uid in members
193
+ scope.cache.put(key, answers[team_uid])
194
+ return frozenset(team_uid for team_uid, member in answers.items() if member)
195
+
196
+
197
+ async def _platform_members(
198
+ scope: TeamMembershipScope,
199
+ person_uid: str,
200
+ team_uids: list[str],
201
+ ) -> set[str]:
202
+ assertion = scope.assertion if scope.active else None
203
+ token = assertion.unexpired_token() if assertion is not None else None
204
+ if token is None:
205
+ raise TauSDKError(
206
+ "in_teams() cannot ask the platform: the request's caller assertion has expired or "
207
+ "the request has ended"
208
+ )
209
+ if scope.client is None:
210
+ raise TauSDKError("in_teams() needs the Agent runtime of this process")
211
+ return await scope.client.team_memberships(person_uid, team_uids, caller_assertion=token)
212
+
213
+
214
+ def deprecated_team_uids(signed: tuple[str, ...] | None) -> tuple[str, ...]:
215
+ """Return the deprecated team list a caller assertion carried, with a warning.
216
+
217
+ It raises when the assertion carried none; it never answers an empty list for that.
218
+ """
219
+
220
+ warnings.warn(
221
+ "team_uids is deprecated; check membership with in_teams() instead",
222
+ DeprecationWarning,
223
+ stacklevel=3,
224
+ )
225
+ if signed is None:
226
+ raise TauSDKError(
227
+ "The caller assertion carries no team list; check membership with in_teams()"
228
+ )
229
+ return signed
@@ -1776,7 +1776,7 @@ class SessionRuntimeManager:
1776
1776
  The platform's answer to the turn start names that person, whoever called: a chat or A2A
1777
1777
  Message turn, a Task attempt, and a turn the platform starts for a caller delivery alike.
1778
1778
  ``caller`` is the verified caller of the request, when there is one; it supplies the
1779
- person's teams only when it is that person.
1779
+ deprecated team list its assertion carried only when it is that person.
1780
1780
  """
1781
1781
 
1782
1782
  if not self._requester_identity_verified():
@@ -1784,7 +1784,7 @@ class SessionRuntimeManager:
1784
1784
  uid = runtime.requester_user_uid
1785
1785
  if uid is None:
1786
1786
  return None
1787
- team_uids = caller.team_uids if caller is not None and caller.uid == uid else ()
1787
+ team_uids = caller.signed_team_uids if caller is not None and caller.uid == uid else ()
1788
1788
  return Requester(uid=uid, team_uids=team_uids)
1789
1789
 
1790
1790
  def _requester_http(self) -> httpx.AsyncClient:
@@ -19,7 +19,7 @@ import asyncio
19
19
  import math
20
20
  import time
21
21
  import uuid
22
- from collections.abc import Awaitable, Callable, Mapping
22
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
23
23
  from contextlib import suppress
24
24
  from contextvars import ContextVar, Token
25
25
  from dataclasses import dataclass, field
@@ -31,6 +31,7 @@ import structlog
31
31
  from ms_tau_sdk.backend.auth import AccessToken, _jwt_expiry
32
32
  from ms_tau_sdk.backend.client import LeaseProof, answer_without_request_credentials
33
33
  from ms_tau_sdk.backend.models import ReleaseRuntimeAccess
34
+ from ms_tau_sdk.backend.team_memberships import deprecated_team_uids, member_team_uids
34
35
  from ms_tau_sdk.errors import (
35
36
  ApplicationReadinessTimeoutError,
36
37
  ApplicationWakingError,
@@ -51,15 +52,48 @@ type Delegation = Literal["auto", "none", "required"]
51
52
  _DELEGATIONS: tuple[Delegation, ...] = ("auto", "none", "required")
52
53
 
53
54
 
54
- @dataclass(frozen=True, slots=True)
55
+ @dataclass(frozen=True, slots=True, init=False)
55
56
  class Requester:
56
57
  """The verified person whose request the current turn serves.
57
58
 
58
- ``team_uids`` can be empty when the turn does not know them.
59
+ ``in_teams()`` asks the platform which teams the person belongs to while the person is the
60
+ verified caller of the request the turn runs in, and raises otherwise. ``team_uids`` is the
61
+ deprecated list the caller's assertion carried: empty when the turn does not know the person's
62
+ teams, and ``signed_team_uids`` is None when the assertion carried no list (#103).
59
63
  """
60
64
 
61
65
  uid: str
62
- team_uids: tuple[str, ...] = ()
66
+ signed_team_uids: tuple[str, ...] | None
67
+
68
+ def __init__(self, uid: str, team_uids: tuple[str, ...] | None = ()) -> None:
69
+ object.__setattr__(self, "uid", uid)
70
+ object.__setattr__(
71
+ self, "signed_team_uids", None if team_uids is None else tuple(team_uids)
72
+ )
73
+
74
+ @property
75
+ def team_uids(self) -> tuple[str, ...]:
76
+ """Deprecated: the team UIDs the caller's assertion carried. Use ``in_teams()``.
77
+
78
+ Warns when read, and raises ``TauSDKError`` when the assertion carried no team list; it
79
+ never answers an empty list for that.
80
+ """
81
+
82
+ return deprecated_team_uids(self.signed_team_uids)
83
+
84
+ async def in_teams(
85
+ self,
86
+ team_uids: Iterable[str | uuid.UUID],
87
+ *,
88
+ max_age_seconds: float | None = None,
89
+ ) -> frozenset[str]:
90
+ """Return which of ``team_uids`` the person is an active member of.
91
+
92
+ It answers while the person is the verified caller of the request the turn runs in, with
93
+ the reuse rules of ``VerifiedCaller.in_teams()``, and raises ``TauSDKError`` otherwise.
94
+ """
95
+
96
+ return await member_team_uids(self.uid, team_uids, max_age_seconds=max_age_seconds)
63
97
 
64
98
 
65
99
  @dataclass(frozen=True, slots=True)
@@ -147,6 +147,14 @@ class TauSDKSettings(BaseSettings):
147
147
  allow_inf_nan=False,
148
148
  validation_alias=A2A_MESSAGE_LIMIT_ENV,
149
149
  )
150
+ # How long a team membership answer may be reused across requests (ADR-0019, #103). The
151
+ # application chooses; 0 reuses an answer only within the same request.
152
+ team_membership_cache_seconds: float = Field(
153
+ default=0,
154
+ ge=0,
155
+ allow_inf_nan=False,
156
+ validation_alias="TAU_TEAM_MEMBERSHIP_CACHE_SECONDS",
157
+ )
150
158
  local_mode: bool = Field(default=False, validation_alias="TAU_LOCAL_MODE")
151
159
  exclude_base_tools: bool = Field(default=False, validation_alias="TAU_EXCLUDE_BASE_TOOLS")
152
160
  exclude_mainsequence_mcp: bool = Field(