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
35 KiB
title, status, sources, related
| title | status | sources | related | |||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Multi-tenancy — orgs, memberships, invites, per-org scoping | shipped |
|
|
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_demomarks 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 inrequire_member/require_identity.User- identity only:id, email (unique), created_at, plusemail_verified_atandsignup_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) andlocal_run_enabled(bool, default true); unique(user_id, org_id). One person in N orgs has N memberships (N tokens).ROLE_RANKorders owner > admin > member.ApiKeyresolves 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 toMembership.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), plustool_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_hashis 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.namestays 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_idNULL = 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
Useron 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_emailgates them out of every login door and out ofrequire_identity(below). An agent needs no new table and no migration — it is aMembership, so it inheritsdaily_call_cap,tool_access,local_run_enabledand per-identity audit for free, which is exactly what makes those controls per-agent. NOTE: "agent" here is an IDENTITY;agents.pyis the unrelated skill-directory table ("where does each coding agent keep its skills"). - Resource tables (
Secret/Tool/Bundle/CallRecord/PendingOAuth) carryorg_id;owner(creator email) is kept for audit + the member role gate.Tool.nameis 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_memberresolves a signed default key or a hash-backed managed key → a liveMembership→ aCaller(membership, user, org, api_key, withorg_id/email/roleproperties). 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
scpclaim.scp=teamis a Default key: itsorgis authoritative, so a conflictingX-Treg-Orgis rejected instead of charging another team. That response tells older CLI users to runtreg update, thentreg login, because those clients can save a team before they obtain its key.scp=bootstrapis the short-lived, org-less login credential: it may identify the account for onboarding (list teams, create one, inspect/accept invitations), butrequire_memberrejects it even when a caller suppliesX-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_registergates create (secrets/tools/ skills/oauth): a viewer (rank below member) maycall+ list only, and gets 403 on any register.ROLE_RANKorders owner > admin > member > viewer.- Per-member tool ACL (the release feature).
_require_tool_access(caller, tool.name)gates all use of a tool — the proxycall_tool, the serverrun_tool_server, AND the localgrant_local_run: allowed if the member'stool_accessis 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 onlocal_run_enabled(off → server runs only). Set viaset_member_access(PATCH /orgs/{id}/members/{user}/access, admin+; an owner can't be restricted):_normalize_tool_accessvalidates 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).Invitecarriestool_access/local_run_enabled(validated atcreate_invite) → copied onto the membership at both accept doors.list_membersreturns both fields. ACL refusal details originate asAccessPolicyError; 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 thecreate_public_tokenrecipe (re-POST the same name rotates — the old token dies there; revoke deletes the membership). Three invariants, each closing a real hole:- An agent token can never act as a USER.
create_orgdepends onrequire_identity, so without the_is_machine_emailrefusal 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. - An agent can never be an owner — blocked in
create_agentAND inset_member_role, for the same exemption reason. - The address is org-scoped (
agent-{org.slug}-{name}@…, mirroring_public_demo_email): two orgs must each own an agent calleddeploywithout sharing oneUserrow, 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_namestrips 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, plusregister_user(which predates it and creates aUserdirectly) andauth_email_start(refuse early, mint no code).list_memberscarriesis_agentso one roster can show people and machines apart.
- An agent token can never act as a USER.
- 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_emailindomain/identity/access.py, pure — it only answers) readsTREG_BLOCKED_EMAIL_DOMAINSand nothing else, parsed once per distinct value inconfig.py(trim, drop a leading@/., lowercase, and drop any dotless entry so a typedcomcannot 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, loggingevent=blocklist_errorand 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 linkPOST /auth/invite-signinrefuse before the row lookup, raisingsignup.BlockedEmailErrorwhich each door translates to ablocked_domainkind),register_user(POST /usersmints 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-basedPOST /invites/accept(which constructs aUserdirectly, so it guards itself). Every refusal is themachine_identitysibling's exact 403this address cannot be used to sign in(a brand page on the browser doors, likesuspended): 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_agentnow writes a field only when the caller actually sent it (body.model_fields_set, the shapeset_member_accessalready used forproject_access); a brand-new agent, having nothing to keep, still takes the documented defaults.AgentInnow DOES takeproject_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_CLIENToverrides) and sendsX-Treg-Client;_client_ofnormalizes it (slug ≤32, versions stripped, unknown-but-well-formed kept so a new runtime needs no release) ontoCallRecord.client/RunRecord.clientat 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_allowedAND_project_allowed). The project scope is the coarse dial,tool_accessthe fine one; both are NULL-means-everything and the owner is exempt from both.project_accessholds 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]withtool_access=NULLmeans "every tool in project X, including ones added later" — the composition that makes the coarse dial useful alone._normalize_project_accessaccepts 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 carryproject_accessonto the membership at both accept doors, exactly astool_accessdoes. - Every list filters by
caller.org_id; every create stampsorg_id = caller.org_id+owner = caller.email;_resolve_callscopes both the named lookup and the host/longest-prefix passthrough to the org;call_toolloads 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, ortreg org create). Their seven-day bootstrap token works before an org exists but cannot call or read team resources.create_orgusesrequire_identity, NOTrequire_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 ifinvite.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 andcreate_orgread the first-party ad-click cookie (application.signup._ad_attribution_from) and, when enabled and present, stampOrg.ad_gclid/ad_click_id_type/ad_landing/ad_click_aton 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). ADenyRuleaimed at one caller (user_idset) is meaningless once that caller is gone, and it lingers in the Policy table naming a user id nobody can resolve.remove_member,leave_organdrevoke_agentsweep the rules for that(user_id, org_id);admin_delete_usersweeps every org's rules for that user, becauseDenyRule.user_idis 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_idNULL) are never touched: they are about the team, not about one caller. The same helper deletesIdempotentCallrows 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 usesON DELETE CASCADEas 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 howdelete_projectsweeps the id it deletes out of everyproject_access. - Org administration:
set_member_role(PATCH /orgs/{id}/members/{user}, owner-only via_require_owner_of; a_count_ownerslast-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 throughcascade_delete_org/ORG_SCOPED_MODELSindomain/governance/teams.py- including any pendingAdConversion: a queued conversion belongs to the team it would be attributed to,Media: hosted reference files would otherwise outlive the team until their TTL, andHubListing/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_orgalso deletes everyHubRunwherecaller_org_id == org.idbefore 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_tablewalks the models module for anything carryingorg_idand also refuses a reaper that keeps a private copy. The sandbox reaper did until 2026-09-02, its copy never learned aboutIdempotentCall(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(defaultINVITE_TTL_DAYS),accept_invitereturns410past expiry.list_invites(GET /orgs/{id}/invites, admin+) andrevoke_invite(DELETE /orgs/{id}/invites/{invite}, admin+); expired codes are garbage-collected byhealth.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, soBob@X.comandbob@x.comare one user/one personal org and an invite is always redeemable regardless of the case typed. - Invite hygiene.
create_inviterefuses 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_inviteonly deletes a still-pendinginvite. An admin may not issue anadmininvite (owner-only, mirroringset_member_role). Suspended users/orgs can neither view nor accept. - Governance never evaporates.
admin_delete_userpromotes the earliest-joined survivor to owner when it removes an org's sole owner; the accept/create paths return a clean409(not a 500) on the membership/slug uniqueness race (create_orgretries with a fresh_unique_slug). - Slug vs id.
_resolve_orgresolvesX-Treg-Orgby slug first (an all-digit slug like2024is producible and must not be reinterpreted as a primary key), then byprevious_slug, then by id. - Rename.
PATCH /orgs/{id}(admin+,teams.rename_org) changesnameand/orslug. The slug is baked into signed team keys,~/.treg, MCP pins and agent addresses, so a slug change retires the old one intoprevious_sluginstead of revoking every copied key: it still resolves, and no other team may take it (_slug_takenchecks both columns). One alias only; a second rename overwrites it. Slugs are validated as their own_slugify, 3–40 chars, neversbx-(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 — sotrеg-hub(Cyrillic е),t-r-e-g,apol1oandHunter.io dataare all caught.signup.reserved_team_names()supplies the catalog-derivedextraset (provider slugs exact-or-prefix, platform slugs=-marked for an exact-word match only). Checked atcreate_org,register_user's default team name, andrename_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 anorg_idfilter so/health/runnever 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
AsyncTaskRecordorAsyncResourceRecordbefore the upstream is contacted._one_resource_valuereads 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
ProviderResourcebefore contacting the provider. Ausetool 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 requestssource=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.