BackendV2Client
Sync + async client for the Synapse Backend API v2 (/v2/*).
The v2 client lives next to the existing v1 client (BackendClient) and is
opt-in — both can be used in the same process. v2 differs from v1 in three
load-bearing ways:
- Cursor pagination with
{next, previous, results}(nocountfield). - Auth header format:
SYNAPSE-ACCESS-TOKEN: syn_<token>and a rawSYNAPSE-Tenant: <code>(noTokenprefix by default). - Resource attributes:
client.projects.list(...)instead of flatclient.list_projects(...).
Targets synapse-backend release
v2026.1.6(API contract2.0.0). Re-sync the schema withmake sync-v2-alland bumpsynapse_sdk.clients.backend_v2.SYNAPSE_BACKEND_VERSIONwhen the backend ships a new compatible release.
Overview
The v2 backend exposes 102 paths / 149 operations across 22 domains. The SDK implements 23 domains today — the 15 P0 group required for the primary CLI workflows plus 8 P1 domains added in Phase 3:
| Group | Resources |
|---|---|
| Auth / infra | auth, tenants, tokens, schemas |
| Data | data_collections (+ nested groups), data_units, data_files |
| ML | ground_truth_datasets (+ nested versions), ground_truths, models, experiments |
| Workflow | projects (+ nested tags), tasks, assignments, reviews |
| Workspace (P1) | members, groups (top-level, distinct from data-collection groups), validation_scripts, workshops |
| Execution (P1) | jobs, job_logs, plugins, plugin_releases |
The full endpoint catalog (and a v1↔v2 diff table — see
v1 ↔ v2 endpoint diff) lives in
INVENTORY.md.
Constructor
BackendV2Client(
base_url: str,
*,
access_token: str | None = None,
drf_token: str | None = None,
tenant: str | None = None,
tenant_token_prefix: bool = False,
timeout: dict[str, int] | None = None,
allow_insecure: bool = False,
)
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
base_url | str | Yes | – | Backend host. Do not include the /v2 prefix; resources add it themselves. |
access_token | str | None | No | None | syn_* access token. Falls back to SYNAPSE_BACKEND_V2_ACCESS_TOKEN. |
drf_token | str | None | No | None | DRF token from /users/login/. Falls back to SYNAPSE_BACKEND_V2_DRF_TOKEN. |
tenant | str | None | No | None | Tenant code. Falls back to SYNAPSE_BACKEND_V2_TENANT. |
tenant_token_prefix | bool | No | False | When True, sends SYNAPSE-Tenant: Token <code> for v1 compatibility. |
timeout | dict[str, int] | None | No | {'connect': 5, 'read': 15} | Connect / read timeout in seconds. |
allow_insecure | bool | No | False | Suppress the warning when base_url uses plain HTTP. |
AsyncBackendV2Client accepts the same arguments; its timeout accepts an
httpx.Timeout instance instead of the dict shape.
Quick start
Synchronous
from synapse_sdk.clients.backend_v2 import BackendV2Client
client = BackendV2Client(
'https://api.test.synapse.sh',
access_token='syn_xxxxx',
tenant='acme',
)
# Cursor-paginated single page
page = client.projects.list(category='image', per_page=50)
for project in page.results:
print(project.id, project.title)
if page.next:
next_page = client.projects.list(cursor=page.next.split('cursor=')[-1])
# Stream every page lazily
for project in client.projects.list(category='image', per_page=50, list_all=True):
print(project.id, project.title)
Asynchronous
import asyncio
from synapse_sdk.clients.backend_v2 import AsyncBackendV2Client
async def main() -> None:
async with AsyncBackendV2Client(
'https://api.test.synapse.sh',
access_token='syn_xxxxx',
tenant='acme',
) as client:
page = await client.projects.list(per_page=50).first_page()
async for project in client.projects.list(per_page=50):
print(project.id, project.title)
asyncio.run(main())
Authentication
The v2 backend honours four auth schemes; the client composes them based on which arguments / env vars are present.
| Header | Value | Source |
|---|---|---|
SYNAPSE-ACCESS-TOKEN | syn_<token> | access_token= arg or SYNAPSE_BACKEND_V2_ACCESS_TOKEN env |
Authorization | Token <drf> | drf_token= arg, SYNAPSE_BACKEND_V2_DRF_TOKEN env, or returned by client.auth.login() |
SYNAPSE-Tenant | <code> (default) or Token <code> (legacy) | tenant= arg or SYNAPSE_BACKEND_V2_TENANT env; tenant_token_prefix=True opts into the legacy prefix |
tenant_token_prefix default — v2-nativeSince the OQ-1 live verification on api.test.synapse.sh
(see Open items pending live verification),
tenant_token_prefix defaults to False — the SDK sends the raw tenant code
(SYNAPSE-Tenant: <code>). The dev backend accepts both formats, so v1-style
deployments that still expect the Token <code> prefix can opt back via
tenant_token_prefix=True:
client = BackendV2Client(
'https://api.example.com',
access_token='syn_...',
tenant='acme',
tenant_token_prefix=True, # ⇒ "SYNAPSE-Tenant: Token acme"
)
mask_token() helper provides log-safe representations of secrets:
from synapse_sdk.clients.backend_v2 import mask_token
mask_token('syn_12345678901234') # 'syn_***1234'
mask_token(None) # '<unset>'
Login → access-token bootstrap
client = BackendV2Client('https://api.test.synapse.sh', tenant='acme')
# 1. Exchange credentials for a DRF token (auto-stored on the client).
client.auth.login(email='[email protected]', password='...')
# 2. Issue a long-lived access token.
created = client.tokens.create({'description': 'sdk-cli'})
print(created.token) # 'syn_xxxxx' — only returned at creation time
# 3. Switch the client over to the new token.
client.set_access_token(created.token)
client.set_drf_token(None)
Pagination
The v2 backend uses DRF CursorPagination. After the transport unwraps the
{data, meta} envelope (see Response envelope handling),
list endpoints surface this CursorPage shape to resource modules:
{
"next": "<cursor-or-null>",
"previous": "<cursor-or-null>",
"results": [...]
}
There is no count field. The client exposes pagination via:
| Call shape | Returns |
|---|---|
client.projects.list(per_page=50) | CursorPage[ProjectV2List] (single page) |
client.projects.list(per_page=50, list_all=True) | Iterator[ProjectV2List] (streams every page) |
await client.projects.list(per_page=50).first_page() (async) | CursorPage[ProjectV2List] |
async for project in client.projects.list(per_page=50) (async) | streams every page |
Re-implement counters in user code if you depend on totals.
Response envelope handling
The v2 backend wraps every successful response in a {"data": ..., "meta": ...}
envelope. The transport (BackendV2Client._request / AsyncBackendV2Client._request)
calls unwrap_envelope
on every successful payload so resource modules consume the legacy shape
unchanged.
| Backend payload | After unwrap |
|---|---|
{"data": [...], "meta": {"pagination": {"next_cursor": "...", "previous_cursor": null, "per_page": 50}}} | {"results": [...], "next": "...", "previous": null} |
{"data": {"id": 1, ...}, "meta": {"request_id": "..."}} | {"id": 1, ...} |
{"data": null, "meta": {...}} | null |
Any payload missing either data or meta at the root | unchanged (forward-compat passthrough) |
The unwrap is strictly conditional: it triggers only when both data and
meta are present at the root. v1-style payloads, fixtures, and proxies that
emit a non-enveloped shape pass through untouched.
meta.request_id is captured into a per-context slot before the unwrap
discards meta, and is exposed via
client.last_request_id and
client.capture_request_ids()
for log correlation.
Validation & Models
Each resource returns Pydantic models named after the OpenAPI schema:
| Suffix | Used for |
|---|---|
*V2List | List responses (slim payload) |
*V2Detail | Retrieve / create / update responses |
*V2CreateRequest | POST body |
Patched*V2CreateRequest | PATCH body |
response_model= / request_model= kwargs
The transport helpers (client._get, client._post, client._put,
client._patch, client._delete and their async counterparts) accept two
optional Pydantic kwargs that resource modules can lean on instead of calling
Model.model_validate(...) manually:
# Before — manual validation in every method
return ProjectV2Detail.model_validate(self._get(f'/v2/projects/{pid}/'))
# After — kwarg-driven (resource methods become one-liners)
return self._get(f'/v2/projects/{pid}/', response_model=ProjectV2Detail)
# POST also accepts a Pydantic instance directly
return self._post(
'/v2/projects/',
data=ProjectV2CreateRequest(title='demo'), # sync uses `data=`
request_model=ProjectV2CreateRequest,
response_model=ProjectV2Detail,
)
# Async equivalent uses `json=` instead of `data=`.
Behaviour:
response_model=Modelreturns a fully constructedModelinstance (not the raw dict).request_model=Modelaccepts a Pydantic instance or a dict; both are serialised viamodel_dump(by_alias=True, exclude_none=True, mode='json'), matching the existingresources/_helpers.model_dumphelper. Wire shape is identical whether callers pass a Pydantic instance or a plain dict.- A
pydantic.ValidationErrorraised during response decoding is wrapped insynapse_sdk.exceptions.ValidationError. The original exception is preserved as__cause__. - A
pydantic.ValidationErrorraised during request validation is propagated unchanged — caller-supplied data benefits from the full pydantic error detail for debugging (intentional asymmetry). - Omitting both kwargs leaves transport behaviour unchanged.
All 60+ resource methods across the read and write domains were migrated
to the response_model= / request_model= kwarg pattern in the SYN-6854
bundle. Caller signatures and wire shapes are unchanged — the conversion was
purely internal — but pydantic.ValidationError no longer leaks out of any
SDK call path. Catch synapse_sdk.exceptions.ValidationError
and inspect __cause__ if you need the raw pydantic detail.
ValidationError.detailThe mixin calls pydantic.ValidationError.errors(include_input=False) before
wrapping, so each error entry strips the raw response body. Responses may
carry secrets (e.g. the plaintext syn_* token returned by
tokens.create()) and SDK consumers commonly forward ValidationError.detail
to external logging sinks. Full-fidelity diagnostics remain available via
exc.__cause__ (the original pydantic.ValidationError).
from synapse_sdk.exceptions import ValidationError as SdkValidationError
import pydantic
try:
project = client.projects.retrieve(42)
except SdkValidationError as exc:
# Single SDK-native exception type — never bare pydantic.
print(exc.detail['model']) # 'ProjectV2Detail'
print(exc.detail['detail']) # list of error dicts (no 'input' key)
if isinstance(exc.__cause__, pydantic.ValidationError):
# Full pydantic detail (including raw input) for diagnostics.
for err in exc.__cause__.errors():
print(err['loc'], err['msg'])
The kwarg path is implemented in
BackendV2ValidationMixin
and is shared between sync and async clients via the MRO:
Error handling
BackendV2Client raises the standard SDK exception hierarchy from
synapse_sdk.exceptions — same as the v1 client.
from synapse_sdk.exceptions import (
AuthenticationError,
AuthorizationError,
NotFoundError,
RateLimitError,
ServerError,
ValidationError,
)
try:
project = client.projects.retrieve(42)
except NotFoundError:
...
except AuthenticationError:
# Re-login or refresh access token.
...
except ValidationError as e:
# 400 / 422 with backend `detail` payload preserved on the exception.
# Also raised when `response_model=` decoding fails — see Validation & Models.
...
Resource map
The same surface area is available on BackendV2Client (sync) and
AsyncBackendV2Client (async).
Top-level resources
| Attribute | Endpoints | Notable methods |
|---|---|---|
client.auth | POST /users/login/ | login(email, password, set_drf_token=True) |
client.tenants | GET /v2/tenants/(...) | list, retrieve(code) |
client.tokens | */v2/tokens/(...) | list, create, retrieve(id), destroy(id) |
client.schemas | GET /v2/schemas/... | annotation_configurations(), file_specifications() |
client.data_collections | */v2/data-collections/(...) | full CRUD + membership + nested groups(dc_id) |
client.data_units | */v2/data-units/(...) | full CRUD + permission trio |
client.data_files | GET /v2/data-files/(...) | list, retrieve(id) |
client.ground_truth_datasets | */v2/ground-truth-datasets/(...) | full CRUD + nested versions(dataset_id) |
client.ground_truths | GET /v2/ground-truths/(...) | list, retrieve(id) |
client.models | GET /v2/models/(...) | list, retrieve(id) |
client.experiments | */v2/experiments/(...) | full CRUD + membership family |
client.projects | */v2/projects/(...) | full CRUD + membership + nested tags(project_id) |
client.tasks | */v2/tasks/(...) | full CRUD + permission trio |
client.assignments | GET /v2/assignments/(...) | list, retrieve(id) + permission trio |
client.reviews | */v2/reviews/(...) | list, create, retrieve(id) + permission trio |
client.members | */v2/members/(...) | list, retrieve(id), create |
client.groups | */v2/groups/(...) | full CRUD (top-level workspace groups) |
client.validation_scripts | */v2/validation-scripts/(...) | full CRUD |
client.workshops | */v2/workshops/(...) | list, retrieve(id) + permission trio |
client.jobs | GET /v2/jobs/(...) | list, retrieve(id) (UUID-keyed) |
client.job_logs | GET /v2/job-logs/(...) | list, retrieve(id) |
client.plugins | GET /v2/plugins/(...) | list, retrieve(id) |
client.plugin_releases | GET /v2/plugin-releases/(...) | list, retrieve(id) |
Membership family
Top-level resources (projects, experiments, data_collections) expose:
.check_permission(id, permission='view')
.permissions(id)
.roles(id)
.grantable_roles(id)
.default_roles()
.invite(id, payload)
.get_join_requests(id)
.request_join(id, payload)
.confirm_join_request(id, payload)
.my_join_requests()
.my_join_request_detail(join_request_id)
Permission trio
Child / leaf resources (tasks, data_units, assignments, reviews,
workshops, nested groups (under data-collections), nested versions,
nested tags) expose only:
.check_permission(id, permission='view')
.permissions(id)
.roles(id)
Membership helpers — typed payloads
Membership operations on top-level resources (projects, experiments,
data_collections) accept typed payload models for the mutating verbs:
from synapse_sdk.clients.backend_v2.models import (
MemberRoleJoinRequestCreateRequest,
PatchedMemberRoleJoinRequestConfirmRequest,
TargetRoleCreateRequest,
)
# 1) Invite a target user/group with a role binding.
client.projects.invite(
project_id,
TargetRoleCreateRequest(target=42, role=7),
)
# 2) Request to join an existing project.
client.projects.request_join(
project_id,
MemberRoleJoinRequestCreateRequest(role=7, message='please add me'),
)
# 3) Confirm / reject a pending join request.
client.projects.confirm_join_request(
project_id,
PatchedMemberRoleJoinRequestConfirmRequest(confirm=True),
)
Plain dicts are still accepted — the wire shape is identical.
The helpers themselves live in
resources/_membership.py
behind two @runtime_checkable Protocols
(SyncMembershipResource / AsyncMembershipResource). Static type checkers
(mypy, pyright) and the runtime guards reject sync↔async cross-wiring so a
silent coroutine was never awaited symptom can no longer slip through
review.
P1 domain quick examples
# Workspace members (top-level /v2/members/)
page = client.members.list(per_page=20)
member = client.members.retrieve(123)
# Workspace groups (top-level — distinct from data-collection groups)
group = client.groups.create({'name': 'reviewers'})
client.groups.partial_update(group.id, {'name': 'qa-reviewers'})
# Validation scripts
script = client.validation_scripts.retrieve(7)
# Workshops (read-only + permission trio)
ws = client.workshops.retrieve(3)
client.workshops.check_permission(3, permission='view')
# Async jobs (UUID-keyed) and their console logs
job = client.jobs.retrieve('00000000-0000-0000-0000-000000000001')
for log in client.job_logs.list(job=job.id, list_all=True):
print(log.message)
# Plugins + releases (read-only)
client.plugins.list(per_page=10)
client.plugin_releases.retrieve(42)
Common patterns
Building request bodies with the auto-generated models
Pydantic models are documented in Validation & Models. Quick example:
from synapse_sdk.clients.backend_v2.models import ProjectV2CreateRequest
payload = ProjectV2CreateRequest(
title='New project',
category='image',
data_collection=10,
)
project = client.projects.create(payload)
You can also pass plain dicts; they are validated by the backend.
Bootstrapping from environment variables
import os
os.environ['SYNAPSE_BACKEND_V2_ACCESS_TOKEN'] = 'syn_...'
os.environ['SYNAPSE_BACKEND_V2_TENANT'] = 'acme'
# All three values can be omitted on the constructor.
client = BackendV2Client('https://api.test.synapse.sh')
Working with nested resources
# Data collection groups
client.data_collections.groups(dc_id=7).list(per_page=20)
client.data_collections.groups(dc_id=7).create({'name': 'g1'})
# Ground truth dataset versions
client.ground_truth_datasets.versions(dataset_id=3).list()
# Project tags
client.projects.tags(project_id=42).list(per_page=10)
client.projects.tags(project_id=42).create({'name': 'urgent', 'category': 'project'})
Observability — last_request_id tracing
Every successful v2 response carries a meta.request_id the backend logs
against the request. The transport captures it before
unwrap_envelope discards meta, into a
contextvars.ContextVar
(async-safe and thread-safe by construction). Two surfaces are exposed:
| Surface | Returns | When to use |
|---|---|---|
client.last_request_id (property) | str | None | Read the most recent request_id observed in the current context. |
client.capture_request_ids() (context manager) | None | Reset the slot on enter, restore the outer value on exit — scopes a block of work to its own request_ids. |
project = client.projects.retrieve(42)
print(client.last_request_id) # e.g. 'req_01H8...'
with client.capture_request_ids():
page = client.projects.list(per_page=20)
log.info('listed', request_id=client.last_request_id)
# Outer context's request_id is restored here.
The async client mirrors the same surface — both last_request_id and
capture_request_ids() are usable from AsyncBackendV2Client. Because
ContextVar participates in asyncio.Task context capture,
asyncio.gather(call_a(), call_b()) does not race — each task sees its own
last_request_id.
The implementation lives in
observability.py.
record_request_id is the same gate as unwrap_envelope (only triggers when
both data and meta are present at the root) — non-enveloped responses
leave the slot untouched, so the previous request_id is preserved.
CLI — synapse v2 ...
The SDK ships a read-only CLI sub-command that wraps BackendV2Client for
ad-hoc operations and tenant smoke tests. Auth is shared with the existing
synapse login flow (~/.synapse/config.json).
synapse v2 projects list --tenant acme --per-page 20
synapse v2 projects retrieve 42 --tenant acme
synapse v2 tokens list --tenant acme
synapse v2 tokens retrieve 7 --tenant acme
synapse v2 tenants list
synapse v2 tenants retrieve 1
| Flag / env | Purpose |
|---|---|
--tenant | Tenant code. Falls back to SYNAPSE_BACKEND_V2_TENANT env. |
--host | API host. Defaults to the host from synapse login. |
--token / -t | Access token. Defaults to the token from synapse login. |
--drf-token | Legacy DRF token. Usually omitted. |
--per-page | List page size (default 20). |
The list verbs print a Rich table; projects list also echoes
client.last_request_id so backend log correlation is one shell command
away. Mutating verbs (create / update / destroy) are deferred to a
follow-up PR — they need richer input (JSON body, confirm prompts, dual
auth) which inflates the surface beyond a smoke MVP.
The CLI lives at
synapse_sdk/cli/v2.py
and is registered in synapse_sdk.cli.main via cli.add_typer(v2_app, name='v2').
Performance — lazy imports
BackendV2Client is designed to import cheaply. Two layers participate:
- Resource attributes are lazy
@propertyaccessors —client.projectsonly constructs the resource object on first use. - Pydantic models in
synapse_sdk.clients.backend_v2.modelsre-export the auto-generated_generatedmodule via PEP 562__getattr__. The 1900-line_generatedmodule (137 pydantic classes) is loaded on first attribute access rather than at import time.
# Import-time work: minimal — no _generated load.
from synapse_sdk.clients.backend_v2 import BackendV2Client
# Triggers _generated import (one-time, ~140ms cold).
from synapse_sdk.clients.backend_v2.models import ProjectV2List
Workflows that only touch BackendV2Client (and never reach for a model
class directly) skip the ~140ms cold-import cost. From the caller's point
of view the lazy export is transparent — from synapse_sdk.clients.backend_v2.models import ProjectV2List works exactly
as before.
Optional dependencies
email-validator was removed from the runtime dependency set in Phase 4.
The single EmailStr use site (UserThumbnailInfo.email) is a read-only
response field — backend validation is authoritative there, so client-side
RFC 5322 enforcement adds no value. A post-codegen patch
(_patch_email_str in scripts/sync_v2_schema.py) rewrites every
generated EmailStr annotation to str while preserving the
max_length=254 constraint, so models continue to validate the documented
length without pulling in email-validator at runtime.
If your workflow constructs UserThumbnailInfo directly with hostile input
and needs RFC 5322 validation, install email-validator yourself and
validate before passing the value. Most callers do not need this.
v1 ↔ v2 endpoint diff
The INVENTORY.md "Diff vs v1" section is auto-generated by make sync-v2-all (or the standalone make gen-v2-diff target). It introspects
v1 paths from synapse_sdk/clients/backend/*.py and compares them against
the v2 schema (with the /v2/ prefix stripped before matching).
| Bucket | Count | Meaning |
|---|---|---|
| v1 only | 31 | Endpoints exposed by BackendClient that have no v2 equivalent. Continue calling the v1 client for these (agents, storages, the legacy serve_applications/ etc.). |
| v2 only | 101 | New surface area unique to v2 — membership / permission trio routes, workshops, etc. |
| Shared | 5 | Identical method+path on both clients (e.g. GET /jobs/, POST /tasks/). Migrate at your convenience. |
The full tables live in
INVENTORY.md.
Re-run make sync-v2-all whenever the backend ships a new release and the
table updates in place.
Migration from v1 (BackendClient)
| Concern | v1 | v2 |
|---|---|---|
| Pagination | count/next/previous/results (offset) | next/previous/results (cursor; no count) |
| Page size param | page_size= | per_page= |
| Tenant header | Synapse-Tenant: Token <code> | SYNAPSE-Tenant: <code> (default; opt back via tenant_token_prefix=True) |
| Access token header | Synapse-Access-Token: Token <token> | SYNAPSE-ACCESS-TOKEN: syn_<token> |
| Method shape | flat (client.list_projects(...)) | namespaced (client.projects.list(...)) |
| Response | raw dict | typed Pydantic model (*V2List / *V2Detail) |
Both clients can coexist; convert call sites incrementally as v2 endpoints become available for your workload.
Schema sync & drift handling
The Pydantic models and INVENTORY.md are derived from the cached OpenAPI
schema (schema/openapi.yaml). Use the Make targets to refresh them after the
backend ships a new release:
make sync-v2-schema # download + cache schema only
make gen-v2-models # regen Pydantic models
make gen-v2-inventory # regen INVENTORY.md
make sync-v2-all # all of the above
The script lives at
scripts/sync_v2_schema.py.
It downloads from api.test.synapse.sh by default — override with --host.
Drift between live backend and OpenAPI (Path γ)
The dev backend occasionally observes shapes that the published OpenAPI schema does not encode (or encodes incorrectly). Rather than hand-edit the generated models, the SDK uses a codegen + override hybrid ("Path γ"):
make gen-v2-modelsrunsdatamodel-codegenon the cached schema as the single source of truth.scripts/sync_v2_schema.pythen applies declarative build-time overrides to the generated tree.- Each override is self-diagnosing — once the upstream schema is corrected,
the override raises
SystemExitso contributors notice the drift has resolved and the override can be removed.
Currently tracked overrides:
| Drift | Location | Override |
|---|---|---|
Category10 enum missing data member (live backend returns category=data, schema omits it) | _ENUM_DRIFT = {'Category10': ['data']} in _patch_enum_drift | Adds the missing enum member after codegen |
JobV2Detail.completed / JobV2List.completed declared nullable+required but missing from the schema body | model patcher in the same script | Promotes the attribute to Optional[...] so live null payloads validate |
Document the upstream issue in the script header and include a self-diagnose
guard (SystemExit-style assertion) so the override deletes itself from the
codebase the moment the schema is fixed. Long-lived undocumented overrides
are an anti-pattern.
Open items pending live verification
These items require credentialed dev backend access. The client ships with safe defaults, but bump the linked knob if your tenant disagrees.
- OQ-1 (resolved — SYN-6854) —
SYNAPSE-Tenantvalue format. The dev backend accepts both the raw tenant code andToken <code>; the SDK default is now the raw value (tenant_token_prefix=False). v1-style deployments can opt back viatenant_token_prefix=True. - OQ-2 (deferred — needs live data) —
per_pageupper bound. The dev backend returned 200 for every page size from 1 through 10 000 with empty result sets, so the true cap is not yet observable. The SDK does not enforce a cap; track inspecs/backend-v2-followup-roadmap/. - OQ-3 (resolved — SYN-6854) —
tokens.create()plaintext disclosure. The dev backend returns the plaintextsyn_*token in the create response (AccessTokenCreateResponse.token: SecretStr | None).