Files
SToneX fe4a626091 fix(activity): one server-merged feed, paged by one cursor, on indexed queries
Activity fetched one page of 100 calls and runs with no way to go further, and /runs read a
team's whole call history to find its local runs.

- GET /activity merges calls, server runs and local runs newest first and pages them by one
  (created_at, source, id) cursor, so Load older never lands rows above ones already shown.
  Local runs and owned polling are excluded from its calls before the limit.
- /calls, /runs and /activity share one row format (routers/activity.py).
- 0054 adds (org_id, id) for /calls and a partial (org_id, created_at, id) over local runs,
  built concurrently.

Fragments: interface/api.md, interface/dashboard.md, architecture/data-model.md,
architecture/multi-tenancy.md
2026-09-30 17:08:37 +08:00

35 KiB
Raw Permalink Blame History

title, status, sources, related
title status sources related
Multi-tenancy — orgs, memberships, invites, per-org scoping shipped
src/treg/domain/governance/access.py
src/treg/alembic/versions/0042_pinned_read_scope.py
tests/test_pinned_read_scope.py
src/treg/models.py
src/treg/api.py
src/treg/caller_metadata.py
src/treg/application/auth.py
src/treg/application/asynctasks.py
src/treg/application/call/resolve.py
src/treg/application/provider_resources.py
src/treg/domain/provider_resources.py
src/treg/routers/provider_resources.py
src/treg/alembic/versions/0043_provider_resources.py
src/treg/application/signup.py
src/treg/domain/governance/access.py
src/treg/domain/governance/budgets.py
src/treg/domain/governance/publicdemo.py
src/treg/domain/governance/teams.py
src/treg/domain/governance/usage.py
src/treg/domain/identity/access.py
src/treg/domain/identity/api_keys.py
src/treg/domain/identity/session.py
src/treg/domain/identity/promotions.py
tests/test_team_limit.py
tests/test_auth.py
tests/test_token_revocation.py
src/treg/routers/auth.py
src/treg/routers/orgs.py
src/treg/routers/resources.py
src/treg/domain/tools/bundles.py
src/treg/infra/db.py
src/treg/alembic/versions/0017_async_task_record.py
src/treg/alembic/versions/0018_async_resource_ownership.py
tests/test_asynctasks.py
architecture/data-model.md
architecture/proxy-model.md
interface/api.md

Multi-tenancy (orgs)

The registry is tenant-isolated: an Org owns resources, a User is a global identity, and a Membership links them with a role and IS where the caller's token lives. A token = a (user, org) pair, so every list/create/mutation and the proxy are scoped to the caller's org. Design source: docs/MULTI-TENANCY-PLAN.md (standalone plan).

Owned-team limit

An account may own at most 10 teams (MAX_OWNED_TEAMS). All owner memberships count, including demo and suspended teams; joining as a member, admin or viewer does not. require_owned_team_slot locks the user through the ownership write and commit, then checks current owner memberships. The identity lock_user uses a no-op update for cross-process serialization on Postgres and SQLite. Normal creation, onboarding demo creation and explicit owner promotion share the guard. Requests over the limit return HTTP 403 with an actionable message. Deleting a team or relinquishing ownership frees a slot. Existing excess teams stay accessible, and recovery when an administrator deletes a sole owner is preserved; no migration or balance change is required. This is a current-ownership cap, not a daily creation limit.

The model (models.py)

Enrich Arena runs and evaluations require both the creating user and the active team to match. Regular team membership alone does not expose another member's results. Team deletion removes evaluations before their runs through ORG_SCOPED_MODELS.

  • Org — id, name, slug (unique), previous_slug, suspended, demo, public_demo, created_at. The tenant that owns secrets/tools/bundles. public_demo marks a team whose member token is PUBLISHED (e.g. on the landing page): non-admin members are locked to /call + reads and may never act as a user — enforced in require_member / require_identity.
  • User - identity only: id, email (unique), created_at, plus email_verified_at and signup_promo_available. No token, no role. Verified accounts can claim signup credit once across all teams; old accounts cannot claim again. See money.
  • Membership — user_id, org_id, role (owner|admin|member|viewer), token_hash (idx), webhook_url, daily_call_cap (per-user daily usage cap; -1 = unlimited, admin-set — see the API fragment's usage-metering section), tool_access (JSON; NULL = ALL tools — the default, so nobody is restricted on upgrade — else the list of allowed tool NAMES) and local_run_enabled (bool, default true); unique (user_id, org_id). One person in N orgs has N memberships (N tokens). ROLE_RANK orders owner > admin > member.
  • ApiKey resolves a credential to a membership. The membership is still the only source for role, tool access, project access, local-run permission, daily cap, and billing identity. More keys do not create more quota. A known disabled or revoked key cannot fall back to Membership.token_hash. New human memberships use only their signed default key and leave that legacy compatibility field empty; existing hash-backed credentials continue through migration. After membership removal, signed-token resolution checks the detached, revoked Default control so the old token remains unauthorized; a token pinned to a deleted team is invalid rather than an ambiguous missing-team request. A signed Default token also carries the row's team-local generation; rotation increments it on the same row, invalidating the prior token without affecting another team.
  • Invite — org_id, email, role, code_hash (idx), status (pending|accepted|revoked), invited_by, expires_at, email_token_hash (idx, nullable), plus tool_access + local_run_enabled (the access to seed onto the membership when accepted — set access at invite time, edit later). Attached to an email: redeem the one-time code, or prove that email (any identity door) and accept it code-free — the code is a shortcut, not a requirement. email_token_hash is the inbox-only second secret in the emailed link — it can sign the invitee in (GET/POST /auth/invite-signin?t=, one-time), while the admin-visible code never can.
  • Project — an OPTIONAL sub-scope inside an org (org_id, name, slug, unique (org_id, slug)). The org stays the hard isolation boundary; a project is a softer grouping on top. Deliberately a label + ACL scope, NOT a namespace: Tool.name stays unique per (org_id, name), so no unique constraint had to be rebuilt (the flat-to-orgs migration had to do that once already, and SQLite cannot alter constraints portably). Tool.project_id NULL = org-wide, which is every tool that predates projects — so this shipped purely additive. Secrets stay org-level on purpose: one shared credential legitimately backs tools in several projects.
  • Machine identities — a User on an unroutable domain, which is what makes it a machine rather than a person. Two exist: the published demo token (PUBLIC_DEMO_DOMAIN) and an agent (AGENT_DOMAIN = agents.treg.local). Both are minted by an admin and act ONLY by their token: _is_machine_email gates them out of every login door and out of require_identity (below). An agent needs no new table and no migration — it is a Membership, so it inherits daily_call_cap, tool_access, local_run_enabled and per-identity audit for free, which is exactly what makes those controls per-agent. NOTE: "agent" here is an IDENTITY; agents.py is the unrelated skill-directory table ("where does each coding agent keep its skills").
  • Resource tables (Secret/Tool/Bundle/CallRecord/PendingOAuth) carry org_id; owner (creator email) is kept for audit + the member role gate. Tool.name is unique per (org_id, name) (UniqueConstraint("org_id", "name")), so two orgs may reuse a name.

Enforcement (domain.identity.access and domain.governance.access)

  • require_member resolves a signed default key or a hash-backed managed key → a live Membership → a Caller (membership, user, org, api_key, with org_id/email/role properties). It checks managed state before the legacy membership-hash fallback. It returns 401 for a missing, disabled, or revoked key. Last-used display metadata is a throttled background write after this dependency commits; it never extends the authentication transaction or changes authorization.
  • Newly minted signed human credentials declare their purpose in the signed scp claim. scp=team is a Default key: its org is authoritative, so a conflicting X-Treg-Org is rejected instead of charging another team. That response tells older CLI users to run treg update, then treg login, because those clients can save a team before they obtain its key. scp=bootstrap is the short-lived, org-less login credential: it may identify the account for onboarding (list teams, create one, inspect/accept invitations), but require_member rejects it even when a caller supplies X-Treg-Org. Untyped tokens minted by older releases retain the prior header-first behavior during the compatibility window.
  • _role_at_least + _can_manage: admin/owner may manage any resource in the org; a member only what they created (resource.owner == caller.email). Update/delete return 404 when the resource is in another org, 403 when the role gate fails. _require_can_register gates create (secrets/tools/ skills/oauth): a viewer (rank below member) may call + list only, and gets 403 on any register. ROLE_RANK orders owner > admin > member > viewer.
  • Per-member tool ACL (the release feature). _require_tool_access(caller, tool.name) gates all use of a tool — the proxy call_tool, the server run_tool_server, AND the local grant_local_run: allowed if the member's tool_access is NULL (all) or names the tool; the owner is exempt (_tool_allowed), admins + members can be restricted. _require_local_run(caller) additionally gates the LOCAL tier on local_run_enabled (off → server runs only). Set via set_member_access (PATCH /orgs/{id}/members/{user}/access, admin+; an owner can't be restricted): _normalize_tool_access validates the names against the org's tools (422 on unknown) and collapses an all-tools selection back to NULL so a fully-checked member keeps auto-getting new tools. It's an explicit allow-list: a customized member does NOT auto-get a newly-registered tool (the dashboard toasts a reminder). Invite carries tool_access/local_run_enabled (validated at create_invite) → copied onto the membership at both accept doors. list_members returns both fields. ACL refusal details originate as AccessPolicyError; call, run, and resource HTTP surfaces translate them to the same 403 response.
  • Agents (create_agent / list_agents / revoke_agent, /orgs/{id}/agents, admin+). Mints a member identity for a machine caller, reusing the create_public_token recipe (re-POST the same name rotates — the old token dies there; revoke deletes the membership). Three invariants, each closing a real hole:
    1. An agent token can never act as a USER. create_org depends on require_identity, so without the _is_machine_email refusal there an agent could create a fresh org in which it is owner — and owners are exempt from _require_tool_access / _require_local_run, escaping every limit on it.
    2. An agent can never be an owner — blocked in create_agent AND in set_member_role, for the same exemption reason.
    3. The address is org-scoped (agent-{org.slug}-{name}@…, mirroring _public_demo_email): two orgs must each own an agent called deploy without sharing one User row, or a superadmin suspending one tenant's agent would kill the other's. Agents are always looked up by (org + domain), never by recomputing the address, so an org rename can't orphan them; _agent_name strips the current OR previous slug prefix so pre-rename agents keep their name. Every identity door is blocked at the shared choke point _find_or_create_user, plus register_user (which predates it and creates a User directly) and auth_email_start (refuse early, mint no code). list_members carries is_agent so one roster can show people and machines apart.
  • Email-domain blocklist. The same choke points, for throwaway mail and domains used for bulk registration. New verified accounts can receive one promotional balance, so farming verified inboxes remains an abuse path even though repeated team creation no longer earns credit. Entirely configuration: the classifier (_is_blocked_email in domain/identity/access.py, pure — it only answers) reads TREG_BLOCKED_EMAIL_DOMAINS and nothing else, parsed once per distinct value in config.py (trim, drop a leading @/., lowercase, and drop any dotless entry so a typed com cannot refuse the world). Unset — the default — blocks nothing, and the next domain is a dashboard edit, no redeploy. No list lives in the code: a blocklist is a speed bump, since a new domain costs the other side minutes, so its only real value is being editable in the same minutes, which a deploy is not. Substring rules on the domain were tried and removed — measured against a public throwaway-domain corpus they matched 0.17% of it, added nothing over the exact entries, and refused a real company whose domain merely contained one of the strings. The rules that remain, each because the obvious implementation is wrong: match the domain only, never the whole address (matching the address false-flags real people whose username happens to contain a listed string); walk parent domains, whole labels off the front and never the bare last label, because registering <random>.<listed-domain> is otherwise a one-line bypass; sign-in as well as sign-up (an account that predates the listing gets no new session; existing accounts are suspended out of band). The DECISION lives in the application layer, signup.blocked_email(email, door): it refuses, writes one structured line per block (event=signup_blocked_domain door=<door> domain=<domain> — the refusal reveals nothing, so the log is the only detection a burst has), and fails open, logging event=blocklist_error and letting the sign-in through if the classifier ever raises, because a misconfiguration must never break a real sign-in. The doors: start_email_login (before the rate window, so no code and no mail), find_or_create_user (so OTP verify, the GitHub and Google callbacks and the emailed invite link POST /auth/invite-signin refuse before the row lookup, raising signup.BlockedEmailError which each door translates to a blocked_domain kind), register_user (POST /users mints user + team + promo in one call), create_org (POST /orgs, the other promo door, reachable with a token minted before the listing) and the code-based POST /invites/accept (which constructs a User directly, so it guards itself). Every refusal is the machine_identity sibling's exact 403 this address cannot be used to sign in (a brand page on the browser doors, like suspended): the caller learns neither that a list exists nor what is on it. Deliberately a blocklist and nothing more: no allowlist, no table, no admin UI. Not covered: a session or identity token already live when the domain was listed keeps working until suspension or expiry (the out-of-band suspension); the promo grant and referral bonus are not separately gated, since with the doors closed no promo-funded team on a blocked domain can come into existence; and vendoring a full public disposable-domain list is a follow-up (megabytes of package data in the base wheel, which also ships the light CLI, and not yet checked against real users). A rotate replaces the TOKEN, never the limits. Because rotate is the same endpoint as create, an absent optional field used to fall back to its permissive default — and the dashboard's Rotate button sends only {name, role, daily_call_cap}, so a scoped agent silently became unrestricted (tool_access=None = every tool) just by getting a new token: round-4 blocker #2. create_agent now writes a field only when the caller actually sent it (body.model_fields_set, the shape set_member_access already used for project_access); a brand-new agent, having nothing to keep, still takes the documented defaults. AgentIn now DOES take project_access (slugs or ids, via _normalize_project_access) so an agent can be project-scoped at mint time — under the same sent-guard, so a rotate that omits it still preserves it. Membership.created_by (migration A23) stamps the minting admin, giving every agent an owner in the roster.
  • Observed agents (GET /orgs/{id}/agents/observed, admin+). The OTHER half of the agents story: the runtimes already calling under members' own tokens. The CLI fingerprints its host runtime (CLAUDECODE → claude-code, CODEX_*, CURSOR_*, …; TREG_CLIENT overrides) and sends X-Treg-Client; _client_of normalizes it (slug ≤32, versions stripped, unknown-but-well-formed kept so a new runtime needs no release) onto CallRecord.client / RunRecord.client at all three audit points. The endpoint aggregates 30 days into one row per (member, runtime), excluding ''/cli (a roster listing every human twice teaches nothing) and machine identities (already attributed to themselves). Attribution, never authentication — anything holding the token can claim any name, so nothing gates on it; scoping a detected agent for real = minting it a token (the dashboard's "Scope this agent" promotion).
  • Two ACL axes, composed as AND (_tool_usable = _tool_allowed AND _project_allowed). The project scope is the coarse dial, tool_access the fine one; both are NULL-means-everything and the owner is exempt from both. project_access holds project IDs, not slugs, so the hot-path check is a pure set test (no id→slug query per call) and a rename cannot strand an access list. project_access=[X] with tool_access=NULL means "every tool in project X, including ones added later" — the composition that makes the coarse dial useful alone. _normalize_project_access accepts slugs or ids, 422s on an unknown one, and collapses an all-projects selection back to NULL (mirroring _normalize_tool_access). Endpoints: create_project / list_projects (a scoped member sees only their own) / delete_project (admin+) — deleting frees its tools back to org-wide rather than hiding them, and drops the id from every member's scope, storing an emptied list as [], never NULL. NULL means every project, so collapsing [] would hand a member scoped to only the deleted project the run of every OTHER project's tools — a privilege escalation fired by an unrelated delete (round-4 blocker #1, test_security_round4.py). [] already carries the intended meaning (org-wide tools only), and nobody is locked out because the freed tools are what they keep: whatever the member could reach before the delete they can still reach after it. That, not a widened scope, is what "never lock anyone out" rests on. Invites carry project_access onto the membership at both accept doors, exactly as tool_access does.
  • Every list filters by caller.org_id; every create stamps org_id = caller.org_id + owner = caller.email; _resolve_call scopes both the named lookup and the host/longest-prefix passthrough to the org; call_tool loads only same-org secrets. See proxy-model.

domain.identity.access is the shared identity/access boundary: Caller, token/session/org resolution, dependencies, role comparison, and machine classification. Session signing and validation live in domain.identity.session. Two token families share one HMAC key but newly minted credentials carry a signed audience: make_session creates aud=session with a required 7-day exp, while make_identity creates aud=identity; copied team keys omit exp, while an org-less scp=bootstrap identity expires after seven days. read_session_claims and read_identity_claims reject the other audience in both directions; token_version remains the revocation mechanism for either family.

Legacy tokens predate aud, so the compatibility boundary follows what the signed shape can actually prove. An org claim identifies a team-pinned copied key, which remains usable after its former 30-day exp; an untyped no-exp key is also identity-only. An untyped org-less token with exp is indistinguishable from a browser session: it works on either path only until that timestamp, and the bearer path refuses it once expired rather than reviving an expired cookie.

  • Registration is shared across doors: application.signup.find_or_create_user(db, email) finds a user or creates them — the user ONLY, no auto personal org. Every identity door calls it (GitHub / Google callbacks, email OTP), so "first proof = registration" is identical. A brand-new user therefore lands with zero teams and must name + create their first one (the dashboard's mandatory welcome, or treg org create). Their seven-day bootstrap token works before an org exists but cannot call or read team resources. create_org uses require_identity, NOT require_member — else a zero-org user could never make their first team — and returns the new membership's team-scoped Default key. See api.
  • Code-free invites: my_invites (GET /invites/mine, require_identity) lists pending invites for the caller's proven email, newest creation time first with descending ID breaking timestamp ties; accept_my_invite (POST /invites/{id}/accept, require_identity) joins with no code (403 if invite.email != user.email, 409 if already a member). The code path stays.
  • Org management endpoints: register_user (POST /users, legacy open-registration, used by the test fixture) still creates the user + an org + owner membership via _make_org_membership (mints the token) — NOT reached by the dashboard/CLI login doors, which no longer auto-make an org. Both this door and create_org read the first-party ad-click cookie (application.signup._ad_attribution_from) and, when enabled and present, stamp Org.ad_gclid/ad_click_id_type/ad_landing/ad_click_at on the new org — preserving whether the click was a GCLID, GBRAID or WBRAID — see ads-conversions. create_org (POST /orgs, require_identity), list_orgs (GET /orgs), create_invite (POST /orgs/{id}/invites, admin+), accept_invite (POST /invites/accept, open + code-protected → registers the user if new, joins them to the invited team, mints its token; a brand-new invitee joins the invited team only — no separate personal org), list_members / remove_member (GET/DELETE /orgs/{id}/members[/{user}], admin+; owners cannot be removed). _require_admin_of(org_id, caller) gates the admin endpoints (token must be for that org + role ≥ admin).
  • An identity leaving takes its caller-owned state with it (delete_membership). A DenyRule aimed at one caller (user_id set) is meaningless once that caller is gone, and it lingers in the Policy table naming a user id nobody can resolve. remove_member, leave_org and revoke_agent sweep the rules for that (user_id, org_id); admin_delete_user sweeps every org's rules for that user, because DenyRule.user_id is a foreign key and a surviving row would dangle — Postgres rejects that outright, while SQLite only hides it by not enforcing FKs (so the test suite alone cannot catch it). ORG-wide rules (user_id NULL) are never touched: they are about the team, not about one caller. The same helper deletes IdempotentCall rows keyed to the membership before deleting it; those are replay caches, not audit history, and no valid caller remains after revocation. The foreign key also uses ON DELETE CASCADE as a database-level backstop. This closes the production failure where revoking an agent that had made an idempotent paid call returned 500 and rolled its token revocation back. Mirrors how delete_project sweeps the id it deletes out of every project_access.
  • Org administration: set_member_role (PATCH /orgs/{id}/members/{user}, owner-only via _require_owner_of; a _count_owners last-owner guard blocks demoting the sole owner — ownership transfer = promote another to owner, then step down), leave_org (POST /orgs/{id}/leave, self-removal, same last-owner guard), delete_org (DELETE /orgs/{id}, owner-only, cascades every org-scoped row through cascade_delete_org / ORG_SCOPED_MODELS in domain/governance/teams.py - including any pending AdConversion: a queued conversion belongs to the team it would be attributed to, Media: hosted reference files would otherwise outlive the team until their TTL, and HubListing/HubTool: a maker's published tools and search listings go with the team that owned them). That list is the only one, plus one named exception: cascade_delete_org also deletes every HubRun where caller_org_id == org.id before that sweep, because a run names the team that CALLED by that foreign key and the maker only by number — a caller's traces go with the caller, a maker's deletion leaves callers' history intact. Owner delete, admin force-delete, the landing-sandbox reaper and the demo reset all go through it; test_org_delete_clears_EVERY_org_scoped_table walks the models module for anything carrying org_id and also refuses a reaper that keeps a private copy. The sandbox reaper did until 2026-09-02, its copy never learned about IdempotentCall (which references a Membership), and every sandbox mint 500'd at the foreign key until it was fixed.
  • Invites lifecycle: one-time and time-bounded — Invite.expires_at (default INVITE_TTL_DAYS), accept_invite returns 410 past expiry. list_invites (GET /orgs/{id}/invites, admin+) and revoke_invite (DELETE /orgs/{id}/invites/{invite}, admin+); expired codes are garbage-collected by health.gc_expired_invites (opportunistically on list, periodically in the health run).

Hardening (invariants enforced)

  • Email is a case-insensitive identity. _norm_email (strip + lowercase) is applied at every identity door and every invite comparison, so Bob@X.com and bob@x.com are one user/one personal org and an invite is always redeemable regardless of the case typed.
  • Invite hygiene. create_invite refuses to invite an email that is already a member (409, no dead-end invite) and supersedes any prior pending invite for that email (one live code per invitee). revoke_invite only deletes a still-pending invite. An admin may not issue an admin invite (owner-only, mirroring set_member_role). Suspended users/orgs can neither view nor accept.
  • Governance never evaporates. admin_delete_user promotes the earliest-joined survivor to owner when it removes an org's sole owner; the accept/create paths return a clean 409 (not a 500) on the membership/slug uniqueness race (create_org retries with a fresh _unique_slug).
  • Slug vs id. _resolve_org resolves X-Treg-Org by slug first (an all-digit slug like 2024 is producible and must not be reinterpreted as a primary key), then by previous_slug, then by id.
  • Rename. PATCH /orgs/{id} (admin+, teams.rename_org) changes name and/or slug. The slug is baked into signed team keys, ~/.treg, MCP pins and agent addresses, so a slug change retires the old one into previous_slug instead of revoking every copied key: it still resolves, and no other team may take it (_slug_taken checks both columns). One alias only; a second rename overwrites it. Slugs are validated as their own _slugify, 3–40 chars, never sbx- (the sandbox shape). Stripe metadata and the analytics group key keep the slug they were stamped with.
  • Reserved names. teams.reserved_reason(text, extra) refuses a name or slug that reads as treg itself, as "official"/"verified", or as a catalog provider/platform (a hub tool's callable id is <team slug>.<name>, so a squatted slug could pass a stranger's tool off as ours or a provider's). Matching is by how the text reads, not its bytes: NFKC-fold, map lookalike Latin/Cyrillic/Greek letters and leetspeak digits (teams._LOOKALIKE), strip separators, then compare words — so trеg-hub (Cyrillic е), t-r-e-g, apol1o and Hunter.io data are all caught. signup.reserved_team_names() supplies the catalog-derived extra set (provider slugs exact-or-prefix, platform slugs =-marked for an exact-word match only). Checked at create_org, register_user's default team name, and rename_org (PATCH /orgs/{id}); a superadmin may bypass it (allow_reserved).

Schema ownership

Alembic owns the multi-tenant schema. The 0.14.x adoption release converted and stamped legacy databases; current releases refuse a non-empty unstamped database and direct the operator through that floor. db.verify_db() checks revision compatibility without creating or repairing tenancy tables.

Health (run_all) takes an org_id filter so /health/run never leaks other orgs' credentials, and alerts resolve the owner's per-org membership webhook. See auth-secrets.

Caller tags and pinned read scopes

Caller-supplied X-Treg-Meta tags remain attribution labels: an unpinned org token may choose any valid value. A restricted agent's Membership.pinned_tags is enforced by the server; a conflicting header is a 403. An unpinned operator retains the org-wide view and the shared balance.

domain.governance.access.pinned_tag_predicates requires every pinned key/value in the stored tags. /calls, /calls/{id}/result, /calls/{call_ref}, /runs and /activity apply that scope before pagination or loading archive bodies. A known foreign or unattributed id is a 404, just like an unknown id. POST /reviews resolves its call through the same predicates, so a pinned caller can neither rate nor probe another pin's call. Every audit writer stores the pin, the routed parent row and the router's refusal fallback included. A feedback report snapshots the reporter's pin (Feedback.tags, also in revision 0042): GET /feedback/{id} reads through it and a pinned reporter's call_ids verify only against its own pin's rows. Matching is by pin, not by current membership: two identities with the same pin share that view. Changing a membership's pin does not relabel its earlier records.

AsyncTaskRecord.tags and AsyncResourceRecord.tags snapshot the effective submission tags in their own transaction, independent of the lossy audit queue. Resources discovered by a terminal poll or worker inherit the original task's tags. Shared-provider poll/fetch ownership checks use those snapshots as well as org, provider and resource identity; a pinned refusal is 404. The unpinned 403 contract is unchanged. BYOK and own-tool upstream access still follows the existing credential and tool ACLs: these read scopes do not partition a team's own provider account.

The reserve ledger entry freezes authoritative tags in meta.tags; /calls/{call_ref} can therefore serve an authorized ledger-only history even when audit was shed and the hold was released. Missing historical attribution is never inferred from current membership or mutable spend counters. Revision 0042 adds nullable tags to run/task/resource records without backfilling guesses. Old untagged rows remain readable by unpinned org members, but not by pinned identities. An older binary can run with the additive schema, but does not enforce the new read boundary.

Both run audit writers store membership pins. Both history sources in /runs, and all three in /activity, filter before their limits. Runs do not gain caller-supplied metadata parsing in this change.

Treg's replay key includes the full pin as well as the existing primary-tag scope, so changing a secondary pin cannot expose an old replay. On the shared provider credential, the forwarded idempotency label is additionally partitioned by the full pin; unpinned forwarding is byte-for-byte unchanged. The plain BYOK label remains verbatim. Public media URLs are still bearer-by-possession links for vendor fetching; filtering history prevents discovery through those authenticated reads, not access by someone already holding a URL.

TagBudget remains a ceiling on the org's shared balance, never a sub-account. TagSpend and TagBudget remain org-scoped in domain.governance.teams.ORG_SCOPED_MODELS, with TagSpend ahead of the ledger/hold it references. Pinned read scopes do not change budget concurrency or settlement.

  • Shared-provider async objects are org-scoped. Platform-key poll and result-fetch utility calls must resolve their id through an org-owned AsyncTaskRecord or AsyncResourceRecord before the upstream is contacted. _one_resource_value reads the id from the query string or, when the catalog descriptor names a body parameter (in: body), from the JSON body — either way exactly one value must be supplied. BYOK calls keep access to ids in the team's own provider account.
  • Shared-provider durable objects are org-scoped. A platform-key managed-resource call verifies every scalar or array id against ProviderResource before contacting the provider. A use tool may additionally declare a read-only public lookup: an id absent from the ownership table is accepted only after the provider confirms the catalog predicate with no DB connection held. Any id assigned to another organization or tombstoned is denied locally and never sent through that lookup. All members may create, list, use, rename and delete their team's objects; the organization boundary, not the creator, owns them. BYOK bypasses this table. The dashboard's Team resources inventory explicitly requests source=platform; a connected BYOK account therefore never replaces or widens the organization's durable-resource inventory.

Signup analytics boundary

find_or_create_user optionally collects the IDs it actually inserted after a successful flush; a concurrent insert loser returns the existing user without marking it new. Email OTP and GitHub/Google auth pass that collection to track_signup after their commit, emitting signup_completed only for new accounts. The optional entry-surface cookie is analytics metadata, allowlisted by analytics.funnel_surface; it never affects authentication or team access.

Released CLI compatibility

The unmodified PyPI CLIs 0.16.0 and 0.19.0 can use existing saved tokens, complete browser login, and exchange Default keys with org use. Their email flow discards the browser cookie and would save a restricted bootstrap token. Their team-create and identity-mode invite flows keep the previous token after selecting the new team. A scoped Default key must still reject that mismatch.

routers.auth_helpers.require_managed_cli stops these known old-client requests with HTTP 426 before issuing email credentials, creating a team, or consuming an invite. The response tells the user to run treg update and retry. Current CLI requests send X-Treg-Key-Protocol: 1 and save the returned team's key. The legacy-client hint is the released CLI's python-httpx/ User-Agent plus ngrok-skip-browser-warning: 1, without that protocol marker. It is a compatibility check, not an authorization boundary or a universal client-version detector. Browsers and generic API clients retain their API behavior; omitting or forging the hint never relaxes token restrictions.

Existing unscoped tokens retain their old team-create behavior. Fresh email login and team changes with typed credentials require the updated CLI on the affected paths. This is a controlled upgrade requirement, not full support for all fresh-login flows in old clients. The released-wheel test in test_released_cli_compat checks that refusal preserves config bytes and the prior usable team.