Repository navigation
Merge Sunrise 0.14.0 - #125
Merged
Merged
Conversation
* feat(tenancy): Org and OrgMembership, the install org, and every existing user made a member One migration for the identity release, as the design record promises forks: the two tables and enums, the install org (fixed id 'install', ON CONFLICT DO NOTHING), a membership for every user — a real platform ADMIN as OWNER, everyone else including the SERVICE config-owner as MEMBER — and a nullable orgId on the four credential models backfilled to the install org. An admin-scoped API key stays NULL: finding 13 says admin means a platform credential, and t-673 forbids an org-bound admin key at mint, so binding the existing ones would create the state the next task has to undo. Session.activeOrgId lands here too, unread until t-670 wires it, so the feature still ships one migration. Hand-folded: the three pgvector/tsvector DROP INDEX lines and the generated column's DROP DEFAULT that prisma migrate diff regenerates are stripped, as in every migration in this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * feat(tenancy): the org-role vocabulary, and every new user joins the install org lib/tenancy/roles.ts is the client-safe vocabulary (the lib/auth/roles.ts shape, importing nothing); constants.ts names the install org; membership.ts is the one statement of the role rule the migration also applies, plus an idempotent upsert. userCreateAfterHook calls it first and BLOCKING — every other step in that hook is non-blocking, but a signup that reports success while leaving the user memberless is a state nothing downstream can detect. Inline rather than a registerUserCreatedHook contributor because that registry swallows every throw, the opposite of what this write needs. OrgMembership is an export source and Org an attribution source (orgs the subject OWNS) in SUBJECT_DATA_SOURCES. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): the invariant both ways, the migration's statements, and two real-DB smokes migration.test.ts reads the SQL and asserts the install-org INSERT, the backfill and its ON CONFLICT guards, the four credential UPDATEs (admin keys exempt), the session column, no regenerated drift — and that the SQL CASE and initialMembershipFor() apply one rule. The hook test gains the membership cases, including that a failed write fails the signup (control run: swallowing the throw turns it red). The role-literal guard now polices both vocabularies. smoke:tenancy proves the invariant against Postgres, creating the population it asserts on — two unbound keys and the migration's own UPDATE — rather than asserting absence on an empty table; smoke:erasure proves a membership cascades and the org survives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the org identity guide, the spec banner corrected, and the CHANGELOG entry Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(auth): the after hook has one blocking step now, and the CHANGELOG says who reads the new rows /pre-pr step 5c: overview.md and user-creation.md described the after hook as entirely non-blocking; the install-org membership is not. Step 5e: the bullet said nothing reads the new rows yet — the export manifest does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): name the invited-admin under-grant the identity invariant has until t-670 Found by /security-review and verified: accept-invite applies the invited platform role after signUpEmail, so the hook's membership write sees USER and an invited ADMIN becomes install-org MEMBER. Recorded on t-670 (the task that rewrites the route) with a done-when clause; the guide says so rather than claiming the invariant holds for every later user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(tenancy): the seeded config-owner joins the install org on a fresh database, and the smoke never touches rows it did not create /code-review round 1, three findings fixed and one surfaced: 1. On a FRESH database the identity migration runs before the 001 seed, so its backfill never saw the SERVICE config-owner and every new install had one memberless user — the invariant broken on day one, and smoke:tenancy red in CI. The seed now calls ensureMembership() on the runner's own client (the function takes a db parameter for it); proven on a throwaway database: migrate deploy → seed → smoke green, and red again with the membership deleted. 2. The smoke re-ran the migration's UPDATE unscoped, which would bind — and then miscount — every key an operator minted since the migration (nothing writes orgId at mint until t-673). Scoped to the two fixture ids; proven by planting an unbound operator key and watching it stay NULL. 3. smoke:tenancy joins the CI smoke job, after migrate deploy + db:seed — the sequence that exposes finding 1. 4. Surfaced, not settled: the platform-to-org role mapping is applied once at creation and not re-synced by the admin PATCH, so a demoted admin keeps OWNER. Documented as a known gap in identity.md, put on t-672 as a ruling it must make, and the smoke's two whole-table 'every ADMIN is OWNER' checks replaced by one on the seeded config-owner by its fixed email. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(tenancy): the signup membership write is non-blocking, and the record says why /code-review round 2. The blocking throw was wrong: the after hook runs inside better-auth's createUser BEFORE linkAccount mints the credential account, so a throw stranded a user nobody could sign in as, with no path that re-runs the hook, while forgot-password would later mint the credential and sign them in memberless anyway. Reversed: logged at error, signup completes; the self-heal belongs to t-670's session.create.before hook and the guard's null-membership rule (t-671). Recorded as a decision on the feature; t-669's done-when and t-670's plan updated. Also from the round: the identity guide claimed a smoke check that no longer exists (fixed); the "not rewritten" smoke assertion could not fail because the already-bound key was outside the scoped UPDATE (now inside it); and the fork note on prismaAdapter's transaction option, under which this upsert cannot see the user row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the non-blocking rationale now matches how better-auth actually orders its hooks /code-review round 3 (the cap). Round 2's premise — that the after hook runs before linkAccount — was false, and I recorded a ruling on it without reading the mechanism. with-hooks.mjs queues create.after hooks and runWithTransaction runs them only after the sign-up transaction resolves, so user, account and session are committed first. The ruling stands (a throw would only 500 a usable signup; same memberless row, same self-heal) but every place that gave the wrong reason now gives the right one: the config comment, the identity guide, the hook test's comment. The fork note claiming transaction: true would break the FK is deleted from membership.ts and the guide — it is post-commit there too. Superseding decision recorded on the feature. Also: the interim-key gap (credentials minted before t-673 carry NULL orgId) is documented in the guide and put on t-673, which re-runs the backfill. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…on can name one (#808) * feat(tenancy): a session knows its org, a member can switch, and an invitation can name one Session.activeOrgId becomes a better-auth session additionalField, declared `input: false` — load-bearing, the same way it is on user.role (#804): the public POST /api/auth/update-session runs every declared session field through the input parser and writes what survives to the caller's row with no membership check. That is also why the switch does not go through auth.api.updateSession (the parser refuses the field there too): it writes the row with Prisma after the membership check, then re-issues the cookie cache with a getSession that bypasses it — a row update alone leaves the old org live for five minutes. sessionCreateBeforeHook chooses the org at every sign-in: a signup in flight, else the user's only org, else the install org if they belong to it, else the most recently joined — and a user with no membership at all is given the install-org default right there (the self-heal t-669's review ruled on), logged at error. The membership a new user gets is one function of (user, invitation) — membershipForNewUser — judged on the platform role the invitation GRANTS, so an invited ADMIN owns the install org even though accept-invite promotes the row only after signUpEmail returns (t-669's finding). An invitation may name another org and an org role; the first member of a new org is its OWNER, scoped to non-install orgs so the seeded SERVICE account stays a MEMBER. The three hooks do not run in the order their names suggest: on an auto-signed-in signup the session is created inside the sign-up transaction, before the queued user.create.after hook, so a naive self-heal would write first and the invitation's membership would land as a no-op upsert. The before hook now decides the membership and records it on better-auth's request state (lib/auth/pending-signup.ts — defineRequestState, what getOAuthState is built on); the session hook reads it and writes nothing; the after hook writes it. @better-auth/core moves from devDependencies to dependencies at the same exact pin. runInvitedSignup carries the invitation in for the password path; the OAuth before hook hands over the one it consumes. The invite route keeps withAdminAuth: the org is in the body, which a guard resolver cannot read, so the handler asks canAdminister about an org resource after parsing — under the default policy the same answer the guard already gave, and the seam t-671 makes org-aware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): the membership table, the session hook's order, the switch through the real guard, and the field with a control membership.test.ts pins membershipForNewUser's table — including the case t-669 found missing (an ADMIN invitation on a USER row → OWNER, with the USER-invitation control) and that the per-org bootstrap does not apply to the install org — and activeOrgForSession's four arms, with the self-heal writing for a memberless user and for nobody else. config-session-field.test.ts runs better-auth's own parser over the real options: activeOrgId is refused on update (the public /update-session path) and on create, and the control with `input: false` removed shows the body's org going through. It also pins the Prisma column the field maps to and that the inferred server session type carries it. config-session-hook.test.ts pins the order: a signup in flight decides and the memberships are not read; a self-heal logs at error; a fault mints the session with a null org rather than refusing the sign-in. config-database-hook.test.ts gains the before hook's pending-membership arms (OAuth invitation with an org, the password path through a real runInvitedSignup, the bootstrap, a plain signup, a failure that never blocks) and the after hook writing what was recorded, falling back to the install-org rule otherwise. Its header no longer says a throw would strand a credential-less user. The switch route test runs the REAL withAuth — the `ownership: 'self'` declaration and the API-key refusal through resolveApiKey are what is under test — and asserts the refresh bypasses the cookie cache and its Set-Cookie headers reach the response. The invite route test, also through the real guard and the real default policy, proves an invitation without an org writes metadata byte-identical to before, and that a policy refusing the org is a 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the active org and org-aware invitations in the identity guide, the session and auth-endpoint pages, and the CHANGELOG identity.md gains the invitation table (where a new user lands, given what the invitation says), the active-org section (how it is chosen, why the switch writes with Prisma and re-issues the cookie, why `input: false` is load-bearing) and retires its "until t-670" gap. sessions.md documents the field; user-creation.md the metadata's org keys and the carrier between the hooks; auth-endpoints.md the new body keys and the switch endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): switchOrgSchema at the boundary — the install org id is not a cuid /pre-pr 4f named lib/validations/tenancy.ts as the one changed module with no test of its own; the route test reaches it, this pins the shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs: the org is on session.session, and the t-669 bullet no longer calls activeOrgId unread /pre-pr 5b/5e: rate-limiting.md's fork example read the org off session.user, where nothing puts it; the inherited [Unreleased] bullet said Session.activeOrgId was unread and that only the export read the org rows, both false once the next bullet in the same release lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(invite): the response says where an invitation points, and the two entry-time stances are written down /code-review round 1 (five lows). Fixed: the "already pending" and the created responses now carry `orgId`/`orgRole` (null when none), because a resend rewrites the metadata from the body and an admin could not see the pending org before overwriting it. Documented rather than changed, with the reason in the code: `activeOrgForSession` does not read Org.status (suspension is enforced at entry by the guard and the switch — skipping a suspended org at sign-in would re-admit its members elsewhere), the ACTIVE check is invite-time only for the same reason, and the switch deliberately does not swallow a throw from the cookie re-issue (a retry converges; a 200 without the cookie leaves the old org live). The first-member OWNER race is on t-672's record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(invite): a resend re-sends THIS invitation — the pending org keys carry over unless the body names an org /code-review round 2 found round 1's rationale false: "the pending response echoes the org keys so a resend that changes them is a choice" — but the only shipped resend caller, the admin table's Resend button, posts { name, email, role } and never sees that response, so a bounced invitation into an org would have been silently re-targeted to the install org. Now the org is resolved once — the body's, else on a resend the pending row's — and the inherited org goes through the same existence, status and policy checks as a named one. Tests pin inherit, explicit override, a keyless legacy resend staying keyless, and a resend into a since-suspended org refused. The endpoint reference gains the pending 200 shape it never had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(invite): an orgRole without an orgId is the install org with an explicit role — round 2 had dropped it /code-review round 3: the resend carry-over keyed "the body names a target" on orgId alone, so a schema-valid, documented { orgRole } body (install org, explicit role — which membershipForNewUser honours) was written without a role and the invitee landed as MEMBER. The body now names a target when it sends either key, and its pair replaces both; tests pin the orgRole-only fresh invite and the orgRole-only resend override; both doc passages state the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the invitation table names the orgRole-only arm Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…n read it (#809)
* feat(tenancy): a request knows which org it acts for, and the admin decision can read it
lib/tenancy/context.ts is the primitive: an AsyncLocalStorage<TenantContext>
with getTenantContext / requireTenantContext (throws at multi when absent;
answers the install org, marked `implicit`, at single — the one org there
is), runAsOrg, runAsSystem(reason) (logged: the audited bypass), and
forEachOrg (one org scope per ACTIVE org, sequential; nothing calls it in
production until §108 wires the tick).
lib/tenancy/entry.ts is the read rule the guards apply, one function per
credential class. At single the install org is entered WITHOUT a membership
read when the session names null or the install org — its role is the
platform role projected by initialMembershipFor, the one statement the
migration and the signup hook also apply. That is the model, not a shortcut:
at single the install org is the only answer. It is also what keeps the hot
path query-free and the 124 test files that run the real guards free of a
Prisma mock they never needed (91 mock Prisma without orgMembership, 32 not
at all — a per-request read would have hit a real database from those). The
membership row IS read and verified — present, org ACTIVE — whenever a
non-install org is named, a resolver header is present, or the mode is
multi. An admin-scoped key enters no org (finding 13); any other key enters
the org it was minted in, or the install org at single while that column is
NULL.
Both guards enter the org once, carry it two ways — on the principal
(orgId/orgRole, the feature's journal decision: told, not sniffed) and as
the tenant context the handler runs inside — and pass { org } as the policy
scope. A refusal is a 403 that names nothing. The guard-less webhook trigger
enters its key's org the same way.
lib/app/tenant-resolver.ts (fork seam, ships empty) → lib/tenancy/resolver.ts
(Web-standard only): proxy.ts registers it at module scope, asks it per
request, and sets x-sunrise-org — deleting any inbound copy when there is no
answer, the visitor-id shape. The guard trusts the header for WHICH org
because of that and still verifies membership.
DEFAULT_AUTHORIZATION_POLICY grows the org arm: an org OWNER/ADMIN
administers, and reads the ownerless this-row of, a resource that carries
THEIR org — checked before the unattributed diagnostic, and guarded on the
resource side so undefined === undefined cannot grant. A null resource, or
one without an orgId, grants nothing: platform-ops surfaces stay
platform-only, and since no core resolver names an org until §107 the arm
cannot fire on any existing route. The capability question stays
platform-only until the data layer scopes those reads by org.
getRequestContext / getFullContext carry orgId from the context; the admin
layout and the maintenance wrapper pass the session's org (no role — they
ask with a null resource, where a read could not change the answer).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* test(tenancy): the context's six ALS behaviours, the read rule's every arm, the guards' three sources, the proxy header, the org arm, and the byte-identical sweep
context.test.ts pins the six store behaviours signup-mode.test.ts pins for
its own (await boundary, no leak on resolve or throw, no leak into
concurrent work, two scopes blind to each other), requireTenantContext
answering the install org at single and throwing at multi, runAsSystem's
logged reason, and forEachOrg's one-scope-per-ACTIVE-org, sequential.
entry.test.ts asserts the property the design rests on: at single the
install org is entered with the membership read NEVER called, and the row
is read and verified for any other org, a resolver header (even one naming
the install org, even for an admin), and at multi. Both credentials, every
refusal.
guards-tenancy.test.ts drives the three sources through the real guards
with Prisma mocked at the membership read: each enters the right org, the
header wins for that request, refusals are one 403 that names nothing and
the handler never runs, an admin key runs outside any scope, and the
handler runs inside the same org the policy was told. guards-
authorization.test.ts's principal assertions now carry the org facts every
principal has at single.
proxy.test.ts: x-sunrise-org stripped with no resolver (the shipped
default), set from one, stripped when it answers null, overwritten when it
answers. authorization-org.test.ts: what the org arm grants and everything
it does not (null resource, orgless resource — the undefined === undefined
trap — another org, a MEMBER, the capability question), the diagnostic
skipped for an org resource, parity and reachability over a roster with an
org owner (and that the org owner alone would close every kind — the
deliberate limit, stated), and the sweep: every principal × every question
a core route can ask, with and without org facts, on both policies,
identical — with a control showing the sweep notices an org resource.
Logging carries orgId inside a scope and not outside; the webhook trigger
creates its execution inside the key's org and refuses a key whose owner
left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* docs(tenancy): the tenant-context guide, the org input in the authorization guide, the spec banner brought to t-671, and the CHANGELOG
.context/tenancy/context.md is the primitive, the read rule as a table (which
source, verified when), why the install org needs no read at single, the
header contract with the sole-writer else, who reads the context, and what is
NOT entered yet with its owner. authorization.md gains "The org input": the
arm and everything it deliberately does not grant, the undefined ===
undefined trap, and that an org admin is a narrowed admin. The design
record's banner now says what exists down to the policy. identity.md's
"until t-671" phrasings retired; CLAUDE.md docs row added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* docs: the two context tables that name changed functions, and the inherited bullets no longer say nothing reads the org rows
/pre-pr 5c: request-context.md's table and api-keys.md's resolveApiKey
steps gain the org; 5e: t-669's bullet said no request path reads the org
rows for an authorization decision and t-670's said the invite check was
platform-admin-only under the default policy — both false once the third
bullet in the same release lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* docs(tenancy): the sole-writer property is scoped to the proxy matcher; membership is what admits
/security-review low: image-extension paths bypass the proxy, so the header
can reach a guard unstripped there. Harmless because the guard verifies
membership regardless — said so where the header contract is documented.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* fix(tenancy): the switch is reachable from a refused org, sign-in prefers an active one, a throwing resolver is logged, and a resolver's answer is shape-checked
/code-review round 1, six findings. The lockout was real: a session whose
active org was suspended, or lost, was refused on every guarded route
including POST /api/v1/orgs/switch — the route that exists to leave it.
The switch now declares `tenancy: { entersOrg: false, because }` (a
WithAuthOptions marker with a required sentence, the ownership shape) and
runs outside any scope with no org facts, checking membership of the org it
switches TO; and activeOrgForSession chooses among ACTIVE orgs first, so a
member of one suspended and one active org starts in the active one. A user
whose every org is suspended still starts there and is refused, which is
what suspension means.
A throwing fork resolver was swallowed with a docblock promising a log
nothing wrote: resolveTenantFromRequest now takes onError and the proxy
logs at error — every request it throws on falls back to the session's org
while the hostname says otherwise. Its answer is shape-checked
([A-Za-z0-9_-]{1,200}) so a value derived from an inbound header can never
make Headers.set throw in the proxy.
Both guards now wrap the resource resolver and the policy call in the
scope, not only the handler, so §107's data layer scopes the resolver's own
query rather than throwing on it at multi. The session path's missing
accountType is documented at the one call site (better-auth's session user
does not carry it; a SERVICE account has no credential). And the policy
docblock no longer says an org admin "reads what carries their org": the
subject arm is deliberately unchanged, because widening it would break
parity with a SubjectFilter that cannot yet say "rows in my org".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* fix(tenancy): a malformed resolver answer is reported like a throw; the switch's cross-org reads are §107's to scope; three docblocks
/code-review round 2, five smalls. resolveTenantFromRequest now reports a
non-org-id-shaped answer through onError (shape named, value withheld) — a
silent strip was the exact failure the previous commit said must never
happen for a throw. The tenancy opt-out's docblock and the switch route say
what §107 owes the route (an audited entry around its membership read and
session write, or a data-layer exemption) rather than calling "outside any
scope" the end state. The resolver docblock no longer calls a slug a valid
answer; the session hook's docblock describes the ACTIVE-first rule; the
proxy test restores its logger spy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ng its members' other orgs (#812) * feat(tenancy): an org can be created, suspended, exported and erased, and its members managed, without touching its members' other orgs lib/tenancy/lifecycle.ts is the write path: createOrg (names the founding OWNER in the same write, so membershipForNewUser's count arm is the fallback), updateOrg (rename, re-slug, suspend, reinstate), addMember, changeMemberRole, removeMember, syncInstallMembershipRole, and resolveOrgResource — the resource every /orgs/[id]/** route hands the policy. Its rules, stated once: the install org can be renamed but never suspended, re-slugged or deleted (ruling b); its memberships follow the platform role and are the account's floor, so the members API refuses to edit or remove them; an org keeps at least one OWNER, the users/me last-admin precedent; a removed member loses the sessions acting in that org and keeps the rest. OrgLifecycleError is an APIError, so every route answers it by letting it propagate to the guard. Ruling a taken: the install org's OWNER set follows the platform-admin set. users/[id] PATCH with a role now upserts the install membership to initialMembershipFor(updated).role in the transaction that writes the role. The member view (withAuth): GET /api/v1/orgs (self; does not enter the session's org, the switch's reason), GET /api/v1/orgs/[id] (self, keyed on the caller's own membership row — with the org resolver the default policy's org arm refuses a MEMBER, so "must be a member" cannot be a resource route), GET/POST /orgs/[id]/members and PATCH/DELETE /orgs/[id]/members/[userId] (resource: the org; the policy's org arm admits its OWNER/ADMIN while acting in it, or a platform admin; no role check in the routes). Mutations refuse an API key. The platform view (withAdminAuth, ownership 'nothing'): GET/POST /api/v1/admin/orgs (member and owner counts in two queries), GET/PATCH/DELETE /admin/orgs/[id], GET /admin/orgs/[id]/export. lib/privacy/org-sources.ts is the org manifest: every orgId-carrying model as a source or an exclusion (OrgMembership with member id/name/email as export; the four credentials as attribution, hashes and scopes omitted), and pending invitations INTO the org listed by hand — Verification has no orgId column, the org is in the metadata JSON. exportOrgData walks it, nothing best-effort. eraseOrg: one transaction deleting the pending invitations, clearing session.activeOrgId on every session still pointing at the org (no FK — better-auth owns that table), and the org row; memberships and credentials cascade; users are never deleted (ruling a, alternatives in the docblock); the install org is refused. revokeUserSessions gains an optional activeOrgId filter (additive). ORG_STATUSES joins the client-safe vocabulary, pinned against the Prisma enum. smoke:tenancy extended and green against the dev database: the founding owner, the last-OWNER guard both ways, install immutability, a SUSPENDED org refusing its own OWNER through the real entry function and admitting them once reinstated, a removal revoking exactly the session in that org, the export carrying every manifest section, and an erasure that leaves both users standing with the install membership intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): every lifecycle rule on a populated org, the org manifest guard shown to fire, and the routes through the real guards and policy lifecycle.test.ts: the last-OWNER guard refuses to demote or remove the only OWNER of a two-member org and passes once a second OWNER stands; promotions never count; the install-org refusals happen before any read or write; the removal revokes only the sessions acting in that org; createOrg names the owner in the same transaction; syncInstallMembershipRole makes a promoted human admin OWNER, a demoted one MEMBER, and never the SERVICE account; resolveOrgResource selects one column keyed off the URL and answers null for a missing org or a malformed segment. org-sources.test.ts: the coverage guard, with its own guard — the scan must find exactly the five orgId models, must not mistake Session.activeOrgId, and is run against a synthetic schema where it NAMES an undeclared model (what §107 will hit ~23 times). The invitation source is pinned by hand, the credentials as attribution, the token omitted, the roster's include exact. erase-org/export-org: the transaction's three steps in order, no user delete, the install refusal before a transaction, one failing source fails the export. Routes: the members routes run the real withAuth, the real default policy, the real entry rule and the real resolver — an org ADMIN acting in the org is admitted, a MEMBER is not, an ADMIN acting in the WRONG org is not (the arm compares the entered org), a platform admin is from anywhere, a non-member and a missing org get one 403 that names nothing, a suspended org refuses at entry, and an admin-scoped API key the policy admits is still refused by the mutation routes. GET /orgs answers a member whose active org is suspended with no entry read at all. GET /orgs/[id] answers a MEMBER, keyed on their own membership. The admin routes refuse an org OWNER who is not a platform admin. users/[id] PATCH: a promotion upserts the install membership to OWNER, a demotion to MEMBER, a name-only patch touches nothing. revokeUserSessions' activeOrgId filter; ORG_STATUSES against the Prisma enum; orgIdSchema shown to agree with ORG_ID_SHAPE (now exported and shared). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the org API reference, the org export and erasure guides, the lifecycle section and ruling a in the identity guide, and the CHANGELOG .context/api/org-endpoints.md is the HTTP reference for both views, with the admission table ("while acting in it" spelled out) and every refusal code; the switch section moves there from auth-endpoints.md. org-export.md and org-erasure.md are the two privacy pages in the existing pages' shape — the orgId manifest and its guard, the two dispositions, the hand-listed invitation source, what goes and what stays, ruling a with its rejected alternatives, suspension as not-erasure. identity.md: the known-gap paragraph becomes the ruling, the lifecycle section states every rule with where its test is, the owner-less-org and session-revocation promises are answered. context.md, guards.ts, authorization.ts and authorization.md no longer say no core resolver names an org — the members routes do, and why that is still byte-identical at single is said where the old claim was. Spec banner brought to t-672; CLAUDE.md rows; cross-links from the subject pages and security.md; CHANGELOG bullet, with the inherited t-671 sentence narrowed to the routes that predate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(privacy): the cookie-cache window after an org erasure, seen on the live probe Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): widen the fixture's role parameter so an ADMIN fixture type-checks Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(users): the integration PATCH double runs the transaction the role sync now needs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(orgs): the export route's rethrow branch — any failure but a missing org is a 500 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(tenancy): only an OWNER confers or revokes OWNER, and the policy's org arm is a session grant Two findings from /security-review, both confirmed by an independent false-positive pass at 8/10. An org ADMIN could take the org from the person who appointed them in two requests: PATCH themself to OWNER (not a demotion, so no guard ran), then DELETE the original OWNER (two owners stood, so LAST_OWNER passed) — whose sessions in the org removeMember then revoked. The last-OWNER guard protected the COUNT of owners while a delegate could rewrite WHO they were. The routes now hand the lifecycle the caller's standing off the principal the guard built ({ platformAdmin, orgRole } — MembershipActor), and addMember / changeMemberRole / removeMember refuse OWNER_STANDING (403) when an actor without it grants OWNER, changes an OWNER's role, or removes an OWNER. An ADMIN still manages MEMBERs and ADMINs; an OWNER, or a platform admin from anywhere, does all three — which is how an owner-less org is repaired. The ORG_ADMIN_ROLE docblock's "without the OWNER's standing" is now something the code keeps. A chat-scoped API key minted by a platform admin could read the install org's roster — the user directory at single. withAuth admits a key of any scope on a route that names none, enterApiKeyOrg projects the key OWNER's platform role onto the key at single (OWNER for a human admin), and t-671's org arm read orgRole without looking at the credential — while the same key is refused every admin route by hasScope(…,'admin'). administersOrgOf now returns false for an api-key principal: a key's standing is its scopes, none of them mean "administer the org", and an admin key still administers through administersEverything. t-671's test asserting the opposite is reversed with the reason, and a control shows the refusal is the credential. The members GET is covered by a real-resolveApiKey test with a chat key owned by a platform admin. Also: the owner-count-then-write transactions run SERIALIZABLE — the comment claimed the transaction alone stopped two concurrent demotions each seeing the other OWNER, which READ COMMITTED does not; now one of them fails with a serialization error instead of leaving the org owner-less. The add-by-id-without-consent observation (Low) is answered in the endpoint reference: it mirrors naming an owner at creation, the invitation is the consenting path, and the 404 confirms only that an unguessable cuid names nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(orgs): the roster GET refuses an API key as the doc already said, and the list names the active org the way the entry rule does /code-review round 1. The endpoint reference promised "browser session only, GET included" and "an API key of any scope is refused on every members route"; the GET had no check, so an admin-scoped key — which the policy admits everywhere — would have read the roster where the doc said 403. The check is added rather than the sentence softened: the roster is people, and an admin key's surface is the platform view. The test now runs both a chat and an admin key through the real resolveApiKey against the GET. GET /api/v1/orgs computed `active` from the cookie alone; on a fork with a hostname resolver the header wins for every other request on that host, so the list would have marked the wrong org. It now reads the header first via next/headers — the same source the guard uses — then the cookie, then the single/multi default; read, not entered, so the route still answers a refused org. The Low on add-by-id was already answered in org-endpoints.md by the previous commit (mirrors naming an owner at creation; the invitation is the consenting path; a cuid is unguessable) — no change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(orgs): the list answers an API key with the org the guard entered for it; LifecycleDb says what a transaction client cannot satisfy /code-review round 2, two Lows. `entersOrg: false` skips only the cookie-session entry — a key minted in org X still enters X — so the list now reads `session.principal.orgId` first and falls back to the header / cookie / default chain only for the cookie session that was not entered. Test through the real resolveApiKey with a key bound to the other org. `LifecycleDb` includes `$transaction`, so the comment claiming a transaction client satisfies it was wrong; it now says these functions open their own transactions and why `syncInstallMembershipRole` is typed narrower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…(#815) * feat(tenancy): every credential binds an org at mint and acts only there The four long-lived credentials — API keys, embed tokens, agent invite tokens, MCP keys — gained an orgId column in 0.12.0 that nothing wrote at mint, so under tenancy each was a credential that worked everywhere. Now every mint records the org the request was made in (`orgForMint` in lib/tenancy/entry.ts — one read of the tenant context, never a body field), and every resolution enters it. Mint. POST /api/v1/user/api-keys binds the entered org; an `admin` key is a platform credential and binds none, and asking for `admin` while acting in any org but the install org is a 400 — the intent behind the request is the org it was made from, and a platform key minted from a customer org's screen would read as that org's. The three admin mint routes (embed tokens, invite tokens, MCP keys) bind the entered org; an admin API key calling them has no entry, which is the install org at `single` and a 403 at `multi`. Rotation never touched the column. Response shapes carry `orgId`. Resolve. `resolveEmbedToken` and `authenticateMcpRequest` apply the read rule themselves (`resolveCredentialOrg`): the org's status rides on the row's own read, so a suspended org's token is a 401 with no extra query, and a null-org row is the install org at `single` / refused at `multi`. Their context objects carry a non-null `orgId`, and the six guard-less handler bodies run inside `runAsOrg(orgId, …)` with `source: 'embed-token' | 'mcp-key'` — the MCP transport through one `withMcpKey`, the embed routes through a named function each, the webhook's shape. An invite token is a gate the session passes through, not a credential that acts: lib/orchestration/invite-tokens.ts is the one implementation both routes now use, comparing the token's org with the org the guard entered (`wrong-org` reads as not found), with the atomic use_count increment kept as `consumeInviteToken`. The `withAdminAuth` floor refuses an org-bound key whatever its scopes: mint forbids the combination, the backfill leaves admin keys unbound, and a row that has both is refused rather than admitted to every org's admin surface from inside one. lib/app/authorization.ts says the rule now holds. The interim rows — everything minted between 0.12.0 and this — are bound by 20260918120000_credential_org_backfill, the identity migration's four UPDATEs verbatim (a test holds them byte-equal), a data migration rather than a startup reconcile because the gap is one-time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(tenancy): every mint binds the entered org, every resolver enters it, and the invite token compares — seen from inside the handlers entry.test.ts: orgOfColumn in both modes; resolveCredentialOrg's four arms (null → install at single / refused at multi; the install org at single skips the status; any other org must be ACTIVE, a dangling column included; at multi the install org is verified too); orgForMint from a context, from none at single, and from none or a system scope at multi. embed/auth.test.ts and mcp/auth.test.ts: the org's status is read with the row (asserted on the include, one call); a null-org row is the install org at single and refused at multi; a suspended org's token or key is refused, and an MCP key refused this way is not marked used. invite-tokens.test.ts: the outcome vocabulary in order; the org comparison against the org the request acts in, with the null-column rule at both modes and a request with no org matching nothing at multi; the consume SQL asserted verbatim as the TOCTOU guard it always was. chat/stream and validate-token: a token from another org is refused with the words used for a bad token and spends no use / reads as not found, with the in-org control. Mint routes: api-keys binds the entered org and says so, the install org for a session that chose none, an admin key with no org, and refuses an admin key asked for from a customer org (400, naming no org); the three admin mint routes bind the entered org (real guard, membership mocked) and the response carries it; an admin API key mints into the install org at single and is refused at multi; rotate never touches the column and reports it. Resolve routes: each embed handler and each MCP method is shown to run inside the token's org by reading getTenantContext() from inside the first thing the handler does — streamChat, the agent read, the transcribe call, the RPC dispatch, the server-config read, the session lookup — with nothing leaking past the response; a key the resolver refuses is a 401 before any handler runs. guards-tenancy: the admin-key floor refuses an org-bound key whatever its scopes, with the unbound admin key as the control; the old fixture (an admin key bound to an org) was the very state the floor now rejects. migration.test.ts: the re-run is exactly the identity migration's four UPDATE statements, byte-equal, and nothing else. smoke:tenancy, green at 50 checks against the dev database: an embed token, an MCP key and an invite token minted in the fixture org resolve to it through the real resolvers; the invite token is wrong-org from the install org; suspending the org refuses the embed token and the MCP key and reinstating restores them; a token written with no org is bound by the re-run migration's own UPDATE read from the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(tenancy): the org-binding paragraphs on the four credential guides, the credentials section retires the interim gap, and the CHANGELOG api-keys.md, embed.md, agent-visibility.md and mcp.md each gain an "org binding" section — what is written at mint, what enters at resolution, what a suspended org does to the credential, and what a null column still means. mcp.md's "not sufficient for multi-tenant SaaS" note is narrowed to what remains: per-end-user identity (OAuth) and per-org scoping of exposed tools and prompts (§107/§111). identity.md's credentials section replaces the known-gap paragraph with the rule as built, the admin-key exception in both directions, and the backfill re-run; context.md's source table gains the embed-token and mcp-key rows and the "not yet entered" list loses the three credentials and gains the invite token's distinction. authorization.md and lib/app/authorization.ts no longer say the org-bound-admin rule is unenforced. Design-record banner brought to t-673. CHANGELOG: one Added bullet in the §106 series and a Changed bullet for the response shapes, the resolver context types, and the two admin-key rules now enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(db): the raw-SQL allowlist follows the invite-token increment to its module The one raw statement app/api/v1/chat/stream/route.ts carried — the atomic use_count increment, which the entry had described as a vector lookup — now lives in lib/orchestration/invite-tokens.ts. The entry moves with it, sorted, with the reason the call is raw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * test(chat): validate-token's three pre-token branches — bad body, no agent, an agent that needs no token Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(tenancy): withAuth refuses an org-bound admin key too — the row the withAdminAuth floor refuses was admitted, unscoped, everywhere else Code review, round 1. `enterApiKeyOrg` answered null (platform credential, no org) for any admin-scoped key before looking at its orgId, so a key carrying both — the hand-edited or fork-widened row the floor names — was 403 on every admin route and admitted on every withAuth route with no org scope at all. Now `enterApiKeyOrg` refuses it (`bound-admin-key`, a 403 naming nothing, logged with the reason) so both guards, and the webhook trigger that shares the function, answer the same question. Pinned in entry.test.ts (both modes, both the install org and another) and guards-tenancy.test.ts (the handler never runs; the warn line names the refusal); the earlier fixtures that used an org-bound admin key as the platform-credential case now use an unbound one, which is the only honest shape. Prose that said "withAdminAuth refuses" now says both guards do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * fix(chat): a request acting in no org passes no invite gate — said, logged with its own reason, and tested Code review, round 2. At multi an admin-scoped API key enters no org, so resolveInviteToken answered wrong-org for every token — a live one included — and the deliberately opaque message hid that the cause was the key, not the token. The behaviour stands (an invite token admits members of its org; a platform credential is a member of nothing; the consumer surface is reached with a session or an org-bound key) and is now stated in the module docblock and agent-visibility.md, and the case is logged at warn with `refused: 'no-request-org'` before the same wrong-org answer, so an operator can tell it from a foreign token. Pinned both ways: the no-org case logs, a plain wrong-org does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * docs(changelog): the no-org invite refusal at multi joins the Changed bullet; reflow Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…urvives Sunrise's transactions, raw SQL and pooler (§107 t-704) (#818) * Spike: the RLS chokepoint pattern against Sunrise's transactions, raw SQL and pooler (§107 t-704) Throwaway script using the real generated Prisma client against a migrated throwaway database as a NOBYPASSRLS role. Answers the eight questions the design doc's spike register left open before 3.2/3.3 could be sized: extension × interactive/batch transactions, raw SQL, nested creates, per-op cost direct vs pooled, dormant policies, FORCE RLS vs the migrate role, types and layered $extends, and deriving the tenant-owned set from the client's runtime data model. 38 checks; passes on a local pgvector container (direct and PgBouncer transaction mode) and on a Neon preview branch. Creates and removes its own roles, policies and rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Design doc: spike register answered with the measured findings (§107 t-704) Items 1–4 answered with numbers; 5–6 pointed at §106 where they were resolved; 7 (FORCE RLS × migrate role, the bypass GUC) and 8 (types, layering, runtime data model) added from the §105/§106 notes. Leads with the deploy-target fact: Neon's neondb_owner has BYPASSRLS, so the app must connect as a separate role at multi. The data-plane paragraph no longer says child rows use join-based policies — every tenant-owned row carries its own orgId (planning decision on the Hub journal). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Design doc: register item 9 — bypass GUC versus bypass role, open for 3.2 The security review of the spike PR pointed out what the spike's own Q7 proves: a GUC the NOBYPASSRLS app role can set is a bypass any SQL injection into the app connection can set too. Recorded as an open consideration with the role-based alternative and its cost, not a ruling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Design doc: item 9 states the measured RawUnsafe count, not an absence Ten parameterised $queryRawUnsafe/$executeRawUnsafe sites exist in lib/ and app/; the earlier sentence claimed there were none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Spike + register: the five code-review findings, each measured (§107 t-704) The injection walk now runs on every write (an update root carrying a nested create was refused by WITH CHECK before; 3d measures it) and descends into nested update/upsert. 1g proves the inverse of the 1a escape — a root-client op inside a tx callback passes through unwrapped and reads 0 rows — and that Prisma's undocumented __internalParams.transaction tells the two apart. 1d asserts the single setter it claimed. The spike's runAsOrg awaits inside the scope, which closes the lazy-PrismaPromise hazard at the seam (1f now proves both shapes); the register recommends that change for lib/tenancy/context.ts in 3.2. Cleanup drops the owner role and its table as separate entries. 41 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Design doc: register carries the review-round measurements Item 1 records the root-client-inside-callback inverse of the escape and the __internalParams.transaction signal; item 2 records the walk on every write including update roots and nested update/upsert; the closing paragraph recommends the seam-level await for lib/tenancy/context.ts in 3.2 instead of a caller rule. 41 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Spike + register: review round 2 — tx delegation, create-only stamping, a falsifiable enable check The $transaction override now calls the runtime's own $transaction with the OUTERMOST client as `this` (Prisma.getExtensionContext); closing over the inner layer cloned the tx client from that layer, so a later $extends hook (§115's guard) never fired inside a transaction — 6b now asserts the outer hook fires on the tx client. The injection walk stamps orgId on create-shaped nodes only and merely descends update payloads; stamping an update moved rows between orgs at single (3e measures both update and updateMany leaving org B's rows in B). The "ENABLE twice" check compares pg_class flags instead of reporting true. 42 checks; the register carries all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Spike + register: review round 3 — DISABLE needs NO FORCE too, to-one nested update, cleanup survives a failed disconnect The register said DISABLE clears both pg_class flags; it clears one, and the spike issued NO FORCE as well — now stated, since 3.3's disable script is written from that sentence. The walk descends the to-one shorthand `relation: { update: { ...fields } }` for nested creates. A rejected disconnect no longer skips the cleanup that follows. Two comments that still described the closure/Proxy shape corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ails the build (§107 t-705) (#819) * Every tenant-owned row knows its org: orgId on the 38 remaining models (§107 t-705) One nullable orgId + Org relation (Cascade) + index on each tenant-owned model that did not yet carry one, child rows included, and a back-relation per model on Org. The migration hand-folds the known unmodelled drops (three pgvector/tsvector indexes, the GENERATED searchVector default) and backfills every existing row to the install org. NOT NULL is a later staged migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * A model nobody classified fails the build (§107 t-705) lib/tenancy/classification.ts: SYSTEM_MODELS and GLOBAL_CONFIG_MODELS allowlists, classifyModels() over a runtime data model, and tenantOwnedModels() — the model→table roster derived from the generated client at runtime, no hand list. The test parses prisma/schema/*.prisma, requires every model to be exactly one of the three, shows the rule firing on a synthetic model, and pins the runtime derivation to the schema text (same 42 models, same table names). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * An org's export carries every tenant-owned table (§107 t-705) 36 export dispositions (signing secrets on AiWebhookSubscription, AiWorkflowTrigger and AiEventHook omitted) and 2 exclusions with reasons (AiMessageEmbedding: vectors only; AiWorkflowExecutionLeaseEvent: engine lease bookkeeping). The manifest test's stale five-column pin becomes a by-name pin plus the count, and its synthetic sixth model stops being AiAgent, which is now real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Docs + CHANGELOG for the tenant-owned orgId columns (§107 t-705) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * The column's consequences: fixtures, the agent field registry, the chunk projection, the ownerless guard (§107 t-705) Every AiAgent scalar must be a registered config field or an explicit exclusion — orgId is excluded (set by the data layer, never a form field), in the registry and its mirror test. The raw hybrid-search SELECTs and pickChunk carry the column so the projected chunk is a whole AiKnowledgeChunk. Thirty factory literals in tests gain orgId: null. lib/privacy/org-sources.ts is declared a by-design ownerless surface, like the subject manifest. And the manifest test now drives all 36 tenant-owned export sources through a Proxy-backed Prisma mock, asserting each scopes by orgId, never selects, and withholds exactly its named secret — the three that have one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Org-export guide: the manifest now covers every tenant-owned model (§107 t-705) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Org export: hook header values and lease tokens are not the org's to receive (§107 t-705, security review) The AiEventHook source returned action.headers in plaintext — in practice the receiver's Authorization — while the admin API's toSafeHook exists to mask exactly that. The source now maps rows through toSafeHook (names kept, values masked, secret dropped), pinned by a test with a live-looking bearer token that must not appear anywhere in the exported row. The AiWorkflowExecution source omits leaseToken, which the admin lease inspector already redacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Org export: a NULL orgId is the install org's at single (§107 t-705, review round 1) Nothing writes orgId until the chokepoint (3.2) lands, so every row born after the backfill migration carries NULL and a strict `where: { orgId }` silently omitted it — a fresh install would have exported none of its seeded agents. The 36 tenant-owned sources now read through ownedBy(): the install org matches NULL too at single, strict at multi (enable backfills first); credential attributions stay strict (a NULL-org key is a platform key). The tenancy smoke now asserts a NULL-org agent is in the install export and absent from another org's — shown to fail without the rule. Hard-coded manifest totals become lower bounds so a fork adding a column and a disposition need not edit an upstream test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * An org's cost rows outlive its erasure; the NULL rule names its owner (§107 t-705, review round 2) AiCostLog.org is SetNull, not Cascade: a cost row is a billing record and erasing an org detaches its spend rather than deleting it — the rule the erasure design already applies to a person. The migration's FK matches; the tenancy smoke now watches the cascade from both sides (an embed token goes with the org, the cost row survives with orgId null and the amount unchanged). The comment and guide stop asserting an enable script that does not exist yet and name the task that owns the multi backfill. The export's single-body size limit is documented and recorded on the Hub as follow-up rather than half-built here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Org-export guide: two consequences stated (§107 t-705, review round 3) A detached cost row reads as the install org's at single until NOT NULL; and the export's Promise.all over 42 sources shares the pool's ceiling with its response-size limit — both recorded as follow-up, not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… path fails loud instead of reading wide (§107 t-706) (#821)
* A query runs only inside the org the request entered, and a forgotten path fails loud instead of reading wide (§107 t-706)
lib/db/tenancy-extension.ts: withTenancy(), the Prisma $extends applied to
the client lib/db/client.ts exports. Every create of a tenant-owned row is
stamped with the context's org — the install org at single when nothing
entered one — by a walk over every write shape that stamps create-shaped
nodes only, never an explicit orgId, nothing under runAsSystem, and
nothing reached through the org relation itself. At multi every op on a
tenant-owned model, every raw op and every write under a context runs as
$transaction([set_config('app.current_org', org, true), op]); the
$transaction override gives an interactive or batch transaction one setter
at its top, delegating to the runtime's own $transaction with the outermost
client as `this`, and keys the per-op pass-through on Prisma's
__internalParams.transaction rather than an ALS flag. runAsSystem sets
app.bypass_rls (item 9 decided for the GUC). An op that needs an org and
has none throws before any SQL. TENANCY_MODE=multi no longer throws at
import.
The exported client is typed Omit<PrismaClient, '$on'> and asserted from
the $extends result: typing it as the extension's own result type made tsc
exhaust a 4 GB heap after 150 s across this tree (baseline 7 s).
lib/tenancy/context.ts: runAsOrg / runAsSystem await the callback inside
the scope, so a non-async callback returning a lazy PrismaPromise keeps its
org. lib/tenancy/classification.ts: tenantOwnedModels(client) takes the
client rather than importing it, so the module the chokepoint builds from
does not import the chokepoint.
Tests: the real generated client and the real extension on a recording
driver adapter — the stamped column on every create shape in both modes,
no set_config at single, the exact statements and transaction boundaries
at multi, the bypass GUC, the refusal with no context, the cross-org
refusal inside a transaction, a later $extends layer firing inside one,
and the undocumented __internalParams.transaction pinned for both forms.
A lazy-thenable test pins the seam. smoke:tenancy exercises the chokepoint
against the dev DB at single; the interim NULL fixtures it needs are now
written with an explicit orgId: null.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The setters join the raw-SQL allowlist, and every doc that said multi throws at import stops saying so (§107 t-706)
The CHANGELOG bullet joins the existing Changed section; the client
excerpts in .context/database/{schema,models}.md, the org-export NULL
paragraph, the playbook's seam item and the commercial proposition's
aside describe the chokepoint instead of the removed guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* A read that reaches a tenant-owned table through a relation is scoped like one on the table itself (§107 t-706, security-review follow-up)
At multi an include / select / _count, a relation filter or a relation
orderBy on a non-tenant root joins tenant-owned rows; under the policies
those rows are simply absent without the GUC, so
aiCapability.findMany({ include: { agents } }) and
knowledgeTag.findMany({ include: { _count } }) — both in the tree — would
have answered "none" for every org rather than failed. Such a read now
needs a context and gets the setter. Named in context.md: the credential
resolvers' own hash lookup reads a tenant-owned row to learn the org, so
at multi it is refused until t-707/t-709 gives it an audited entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The stamp takes the form the row already uses, a relation read is scoped at any depth, and a reload re-wraps the retained base client (§107 t-706, code review round 1)
Prisma's create input is checked XOR unchecked: a row naming a relation
that carries its own foreign key (creator: { connect }) cannot take a
scalar orgId beside it, so the stamp becomes org: { connect } there. Which
relations those are is not in the compact runtime data model; it is read
from the schema text the generated client embeds for its compiler
(readInlineSchema → foreignKeyRelations, pinned to the on-disk schema by
the classification test). reachesTenantRelation now handles the
_count: true shorthand and descends non-tenant relations in include /
select / where / orderBy, so a tenant table behind Session.user is scoped
too. lib/db/client.ts retains the base client across hot reloads and
applies withTenancy on every evaluation, because the extension closes over
the context module's AsyncLocalStorage and a reload re-creates it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* A relation key set to undefined does not make a row the checked form (§107 t-706, code review round 2)
Prisma strips an undefined key; the optional-connect pattern
`profile: id ? { connect } : undefined` is the unchecked form and takes
the scalar stamp.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* Nothing below the org relation is stamped, and the client-singleton doc snippets retain the base client (§107 t-706, code review round 3)
A create nested under org.update({ data: { aiAgents: { create } } })
belongs to that org, and so does everything nested under it; stamping a
grandchild with the context's org made it another org's child. The walk
now carries "no org" down that subtree. The .context/database snippets
showed the retained-extended-client shape round 1 removed from the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…s them on or off (§107 t-707) (#824)
* Isolation policies ship dormant with the schema, and one command turns them on or off (§107 t-707)
prisma/migrations/20260920120000_org_isolation_policies: one org_isolation
policy per tenant-owned table (42), USING and WITH CHECK both
"orgId" = NULLIF(current_setting('app.current_org', true), '') behind
the bypass arm, created WITHOUT enabling RLS — inert until switched on,
so a fork's migrate deploy carries them while a single-tenant install
pays nothing. Generated from the roster the client derives; the text is
lib/tenancy/isolation.ts's orgIsolationPolicySql, and
policy-coverage.test.ts fails naming any tenant-owned table without
exactly one policy or any other table carrying one.
scripts/db/tenancy-enable.ts (db:tenancy:enable|disable): in one
transaction as the owner DSN, set the bypass GUC, read both pg_class
flags, on enable backfill NULL orgId to the install org before anything
enforces, apply only the ALTERs each table is missing (DISABLE and NO
FORCE independently — DISABLE alone leaves FORCE set), read the flags
back and refuse to report success otherwise. Exit 0 / 1 / 2.
scripts/db/tenancy-role.ts (db:tenancy:role --create|--drop): the
required LOGIN NOBYPASSRLS app role with schema/table/sequence grants and
default privileges; idempotent create; drop revokes before DROP ROLE
because Neon refuses DROP OWNED BY. MIGRATE_DATABASE_URL (optional, falls
back to DATABASE_URL) names the owner for prisma.config.ts and the two
scripts. check-drift.ts gains the derived T-series (tenancyDriftProbes:
policyExists per table always, rlsEnabled forced at multi) and runs
under runAsSystem, since at multi the chokepoint refuses raw ops with no
context.
Measured on the dev DB: enable → 93 probes green at multi → enable (no
change) → disable → disable (no change) → enable → disable, flags 0|0
→ 42|42 → 0|0 as expected. On a throwaway container as the created role
with the shipped client at multi: org A sees only A, B only B, a system
scope both, a cross-org insert refused by WITH CHECK (42501), a
cross-org update finds no row, an unscoped plain connection sees 0 rows;
dropping one policy fails the drift check by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The two schema-reading tenancy guards are always-run (§107 t-707)
policy-coverage.test.ts and model-classification.test.ts read the schema
and the migration off disk; a new model with orgId reaches neither through
the module graph. The scoped run's advisory had been naming them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The app role's password travels as a SCRAM verifier, the ledger stays out of its grants, and the script refuses to demote a privileged role (§107 t-707, security-review follow-up)
CREATE/ALTER ROLE … PASSWORD '<cleartext>' is written verbatim by
log_statement = 'ddl', the hardened default on managed Postgres; the
script now sends the SCRAM-SHA-256 verifier it computes locally
(scramSha256Verifier in lib/tenancy/isolation.ts; a login with the
cleartext against a real server proves it). _prisma_migrations is revoked
from the app role so a compromised app cannot mark a migration applied.
TENANCY_APP_ROLE naming the connecting role, a superuser, a BYPASSRLS
role or a table owner is refused before any statement — the idempotent
--create would otherwise demote and re-password the operator's own login.
prisma/seed.ts prefers MIGRATE_DATABASE_URL, which the docs already
claimed; the check-drift header stops claiming it connects as the owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The coverage guard reads every migration, the seed lands rows an org can see, and the role re-run works as a CREATEROLE owner (§107 t-707, code review round 1)
policy-coverage.test.ts globs every prisma/migrations/*/migration.sql
(creates minus drops), so a fork's later migration counts — the fixed
single file made the documented "add it to a new migration" instruction
unsatisfiable. ALTER ROLE drops the SUPERUSER/BYPASSRLS words: mentioning
either needs a superuser whatever the value (measured on PG15), so the
idempotent re-run failed on Neon/RDS; the guard already proves the role
is neither. prisma/seed.ts runs through the chokepoint as the install org
and SeedContext.prisma is the extended client, so every seeded
tenant-owned row is stamped — and the three raw INSERTs on tenant-owned
tables (message embeddings, knowledge chunks from the seeder and the
document manager) read orgId off the parent row in the same statement,
the one create shape the chokepoint cannot stamp; a seed at multi with
RLS forced now leaves no NULL-org row on any table (191 chunks did
before). policyExists's docstring stops claiming migrate dev drops
policies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* db:seed loads its env before anything validates it, a blank MIGRATE_DATABASE_URL is unset everywhere, and the coverage guard survives a fork's own policies and dropped tables (§107 t-707, code review round 2)
prisma/load-env.ts is a side-effect module seed.ts imports first: its
dotenv calls used to sit above imports that ES hoists, so lib/env.ts
validated an empty environment and `npm run db:seed` threw from a plain
shell (reproduced with env -i; masked under migrate reset and in Docker).
ownerDsn() in lib/tenancy/isolation.ts is the one reader of
MIGRATE_DATABASE_URL for the two scripts and the seed, treating '' as
unset like prisma.config.ts does, so pg never sees an empty string and
connects to libpq defaults. The role script checks the migration ledger
exists before anything else — created earlier, the default privileges
would grant it later — and the doc names the two orderings (migrate
first; re-run --create after db:reset, which drops the grants).
policy-coverage.test.ts only shapes org_isolation blocks, so a fork's or
§115's policies are theirs; a DROP TABLE takes its policy with it; bare
identifiers count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The backfill leaves a platform admin key unbound, enable refuses a table with no policy, and a blank MIGRATE_DATABASE_URL is unset in lib/env.ts too (§107 t-707, code review round 3)
`UPDATE ai_api_key … WHERE orgId IS NULL` would have bound every
admin-scoped key to the install org, and withAdminAuth refuses an admin
key that carries an org — every platform key locked out by the switch,
and disable does not undo an UPDATE. BACKFILL_EXEMPTIONS carries the §106
migration's own predicate. enable now checks pg_policies before FORCE:
RLS on with no policy is default-deny, and a dropped policy is exactly
what no migration notices (measured live: refused by name; an admin key
and a chat key backfilled as k-adm|NULL, k-chat|install). lib/env.ts
preprocesses '' to undefined so the claim "blank means unset in every
reader" is true of the app and the enable switch, not only of
prisma.config.ts and ownerDsn(). The two dev scripts that raw-insert
chunks stamp orgId from the document like the lib sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* Which NULL orgs mean something: the doc says why the admin key is the only backfill exemption (§107 t-707)
Checked against every NULL-org reader: orgOfColumn (every other
credential's NULL is the interim state §106 backfilled unconditionally),
the org export's NULL rule, and AiCostLog's SetNull, whose detached rows
already read as the install org's at single.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* Detached cost rows stay detached, the dev knowledge script goes through the chokepoint, and enable refuses a contradictory flag (§107 t-707, code review round 4)
AiCostLog is skipped by the backfill: a SetNull-detached row and an
interim one are indistinguishable, and binding them would hand an erased
org's spend to the install org's export — they stay NULL, visible to
runAsSystem and to no org. scripts/test-knowledge-base.ts used a bare
client, so its document and chunks landed NULL despite the subselect;
it now imports the app client (through load-env first). tenancy-enable
refuses --enable with --disable rather than letting the second win.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ted inbound call still finds its workflow (§107 t-708) (#826) * Slugs are unique per org, and the token-authenticated routes enter the row's org (§107 t-708) AiAgent, AiKnowledgeBase and AiKnowledgeDocument move from `slug @unique` to `@@unique([orgId, slug])` (20260921120000_org_scoped_slugs), so two orgs can each hold an agent called `support`. AiWorkflow.slug stays global (the inbound URL segment; journal decision). The two partial uniques on the same tables move with them by hand: the ready-document dedupe becomes (orgId, fileHash) and "one default knowledge base" becomes one per org — both would otherwise fail an org's upload with a violation from a table it cannot see into. getOrCreateDefaultKnowledgeBase upserts the caller's org's default; only the install org's keeps the fixed id. Every `where: { slug }` on the three models is now findFirst (the org context scopes it at multi, the same row at single) or the compound key with `requireOrgId()` where the caller has the org — the seeds' upserts — and the importer writes by the id its read returned. The inbound route resolves its trigger under runAsSystem, puts the workflow's org through resolveCredentialOrg and runs the rest inside runAsOrg(source 'inbound-trigger'); the approval helpers and the status route do the same for the execution (runAsExecutionOrg, source 'approval-token'). A refusal is the route's existing 404. The smoke scripts run their main inside the install org. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * The guards for the per-org namespaces, and the tests that record the scope each route runs in (§107 t-708) tests/unit/lib/tenancy/org-scoped-slugs.test.ts (always-run): parses the schema and fails naming any tenant-owned model whose slug is still a global @unique or lacks @@unique([orgId, slug]); AiWorkflow is the exception by decision; the two per-org partial uniques are checked against the migration SQL. The A5/A7 drift probes assert their definitions name "orgId" (indexExists gains definitionContains, the constraintExists shape) — the name alone vouched for the old shape, measured: the un-migrated spike DB fails both by name. The inbound integration suite and the approve suite record getTenantContext() inside the prisma mocks: the lookup runs as { orgId: null, source: 'system' }, everything after it — the execution create, the trigger update, drainEngine / resumeApprovedExecution — as the row's org with source 'inbound-trigger' / 'approval-token', at single and at multi with no context entered; a workflow with no org at multi and a suspended org are 404s that write nothing. requireOrgId and the two new sources are pinned in the tenancy unit tests; the slug-read mocks move from findUnique to findFirst. Docs: tenancy/context.md (who enters it; requireOrgId), inbound-triggers.md (request flow + the org), orchestration-approvals.md, database/schema.md, models.md, knowledge.md, prisma-unmodelled-objects.md (A5/A7 per org), the design record's namespace rules; CHANGELOG Changed bullet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Every agent seed keys its upsert on the org the seed runs for, and the status route's refusal is pinned (§107 t-708) tests/unit/prisma/seeds/agent-seeds-org-key.test.ts runs all eight agent-seeding units against a permissive Prisma stand-in inside runAsOrg(INSTALL_ORG_ID) and asserts every aiAgent.upsert names that org through orgId_slug — a hard-coded org would compile and land the system agents in the wrong namespace at multi. The status route test gains the suspended-org 404 and the database-failure passthrough. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * The mcp-system agent id is cached per org (§107 t-708, review round 1) Slugs are unique per org now, so each org can hold its own mcp-system and the process-wide cache handed org B whichever org resolved first — its disabled capabilities invisible under B's scope, its cost rows misattributed. Keyed by the tenant context's org (system scope under its own key); clearMcpToolCache clears the map. Pinned by a three-call test across two orgs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…he other (§107 t-709) (#827)
* A credential's row is read under a lookup scope of its own, and single-tenant proves its policies are dormant (§107 t-709)
runAsCredentialLookup(credential, fn) on lib/tenancy/context.ts: the same
null-org system scope as runAsSystem, for the ONE read that learns which
org a credential belongs to, logged at debug — once per credential request
an info line would drown the signal runAsSystem's log exists to give. The
three resolvers that read a tenant-owned row before any org is known —
resolveApiKey, resolveEmbedToken, authenticateMcpRequest — run their lookup
and the lastUsedAt touch inside it (the touch has no org to run in either:
a platform key carries none); the resolver hands the org back and never
enters it. The t-708 routes move from runAsSystem to the same scope. Each
resolver's unit test records getTenantContext() inside the prisma mock at
multi with no context and pins { orgId: null, source: 'system' } on the
read and the touch, and null outside.
scripts/smoke/tenancy.ts asserts at single that pg_class shows RLS neither
enabled nor forced on every tenant-owned table (roster from the generated
client, non-empty first): the same migrations carry the policies, and a
single-tenant install must never have switched them on.
Docs: tenancy/context.md (the scope; who enters it; the closed gap),
isolation.md (known gap closed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* A CI job drives the platform as two orgs and proves neither can see the other (§107 t-709)
scripts/smoke/tenancy-isolation.ts: on a real Postgres with the policies
enabled and forced, as the restricted role, two orgs get equivalent rows
(an agent called `support` in each, the same file `ready` in each, their
own default knowledge base, a conversation with messages, chunks with
embeddings, a workflow with a published version and an HMAC trigger, an
execution, a cost log). As A, every read path answers A's rows and none of
B's — plain reads behind the admin lists, and the raw-SQL ones: vector
search (searchKnowledge, the embedder answered locally), cost summary and
breakdown, conversation semantic search, the message embedder's backfill
INSERT stamping each row's org from its parent message. A global root
reaching a tenant table (capability → agents, tag → _count) sees only A's
side; forEachOrg counts one org per iteration; a create with no context
throws; a nested create lands in the org; runAsSystem sees both. The
three credential resolvers and the inbound route, called as nobody, learn
B from B's row. Every absence assertion follows a non-empty population
check. Measured on the spike container: 48 checks green; a policy widened
to USING (true) fails 5; a dropped policy fails the first create.
The conversation search SQL moves out of the route into
lib/orchestration/chat/conversation-semantic-search.ts
(searchConversationEmbeddings) so the harness drives the statement itself,
not a copy; the route embeds, calls it, groups and audits as before.
ci.yml gains `smoke-multi` (in ci-status's needs): migrate + seed as the
owner, role --create, --enable, check-drift at multi as the app role (93
probes), the harness as the app role, the switch round-trip, then the
single-mode smoke on the same database. package.json:
smoke:tenancy-isolation. Docs: architecture/ci.md (the job, what a fork
supplies), tenancy/isolation.md (proving it), scripts/smoke/README.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* CHANGELOG: the two-org CI job and runAsCredentialLookup (§107 t-709)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* The API-key touch tolerates a bare double, and the search query's ownerless exception follows it to its module (§107 t-709)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* conversation-semantic-search: includeOwnerless is the policy's answer, said at the export (§107 t-709)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* Review round 1: the seam says what fences it, a nullable userId, the stale scope names, the warn asserted (§107 t-709)
conversation-semantic-search: the docblock no longer claims the
ownerless-surfaces guard fences a caller of the export (it fences this
module, which names the tables); ConversationSearchRow.userId is
string | null — the ownerless arm admits system-owned threads. entry.ts,
inbound-triggers.md and the search route's comment name the scope that
exists now. The api-keys "is logged" test asserts the warn. CHANGELOG
records that AiApiKey.lastUsedAt is now actually written: the previous
`void prisma.update(...)` never ran, a PrismaPromise being lazy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
* smoke-multi: DATABASE_URL at job level so postinstall's prisma generate loads the config (§107 t-709)
The first GitHub run failed at npm ci — prisma.config.ts refuses to load
without DATABASE_URL defined, which the sequence's per-step env set only
later. The steps that connect still override it with the role they need.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d it (§107 t-710) (#833) * The playbook tells an operator how to enable tenancy, not how to build it (§107 t-710) .context/architecture/multi-tenancy.md is rewritten from a 657-line retrofit recipe into the enablement guide: who it is for; what multi gives today and what it honestly does not yet (jobs enter no org until §108, system agents are the install org's, one console); enabling it end to end (the role split, db:tenancy:role/enable, TENANCY_MODE=multi, drift-check + the harness, the orderings); what a fork adds for its own models as the table of tests that name it, including the one core edit (the org-export manifest) that has no seam until §109; what the tests catch versus the two greps a merge still does by hand; the gotchas that survive; the control-plane map, kept here because its reader is the same fork, keyed on the classification allowlists; the original spike, kept runnable, with how the shipped policy differs. The hand inventory, the retrofit recipe and the pre-§107 seam description are gone — the classification test is the inventory now. Cross-references brought into line: the design record's companion note, "What a fork gets" (shipped vs still-to-ship by feature) and "Merge impact"; CLAUDE.md's row; substrate's task lookup; CUSTOMIZATION §9; VERSIONING's tenancy seam wording (requireOrgId, runAsCredentialLookup, the operator surface, the allowlists); overview.md's tenancy section; authorization.md's inventory link; identity.md and isolation.md pointers; the research doc's three anchors; the commercial proposition's two lines. lib/app/db-drift.ts's docblock no longer tells a fork to hand-register RLS probes the capability derives. /docs-audit (three agents; report in .reviews/): 0 of ~85 references inaccurate; seven coverage/pattern findings, all applied — forEachOrg skips suspended orgs and runs sequentially, the derived probes, @@Map in the fork snippet, the allowlist's scope, the enable refusal's exit code, the no-context throw stated precisely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Review round 1: a reset loses the flags too, an enable at single has no symptom on the owner, rlsEnabled is derived at multi only (§107 t-710) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * isolation.md: the same enable-at-single correction the playbook carries (§107 t-710) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Review round 2: a plain owner is under FORCE too, mode flips before disable, the post-reset symptom is permission-denied (§107 t-710) Round 1's wording said an accidental enable at single has no symptom on the owner DSN; that is true only of a superuser or BYPASSRLS owner. A plain NOBYPASSRLS owner — Docker Compose, RDS — is under FORCE and sees zero rows and a WITH CHECK failure on every write. Corrected in the playbook (two sites), isolation.md and the enable script's own header. Turning off now says mode first, policies last, with the leak window the other order opens; the reset bullet names permission-denied as what the operator sees first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE * Review round 3: Compose runs as a superuser, the turn-off order is stop → disable → restart, the research doc describes the playbook it points at (§107 t-710) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nyt7kcgZKZXyRjzGpoopyE --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Every platform job and the scheduler run per org; the fork job seam gains a scope (§108 t-711)
At multi the maintenance tick ran nothing: each job's first tenant-owned
query threw \`No tenant context\` (contained, so the tick logged and moved
on), and a tick fired from an admin's session ran every job inside that
admin's org. forEachOrg has existed since §106 with no caller.
- lib/orchestration/maintenance/job-scope.ts: JobScope ('per-org' |
{ system: reason }) and runScopedJob. Per-org runs once per ACTIVE org
through forEachOrg; with one org (every single install) the result and
any throw come back untouched, so the tick's summary line keeps its
shape; with more, numbers sum, arrays concatenate, per-org failures are
contained, logged with the org, folded as orgErrors and count as work.
- Every PlatformJob declares a scope. All eight existing jobs are per-org:
the reaper writes lease events, recovery and the scheduler start the
engine, the evaluation worker writes cases — under the system scope
nothing is stamped and each would land a NULL-org row.
- Retention splits: the tenant sweep stays \`retention\` (per org); the
admin/MCP audit-log prunes become \`auditLogRetention\`, appended,
system-scoped (\`enforceSystemRetentionPolicies\`). backgroundTasks
gains the name.
- run-tick: processDueSchedules runs per org; the idle-gate horizon
(earliest nextRunAt across all orgs) runs under runAsSystem with a
reason. The tick no longer inherits the caller's org.
- AppJob.scope?: optional, default 'per-org'; JobScope re-exported from
app-jobs.ts. lib/app/jobs.ts untouched (fork-owned scaffold).
- Tests: job-scope (fold + runner, real forEachOrg/runAsSystem), new
run-tick test, platform-jobs/app-jobs scope tests at single and multi,
the scheduler's fire-and-forget drain proved to keep its org (negative
run confirmed: undefined without the scope), retention split.
- Harness: scenario [9] drives the zombie reaper through the registry
from inside a foreign org; both orgs' stale executions are reaped and
every lease release event carries its org.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Docs and CHANGELOG for the per-org tick (§108 t-711)
- scheduling.md: the job table gains a Scope column and the auditLogRetention
row; the tick section explains per-org vs system and the fold; the response
example lists nine tasks; App jobs documents \`scope\` and its default.
- retention.md: two sweeps, which tables each owns, and where a new prune on
a system table goes.
- tenancy/context.md: "Background work enters it per job" replaces "not yet
entered"; the proving-it list names the four maintenance tests.
- Playbook: background work moves to "what you get"; the data subject is
per org by decision; the background-jobs grep is retired with the three
controls that replace it; the app-jobs gotcha is rewritten for the scope.
- Design record: the tick's forEachOrg leg exists; what §108 t-712 still
retires.
- CUSTOMIZATION.md §4: the scope field on registerAppJob.
- CHANGELOG: Added (AppJob.scope) and Changed (per-org tick, the appended
backgroundTasks name, the retention result split).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Harness: number the per-org-job scenario [10]
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The legacy schedules tick sweeps per org too (§108 t-711)
`POST /admin/orchestration/schedules/tick` called `processDueSchedules()`
bare, so at multi it swept only the org the calling admin's guard had
entered — a second door to the function the maintenance tick now runs per
org. Routed through the same `runScopedJob`, with a test that records the
context the sweep ran in.
Found by the security review of this branch, which correctly filed it as
out of its scope; it is in scope for this task, being the same seam.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review findings (§108 t-711)
Round 1 of /code-review, high. Six code/doc defects fixed, one accepted cost
documented, one declined.
- schedules/tick route: a sweep that failed for EVERY org now answers 500
rather than 200 with an error list. With one org the throw reaches the
guard and the route 500s, so without this, creating a second org would
silence a cron monitor exactly when the failure got worse. New
`everyOrgFailed()` on the runner, plus route tests for all-failed and
some-failed.
- job-scope: zero active orgs is now a warning and counts as work, instead
of arming the idle gate and stopping all maintenance on a query that told
us nothing ("I could not look" read as "I found nothing").
- job-scope: the fold keeps the first org's descriptive value by key
PRESENCE, not by `!== undefined` — a leading `null` no longer blocks a
later org's real value.
- app-jobs: `run: () => job.run()` keeps the receiver, so a fork's
method-shorthand `run()` reading `this` still works.
- retention: `auditLogRetentionDays` dropped from the tenant sweep's hoisted
read and from `RetentionWindows` — dead since the audit prune moved to the
system sweep. Docblock says why the system sweep resolving its own window
is not the #442 pattern.
- Docs: orchestration-audit-log.md still named `enforceRetentionPolicies()`
and `RetentionResult.auditLogsDeleted`, both now false; two references to
smoke scenario [9] corrected to [10] after the renumber.
- scheduling.md: tick duration is O(active orgs) while the overlap watchdog
stays a fixed 5 minutes — the accepted cost from planning, with the scale
it bites at and what the fix would be.
Declined: adding `scope` to `lib/app/jobs.ts`'s header. That file's own
contract is that Sunrise ships it empty and does not change it after
release; CUSTOMIZATION.md §4 and scheduling.md document the field.
Harness re-run at multi as the restricted role: 57 checks, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Test the three branches round 1 added (§108 t-711)
The review fixes arrived without tests of their own; job-scope's coverage
showed it (96.15% lines — the new branch).
- zero active orgs: warns, reports work, runs nothing
- the fold keeps a leading `null` for a descriptive key
- a method-shorthand `run()` reading `this` still works — negative-run
confirmed: reverting the one-line fix fails it with "Cannot read
properties of undefined (reading 'name')"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 2 findings (§108 t-711)
The one that matters: `scope !== 'per-org'` was FAIL-OPEN. Any value that
was not the exact literal — `'system'` (the word the docs use), a typo, a
value that survived a JSON round-trip, anything a fork registering from
plain JavaScript passes — was handed to runAsSystem and ran the job under
the RLS bypass with `undefined` as its audit reason, which is the opposite
of what the seam promises. Now a typed guard: a well-formed
`{ system: <non-empty string> }` takes the bypass, anything unrecognised
runs per-org and logs an error.
Also:
- run-tick arms the watchdog BEFORE the awaited sweep. The guard is taken
above it, so a watchdog armed after a sweep that never settles is never
armed at all — `tickRunning` stays true for the process's life and every
later tick reports "previous tick still running". §108 widened the window
by making that sweep N sequential per-org passes. Negative-run confirmed.
- One org-list read per tick, handed to every per-org job (`orgIds` on the
runner, `listActiveOrgIds()` on the context module, which `forEachOrg` now
uses so the ACTIVE rule is written once). A tick with eight due jobs was
paying eight identical queries — the per-tick count #442 exists to hold
down. A failed read falls back to each job reading it itself.
- platform-jobs header no longer claims the ninth task existed before #442.
- scheduling.md: where an app job's failure is reported now depends on the
scope; the old bullet promised `{ error }` for any rejection.
- retention.ts: the "one settings read for the whole sweep" comment now says
what it means at multi (per org, global windows, repeated warning).
- CHANGELOG: the two public-surface items the entry missed — RetentionWindows
losing a field, and the schedules tick's new 500.
Declined: making the maintenance tick 500 when every org's schedules sweep
fails. That route answers 202 with `schedules: { error }` today for a total
failure at single too — it has never signalled a sweep failure by status, so
changing it is a behaviour change to the preferred endpoint that nothing
here requires.
Tests: fail-closed scope (4 shapes), caller-supplied org list, one-read-per-
tick, the watchdog ordering. Harness re-run at multi: 57 checks, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 3 findings (§108 t-711)
Round 3 of 3. Five findings, all low; the two worth fixing are both the
"I could not look" shape on code this branch added.
- The org-list read failure was swallowed with no log line. If the database
is unhealthy the logs would show a dozen per-job failures and nothing
naming the cause they share.
- `everyOrgFailed` → `noOrgSucceeded`, and it now covers `orgs: 0`. The
runner deliberately refuses to let "no active org" pass silently, but the
one externally monitored surface — the schedules tick, whose 500 exists so
a monitor keeps alerting — reported 200 for it, indistinguishable from a
healthy idle sweep on a mis-seeded database.
- The watchdog is re-armed after the awaited sweep, so the sweep and the
background chain each get the full five minutes instead of sharing one
allowance. Arming it early (round 2) fixed a wedged guard; without the
re-arm, N per-org passes could spend the chain's budget and release the
guard mid-tick.
- Playbook gotcha: flipping `TENANCY_MODE=multi` before enabling the
policies now costs N× the work per tick rather than throwing once, since
confining a per-org job is RLS's doing. Documented with the order and the
check, rather than adding a runtime assertion — a guard needs a remedy,
and `db:drift-check` already is one.
- Stale prose I wrote: "six windows / eight round-trips" after the interface
dropped to five, and `runDueAppJobs(now?)` in the export table.
Stopping here: three rounds is the limit, and the findings have converged
from a fail-open bypass (round 2) to comment drift.
Harness at multi: 57 checks, exit 0. validate clean, 10,118 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… (#836)
* Two process-global caches stop mixing orgs (§108 t-712)
Found by the posture scan this task builds, before the manifest existed.
- lib/orchestration/hooks/registry.ts: the hook cache was one process-wide
Map keyed by eventType, a label every org shares, holding tenant-owned
AiEventHook rows. Whichever org emitted first filled it, and for the next
minute every org dispatched that org's hooks: org B's event POSTed its
payload to org A's URL, signed with org A's secret, and B's own hooks
never fired. Now keyed by org (`'system'` for the system scope, matching
mcpSystemAgentIdByOrg). invalidateHookCache clears every partition — an
admin edit runs in one org, but a hook that keeps firing after it was
disabled costs more than N re-reads.
- lib/orchestration/mcp/protocol-handler.ts: keyRateLimitCache is keyed by
MCP API key id, unique across orgs, so one map is right — but it was
FILLED inside whichever org's request triggered the refresh, and
McpApiKey is tenant-owned. Every other org's override was silently
dropped for five minutes. The read is genuinely global, so it takes the
audited system scope, as the tick's idle-gate horizon does.
At single both are behaviour-neutral: one partition (the install org), and
a system-scoped read that sees the one org's rows.
Tests: registry.tenancy.test.ts drives two orgs through the real context
and verifies each dispatch against the real signing scheme under its own
org's secret (comparing the two signatures would have passed for the wrong
reason — the payload timestamp differs per dispatch). Negative run: six of
seven fail with the fix reverted, the `single` one passes. protocol-handler
asserts the refresh runs as { orgId: null, source: 'system' }.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Declare a tenancy posture for every process-global cache (§108 t-712)
RLS confines what a query returns; it says nothing about a Map a module
built five minutes ago and is about to serve to a different org. The only
control was a grep an operator was asked to run at every sync — and a grep
is not run. That one could not have worked either: it looked for `new Map(`
and every cache in this tree is written `new Map<string, X>()`, so it
matched none of them.
- lib/tenancy/process-state.ts: 106 holders in 66 files, each with a
posture from a closed vocabulary (no-tenant-data, global-config,
row-keyed, org-keyed, shared-by-decision, async-local, mixes-orgs), its
key, and one sentence written to be read at the next sync. Chosen by one
question: if two orgs used this install, could one org's entry be served
to the other?
- tests/unit/lib/tenancy/process-state.test.ts: the scanner, in both
directions — an undeclared holder fails, and a row whose holder is gone
fails, because a stale row is a lie that reads like a decision. Five
self-tests prove the detector can report before any clean result is
trusted, including the lookahead case that would have matched a class
field four lines below a commented declaration.
Calibration is stated in the test: what it matches (every module-level
let/var, a const initialised with `new` or `globalThis`), what it excludes
by measurement (inline-literal lookup tables, stateless codecs), and the
three shapes it cannot see (factory calls, holders nested in object
literals, `??=` off a globalThis bag) — all three are in the manifest by
hand, so the completeness check is deliberately one-way.
mixes-orgs has one row: the admin log ring buffer, which at multi shows an
org admin every org's log lines. Not one file to fix, so it is t-714, and
the test refuses a mixes-orgs row whose why names no task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The posture is in the file too, where it carries a decision (§108 t-712)
A one-line `Tenancy posture:` in the module docblock of all 29 files whose
row is anything but no-tenant-data, so the reader who opens the file rather
than the manifest sees which decision governs its state and what would make
someone revisit it.
The no-tenant-data files deliberately do not get one: forty files each
saying "holds no tenant data, see the manifest" dilutes the twenty-nine
that say something, and every line here is a merge a downstream fork pays
for. The manifest is what is complete — the test proves that — and the
inline line is what carries the decision. Recorded in the manifest header
and on the task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Docs and CHANGELOG for the posture manifest (§108 t-712)
- multi-tenancy.md: the per-sync checklist section is now entirely tests.
Both greps are retired, and the state one is recorded as having been
broken all along — it matched `new Map(` and every cache in this tree is
written `new Map<string, X>()`, so it reported clean on all of them. The
what-the-tests-catch table gains process-state.test.ts; the "process-
global state is global" bullet becomes what is shared and why, naming the
§109 trigger and the one declared defect (t-714).
- tenancy/context.md: a new "Adding process-global state" section — the one
review step the context cannot cover, because runAsOrg scopes a query and
not a Map. One question, the five answers it maps to, and the note that
the test only checks an answer EXISTS; which answer is right is the
reviewer's. Two new entries under Proving it.
- multi-tenancy-design.md: the retired-greps sentences; what enabling the
capability gives a fork today; and Q4's row records the confirmed
hypothesis — nothing needed re-keying, because the credential is an env
var so the provider slug IS the credential identity until §109.
- orchestration/hooks.md: the cache is per org, the invalidation clears all
partitions, and an emit with no org dispatches nothing.
- .reviews/docs-multi-tenancy-audit.md (gitignored): §108 line ticked.
- CHANGELOG: Fixed, for the two cache defects. The task said no entry (an
internal guard), which was right about the manifest — the two fixes are
behaviour at multi and a fork running it needs them named.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Say that the error-dedupe set is a browser tab, not the server process
Spot-checking the manifest's own claims: `processedErrors` reads as
process-global state that suppresses one org's error because another org
hit the same message, until you notice initGlobalErrorHandler returns
early without a window. The why now says so, because the next reader will
do the same analysis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Tighten two claims in the CHANGELOG entry
The no-org emit clause read as if it applied at single too, and "neither
cache could be reached by a fork before this release" was a claim about
forks I cannot make — multi is enableable today, it is just incomplete.
Both replaced with what is provable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Correct three counts, and say what the 'system' sentinel rests on
The counts were taken from an ad-hoc grep written before the test's own
scanner existed, and all three were wrong: the scanner finds 105 holders,
not 106 (the manifest declares 108 — three shapes it cannot see are in by
hand); 32 literal lookup tables are excluded, not 20; and 37 files hold
only no-tenant-data, which was 40 rows across 37 files.
Security review's one non-blocking observation, taken: the 'system'
partition key is a bare string, safe only because an org id is a cuid and
createOrg has no caller-supplied-id path. Said so on the org-keyed posture,
so a fork that changes that assumption reads it before the collision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Review round 1: a third cache, a refused scope, and a scanner that missed two
Six findings, all real. Three change behaviour:
- context-builder's cache keyed on (type, id, userId), all three supplied by
the request. `pattern` keys by a pattern NUMBER over tenant-owned
AiKnowledgeChunk rows, and the default branch hands a fork's contributor
the caller's own type and id — so a user who belongs to two orgs could
open pattern:3 in one and, inside the 60s TTL, be served that org's
knowledge inside the other org's system prompt. The org now comes first
and is not optional. The row moves row-keyed -> org-keyed; the manifest
had asserted "the entity id is a cuid, so two orgs cannot collide", which
is true of the ids elsewhere and not of a number.
- The hook cache's 'system' partition was not a partition: under
runAsSystem the bypass returns EVERY org's hooks and would have cached
them under one key, fanning one event out to every org's webhook signed
with that org's secret — this module's own defect, back through the
sentinel. A null-org scope is now refused. Nothing emits under
runAsSystem today; this is what keeps that true.
- The MCP override refresh gains an in-flight latch and a catch. Without
the latch every request during a refresh started another, and each is now
an audited bypass with an info line — the exact signal runAsSystem's log
exists to give. Without the catch a DB failure was an unhandled
rejection.
Two are about the guard itself, and it caught my own new holder while I
fixed them (keyRateLimitRefresh had no row):
- The scanner ignored `const x: T[] = []` and `= {}`, so two live holders
were invisible: rate-limit-policy's appRules and drift-probes'
appDriftProbes, the second with no row at all. An EMPTY literal is now
matched; a populated one is still a constant table.
- indexOf('=') found the arrow in `Map<string, (x: T) => void>` rather than
the assignment, silently skipping the holder. It now scans from the
declaration's end, bracket-aware.
And hookCacheByOrg now drops expired partitions on refresh rather than
holding one entry per org that ever emitted, each with every hook's secret.
Negative runs: the org-prefix removed fails the cross-org test; the latch
removed fails the burst test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Review round 2: the manifest was not complete, and said so anyway
The finding that matters: lib/security/rate-limit.ts holds 24 limiters, each
closing over its own LRU of caller tokens, and the row named two of them.
The scanner could not demand them — a factory call has no syntax that says
"this holds state" — and the header nonetheless claimed the blind spots were
covered by hand. A hand-derived list that claims authority is the shape that
keeps costing us.
- The scanner gains a named STATEFUL_FACTORIES allowlist
(createRateLimiter, createDynamicLimiter, createAppInitGate, betterAuth),
which pulled 37 undeclared holders into the light: 24 limiters, 11 fork
init gates and the better-auth instance. All now declared. The allowlist
is itself hand-kept, and the header now says that plainly rather than
claiming completeness it cannot have.
- rate-limit.ts splits in two: the section and per-flow limiters are
shared-by-decision (keyed by caller), the two dynamic ones are row-keyed
(agent id + user id, API key hash) because their cap comes from a
tenant-owned row.
- The rate-limit-stores row said it held "the counters behind every
limiter". It does not: getStore() is reached only by the two async
limiters, and every sync limiter the proxy dispatches through holds its
own. Corrected in both rows and in the rate-limit.ts docblock.
Two latent behaviours closed:
- context-builder mapped "no context" and "system scope" to the same '-'
partition. Under runAsSystem the bypass would merge every org's chunks
into one body and cache it there — the sibling fix in this PR refuses
exactly that. A system-scoped entry is now computed but never cached.
- The MCP refresh retried on every request while failing, because the
freshness stamp is only written on success and the latch dedupes only
concurrent calls. A pool exhausted under load would emit one audited
bypass line per request. Thirty-second backoff, and the warn says so.
And every hard-coded count is out of the comments — three of the four had
already rotted by one revision. The test is the measurement.
Negative runs: the backoff removed gives 4 refreshes for 4 requests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Review round 3: four more holders, and a claim narrowed to what is true
The round found four holders the widened scanner still could not see, and
the pattern in that is the finding rather than the four:
- The factory match required `(` straight after the name, so
`createAppInitGate<Policy | null>({…})` in authorization.ts was invisible
while the plain form beside it was caught. Generics allowed now.
- createJobClock and defineRequestState join the allowlist, which pulled in
the two job clocks (platform-jobs had no row at all) and
pending-signup.ts, whose payload carries an org id between three
better-auth hooks.
- db/client's row named three holders and its why described five.
So the playbook no longer says new process-global state "is now a build
failure rather than a search somebody is asked to run". It is a build
failure for the shapes the scanner knows and a review step for the rest,
and the section now says which, carries the question to ask, and cites the
three rounds as the evidence that the blind spot is real. Claiming a
completeness the guard does not have is worse than the grep it replaced,
because a forker would drop the check entirely.
Two more:
- buildContext now REFUSES the system scope rather than only declining to
cache it. Not caching a body merged from every org's knowledge chunks
does not stop it being built and framed into a prompt; the hook cache
refuses the identical shape. It also takes requireTenantContext, so at
single an entered and an unentered call share one partition instead of
splitting the 500-entry cap between two names for the same org.
- TENANT_TOUCHING_POSTURES had no consumer — a new export implying a check
that did not exist. It is now what the test asks keyedBy of, which widens
that assertion from two postures to four.
And the count I had left in the test's own comment, in the file that
forbids counts, is gone; it had already rotted by one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The header's first paragraph agrees with its fourth
Looking again after the last round, as the process asks: the opening still
said the scanner 'proves it complete', which is the exact claim round 3
narrowed everywhere else. It is the sentence most likely to be read and the
least likely to be read twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The context-cache bullet said both things round 3 did
Round 2 made a system-scoped entry uncached; round 3 refused the call
outright. The bullet kept the first sentence and gained the second, so it
described a mitigation that no longer exists alongside the one that does.
Caught by check:changelog-drift, which is the failure mode it exists for —
a claim accurate when written and falsified by a later commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* A fourth cache, found by auditing the claim rather than the code (§108 t-712)
Simon asked what "not reachable today" actually meant, which turned into a
sweep of every system-scope entry point. Nine: six credential lookups, each
a single row read touching no module-level holder (§107 t-709's posture
holding), the tick's horizon read, my own MCP override read, and ONE
generic door — runScopedJob with a system scope, one job through it today.
That sweep found the classifier this whole task had been missing, and a
fourth instance with it. **The test is what a cache's FILL QUERY filters
on, not what the cache is keyed by.** An id unique across orgs answers the
same under runAsSystem as under an org scope. Anything two orgs share
answers from an arbitrary org once app.bypass_rls is on.
`getMcpSystemAgentId` resolves the agent by SLUG — which §107 t-708 made
unique per org and therefore shared across them — and cached the answer
under a 'system' sentinel. Under the bypass that findFirst returns whichever
org's mcp-system agent the planner reaches first, and every later
system-scoped call would have got it: disabled capabilities invisible, cost
rows misattributed. Exactly what the per-org keying was added to prevent,
arriving through the door that keying left open.
It is pre-existing (t-708), not something this branch broke. It is in scope
anyway because THIS branch's manifest declared it safe, and shipping a
roster that vouches for a broken holder is worse than shipping no row.
- tool-registry refuses the system scope, as hooks and context-builder do.
- The manifest row says what it filters on; the org-keyed posture now
carries the classifier instead of the "safe where it fetches ONE row"
rule, which was true and useless — this fetched one row, the wrong one.
- context.md's review step gains "ask it of the fill query, not just the
key", because a cache can be keyed right and filled wrong.
The other tenant-touching caches were checked against the rule and are
safe: resolveAgentDocumentAccess and the dispatcher's bindings both filter
on a cuid, and keyRateLimitCache reads system-wide on purpose.
Negative run: restore the sentinel and the refusal test fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Each org can set its own retention windows (§108 t-713)
The six windows lived on the global AiOrchestrationSettings row, so at multi
every org's history was pruned on the platform's schedule: an org that must
keep executions for a year and one that wants them gone in a week could not
both be served. Org.settings has existed since §106 and nothing read or wrote
it.
- lib/validations/tenancy.ts: the slice's schema — FIVE keys, not six. The
sixth, auditLogRetentionDays, prunes AiAdminAuditLog, a system model with no
orgId that t-711 moved to the system-scoped sweep; rows nobody owns cannot be
kept per owner. Bounds mirror the global schema. Strict on both objects, so
an unknown key is a 400 rather than a value written and silently ignored.
- lib/tenancy/org-settings.ts: the read, the write-merge, and the rule that a
key which cannot be read is ABSENT rather than fatal. Validate-on-read is
resolvePersistedScope's contract, but per KEY, because the blast radius here
is deletion: dropping the whole slice on one bad key would silently inherit
the platform's 90 days for an org that asked for a year, and a release that
changed the slice's shape would do it to every org at once.
- lib/orchestration/retention.ts: loadEffectiveRetentionWindows() — the global
row overlaid by the org whose scope this run of the sweep is in (t-711 made
it per org, so there is no org to pass and no way to ask for another's).
A failed org read SKIPS the prunes rather than falling back to the global
windows: falling back would prune on a window that org explicitly rejected,
and deletion is the direction that cannot be undone. The coherence warning
now runs on the effective pair and names the org.
- The admin route writes the slice and refuses an incoherent EFFECTIVE pair,
the check the settings route already makes one level down. The member route
returns the validated slice, not the raw column: a fork keeps its own org
config in that JSON and the platform cannot vouch for every key in it being
safe for every MEMBER to read.
- updateOrg takes a settings patch in a SERIALIZABLE transaction — a
read-modify-write on one JSON column, the same shape as the owner-count
writes beside it, and a lost fork slice would be silent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Tests for the per-org retention windows (§108 t-713)
- org-settings.test.ts: the read is per key (the malformed window is dropped
and the four beside it survive), the write preserves a fork's keys and
replaces the slice outright, and clearing the last slice nulls the column.
- retention.test.ts: two orgs through the REAL tenant context — one prunes on
the window it set, the next on the global one; a null keeps a class forever
for the org that asked while another still prunes it; an unreadable window
inherits while its siblings hold; a failed org read skips every prune rather
than pruning on windows the org rejected; the coherence warning names the
org whose EFFECTIVE pair is wrong, and fires once for two orgs. Plus the
drift guard: every window the sweep reads has an org-settable key.
- lifecycle.test.ts: the merge runs in one SERIALIZABLE transaction, and a
patch that does not touch settings still takes no transaction at all.
- The two route tests: the 400s (unknown key, unknown slice, string, zero,
out of bounds, non-object), the inherited-half incoherence refusal, the
vendor GET returning the whole column, and the member GET returning the
validated slice only — asserted by a fork token in the column that must not
appear anywhere in the response.
Negative runs, all five reverted in turn: whole-slice strict read fails 7,
ignoring the overlay fails 6, replacing the column instead of merging fails 3,
dropping the route's coherence check fails 1, publishing the raw column to a
member fails 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Docs and CHANGELOG for the per-org retention windows (§108 t-713)
- retention.md: a "Per-org windows" section — the precedence table (absent
inherits, a number overrides, null keeps forever), why five keys and not
six, that precedence is PER KEY so overriding the webhook window does not
shorten the DLQ window inherited beside it, and the two degradations: an
unreadable window is absent, a failed read skips the org's prunes. The
coherence rule is now enforced in four places, not three, and the section
says which two states still get past all of them.
- org-endpoints.md: the PATCH body and its 400s; the member GET's `settings`
shape and why it is the slice rather than the column; the admin GET's whole
column, which is the difference between the two views.
- identity.md: `Org.settings` is one column with exactly one platform-owned
key, and the two rules that follow from it being shared — a fork owns the
route that writes its own slice, and nothing publishes the raw column to a
member.
- CHANGELOG: Added for the API field, Changed for both GET shapes and
`OrgRecord`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Refuse a settings patch that names no slice (§108 t-713)
Reading the schema back as a caller would: `{ "settings": {} }` passed the
"at least one field" refine and wrote nothing, which is an update that moves
`updatedAt` and says it changed something. It is now a 400 like the empty body
it is.
Also moved the vocabulary docblock down onto the schema it describes — it had
landed above the helper — and gave ORG_RETENTION_KEYS the pointer to its
opposite number in retention.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Move the retention windows into a leaf module (§108 t-713)
The version-disclosure guard caught this, which is what it is for: importing
lib/orchestration/retention.ts into the org admin route to read two numbers
pulled McpServerConfig — and through it SUNRISE_VERSION — into an org route's
import graph, putting it inside the closure the guard enumerates.
Widening that allowlist would have been the cheap answer and the wrong one:
the route needs the windows, not the sweep. loadRetentionWindows,
loadEffectiveRetentionWindows, RETENTION_WINDOW_KEYS and the RetentionWindows
type now live in lib/orchestration/retention-windows.ts, which retention.ts
re-exports so nothing else has to know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* The new prune recipe names the org slice, and the file the windows moved to
Step 5c of the gate, doing its job: "add the column to RetentionWindows and
loadRetentionWindows()" no longer said where those live, and the recipe had no
step for the org-settable key — so the next person to add a prune would meet
the drift test as a failure with no instruction attached. Both fixed, with the
rule for when a window does NOT get an org slice.
Also a test file for lib/orchestration/retention-windows.ts: check:missing-tests
reported it referenced-only, and the one test naming it MOCKS it. The cases
here are the ones invisible from the sweep's side — that the system scope and
no-context both resolve to the global windows without reading an org at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Two sentences that travelled with the moved code, and an implicit any
Looking again after the move, per flow.validate. "Read all six retention
windows" reads five — t-711 took the audit window out and the sentence stayed,
which is exactly the claim this PR argues elsewhere. The {@link} to
enforceSystemRetentionPolicies no longer resolves from here. And `let slice;`
was an evolving any in a repo whose rule is that there are none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Say what per-org windows change about running multi without the policies
The security review's one below-bar note, verified and kept. No prune carries
an orgId — confinement at multi is the policies' job (§107's chokepoint rule).
The playbook already calls the unenabled state out, because in it every org
sees every org's rows. What is new is the tick's behaviour there: N runs of one
global window deleted the same set N times, where per-org windows apply the
shortest window anyone set to everybody's rows.
Documented rather than guarded. The state is already total isolation failure,
the playbook requires the enable step for isolation of any kind, and only a
platform admin can set a window today — so a runtime policy probe per sweep
would be buying little at the price of a new failure mode. Worth revisiting if
§111 hands the window to an org admin, which is the point at which the prunes'
reliance on RLS becomes load-bearing for a cross-tenant delete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 1 — five findings, all real (§108 t-713)
1. HIGH, and the one I most needed caught: "null keeps that class forever" was
true of four keys and false of the fifth. pruneWebhookDeliveries reads a
null DLQ window as "use webhookRetentionDays" — the fallback that preserved
pre-DLQ behaviour — so an org nulling it to keep dead-lettered rows had them
pruned on its webhook window instead. My own doc contradicted itself two
lines apart. The claim is now "null carries whatever null means for that
column globally", with the exception named in the schema, retention.md,
org-endpoints.md and the CHANGELOG, and a test that prunes an org's DLQ at
its webhook window so the prose cannot drift back.
2. MEDIUM, and reachable today with nothing misconfigured: at single, one org's
window would delete every org's rows. No prune carries an orgId — at multi
the policies confine each one, at single nothing does — while forEachOrg
iterates every ACTIVE org in both modes and the org API creates orgs in
both. With one global window the N runs were identical and there was no
exposure; per-org windows create it. A slice is now stored and returned in
both modes and APPLIED at multi only, with a log line, because an override
that silently does nothing is the shape an operator debugs for an hour.
3. updateOrg branched on settings.retention where it should branch on settings:
a patch naming a second slice wrote nothing and reported `settings` among
its changes. Unreachable until the second slice exists — which identity.md
explicitly invites a fork to add.
4. The effective-pair coherence check ran for any slice, so on an install whose
global row predates the global check, setting an org's EVALUATION window
returned a 400 quoting two numbers the admin never touched, with no way
through. It now checks only a body that names one of the two.
5. `{ "retention": {} }` was stored as an empty object, giving "this org has
set nothing" the second representation the same module argues against.
Negative runs on the three behavioural fixes: removing the mode gate fails 1,
storing the empty slice fails 1, widening the coherence check fails 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Restore the applyRetentionPatch fix a negative run reverted
My own mistake, caught by the tests it belonged to. The negative runs for round
1 undid each sabotage with `git checkout <file>` — which restores the last
COMMITTED state, so on the one file that also carried uncommitted fixes
(findings 3 and 5) it threw those away with the sabotage. The commit then went
in with the tests and docs for both, and neither implementation.
The type-check and four tests failed immediately, which is the system working:
the tests were written to fail on exactly this behaviour and did.
Restore with a copy, or commit before sabotaging, when the file under a
negative run has uncommitted work in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Restore the other two fixes the same negative runs reverted
Correcting the previous commit, which said "the one file that also carried
uncommitted fixes". It was all three: every negative run in round 1 ended in
`git checkout <file>`, and each of those files was carrying its own
uncommitted fix. The mode gate on an org slice and the narrowed coherence
check went the same way as applyRetentionPatch.
All three are back, and all three had tests already committed that failed
while they were missing — 953 tests now pass, 100% lines on every changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 2 — four of five (§108 t-713)
1. The org read now happens AFTER the mode gate, not before it. At single the
answer is the global row whatever the org stored, so reading first meant an
hourly query per org whose only possible effect was to fail and skip prunes
that were never in question. The "not applied yet" notice moves to the PATCH
that stores the slice — once, where the operator is, instead of hourly from
a job nobody watches.
3. The write guard reads through a THROWING variant now. loadRetentionWindows
swallows by contract, which is right for the sweep — not knowing means not
deleting — and exactly wrong for a guard, where it turns "I could not look"
into "there is nothing to check" and lets through the pair the guard exists
to refuse (.context/architecture/checks.md, the six channels). Failing the
request costs nothing real: the write was about to hit the same database.
4. "The same bounds the global schema applies to its own column" was the one
sentence in this file a reader could disprove. webhookDlqRetentionDays has
no global field at all — the column exists, the prune reads it, nothing
writes it — so that bound mirrors nothing, and this slice is currently the
only way to set that window. Named in the schema and in the prune recipe.
5. "Both objects are strict" overstated the top level: updateOrgSchema strips
what it does not know, like every other body here, so a misspelled `settngs`
renames the org and drops the retention write silently. The doc says which
two objects are strict and what the top level does instead. Not made strict
— that would change an existing API's behaviour for every caller.
2. DECLINED, with the reason in the helper's docblock: the coherence check runs
before updateOrg, so a nonexistent org gets its 400 rather than a 404. That
is the ordering the whole API already has — validateRequestBody refuses a
malformed body for a nonexistent org too — and this is body validation that
happens to need one row. The alternative is an existence query this route
does not otherwise make, or teaching the lifecycle module about
orchestration settings, a coupling it is written to avoid.
959 tests, 100% lines and functions on every changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 3 — four of five (§108 t-713)
1. MEDIUM, and the one two earlier rounds walked past: `null` is not
symmetrical in the cost-log/execution rule, and both the guard and the
runtime warning read it as "unset, therefore uncoupled". Cost logs kept for
ever satisfy the rule against anything. EXECUTIONS kept for ever violate it
against every finite cost-log window — they outlive all of them — so
`{ executionRetentionDays: null }`, which the docs advertise as "keep
executions for ever", was accepted against a 365-day cost-log window and
then not warned about either. Fixed in both places.
A pre-existing test asserted the silence, and its own name carried the
misreading: "stays quiet when execution retention is unset (executions are
never pruned)". Never pruned is the LONGEST retention, not the absence of
one. The test now asserts the warning and says why it changed.
The global schema's refine (lib/validations/orchestration.ts) has the same
hole. Not fixed here — different surface, and it is a defect this work did
not cause; filed against it instead.
2. The error message now names the remedy. Cost-log retention caps at 365 days
and execution retention at 3650, so for any execution window past a year —
and for keeping executions for ever — `null` is the only coherent answer,
and no number the admin could type would satisfy the guard.
3. retention.md still said "the sweep logs that it is ignoring it", which round
2 made false by hoisting the mode gate above the read. The sweep is silent
and does not read at all; the PATCH is what says so.
4. warnOnIncoherentRetention's docblock named loadRetentionWindows, which the
sweep stopped calling.
5. DECLINED, prose sharpened instead: `isMultiTenant()` is an env var, not
evidence db:tenancy:enable ran. True, and the gate never claimed otherwise —
it closes the `single` case, which needed no misconfiguration at all. The
unenabled-policies case at `multi` is the operational one the playbook
already requires closing for isolation of any kind, and it is named in the
docs with the trigger to revisit (§111 handing the window to an org admin).
A runtime probe per sweep is a design decision for the owner, not a
bolt-on in the round-3 commit of this task.
962 tests, 100% lines and functions on every changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Look again after the last round (§108 t-713)
flow.validate's "after the last round, look again", which found three things
three review rounds did not:
- retention.md said the rule is "enforced in code, in four places" while round
3 had just made one of those four enforce MORE than the others. It is now a
table of what each place actually catches, with the three inherited `no`s
named and the third escape state ("a global executionRetentionDays: null")
added to the two already listed.
- `retention-windows.ts` claims "retention.ts re-exports everything here", and
round 2's readRetentionWindows was not in that list. Now it is — the cheaper
fix, and it keeps a fork on one import path.
- CHANGELOG: an org's data export carries `Org.settings`, because it exports
the org row whole. A fork keeping its own config in that column now sees it
in an org export, which is worth knowing before the release rather than
after.
962 tests, 100% lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Scope the admin log buffer to the org reading it (§108 t-714)
At multi an org admin's Logs page showed every other org's lines — message,
context and meta, searchable — because the buffer is one process-wide ring and
getLogEntries filtered only on level and free text. It is the row t-712's scan
declared `mixes-orgs`.
The buffer stays one ring and the QUERY is what is scoped: addLogEntry stamps
each entry with the org its call stack ran in, getLogEntries filters to the
reader's. Stamping in lib/admin/logs.ts rather than in the logger means one
rule for every producer and no tenancy in the logging hot path.
**The unstamped case is the whole design question, and the task's recommended
answer was wrong.** An entry has no org when it was produced outside a tenant
scope: at boot, inside a runAsSystem job, or on a request authenticated by a
PLATFORM CREDENTIAL — an admin API key with no org, which inTenantScope runs
unscoped in BOTH modes and which is how a cron calls the maintenance tick.
Hiding those (option (a)) would have emptied the Logs page of exactly what an
operator opens it for on every single-tenant install — every install today —
while protecting nothing, because there is one org. So: visible at single,
and at multi visible only to a reader who is also outside an org.
`total` is computed after the scope filter, so the admin table's pager does not
offer a reader pages of rows that do not exist for them.
Owner's ruling, recorded at cmuduwclt000204l5n53iblne: a platform operator has
no cross-org view here until §111, which should treat "read the whole buffer"
as part of its scope rather than a follow-up.
The manifest row moves mixes-orgs → org-keyed, leaving that posture with no
rows — kept, with a note, because the vocabulary is what lets the next defect
be declared rather than described in a commit message nobody reads again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Docs and CHANGELOG for the org-scoped log buffer (§108 t-714)
- admin/logs.md: a "Whose lines a reader sees" section — the stamp, the
visibility table across both modes, and what `null` means on each side of it.
Says plainly that a platform operator has no cross-org view here until §111,
and that the owner ruled it waits.
- architecture/multi-tenancy.md: the "one declared defect remains" line is
gone. The buffer is now described as the one holder scoped at the query
rather than partitioned, and no row in the manifest is a declared defect.
- CHANGELOG, Changed: the API behaviour, the new LogEntry field, and the
single-install promise that nothing on that page changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Make the single-mode promise exact, not nearly true (§108 t-714)
The docs-accuracy step of /pre-pr, reading my own claim back against the code.
"At single nothing changed" was NEARLY true — the install org reading its own
lines plus the unstamped ones — and wrong in the one case that matters:
forEachOrg iterates every ACTIVE org in BOTH modes and the org API creates orgs
in both, so a single-mode install holding a second org stamps that org's job
lines with it, and the narrower rule would have made them vanish from a page
that has always shown them.
isVisibleTo now returns true at single, full stop. It is simpler, it makes the
promise exact, and it is the same gate as t-713's per-org retention windows:
the per-org behaviour applies where there is something to confine. The reader
resolution loses its install-org fallback with it.
Test added for the second-org-at-single case, which is the one that would have
regressed silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Reach tenancy by registration, not import — the import broke the build (§108 t-714)
Found by /security-review, measured with npm run build, and confirmed by
running it myself: importing @/lib/tenancy/context into lib/admin/logs.ts puts
`pg` in the BROWSER bundle. The trace is exact —
pg → lib/db/client.ts → lib/tenancy/context.ts → lib/admin/logs.ts
→ lib/logging/index.ts → lib/analytics/analytics-provider.tsx ('use client')
— and the build fails with seven unresolved Node builtins (dns, fs, net, tls,
util/types). lib/logging/index.ts has zero static imports and reaches the
buffer with a literal require, precisely so fifteen-plus 'use client' modules
can import the logger; the comment there even states the constraint I broke
("safe because lib/admin/logs.ts doesn't import from logging"). It was a
constraint about imports in BOTH directions and only half of it was written
down.
The dependency is inverted. lib/admin/logs.ts is back to no runtime imports and
exposes registerLogTenancy(); lib/tenancy/context.ts — server-only, and already
importing the logger — registers a resolver at its module scope. Module-local,
not on globalThis: an AsyncLocalStorage belongs to the instance that created
it, and sharing one across bundles would read another realm's store.
Unregistered means single-tenant behaviour, which is correct rather than a
guess — a realm without the tenancy module is a realm where nothing entered an
org.
**Nothing in the local gate sequence sees this class of defect**: validate is
type-check/lint/format, test:changed is scoped vitest. So the guard is a test
on the SOURCE — lib/admin/logs.ts has no runtime imports, lib/logging/index.ts
has no static ones — with a self-test proving the detector can report before a
clean result is trusted. A negative run confirms it fails on a runtime import.
npm run build now exits 0 with no pg in any client trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply the rest of the security review (§108 t-714)
The review's two low-severity observations, both worth keeping:
- The manifest row now names what stays SHARED because the ring is one ring:
the 1000-entry cap, so a noisy org evicts a quiet one's lines, and the entry
id counter, so a gap in the ids an org sees tells it roughly how much
everyone else logged. `row-keyed`'s rule — name the shared size cap — applies
to an org-keyed holder that is a filter over shared storage, and the row did
not.
- admin/logs.md gains "What an org admin will not see at multi": a request's
own 500 is logged by handleAPIError from the guard's OUTER catch, after
inTenantScope has exited, so it carries no org; so are a refused org entry
and the proxy's access lines. Verified by reading guards.ts — inTenantScope
at 901/1242, the catch at 949/1284. Not new behaviour in the logger; it is
what scoping the read makes visible, and it will be reported as a bug unless
it is written down.
Two things the guards caught, both mine:
- The t-712 scanner refused the new module-level `tenancy` holder until it was
declared. Declared as no-tenant-data, with why it is module-local rather than
on globalThis.
- The admin logs integration test mocks @/lib/admin/logs, so it now has to
supply registerLogTenancy — lib/tenancy/context.ts calls it at module scope
and the guard under test imports that module. Commented, because the next
person to see that line will wonder why a test needs an export it never
calls.
Touching lib/tenancy/context.ts makes the scoped run select 531 files: 553
files, 10265 tests, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 1 — four findings, all real (§108 t-714)
1. MEDIUM, and the justification was mine and false. The scope gate read an
unregistered bridge as single-tenant — show everything — on the grounds
that "a realm without the tenancy module is a realm where nothing entered
an org". Untrue: under NODE_ENV !== 'production' the ring is shared across
module instances through globalThis, so an unregistered realm can hold
fully stamped entries another realm wrote. `next dev` at multi, a reader
that reaches the buffer without pulling in tenancy, and it answers with
every org's lines. A tenant-isolation control that fails OPEN.
The mode now comes from `process.env.TENANCY_MODE` — not from `@/lib/env`,
because this module may have no runtime imports, and not from the
registration, because whether the rule applies must not depend on what
happened to be loaded. A missing resolver now leaves the reader org null,
so it sees only unstamped entries: closed. One variable with two readers is
a drift risk, so a test pins that they agree.
2. The "honour an explicit orgId" branch is gone. It had no production caller —
the logger's payload type does not carry the field — and what it provided
was a mislabel primitive: code in org A writing a line onto org B's Logs
page. My own test demonstrated exactly that and called it a feature; it now
asserts the opposite.
3. MEDIUM: at multi an org admin could not see their own 500s. handleAPIError
logs from the guards' outer catch, after inTenantScope has exited, so every
error line was stamped with no org — leaving the org whose request failed
as the one org unable to see it, on the page that exists for that. Both
guards now re-enter the entered org around that call, identically (the
seam's own docblock warns this is the third rule one guard has applied and
the other has not). `entry` is hoisted above the try to make it reachable
from the catch; when the failure came before an org was chosen it is null
and nothing changes. Two tests: the org's own 500 is logged inside it, and
a pre-org failure still logs outside any.
**This is outside t-714's declared file scope** (lib/auth/guards.ts). Taken
anyway because the narrowing this task ships is what makes those lines
invisible — scope is the issue plus what I broke.
4. The bundle guard only matched `import`, while the coupling it exists to
prevent is written as a `require`. Widened to require( and import(, with
the self-test now a table covering all four forms.
553 files, 10271 tests. `npm run build` exits 0 with no pg in any client trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 2 — eight findings, all real (§108 t-714)
The two that mattered:
1. MEDIUM: round 1's fix moved the fail-open rather than removing it. Reading
the mode from `process.env.TENANCY_MODE` gave one variable two readers — the
buffer raw, everything else validated — and any divergence answers "single"
and shows every org's lines to every org admin. Concretely today: a test
mocking @/lib/env without vi.stubEnv silently puts the buffer in single
mode, which is the pattern in guards-tenancy.test.ts.
There is one reader again — the same isMultiTenant() everything else uses,
reached through the registration — and **no resolver now means `multi`**,
the confining answer. That is strictly closed in both directions: round 1's
case (unregistered realm holding another realm's stamped entries) and this
one. The cost is a dev-only realm hiding stamped lines it holds; not a leak.
The test asserts the behaviour rather than the claim.
2. MEDIUM: the same defect I fixed in the guards was still live in the MCP
transport. `withMcpKey` enters the org and all three verbs catch OUTSIDE it,
so an org's MCP 500 was logged with no org and invisible on that org's own
page. The handler's failure is now caught inside `runAsOrg`, once, instead
of three times outside it; the outer catches keep what happens before a key
is authenticated, where there is genuinely no org.
And six smaller, every one of them a claim of mine that did not hold:
3. The "agrees about what multi means" test could not observe the disagreement
it claimed to pin — the helper wrote both readers and the assertion called
only one. Replaced by the fail-closed behaviour test.
4. The withAdminAuth half of the catch-scope change was untested, and it is the
guard serving /api/v1/admin/logs itself.
5. isMultiMode() ran inside the per-entry predicate: up to 1000 env reads per
query. Hoisted next to readerOrgId.
6. CHANGELOG missed registerLogTenancy and LogTenancy. Fork-visible, not
cosmetic: a fork's vi.mock of @/lib/admin/logs without the export now throws
on import of the tenancy module — this PR had to patch a test for exactly
that.
7. .context/logging/best-practices.md still described getLogEntries as
level/search/pagination only, and showed a bare top-level call that now
returns only unstamped entries at multi with nothing to explain it.
8. The header reasoned about the multi-realm split for the reader and not the
writer.
553 files, 10272 tests. Build exits 0, no pg in any client trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Test the MCP error scope — the negative run said nothing did (§108 t-714)
Round 2's MCP fix had no test. The negative run found it: reverting the inner
catch left the suite green, which by fp6 makes the change decoration. Coverage
did not help either — the route reads 97.24% lines.
Two tests now: a handler failure is logged inside the key's org, and a failure
to authenticate the key is logged outside any org, because at that point there
genuinely is none.
One trap worth the comment left in the file: `tools/list` is refused at 400
before dispatch in stateful mode, so a test written with it would have passed
without ever reaching the catch it is about. `initialize` is the method that
gets there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply code-review round 3 — three fixed, one filed (§108 t-714)
1. MEDIUM, and another claim of mine that read better than it was true. The
docs said an unstamped line at multi is "visible to a reader who is also
outside an org" — accurate, and it implies a reachable reader. There is no
browser path to it: enterSessionOrg refuses a session with no active org at
multi, so every browser-authenticated admin always has one, and the only
null-org reader is a platform-admin API key. Concretely, a failing nightly
tick leaves /admin/logs empty of any trace of it for every human looking at
the page. That is the ruling working; the docs now say it in those words,
and name §111 as what fixes it.
3. The "Calling getLogEntries outside a request" paragraph was inserted BETWEEN
two table rows, orphaning getMaxBufferSize below a terminated table.
4. "Two classes remain unstamped" introduced three bullets and closed with
"Neither"/"Both". Now three, with boot and runAsSystem lines named as the
same shape.
2. NOT FIXED — filed as #840. An AsyncLocalStorage propagates into setInterval,
so McpSessionManager's eviction timer, armed lazily inside the first MCP
request, keeps that org for the life of the process: org A's Logs page shows
lines about org B's session evictions and B sees none of its own. A count
rather than content, but it is the mis-attribution direction this task
exists to prevent, and it is a class — any lazily armed repeating timer.
The fix needs a new export on lib/tenancy/context.ts (AsyncLocalStorage
.exit), a core module and a third file outside this task's declared scope,
in an unreviewed final commit past the round cap. So it is DECLARED instead
— in the buffer's docblock and in its process-state row — rather than left
as a stamp assumed correct.
552 files, 10267 tests. Build exits 0, no pg in any client trace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Look again after the last round (§108 t-714)
flow.validate's "after the last round, look again", and it found exactly the
shape that step warns about: round 3's inserted paragraph stranded the sentence
below it. The buffer's docblock and admin/logs.md each stated the owner's
ruling twice, two sentences apart, citing the same date — and in the doc the
`multi` statement had been appended to the end of the `single` paragraph, so it
read as part of an argument about the other mode.
One statement of each now, in its own paragraph.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Cite the Hub task rather than a GitHub issue for the timer caveat
The mis-attribution belongs on the Hub, not in GitHub issues: it only
manifests at multi, and what makes the inherited org matter is this task's
stamping — so by triage.q3 the change you would amend is §108's. Simon's call,
and my own axis agrees ("does this block the phase I'm in?", not "is it
pre-existing").
Now t-715, a bug on f-mt-jobs-caches. #840 closed pointing at it. The two
citations follow every other provenance marker in these files (§107 t-708,
§108 t-712) rather than an issue number that is no longer the record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…5) (#844)
* Arm repeating timers outside the request that created them (§108 t-715)
An AsyncLocalStorage store is captured when setInterval is CALLED, not when the
callback fires. McpSessionManager is a lazily constructed process singleton and
the first MCP request after boot is what builds it — inside runAsOrg(auth.orgId)
— so its eviction timer carried that one org for the life of the process, and
t-714's addLogEntry stamped every "evicted expired sessions" line with it. At
multi org A read a count of org B's session evictions on its own Logs page and B
saw none of its own: one org reading a line about another org's activity, which
is the direction t-714 exists to prevent.
lib/tenancy/context.ts gains runDetached(fn) over AsyncLocalStorage.exit. Not
runAsSystem, which was the nearest thing that already existed: that logs its
reason at info on every entry — once every few minutes for the life of the
process — and it is the audited DATABASE BYPASS, which is a claim about what the
code may read that arming a timer is not making. runDetached enters no scope at
all, so a detached caller that needs an org gets the same refusal as any other
path nobody taught to enter one.
The lazy construction is deliberate (platform.seam-realm: a registry filled at
boot is empty in the realm that reads it), so arming eagerly is not the fix.
Arming inside exit() is, and the callback body needs no change.
Detaching is right only for something that outlives its unit of work. A timer
whose lifetime IS the work — an execution's lease heartbeat, a webhook retry, a
fetch abort — must keep the context it was armed in, because its callback writes
that org's rows. Both docblocks say so; the sweep is in the PR.
The eviction test asserts the store at the arming call, which is what decides
the stamp rather than a proxy for it. The first version asserted the stamp
itself through vitest's FAKE timers and could not fail: advanceTimersByTime
invokes the stored callback from its own call stack, so the callback sees the
advancing test's context whatever the code does. Real-timer propagation — both
directions, including the defect — is pinned in context.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Docs, CHANGELOG and the settled caveats for detached timers (§108 t-715)
- tenancy/context.md: `runDetached` in the quick reference and the primitive
list, and a section — "A timer is stamped where it was armed, not where it
fires" — with the wrong/right pair and a table of every repeating timer in
the tree saying which scope each one belongs in. The point of the table is
that MOST of them must KEEP their context: detaching a lease heartbeat or a
delivery retry would not fix an attribution, it would break a write, because
at multi a create with no org is refused before any SQL.
- lib/admin/logs.ts and lib/tenancy/process-state.ts: the known-wrong-stamp
caveat is settled. Both now state the rule rather than declaring a defect.
- CHANGELOG, Added: runDetached, with the boundary on when to use it.
- orchestration/mcp.md: the session bullet said expired sessions are evicted
"not by a proactive timer", which was never true — the manager has armed a
5-minute sweep since it was written, and the lazy getSession() eviction is
the second mechanism, not the only one. Corrected, and the correction is
named in the line, since anyone who read it once believed it. Out of this
task's declared file scope, taken because it is one line describing the exact
timer this task is about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Say what actually keeps this module out of the browser bundle (§108 t-715)
The header claimed a module-scope throw in singletons.ts made the MCP tree
"never importable from a browser bundle". A non-sequitur: that throw fires on a
stateful+serverless deploy and says nothing about who may import the module.
What is true, and what was actually checked, is that nothing client-side imports
this tree — checked before adding the import, because the same edge into
lib/admin/logs.ts put pg in the browser bundle one task ago.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Close the two enumerations that left the detached timer out (§108 t-715)
/pre-pr step 5, reading the docs back against the code. Neither of these is
something check:changelog-drift can see — it says so: it correlates identifiers
a bullet quotes, and an OMISSION names nothing.
- CHANGELOG, t-714's bullet: its list of what produces an unstamped entry (boot,
a runAsSystem job, a platform credential) was complete when written and is not
any more. A detached timer is a fourth.
- .context/admin/logs.md: the same list, in the same state, in the page an
operator actually reads. Names the eviction sweep as the one instance.
- .context/tenancy/context.md: two abbreviated paths in the timer table written
as `hooks/registry.ts` and `webhooks/dispatcher.ts`, neither of which exists
at that path. Both spelled out; every path in that section now resolves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Code review round 1 — four claims that did not hold (§108 t-715)
No correctness defect in the change. All four findings were on things the diff
ASSERTS, and one of them was assertable only because I had restated it.
1. MEDIUM, and the reason this round earned its keep. The singletons row in
process-state.ts declares the MCP session manager `row-keyed`, reasoning
"keyed by ids unique across orgs" — the KEY argument applied without asking
what the READS filter on, which is the exact rule t-712 wrote this manifest to
encode. getActiveSessions() filters on TTL alone and the admin sessions route
serves it verbatim; DELETE .../sessions/[id] destroys by id with no ownership
check; and broadcastNotification with no targets reaches every org's SSE sink
(three callers in mcp/index.ts). McpSession carries no orgId, so the filter
does not exist to be applied. Filed as §108 t-716, NOT fixed here — it needs a
type change, a route change and an ownership check, which is a different review
surface from a one-line primitive. The row is split: sessionManager becomes
mixes-orgs naming t-716, rateLimiter stays row-keyed because ITS read is keyed
by the caller's own key. Same file, same lazy-singleton shape, opposite answer
— which is the point of asking per holder. multi-tenancy.md's "no row in the
manifest is a declared defect" line, which t-714 earned, is withdrawn.
This is the third time in this feature that reading the manifest's own rows
back against the tree found something three review rounds and a security
review had passed over. The rows are the highest-yield artefact here.
2. My own mcp.md rewrite replaced one false claim with another. I had corrected
"not by a proactive timer" and written that an expired session "can still
appear in the admin sessions list for up to five minutes". It cannot:
getActiveSessions() filters on lastActivityAt at read time and is all the
route reads, so an expired session leaves the LIST the instant its TTL passes.
The sweep reclaims the MAP and writes one log line; it never changes the page.
The bullet now says both errors out loud, because anyone who read either
version believed it.
3. The decision rule contradicted the table two lines under it. "Does this
outlive the request that armed it" is the tempting test and it is wrong: a
webhook delivery retry is armed inside a request and fires a minute after the
response, and my own table correctly says it must KEEP its org. An author
applying the literal rule detaches it and breaks its writes at multi — the
failure the next paragraph warns about. The test is now whether the timer's
work belongs to one org at all, or to the process.
4. The mis-attribution narrative was stated unconditionally and is reachable only
under MCP_SESSION_MODE=stateful: stateless (the default) synthesises an
ephemeral session per request and indexes nothing, so the map stays empty and
the sweep logs nothing. The precondition is now in all four places that tell
the story. The fix is still right — the wrong stamp is a property of the
arming, not of the mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Apply round 1's finding 3 everywhere, not just where it was reported (§108 t-715)
My own look-again pass (flow.validate), not a review round. The reviewer flagged
the misleading test — "does this outlive the request that armed it" — at two
locations, and I fixed those two. It was in five more, including as the HEADLINE
of the module docblock and as the "Need" column of the quick-reference row, which
is where someone decides whether runDetached is the tool they want.
The phrasing matters because it is wrong in a specific direction: a webhook
delivery retry outlives the request that armed it and must KEEP its org, so
anyone applying the rule as written detaches it and breaks its writes at multi.
The category is now "work that belongs to the process rather than to one org"
wherever it appears, and the three longest statements of it say explicitly that
outliving the request is not the test.
This is the shape flow.validate warns about: a corrected claim lands where it
was reported and the other copies keep the old one, which is worse than before
the correction because now the tree disagrees with itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Code review round 2 — four consistency findings, then stopping (§108 t-715)
Nothing in the code. Three of the four are about round 1's own fixes, which is
the flow.review-rounds stop signal rather than an argument for round 3.
1. MEDIUM. lib/orchestration/mcp/singletons.ts still carried
`Tenancy posture: row-keyed — sessions and limiters by ids unique across
orgs` in its own docblock, so after round 1 split that manifest row the FILE
asserted the opposite of the manifest for sessions. That line exists exactly
so the reader who opens the file rather than the manifest sees the decision,
and nothing tests it. A fork reviewing MCP at the next sync would have read
that sessions are safely keyed and never learned about the declared cross-org
read on the page beside it. Now two lines, one per holder, with the defect and
its preconditions spelled out where the holder lives.
2. The CHANGELOG kept the "outlives its unit of work" test that 736e4704
replaced in six other places — and it is the one artefact a fork reads on a
sync. As phrased it inverted its own rule: the three examples after the colon
are the cases that must NOT be detached.
3. .context/orchestration/mcp.md had ~340 new words on the eviction timer's log
attribution and nothing about the cross-org session list, while the bullet
directly under it advertises the terminate button that at multi ends another
org's session. An operator deciding whether to turn on stateful was being told
the small thing and not the large one. Now says plainly: do not enable
stateful on a multi install until t-716 lands.
4. The "adding process-global state" review step's triggers are all holders — a
cache, a registry, a counter, a latch. Arming a timer adds no holder, so the
rule this task establishes had no trigger in the one review step the docs say
exists for what no scanner can see, and t-716 was found by hand for exactly
that reason. The step now asks the timer question too.
Then I looked again, per flow.validate, and the fixes had introduced a THIRD
phrasing of the same rule ("outlasts one org's work") plus a truncated
cross-reference and some clumsy wrapping. One category now — "work that belongs
to the process rather than to one org" — in all seven places, with the
"outlives the request is not the test" warning kept in the three long-form ones.
Every internal anchor re-verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Scope MCP sessions to the org whose key opened them (§108 t-716)
At multi an org admin opening /admin/orchestration/mcp/sessions saw every other
org's live sessions — id, apiKeyId, creation and last-activity times — and could
terminate any of them by id. McpSession carried no orgId, so the filter did not
exist to be applied.
McpSession.orgId is read from the tenant context at createSession, never from an
argument — the mislabel rule t-714 established for addLogEntry: an argument here
would let code in org A mint a session onto org B's page. The MCP transport
enters runAsOrg(auth.orgId) for every request in both modes (an MCP key is bound
to an org at mint, t-673), so it is always present in production.
Three reads filtered, and TWO DELIBERATELY NOT, which took as long to establish:
- getActiveSessions() — the admin route served it verbatim, AND log-emitter.ts
builds its notification targets from it, so an MCP log line raised in one org
was pushed to every org's open SSE stream. One filter fixed both.
- destroySession() — returns false for another org's session, which the admin
route already turns into its ordinary NotFoundError, so a foreign id is
indistinguishable from a nonexistent one and nobody can probe for ids. No
route change needed.
- getSubscribers(uri, audience) — see below.
- getSession() is NOT filtered: the transport already refuses a session whose
apiKeyId is not the authenticated key's, at both entry points, which is
strictly stronger since a key belongs to one org. A filter here would guard a
state the callers cannot reach (fp5).
- getActiveSessionCount(apiKeyId) is NOT filtered: a key belongs to one org, so
counting one key's sessions cannot cross orgs whatever scope asks.
**RECONCILIATION CHANGED THE DESIGN, and my own done-when was wrong.** It said
getSubscribers should be "left keyed as it is". It cannot be: every org's
sessions subscribe to the same `sunrise://agents` string, so a URI is not an org
discriminator. But blanket-filtering it is ALSO wrong, because the same helper
has two kinds of caller with opposite correct audiences:
- resource-update-hooks.ts fires it when tenant-owned CONTENTS change (an agent,
a workflow, a knowledge document) → only the mutating org may be told;
- mcp/resources/[id]/route.ts fires it after editing an McpExposedResource ROW,
which is a GLOBAL_CONFIG_MODEL → every org's definition changed, and scoping
it would leave every other org holding a stale one with nothing to tell them.
So `audience: 'this-org' | 'every-org'` is REQUIRED on getSubscribers and on
broadcastMcpResourceUpdated. No default: either default is wrong for half the
callers, and wrong invisibly, because a notification that does not arrive looks
exactly like nothing having happened. A required argument is breaking for a fork
calling the barrel helper — deliberate, and in the CHANGELOG.
The same classification is why broadcastNotification itself is NOT org-filtered
and now says so at length: the three list_changed helpers announce changes to
McpExposedTool/Prompt/Resource, all global config, so every org's answer really
did change. Filtering there would have been a regression, silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Tests for whose sessions a scope sees, and the audience each caller asks for (§108 t-716)
Two orgs through the REAL tenant context — `runAsOrg` to mint, `runAsOrg` again
to read — rather than passing an org in, because a stamp that came from anywhere
but the call stack would pass a test that hands it over and fail in production.
The negative runs, each restored from a scratchpad copy: unfiltering
getActiveSessions fails 2; dropping destroySession's ownership check fails 1;
ignoring the audience in getSubscribers fails 1; stamping orgId as null fails 6.
**And the fifth negative run found the most important decision in this task had
NO test.** Flipping all three hooks in resource-update-hooks.ts from 'this-org'
to 'every-org' — precisely the behaviour t-716 exists to remove — left all 474
tests green. index.test.ts mocks getSubscribers and asserts the notification
shape; session-manager's tests cover getSubscribers GIVEN an audience; nothing
asserted which audience these callers ask for. New file
resource-update-hooks.test.ts closes it: the flip now fails 4, and dropping the
argument altogether fails all 5.
The `'every-org'` counterpart is asserted where its only caller lives. That
assertion was ALREADY BROKEN and outside the directory I had been running:
`toHaveBeenCalledWith('sunrise://knowledge/search')` is an exact argument-list
match, so it fails the moment a second argument exists. It now names the
audience and says why that one is global.
Also: the two-org tests passed while `tsc` failed — `McpSession` was used as a
type and never imported. vitest does not type-check, so a green suite says
nothing about that; `npm run type-check` is what caught it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Withdraw the declared defect, and say what decides a push's audience (§108 t-716)
- process-state.ts: `mixes-orgs` is empty again, and the section note now records
that BOTH rows that ever sat in it — the log ring (t-714) and this map — were
found by reading these rows back against the tree, neither by a test. That is
an argument for keeping the rows honest, not for trusting the scanner. The
session manager becomes org-keyed, with the three filtered reads AND the three
deliberately unfiltered ones named, since "we looked and left it" is the part
a later reader cannot reconstruct.
- singletons.ts: the in-file posture line matches again. Round 2 of t-715's
review caught this line asserting the opposite of the manifest, and nothing
tests it, so it is worth changing in the same commit as the row every time.
- multi-tenancy.md: no row in the manifest is a declared defect.
- mcp.md: the "do not enable stateful on a multi install" warning is replaced by
what the code now does, plus the audience rule in its own right — every org's
sessions subscribe to the same sunrise:// URI, so the URI decides nothing and
what CHANGED decides everything.
- CHANGELOG, Changed: the required McpSession.orgId, the filtered admin reads,
and the breaking required argument on broadcastMcpResourceUpdated /
getSubscribers with the reason there is no default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Pin the wiring, not just the stamp (§108 t-716)
`createSession` reads the org from the tenant context rather than taking it as
an argument, so the property has two halves and the manager's own tests only
prove one: given a scope, it stamps. What has to also be true is that the
transport has entered the key's org before calling it — and the route test mocks
the manager, so the stamp is not observable there. This asserts the scope at the
moment of the call, which is the half that lives in the route.
Negative run: arming the call inside t-715's own `runDetached` — the neatest
available way to strip the scope without touching anything else — fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Refuse an SSE stream for a session that is not the key's (§108 t-716)
Found by my own enumeration of every McpSessionManager method while preparing
the security review: which ones can cross an org, and why the id-taking ones
cannot. The answer for almost all of them is that the transport re-checks the
key, so a session id is not a capability. `registerSseListener` was the
exception — GET passed the `Mcp-Session-Id` header straight through with no
lookup and no key check, and it is the path that attaches a LISTENER.
So a caller holding any valid MCP key could open GET with another key's — at
multi, another ORG's — session id and have that session's
notifications/message, resources/updated and progress pushes delivered to it.
The owner stopped receiving them at the same moment, because sseListeners is a
Map keyed by session id and the second registration replaces the first.
**It belongs in this change and not a follow-up.** Scoping who a notification is
ADDRESSED to is worth nothing while the sink for an address can belong to
someone else — the audience work in this task is only sound with this closed,
and the PR would otherwise have claimed server-push is org-scoped when it is
not. It is also one guard mirroring one the same file already applies twice, not
a new control.
Pre-existing rather than mine, and its practical reachability was via the admin
sessions page — the very cross-org read this task removes — so the discovery
path and the hole are closing together. A session id is a UUID, so with the page
scoped there is no longer a way to learn another org's.
Three tests: the refusal, the refusal being indistinguishable from an unknown
session, and the population check that GET still attaches for the caller's own
session. Negative run: removing the guard fails 2.
Also, for the second time in this task, vitest was green while tsc was red —
`mockReturnValue(null)` on a mock whose return type is inferred non-nullable.
The file's existing idiom is `null as never`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Security review: the entry-point count, and the platform key's empty list (§108 t-716)
Two non-findings from the review, both worth acting on. Its enumeration of every
McpSessionManager method and every read of the three maps matched mine and found
no gap; it also found the SSE hole independently and reviewed the fix as correct.
1. `getSession`'s docblock said the transport checks apiKeyId at "both entry
points". It is three since the previous commit, and GET is the one that was
missing — so the count is exactly the thing worth keeping right in that
sentence.
2. **A platform-admin API key now sees an EMPTY session list at multi**, and gets
a 404 from the terminate route: it runs unscoped, so it matches no stamped
session, and production stamps every one. The review flagged it as inverting
"a platform key sees everything" for these two endpoints.
Kept, deliberately. It is the identical trade the owner ruled on for the admin
Logs page on 2026-09-23 — scope to the reading org, accept that an operator
has no cross-org view until §111 supplies the operator role, because multi is
not used until the phase is complete. Applying a standing ruling to the same
shape is not a new product call, so I have not re-asked. It also fails CLOSED,
which is the direction to fail in while the question is open.
The one honest difference is that the loss is TOTAL here and only partial on
the Logs page, which still shows a platform key the unstamped lines. Said in
both the code and the operator doc, with what to do instead (act as the org),
and §111 now owns restoring both views rather than one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Code review round 1 — seven findings, one MEDIUM refuted (§108 t-716)
1. MEDIUM, and REFUTED after tracing it. The claim: a platform credential runs
unscoped, so `notifyMcpAgentsChanged()` → `getSubscribers(uri, 'this-org')`
reaches nobody, and the org that owns the mutated agent loses its
notification. It cannot happen: every 'this-org' caller fires AFTER a
tenant-owned write, and at multi the data layer refuses such a write with no
org before any SQL (`needsScope` is true for a tenant model, then
`if (!ctx) throw`), so the route 500s and never reaches the notify.
Kept as a finding anyway in the shape that is actually true: the day someone
attaches a 'this-org' notification to a GLOBAL-config write, the platform
credential CAN get there and the fan-out reaches nobody — and "nobody was
told" is indistinguishable from "nothing changed". So it now warns and
returns early, with a test, rather than being silently empty.
2. LOW, accepted as a cost rather than fixed. Dropping the `getSession()`
refresh means a client that subscribes and then only LISTENS now expires at
the TTL, where the fan-out used to keep it alive. Keeping the refresh is
worse: one org's 'every-org' edit would silently extend every other org's
sessions. The real fix — an open stream refreshing its own session — is a
behaviour change (a stream could hold a session for ever, which interacts
with maxSessionsPerKey and the point of a TTL), so it is Hub idea #6 with the
trade written into the docblock rather than smuggled in here.
3. LOW, fixed, and it was inconsistent with this PR's own argument.
`destroySession` and `evictExpired` cleared `sessions` and `subscriptions`
but not `sseListeners`, which is what `broadcastNotification` enumerates — so
a force-terminated client's open stream kept receiving every list_changed
ping. Terminate now terminates. Two tests, each with a population check that
the sink really was a recipient first.
4. LOW, fixed: the new `org-keyed` row was filed inside the `row-keyed` section.
The roster's own docblock says it is ordered by posture, and no test asserts
ordering, so nothing would have caught it.
5. LOW, fixed — and it is my most repeated error class. "An MCP log line raised
in one org WAS pushed to every org's stream" is past tense about something
latent: `emitMcpLog` has no caller in the platform and is reachable only by a
fork using that documented API. Qualified in the CHANGELOG, the docblock and
the manifest row.
6. LOW, fixed: the three-item list of list_changed subjects is incomplete —
`broadcastMcpToolsChanged` also fires on an `AiCapability` PATCH and
soft-delete. The conclusion survives (AiCapability is global config too) but
the same wrong list appeared in four places, so the next person to check it
against the tree would have found a subject none of them named. A hand-kept
list miscounted, again.
7. LOW, fixed: the SSE hijack fix was under `### Changed` prefixed "Security:".
Keep-a-Changelog has a Security category and a fork scanning a release for
security entries would not have found the one entry describing a live
cross-key notification hijack. Moved to `### Security`.
Negative runs on the three code fixes: no sink pruning on terminate fails 1, on
eviction fails 1, and removing the no-org guard fails 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Code review round 2 — four, and one was a test I had called load-bearing (§108 t-716)
1. MEDIUM, and the one that stings. My new SSE refusal test asserted
`registerSseListener` was not called — and **could not fail**.
`registerSseListener` runs inside the async generator, which `sseResponse` is
mocked to capture without iterating, so it is never called in a test that does
not drive it by hand — including on the success path. I had even put a
`test-review:accept no_arg_called` suppression on it, which hid exactly that.
The refusal now asserts on `sseResponse` and `capturedIterable`, which is what
the code actually does before returning (no stream is opened at all), and the
success case drives the generator two `next()` calls to prove a sink IS
attached. Negative run on the ownership check now fails 2 where before it
failed on the status assertions alone.
2. MEDIUM: my own comment contradicted a change in the SAME commit. I wrote that
an evicted-but-subscribed client's stream "still receives the unscoped
list_changed pings" — but the sink-prune I added to evictExpired eighty lines
above means it receives NOTHING. The corrected version says so, and says the
keepalive is what keeps the connection looking healthy.
3. LOW: "terminate does not terminate" was half-fixed and I claimed it whole.
Dropping the sink stops the pushes; nothing closes the client's stream, which
stays parked until it disconnects. Narrowed in the docblock and in mcp.md,
with what an actual teardown would need, folded into idea #6.
4. LOW, fixed, and it is the same class as the GET check itself: the ownership
check and the registration are not atomic. The route verifies and returns a
Response; the generator registers later, when the platform pulls the body. A
session destroyed in between left a sink with no session — and
broadcastNotification enumerates sinks, not sessions, so that zombie received
every list_changed ping for the life of the connection. registerSseListener
now refuses an id with no live session. EXISTENCE only, not isVisible: by then
the request's runAsOrg scope has been left, so there is no org to compare
against — ownership was settled before the stream opened. Three tests, one of
them the non-vacuity check that a live session still attaches; negative run
fails 2.
Also the stale test comment the reviewer spotted: "both entry points" is three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Code review round 3 — the last round, and it found the same hole on a third path (§108 t-716)
1. MEDIUM, and the one that justified a third round. `destroySession` and
`evictExpired` were taught to drop the SSE sink; `getSession`'s LAZY TTL
expiry still deleted only the session row — and that is the worst place to be
incomplete, because evictExpired iterates `sessions`, so once the lazy path
removed the row the sweep could never reach that id again. The sink was
orphaned for the life of the TCP connection and kept receiving every unscoped
list_changed ping: the exact "sink outlives the address" defect this task
closes, surviving on the one path nobody looked at. It also leaked the
subscriptions, making the class docblock's "cleared with the session on
destroy / expiry" untrue.
Fixed by making it impossible to get wrong rather than by adding a third
delete: one private `forget(id)` drops all three maps, and all three paths go
through it.
2. LOW: the ownership check I added in round 1 refreshed the session it was
about to refuse. `getSession` bumps `lastActivityAt` BEFORE the caller can
compare `apiKeyId`, so polling GET with another key's — at multi, another
org's — session id kept that session from ever expiring. New `peekSession`
answers without the side effect, and both ownership checks use it; DELETE had
the same shape and is fixed with it, because leaving one of two identical
lookups mutating is worse than either choice.
3. LOW: when `registerSseListener` refused, the route still returned a 200 SSE
stream that could never deliver — the client saw an established,
keepalive-healthy connection with an unwired channel and found out on its next
POST. It now returns a boolean and the generator ends, closing the stream so
the client re-initialises.
4. LOW: a TARGETED `emitMcpLog` resolved its session through the newly
org-filtered list, so a legitimate targeted emit from a system scope, a
detached timer or a platform key silently reached nobody where it used to
deliver. A caller naming a session has already said who the recipient is —
the same reason every other id-taking method here is unfiltered — so the
targeted path now peeks that session directly and only the BROADCAST path
stays org-filtered, which is where the filter belongs.
5. LOW: asked whether the listen-only eviction regression is the intended trade.
It is, and the docblock now says so instead of only declaring the fact:
keeping the refresh makes one org's 'every-org' edit silently extend every
other org's sessions, and a liveness rule that depends on who else is editing
is not a rule.
**Two negative runs passed before I wrote the tests** — the lazy-expiry cleanup
and the generator close were both unasserted, which is how a round-3 fix ships
looking done. Now: lazy expiry leaking the sink fails 1, peekSession refreshing
fails 1, the generator ignoring a failed attach fails 1.
Three test mocks had to learn the real interface (peekSession, and
registerSseListener returning a boolean). Eleven tests failed at first on the
absent method — the mocks telling me they had diverged, which is the useful
direction for that to happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Bring both CHANGELOG bullets up to the final behaviour (§108 t-716)
/pre-pr step 5e, re-read after the last review round — and both entries this
branch wrote had been overtaken by rounds 2 and 3.
- The Security entry described only the ownership check. It now also carries the
two properties those rounds added: the lookup is non-mutating (refusing a
session no longer keeps it alive, which at multi was one org holding another
org's session open), and the stream closes rather than parking when the
session vanished before the body was pulled.
- The Changed entry said an MCP log line "raised in one org" would reach every
org's stream. Round 3 split the two paths, so that is now true of a BROADCAST
only: a targeted emit resolves its session directly and still delivers from a
system scope, a detached timer or a platform key. It also gains the sink being
dropped on terminate and eviction, with the honest limit that the stream is
not closed.
Both were accurate when written. check:changelog-drift flagged them, and this is
the case where the flag was not the usual false positive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Delete the admin MCP Sessions surface (§39 t-718) Sessions only ever existed under `MCP_SESSION_MODE=stateful`, which nothing runs and nothing can: it throws at module scope on any platform announcing itself function-per-request, the platform deploys to Vercel, and no fork runs it. At the default `stateless` the page has always been permanently empty — it said so itself, in a callout. Gone: `GET /api/v1/admin/orchestration/mcp/sessions`, `DELETE …/sessions/:id`, the page, `mcp-sessions-list.tsx`, the two `API.ADMIN.ORCHESTRATION` entries and the dashboard card. Both routes are under `app/api/v1/admin/orchestration/**`, which VERSIONING.md covers, so this is breaking for a fork and earns its own CHANGELOG entry. The dashboard test asserted "renders all 6 quick link cards including Sessions", so deleting the page without touching it would have left a card that 404s and a test still calling that correct. It now asserts the absence, by HREF — the Settings card's own description says "session limits", so a name match on /sessions/i passes for the wrong reason, which is how the first version of this assertion passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Delete the MCP server-push notification plane (§39 t-718) `broadcastMcpToolsChanged`, `broadcastMcpResourcesChanged`, `broadcastMcpPromptsChanged`, `broadcastMcpResourceUpdated` and the three named `resource-update-hooks` callers all delivered down one pipe: the SSE sinks the session manager holds per stateful session. With that transport gone there is no sink, no subscriber and nothing to deliver, so the choice was between deleting them and writing four no-op bodies for thirteen routes to keep calling. Deleted. A no-op called from thirteen mutation sites is a claim the code cannot honour — `notifyMcpAgentsChanged()` in an agent route reads as "subscribers are told", and every reader would have to learn it is not. `clearMcp*Cache()` stays at every one of those sites: cache invalidation is what makes the NEXT `tools/list` correct, and that has nothing to do with push. Push itself is not dead, it moved: MCP revision 2026-07-28 replaces session-scoped notifications with `subscriptions/listen`, a long-lived server-to-client stream opened from a request with an explicit filter (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions`). The mutation sites that fired the old helpers are the input to that work, and `git log -S notifyMcpAgentsChanged` finds them all rather than a hand-copied list here going stale. The reasoning is on the project journal and survives the code: a push audience is decided by what CHANGED, not by who is subscribed (`cmuej7pbc000s04l56ol9hrzh`). The three `listChanged` flags concern `McpExposedTool`, `McpExposedPrompt`, `McpExposedResource` and `AiCapability` — all `GLOBAL_CONFIG_MODELS` — so every listener hears them, while `resourceSubscriptions` over `sunrise://agents` is one org's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Remove the stateful MCP transport, and the session state it held (§39 t-718) There is one transport now and it holds nothing. Every request stands alone: no `Mcp-Session-Id` is issued, one arriving is ignored, GET and DELETE answer 405 with `Allow: POST`, and `MCP_SESSION_MODE` no longer exists. That is MCP revision 2026-07-28's shape — it removes protocol-level sessions, the `initialize` handshake and the GET stream, tells a server to answer 405 for GET and DELETE and to IGNORE the legacy headers, and moves server-push to `subscriptions/listen` (Hub idea #8, deliberately not this task: a long-lived stream on a function-per-request platform is a different design problem from the multi-process one that killed `stateful`). `initialize` is still answered, for older clients that open with one. Nobody was running the mode that goes: it threw at module scope on `VERCEL` or `AWS_LAMBDA_FUNCTION_NAME`, the platform deploys to Vercel, the owner confirmed no fork runs it, and a client on the current revision sends no session id and got `400 Missing Mcp-Session-Id header` for its trouble. Deleted: `session-manager.ts` (598 lines), `log-emitter.ts` and `progress-tracker.ts`. The last two being importable only by MCP and `process-state.ts` is the clean confirmation that the stateful plane really was a plane. Surgery worth reading: - `protocol-handler.ts` takes a `protocolVersion` where it took a whole session. That is the only thing any handler read off one besides `initialized`, which meant nothing once every request stands alone. `resources/subscribe`, `resources/unsubscribe` and `logging/setLevel` are gone from the dispatcher, so an attempt gets METHOD_NOT_FOUND — not the old `STATELESS_UNSUPPORTED`, which said "the method exists, the topology cannot carry it" and was true only while one of two transports could. - `initialize` advertises `{tools:{}, resources:{}, prompts:{}, completions:{}}`. Every capability that PROMISED A PUSH is gone, and gone from `McpCapabilities` itself rather than merely unset, so re-adding one is deliberate. Under 2026-07-28 the listen filter is a request parameter, not an `initialize` capability, so they would not come back in this shape anyway. - GET and DELETE no longer authenticate, read the server config, or audit. Both used to, because their availability was conditional on the mode; it is not conditional now, so nothing about the request can change the answer. The loss is the DELETE audit row that recorded a client still trying to terminate sessions — a client bug rather than an operator one, and it reveals nothing an `Allow` header does not. Both handlers are synchronous as a result, which eslint's `await-thenable` is what pins. - `_meta.progressToken` is ignored rather than shape-validated. Nothing can send a progress notification, so refusing a tool call over a field we discard would fail work for no gain — the same rule the spec states for a stray session header. - The batch-position guard on `initialize` STAYS, and its comment now says why it never was a session rule: every request in a batch takes its version from the `MCP-Protocol-Version` header, absent on a handshake, so `[initialize, tools/list]` would serve the list at 2024-11-05 while the handshake beside it negotiated 2025-06-18 — silently dropping the annotations gated on that. - `lib/api/sse.ts` is untouched. Four non-MCP routes stream through it; the SSE bridge was never MCP's. `McpServerConfig.maxSessionsPerKey` goes with a migration, and it is in this commit rather than a follow-up because it is the same story: the column was read in exactly one place, the `createSession` call on the transport being deleted. An admin field that validates, saves and changes nothing is worse than one fewer field — it used to render with a "No effect" caption, which is a setting apologising for itself. Breaking for a fork reading the column or sending the key to `PATCH …/mcp/settings`; both tests now pin the refusal rather than the field. The migration is hand-applied and resolved rather than authored by `migrate dev`, which refuses on this tree (a 2026-09-09 migration was modified after apply). `db:drift-check` passes, 51/51. Three process-state rows go with their files, and `process-state.test.ts` — which parses the tree — passes because of it, not in spite of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Reconcile the docs and the CHANGELOG for one MCP transport (§39 t-718) Twelve `.context` files, and the CHANGELOG is a reconciliation rather than an append — which is the half of this that has a deadline. **CHANGELOG.** `[Unreleased]` carried §108 t-716's entries describing MCP session org-scoping and the required `broadcastMcpResourceUpdated` audience. Those surfaces do not exist in this release, so left alone the release would read as a breaking change followed by a deletion of the thing it broke. Collapsed into one `### Removed` story with a fork-facing table of every gone symbol, route, env var and column, and what to do about each. The Security item stays visible, in a stronger form: **a fork still on 0.12.x is running the vulnerable code**, so it now says what the hijack was, that only `MCP_SESSION_MODE=stateful` reached it, and that the fix on this release is that there is no session to name. Deleting a security entry because its code went would take the disclosure with it. `runDetached`'s `Added` entry named `McpSessionManager`'s eviction sweep as its caller. It now says it has none, and why it ships anyway. **Docs.** `.context/orchestration/mcp.md` loses its mode-comparison table and gains "One transport, and it holds nothing" — the 2026-07-28 shape, why the other transport went (including the measured Vercel failure: one session id, one instant, three instances, two 404s and a 200), and a table of what each removed feature maps to in that revision. The Subscriptions, Progress and Logging sections say what they were and where push went rather than being deleted silently. Session Management keeps three RULES that outlived the code: a session id is not a capability, a timer is stamped where it was armed, and liveness measured by POSTs rather than by the open stream was a design flaw. Two things deliberately NOT written down. The mutation sites that fired the removed hooks are a `git log -S notifyMcpAgentsChanged` away, not a table here that would go stale — my repeated failure mode is a hand-derived list that claims authority. And `.context/orchestration/mcp-v1.1-plan.md` and `.context/testing/triage-plan.md` are left alone: both say on their face that they are history. `runDetached` now has no caller, and four places say so rather than leaving a reader to grep: its own docblock, `lib/admin/logs.ts`, the `process-state.ts` row's `why`, and the timer table in `.context/tenancy/context.md` — where the MCP row is gone and every remaining row correctly KEEPS its context, which is why the primitive has nothing to do. `hosting-requirements.md` is the one with an operational consequence: the MCP session map was its headline reason to enable sticky sessions on a multi-instance deploy, and long-lived embed connections are now the only reason left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close what /pre-pr found reading the docs back against the tree (§39 t-718) - `sunrise-version-disclosure.test.ts`: the allowlist of routes reaching `SUNRISE_VERSION` was 23 and the walk now computes 13. Ten left, and the reason is the same fact `singletons.ts` used to cite as its blast radius: deleting `resource-update-hooks.ts` cut the MCP barrel out of the agents, workflows and knowledge-document routes, none of which is an MCP surface — they imported a named notify helper, which re-exported the barrel, which reached `config.ts`, which defaults `serverVersion` to the platform version. The docblock's "twenty-two under app/api/v1/admin, the twenty-third is the MCP transport" is now twelve and the thirteenth. This test failing on a SHRINKING list is it working: a stale allowlist is how a guard quietly stops guarding, and it says so. - `mcp.md` claimed 13 files in `lib/orchestration/mcp/`. It is 12, plus 4 resource handlers in `resources/` — I counted an `ls` that included the directory. Same class of error as every other hand-derived count I have written. - `mcp-dashboard.test.tsx`: the replacement assertion I wrote said "renders every quick link card" and named five of six, because the list it replaced named five plus Sessions. Prompts added; the comment says why the count was never checked. - `resource-registry.ts`: `isRegisteredMcpResourceUri`'s docblock said "Used by `resources/subscribe`", which no longer exists. It has no platform caller now — said plainly, kept because it is on the barrel and is exactly the check a `subscriptions/listen` `resourceSubscriptions` filter will want. - `.context/admin/logs.md`: a dead link to `../errors/logging.md`, which has never existed under that path — `../logging/best-practices.md` is the page it means. Pre-existing, one line, in a file this branch already edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Answer the docs the removal made wrong outside the MCP pages (§39 t-718) `flow.validate` — after the last round, look again. A tree-wide sweep for paths and claims naming deleted modules, rather than another pass over the files I had already been editing. Six findings, and the first is mine: - **`lib/api/sse.ts` has SIX non-MCP importers, not four.** I wrote "four" in the CHANGELOG and enumerated them — a hand-derived list that claims authority, which is my most repeated error class. It now gives the count with the grep that produces it, so the next reader checks rather than trusts. The two I missed are `workflows/[id]/execute/route.ts` and `executions/[id]/rerun/route.ts`. - `mcp.md`'s "No External Dependencies" section still said "SSE reuses `lib/api/sse.ts`". MCP streams nothing now; that bridge belongs to the chat and workflow-execution routes. - `architectural-decisions.md` §2.7, "MCP session lifecycle and eviction", is marked **WITHDRAWN** rather than deleted. It is the "why each choice was made" doc, and the reasoning was sound for the design it served — the trade-off it names (in-memory, per-process, per-instance) is the one that decided the reversal. The banner says plainly that nothing in it describes shipped code. §2.2's "sessions are bounded per-key" bullet and its `Where it lives` path corrected, and its definition of "Streamable HTTP" no longer promises an SSE stream the revision removed. - `maturity-analysis.md` described the transport as POST + SSE-via-GET + DELETE-terminate, and listed a sessions browser among seven admin pages. - `multi-tenancy.md` named the MCP session map as the second process-global holder scoped at the read. It is not in the manifest at all now. - `improvement-priorities.md`'s MCP-client proposal pinned its connection-pool TTL to "the same as the server's session manager". The number survives; the thing it pointed at does not, and the line says so. Left alone deliberately: `mcp-v1.1-plan.md` and `testing/triage-plan.md`, both of which state on their face that they are history, and the 0.12.x CHANGELOG entries for #609, which are correct about the release they describe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Apply /code-review round 1 — eight findings, one of them behaviour (§39 t-718) The review found no functional bug in the deletion. Seven of the eight are copy and doc claims the code does not deliver; the eighth is mine and is a real silent failure. **The one that matters.** I wrote, in the CHANGELOG and in the admin doc, that a fork still sending `maxSessionsPerKey` to `PATCH …/mcp/settings` "now gets a 400". Measured: `safeParse({ globalRateLimit: 70, maxSessionsPerKey: 10 })` returns `{success: true, data: {globalRateLimit: 70}}`. A plain `z.object` STRIPS an unknown key, so the 400 only ever came from the "at least one field" refine — it fires when the removed key is the SOLE field, which is the one shape nobody sends. A fork's settings form PATCHes the whole object, gets 200, and the value vanishes. So the migration guidance promised a loud failure where the code was silent, in precisely the case a fork has. Fixed in the code rather than in the prose: `updateMcpSettingsSchema` is now `.strict()`, so an unknown key is a 400 naming the key, alone or alongside a live field. Same reasoning made §108 t-713's org retention slice strict. It costs a fork one 400 at upgrade time and tells it which key to drop; it also catches a typo in a field that IS settable (`globalRatelimit` was accepted and ignored). That is a public-surface behaviour change on a covered route, so it has its own CHANGELOG paragraph rather than riding along in a table cell. My test could not have caught it: it asserted only the lone-field case, and that passes without `.strict()`. The negative run proves the new pair does not — remove `.strict()` and exactly the two "alongside a live field" assertions go red while the old ones stay green. **The test gap.** `emitAnnotations = protocolVersion >= '2025-06-18'` is the one branch this task rewired (`session.protocolVersion` → `context.protocolVersion`) and nothing exercised it as false: every call in `protocol-handler.test.ts` passed the latest version and no assertion in the file mentioned `annotations`. Hard-wiring it to `true` left the suite green while a 2024-11-05 client silently received a field it never negotiated — verified by doing exactly that, one failure. **Copy an operator reads.** The dashboard's Settings card still offered "max sessions per key", and the master-toggle help still said "existing sessions are not terminated but cannot make new requests" — describing deleted behaviour to the person deciding whether disabling the server is safe. The new dashboard test coded AROUND the first of those rather than fixing it, which is the tell. **Docs that contradicted themselves.** `hosting-requirements.md` §2.1 still listed `app/api/v1/mcp/route.ts` among the long-lived SSE streams needing raised proxy timeouts, while §5 of the same file — which this branch edited — says MCP holds nothing. `architectural-decisions.md` §2.2's load-bearing "What we chose" line still stated GET-for-notifications and DELETE-for-termination as the current decision, though I had updated the paragraph above it, the bullet below it and §2.7's banner. **The manifest justified a row by a deleted sibling.** `process-state.ts`'s `rateLimiter` entry read "kept as a separate row from the session manager beside it in the same file" — it is the only holder in that file now. **A count I should not have left standing.** `orchestration-endpoints.md` asserted "120 admin route files … (131 total)" directly under a table this branch deleted two rows from. Measured today: 201 admin, 192 of them orchestration, 242 under `app/api/v1`. Drifted by about seventy, so the figures now ship with the commands that produce them, and the old number is quoted rather than quietly swapped — a count with no provenance is how it got that far. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Apply /code-review round 2 — a test that could not fail, and four stale claims (§39 t-718) Round 2 found no correctness bug either. Five actioned, one left to the owner. **A test of mine that could not fail.** `singletons.test.ts`'s "imports cleanly on a function-per-request platform" set `VERCEL` and `AWS_LAMBDA_FUNCTION_NAME` and awaited a dynamic `import()` — but the same module is statically imported at the top of the file, so it had already been evaluated at load with those variables UNSET, and the dynamic import returned the cached namespace without re-running module scope. Measured, not reasoned: I reinstated a module-scope `if (process.env.VERCEL) throw` and all five tests stayed green. Its docblock meanwhile claimed "asserted with the serverless markers set, because that is the exact configuration that used to be fatal". `vi.resetModules()` inside the arranged environment is the fix, and the same sabotage now fails it. Third instance of this shape in my work on §108/§39, so the docblock says what it cost rather than what it does. **One number in three places, all stale and mutually contradictory.** The count of test files importing `@/lib/env` read 37-of-47 in `env-server-vars.test.ts`, 44-of-47 in `.context/testing/environments.md` and 44-of-47 again in `vitest.config.ts` — two of which I edited in this branch without checking. Measured today: **79 files, 5 under happy-dom.** A developer deciding whether a new env-branching test needs the node default was being told two different wrong things. Now one authority (`environments.md`) carries the figure WITH the command that measures it, the test file points at it, and `vitest.config.ts` stops asserting a number at all. My own first count here was 7, because I grepped `from '@/lib/env'` and missed `vi.mock('@/lib/env')` — the reviewer's 79 was right and I checked before believing either. **The migration comment named a column that does not exist** — `max_sessions_per_key` in prose above `DROP COLUMN "maxSessionsPerKey"` in the DDL. A fork operator writing a pre-upgrade check off the comment finds nothing. **`mcp.md` asserted both that the mode ran on Vercel and that it could not.** "Observed in production on Vercel: one session id, three instances, two 404s and a 200" sat ten lines above "it threw at module scope on `VERCEL`". Both true, in order: the guard shipped in 0.10.0 (#644), so the observation predates it. The section now says so, and says why the two paragraphs do not contradict each other. **An undocumented wire change.** The stateless `GET` answered its 405 with a JSON-RPC `-32005` envelope; it now answers with an empty body. Anything doing `JSON.parse(await res.text())` on that 405 gets a parse error rather than a message. That is a breaking change for a fork's client or ops script and it now has its own row in the `Removed` table, plus a line where `mcp.md` states the 405. Not actioned: `deployed-check-108.js` in the repo root. Untracked, not in any commit, and its step-1 comment describes the module-scope guard this branch deletes — so it is stale as well as lint-failing. It is the owner's file and the owner's call; flagged twice already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Stop quoting the environment directive inside a test file (§39 t-718) CI shard 3/4, `vitest-environment-directives.test.ts`: `env-server-vars.test.ts` declared two environments, `(node, happy-dom)`. Round 2's fix for the stale env-importer count put the measuring command in that file's docblock, and the command contains the literal directive token. Vitest matches it with a regex over the WHOLE file, comments included — which is the trap that guard exists to catch, and its docblock says so in capitals: do not write the token here in any form, including prose. Line 1's `node` still came first, so nothing actually ran in the wrong environment. Had my line come first the file would have moved to happy-dom silently, and that is the exact failure this file documents: under a DOM, `lib/env.ts` validates only the client schema and every server variable reads `undefined`. The guard is load-bearing, not pedantic. The command stays in `.context/testing/environments.md`, which is not scanned. The docblock now points there and says why it does not quote the pattern, so the next person to want the number in this file does not re-introduce it. **Why local gates missed it.** After round 2 I ran a targeted `npx vitest run` over the MCP, env and tenancy paths instead of `npm run test:changed:coverage`. This guard reads the tree, so no import chain reaches it and it lives in ALWAYS_RUN_TESTS — the gate would have selected it; my narrower run could not. Substituting a subset for the gate is the defect, and it cost a preview deploy. Verified with the real gate this time: 1220 files, 24747 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…-721) (#852) * Always run the org-sources guard and the process-state scanner in scoped runs (§106 t-720, §108 t-721) Both tests read the repository rather than importing their subject. org-sources.test.ts parses prisma/schema for orgId models, and process-state.test.ts scans lib/ for module-level state. So `vitest --changed` never selected them for the change each exists to catch: a new tenant-owned model, or a new Map cache in some far-off module. `npm run test:changed` and /pre-pr passed green over both misses, and only CI's full run could fail. The 0.13.0 release notes tell forks these guards will catch them, so they now run where a fork runs its local gate, beside export-sources.test.ts. Also corrects two docs that dated the credential orgId column to 0.12.0. It arrived in 20260917120000_org_identity, which is new in 0.13.0. Found by /code-review on the 0.13.0 release PR (t-719). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin the new always-run entries by name, add two more tree-reading guards, finish the 0.12.0 correction /code-review round 1 on this branch found four things: - The same "0.12.0" dating survived in lib/tenancy/entry.ts (twice) and in tests/unit/lib/tenancy/migration.test.ts. Corrected; these are comments only. - Only export-sources.test.ts was pinned by name in scoped-tests.test.ts, so a later edit could drop org-sources or process-state without anything failing. Both are now pinned, because the 0.13.0 notes promise forks those guards. - Two more guards of the same class were missing. tenancy/roles.test.ts is the only check that ORG_ROLES / ORG_STATUSES match the Prisma enums, and a schema-only edit reaches no test through the module graph. scope-authority.test.ts walks lib/ for capability-dispatch sites. The same review also proposed auth/roles.test.ts. It is left off: User.role is a String, not an enum, and that test's file read checks the import purity of a module already in its graph. - The scoped-tests header and .context/testing/scoped-runs.md now name the tenancy guards among the examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address /code-review round 2: pin all four new entries, and stop the prose overclaiming - roles.test.ts's always-run reason no longer calls it "the only thing that notices" enum drift. It compares against the generated client, so it catches the drift once `prisma generate` has run. - roles and scope-authority are pinned by name beside org-sources and process-state. The test title no longer names a release number. - The header and scoped-runs.md stop hand-listing guards (the list had already drifted) and point at ALWAYS_RUN_TESTS itself. - identity.md and api-keys.md now say that the NULL-org window only ever existed on a deployment tracking main. Both migrations ship in 0.13.0, so a release upgrade applies them together. Not taken: - The CHANGELOG's 0.12.0 dating is fixed on the release branch (t-719). - An applied migration's header comment cannot be edited. - config-session-field.test.ts and the other advisory files go to a separate audit rather than growing this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(release): Sunrise 0.13.0 MINOR. Org identity (§106), row and namespace isolation (§107) and tenant-aware jobs and caches (§108), released together as §107 requires, plus the removal of the stateful MCP transport (t-718). The release blockquote tells forks to merge in two stages: first to 7d51150 (#815, the end of §106) and deploy it, then to the tag. That way the identity migrations land before the 38-table orgId backfill. No tag is cut retroactively at 7d51150: at that commit SUNRISE_VERSION still reads 0.12.1, an ancestor, so the fork-sync ancestry guard passes without one. Also corrects the #815 entry. The credential orgId columns arrived in this release's identity migration, not in 0.12.0. Hub: t-719. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): correct three gaps in the 0.13.0 fork notes These were /code-review findings on the release blockquote: - runInvitedSignup's new invitation argument defaults to null, so a one-argument call compiles. It moves out of "type changes a build finds" and gets its own line. The platform role is unaffected, because the accept-invite route applies it after signUpEmail. What such a call loses is the membership: an invited admin joins the install org as MEMBER, not OWNER, and an invitation naming another org is ignored. - The stage-1 summary of org_identity now names Session.activeOrgId. - The env-var line names TENANCY_APP_ROLE_PASSWORD and TENANCY_APP_ROLE alongside MIGRATE_DATABASE_URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): re-measure the 0.13.0 counts after #852 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#449 batched runInner's independent pre-token reads into one Promise.all, but chat.md never said so. Adds a Pre-token latency section: what is batched, why it matters on serverless, and which reads stay sequential because their order is load-bearing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…token-latency docs(chat): document the pre-token read batch in `streamChat`
…dates (#828) Bumps the github-actions group with 2 updates in the / directory: [github/codeql-action](https://github.com/github/codeql-action) and [trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog). Updates `github/codeql-action` from 4.38.0 to 4.38.2 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](github/codeql-action@v4.38.0...v4.38.2) Updates `trufflesecurity/trufflehog` from 3.97.4 to 3.97.9 - [Release notes](https://github.com/trufflesecurity/trufflehog/releases) - [Commits](trufflesecurity/trufflehog@v3.97.4...v3.97.9) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: trufflesecurity/trufflehog dependency-version: 3.97.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…ctory with 13 updates (#869) Bumps the development-minor-patch group with 13 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@next/eslint-plugin-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-plugin-next) | `16.3.5` | `16.3.6` | | [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.9.5` | `6.11.0` | | [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.13.4` | `24.13.6` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.1` | `5.0.2` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `5.0.1` | `5.0.2` | | [autoprefixer](https://github.com/postcss/autoprefixer) | `10.6.0` | `10.6.1` | | [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.5` | `16.3.6` | | [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.9` | | [tsx](https://github.com/privatenumber/tsx) | `4.23.13` | `4.23.15` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.70.0` | `8.70.1` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.0` | `8.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.1` | `5.0.2` | Updates `@next/eslint-plugin-next` from 16.3.5 to 16.3.6 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.3.6/packages/eslint-plugin-next) Updates `@react-email/ui` from 6.9.5 to 6.11.0 - [Release notes](https://github.com/resend/react-email/releases) - [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md) - [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.11.0/packages/ui) Updates `@testing-library/dom` from 10.4.1 to 10.4.2 - [Release notes](https://github.com/testing-library/dom-testing-library/releases) - [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md) - [Commits](testing-library/dom-testing-library@v10.4.1...v10.4.2) Updates `@types/node` from 24.13.4 to 24.13.6 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `@vitest/coverage-v8` from 5.0.1 to 5.0.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.2/packages/coverage-v8) Updates `@vitest/ui` from 5.0.1 to 5.0.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.2/packages/ui) Updates `autoprefixer` from 10.6.0 to 10.6.1 - [Release notes](https://github.com/postcss/autoprefixer/releases) - [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md) - [Commits](postcss/autoprefixer@10.6.0...10.6.1) Updates `eslint-config-next` from 16.3.5 to 16.3.6 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/commits/v16.3.6/packages/eslint-config-next) Updates `prettier` from 3.9.6 to 3.9.9 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](prettier/prettier@3.9.6...3.9.9) Updates `tsx` from 4.23.13 to 4.23.15 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](privatenumber/tsx@v4.23.13...v4.23.15) Updates `typescript-eslint` from 8.70.0 to 8.70.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/typescript-eslint) Updates `vite` from 8.3.0 to 8.3.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.3.1/packages/vite) Updates `vitest` from 5.0.1 to 5.0.2 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.2/packages/vitest) --- updated-dependencies: - dependency-name: "@next/eslint-plugin-next" dependency-version: 16.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: "@react-email/ui" dependency-version: 6.11.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: development-minor-patch - dependency-name: "@testing-library/dom" dependency-version: 10.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: "@types/node" dependency-version: 24.13.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: "@vitest/coverage-v8" dependency-version: 5.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: "@vitest/ui" dependency-version: 5.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: autoprefixer dependency-version: 10.6.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: eslint-config-next dependency-version: 16.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: prettier dependency-version: 3.9.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: tsx dependency-version: 4.23.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: typescript-eslint dependency-version: 8.70.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: vite dependency-version: 8.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch - dependency-name: vitest dependency-version: 5.0.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…g (§116 t-724) (#875) * Declare each agent field's ownership on a platform agent (§116 t-724) Every core AiAgent field now says whether, on one of the platform's own agents, it is part of what the agent IS (code-owned, written back by every reconcile) or how it RUNS in one org (org-tunable: provider, model, fallbacks, provider config, budget, per-turn cap, rate limit, retention). The ruling on §116 is exhaustive, so the flag is required on core fields; a fork field that declares neither is outside the platform agents' reconcile. providerConfig is org-tunable although the ruling does not name it: it is the chosen provider's own configuration and means nothing apart from the provider the org picks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Define the platform's agents in code, with a fork seam (§116 t-724) The sixteen agents eight seeds used to write once as install-org rows are now definitions in lib/orchestration/agents/platform-agents.ts: the pattern advisor, quiz master, MCP identity, nine judges, case generator and clean-up assistant go to every org; the provider auditor and report writer to the install org only, because the audit workflow writes the provider catalogue every org reads. Prompts, temperatures, token caps, access modes, bindings and tag grants are carried over from the seeds unchanged; the clean-up assistant's model pin moves with it. A definition states only what differs from PLATFORM_AGENT_BASELINE, the schema defaults of every code-owned field, so a reconcile can put a field back as well as set it. A registry digest lets the maintenance job find orgs behind the running code. Forks register or replace a platform agent from lib/app/platform-agents.ts (initAppPlatformAgents), run once and lazily through createAppInitGate; replacing a core slug is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reconcile an org's platform agents with their definitions (§116 t-724) reconcilePlatformAgents(orgId) is the one writer of platform-agent instances. Inside the org's own scope it creates what is missing, writes back every code-owned field, sets bindings and tag grants to the declared set and clears document grants, and writes a version row when a versioned field changes (chat pins provenance to the latest version). Org-tunable fields are set only at creation; the clean-up assistant's model pin fills only while provider and model are both empty. It never adopts an org's own agent that holds a platform slug, and it deactivates rather than deletes an agent the registry dropped, choosing only among the slugs it placed itself. That list, with the registry digest, is the org's platformAgents marker in Org.settings, written beside the retention slice under the same SERIALIZABLE read-modify-write. At single only the install org is reconciled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Materialise platform agents on org creation, per deploy and by job (§116 t-724) Three callers run the reconcile: - createOrg, after its transaction commits. Never fatal: the org is created either way and the job catches up. - a per-org platformAgents job (15 min), which reconciles only an org whose stored registry digest differs from the running code. - seed 021-platform-agents, once per active org, replacing the eight agent seeds. 006, 016, 017, 018 and 020-cleanup-agent are gone; 005, 008 and 010 keep their capabilities, MCP config and audit workflow. listActiveOrgIds takes an optional client so the seed can list orgs on its own (owner) connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the reconcile's properties; refuse to deactivate on an empty registry (§116 t-724) One test per fp4 property, run against an in-memory stand-in that stamps each row with the org the real tenant context holds, so a missing runAsOrg shows up as a row in no org. Writing the safe-on-empty test found the reconcile was not: an empty registry beside a marker listing placed agents would have deactivated every platform agent in the org. A fork can add or replace a platform agent but not remove one, so an empty registry is a broken import; it now logs an error and changes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the registry, the fork seam, the marker and the three callers (§116 t-724) - the core roster is the sixteen agents the seeds wrote, with their audiences; definitions set only code-owned fields; the baseline covers every code-owned field a definition may leave out - the fork seam: lazy once, a core replacement is logged, a throwing init is rolled back, and it ships empty (defaults.test row); the init-seam count moves to 15 - the org-tunable field list is pinned to the ruling - the marker: read defensively, written serializably beside every other settings key, and not dropped by a retention patch - createOrg reconciles after the commit and survives a failure; the platformAgents job runs per org in the org's own scope; the seed reconciles every active org on the runner's client - the clean-up model picker's tests move from the retired seed The two retired seed tests (020-cleanup-agent, agent-seeds-org-key) are replaced by these and by the reconcile's per-org property test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List the platform-agents seam in the roster, VERSIONING and CUSTOMIZATION (§116 t-724) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prove a new org's platform agents run there, in the two-org harness (§116 t-724) Section [11], as the restricted role at multi: org B, made by createOrg with no other step, has its own fourteen platform agents and none of the install-only two; the install org keeps all sixteen; A and B share no row. In B the clean-up upload, an MCP tool call, case generation and an evaluation run scored by eval-judge-relevance all run, and every conversation, cost row and case result they write carries B and names only B's agents. The model calls go to the smoke's own local provider, which now answers /chat/completions (streamed or not) as well as /embeddings, chosen by system prompt. B's instances are pointed at it through their org-tunable provider and model, the way an org would. The interceptor also answers the Resend API: the clean-up upload's fire-and-forget email would otherwise really be sent from a checkout with a key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point comments and the clean-up error at the platform agents, not the retired seeds (§116 t-724) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document platform agents, field ownership and the upgrade effect (§116 t-724) A new .context/orchestration/platform-agents.md: what the platform agents are, who gets which, who owns which field, the three callers and the properties the reconcile holds, and the fork seam. The agent fields doc gains the ownership flag; identity.md the second platform key in Org.settings; scheduling.md the platformAgents task; the multi-tenancy gaps list what is still open on §116. The CHANGELOG says plainly what an upgrading install will notice: a system agent's platform-owned fields are written back on the first seed after merging, while its provider, model and spend settings are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Treat a platform-agent create that lost a race as done (§116 t-724) Two reconciles of one org at once (the seed beside the job, or two instances each serving a tick) both find an agent missing, and the second create meets the per-org slug key. The row exists either way, so the unique violation is logged and the run carries on; the marker is left unwritten so the next run records the org properly. Any other create error still fails the run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave mcp-system's binding rows to the org (§116 t-724) mcp-system has no tool set of its own: MCP clients call whatever the MCP Tools page exposes, and under CAPABILITY_BINDING_MODE=strict an operator grants it tools by adding AiAgentCapability rows. A platform-owned (empty) binding set would have deleted those rows on every reconcile and broken MCP on every strict install after upgrade. A definition may now declare capabilityBindings: 'org', and the reconcile then creates, removes and re-enables no binding for it. mcp-system is the only core agent that does; declaring capabilities alongside it is refused at registration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point the orchestration docs at the platform agents, not the retired seeds (§116 t-724) Eleven docs named the deleted agent seeds or described what re-seeding did to an edited system agent. They now name the definitions, and say plainly that a platform-owned field (a judge's rubric or max tokens, the clean-up prompt, the bindings) does not survive the next reconcile while provider, model and spend settings do. The document-cleanup troubleshooting entry used to promise the opposite. The strict-binding caveat now says mcp-system's rows are the org's and a reconcile never removes them. Two stale counts are corrected along the way: the provider auditor binds 4 capabilities, not 5, and the 'all 15 agents have provider empty' line now allows for the clean-up assistant's pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the trimmed capability seeds, the model ranking and the refused count (§116 t-724) Deleting agent-seeds-org-key.test.ts left 005 and 010 with no test that ran them; these pin that they now write capabilities (and 010 the audit workflow) and nothing else. The clean-up model ranking's fallback branches and the seed's refused-count line are covered too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test that 008-mcp-server seeds config and resources and no agent (§116 t-724) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say in the seeding guide that Sunrise's agents are reconciled, not seeded (§116 t-724) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep retired platform agents managed; wait for missing rows; cache the digest (§116 t-724) Code review round 1: - An agent dropped from the registry was deactivated and then dropped from the marker. Still isSystem, it could be neither deleted nor switched off by the org, and no later reconcile would touch it, so an org that re-enabled it was stuck with it. The marker now keeps a retired agent while its row exists, and each reconcile switches it off again. - The marker recorded the current digest even when a declared capability or tag had no row yet, so the job never came back to bind or grant it. The marker now waits until nothing is missing, and the job counts only a run that wrote something as work, so an org held back this way does not stop the idle gate arming. - The registry digest is cached after the fork's init (cleared by a later registration) rather than re-serialised once per org per run. - createOrg's docblock says the reconcile ignores its injected db. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tolerate version-number races, refuse repeated slugs, keep one org from failing the seed (§116 t-724) Code review round 2: - An update or deactivation that loses a race on (agentId, version) to a concurrent reconcile (the seed beside the job) no longer throws out of the run: the loser's transaction rolled back, the winner wrote the same change, and the marker waits for the next run. Only the create path handled this before. - registerPlatformAgent refuses a definition that repeats a capability or tag slug. The repeat would meet a unique key inside the create transaction and be mistaken for that race, retried for ever. - Deactivating an agent with no history keeps its active config as v1 first, as updates already did; on main cleanup-agent has no v1. - Seed 021 logs one org's failure and carries on. Its marker is unwritten, so the maintenance job reconciles it, as after createOrg. - The job's docs say an org missing a declared row is re-read each run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type the capability-seed test's assertions; cover the seed's non-Error failure (§116 t-724) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… with 16 updates (#874) Bumps the production-minor-patch group with 16 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.125.0` | `0.128.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1131.0` | `3.1141.0` | | [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.1131.0` | `3.1141.0` | | [@better-auth/core](https://github.com/better-auth/better-auth/tree/HEAD/packages/core) | `1.7.4` | `1.7.6` | | [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.11.6` | `12.12.0` | | [better-auth](https://github.com/better-auth/better-auth/tree/HEAD/packages/better-auth) | `1.7.4` | `1.7.6` | | [dompurify](https://github.com/cure53/DOMPurify) | `3.4.15` | `3.4.16` | | [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) | `1.13.13` | `1.13.14` | | [lru-cache](https://github.com/isaacs/node-lru-cache) | `11.5.2` | `11.5.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.45.0` | `1.48.0` | | [next](https://github.com/vercel/next.js) | `16.3.5` | `16.3.6` | | [openai](https://github.com/openai/openai-node) | `7.15.0` | `7.23.0` | | [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.9.5` | `6.11.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.88.0` | `7.89.0` | | [react-window](https://github.com/bvaughn/react-window) | `2.3.1` | `2.3.3` | | [resend](https://github.com/resend/resend-node) | `6.28.0` | `6.30.0` | Updates `@anthropic-ai/sdk` from 0.125.0 to 0.128.0 - [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md) - [Commits](anthropics/anthropic-sdk-typescript@sdk-v0.125.0...sdk-v0.128.0) Updates `@aws-sdk/client-s3` from 3.1131.0 to 3.1141.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1141.0/clients/client-s3) Updates `@aws-sdk/s3-request-presigner` from 3.1131.0 to 3.1141.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1141.0/packages/s3-request-presigner) Updates `@better-auth/core` from 1.7.4 to 1.7.6 - [Release notes](https://github.com/better-auth/better-auth/releases) - [Changelog](https://github.com/better-auth/better-auth/blob/main/packages/core/CHANGELOG.md) - [Commits](https://github.com/better-auth/better-auth/commits/v1.7.6/packages/core) Updates `@xyflow/react` from 12.11.6 to 12.12.0 - [Release notes](https://github.com/xyflow/xyflow/releases) - [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md) - [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.12.0/packages/react) Updates `better-auth` from 1.7.4 to 1.7.6 - [Release notes](https://github.com/better-auth/better-auth/releases) - [Changelog](https://github.com/better-auth/better-auth/blob/main/packages/better-auth/CHANGELOG.md) - [Commits](https://github.com/better-auth/better-auth/commits/v1.7.6/packages/better-auth) Updates `dompurify` from 3.4.15 to 3.4.16 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](cure53/DOMPurify@3.4.15...3.4.16) Updates `libphonenumber-js` from 1.13.13 to 1.13.14 - [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md) - [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/compare/v1.13.13...v1.13.14) Updates `lru-cache` from 11.5.2 to 11.5.3 - [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md) - [Commits](isaacs/node-lru-cache@v11.5.2...v11.5.3) Updates `lucide-react` from 1.45.0 to 1.48.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.48.0/packages/lucide-react) Updates `next` from 16.3.5 to 16.3.6 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](vercel/next.js@v16.3.5...v16.3.6) Updates `openai` from 7.15.0 to 7.23.0 - [Release notes](https://github.com/openai/openai-node/releases) - [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md) - [Commits](openai/openai-node@v7.15.0...v7.23.0) Updates `react-email` from 6.9.5 to 6.11.0 - [Release notes](https://github.com/resend/react-email/releases) - [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md) - [Commits](https://github.com/resend/react-email/commits/react-email@6.11.0/packages/react-email) Updates `react-hook-form` from 7.88.0 to 7.89.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](react-hook-form/react-hook-form@v7.88.0...v7.89.0) Updates `react-window` from 2.3.1 to 2.3.3 - [Release notes](https://github.com/bvaughn/react-window/releases) - [Changelog](https://github.com/bvaughn/react-window/blob/main/CHANGELOG.md) - [Commits](bvaughn/react-window@2.3.1...2.3.3) Updates `resend` from 6.28.0 to 6.30.0 - [Release notes](https://github.com/resend/resend-node/releases) - [Changelog](https://github.com/resend/resend-node/blob/canary/CHANGELOG.md) - [Commits](resend/resend-node@v6.28.0...v6.30.0) --- updated-dependencies: - dependency-name: "@anthropic-ai/sdk" dependency-version: 0.128.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1141.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: "@aws-sdk/s3-request-presigner" dependency-version: 3.1141.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: "@better-auth/core" dependency-version: 1.7.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: "@xyflow/react" dependency-version: 12.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: better-auth dependency-version: 1.7.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: dompurify dependency-version: 3.4.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: libphonenumber-js dependency-version: 1.13.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: lru-cache dependency-version: 11.5.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: lucide-react dependency-version: 1.48.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: next dependency-version: 16.3.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: openai dependency-version: 7.23.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: react-email dependency-version: 6.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: react-hook-form dependency-version: 7.89.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch - dependency-name: react-window dependency-version: 2.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production-minor-patch - dependency-name: resend dependency-version: 6.30.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Add the platform-agent edit guard and reserved slugs (§116 t-725)
A platform agent's code-owned fields are written back by every
reconcile, so an org's edit to one is accepted and then quietly
undone. platform-agent-guard.ts says which writes to refuse, from the
field registry's own split rather than a list of its own: a change to
any 'code' field of a system agent, compared by value (the capability
seed-owned precedent), so a client echoing the whole agent back is
refused only for what it changed.
Bindings are the platform's unless the definition leaves them to the
org (mcp-system), keyed on capabilityBindings, not the slug. A retired
agent with no definition keeps the platform's answer.
Every registered platform slug is reserved for every org, whether or
not the org gets an instance of it: the reconcile never adopts an
org's own agent under one, so a lookup by slug must never find one.
getPlatformAgent(slug) reads the registry by slug. The three-field
SYSTEM_AGENT_PROTECTED_FIELDS set is removed; the code-owned list
replaces it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse edits to what the platform owns on its agents (§116 t-725)
- PATCH /agents/:id refuses (403) a change to any platform-owned field
of a system agent, isActive in both directions included, and names
the fields; provider, model, fallbacks, provider config, budget,
per-turn cap, rate limit and retention stay writable. GET returns the
same answer as platformAgent { lockedFields, bindingsLocked } so the
form can show it.
- The binding routes refuse attach, detach and a change to isEnabled or
customConfig on a platform-bound agent; customRateLimit stays the
org's. mcp-system's bindings stay writable.
- widget-config refuses a system agent outright: it has no org part.
- Version restore brings back only the org-tunable fields on a system
agent, and leaves its grants.
- Create, rename, clone and import refuse or skip a reserved slug.
Clone moves past a reserved generated variant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Skip reserved slugs on backup import; find only system agents by platform slug (§116 t-725)
The backup importer skips an agent whose slug a platform agent holds,
with a warning: the reconcile creates the org's instance, not a backup.
An org that took a platform slug before it was reserved keeps that
agent, and the reconcile refuses to adopt it. The clean-up and
mcp-system lookups, the patterns-tag grant, and chat's loadAgent for a
platform slug (judges, case generator) now match isSystem rows only,
so such an org gets "not found" rather than its own agent run as the
platform's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show a platform agent's platform-owned fields read-only in the agent form (§116 t-725)
GET /agents/:id now returns the org-tunable field list beside the
locked one. The form disables each group of controls whose fields are
all locked (a fieldset per group; the temperature slider explicitly,
since Radix's slider is not a native control), never sends a locked
field on save, and its banner says what the platform sets and names
what this org can change, from the same lists the API enforces. The
old banner promised instruction and capability editing the API
refuses.
The capabilities tab takes bindingsLocked: attach and detach are
hidden, the switch is disabled, and Configure sends only the rate
limit. mcp-system's tab stays fully editable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show the model audit only in the install org; count only an org's own agents (§116 t-725)
The provider audit runs the install org's workflow and its two
install-only agents, which no other org has. The providers page reads
the active org from GET /api/v1/orgs and shows "Audit Models" there
only; any failure hides it.
The dashboard's agent count excludes system agents, as setup state
already does, so a new org does not open on "14 agents".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test every surface that can change a platform agent (§116 t-725)
- the guard: refused only when a platform-owned value CHANGES (jsonb
key order and grant order ignored, list order not), org-tunable
fields and an org's own agent never, bindings by the definition's
capabilityBindings, every registered slug reserved (install-only and
a fork's included)
- PATCH per field class, re-enabling a retired agent, profile link and
grant sets, a whole-agent echo; GET's edit policy
- binding routes: attach, detach, isEnabled and customConfig refused,
customRateLimit accepted, mcp-system writable
- widget config refused; restore brings back only org fields and no
grants; create, rename, clone and both importers refuse or skip a
reserved slug
- the clean-up, mcp-system and patterns-tag lookups and chat's
loadAgent match system rows for a platform slug only
- the form disables exactly the locked groups, never sends a locked
field, and its banner names what the org can change; the
capabilities tab goes read-only but keeps the rate limit
- "Audit Models" shows only in the install org; the dashboard counts
only an org's own agents
The three old protection tests and the restore test now assert the
wider rule; the integration test that edited a system agent's
description now edits its budget, and a new one refuses the
description.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document what an org can change on a platform agent (§116 t-725)
platform-agents.md gains the section on what the API refuses, what it
accepts, the reserved slugs and the lookups that match system rows
only. admin-api.md's system-agent protection and restore rules, the
agent form's platform-agent behaviour, the clean-up troubleshooting
entry, the audit button's install-org rule and the dashboard count
follow. The multi-tenancy gaps list drops the refusal it named as
open.
CHANGELOG: the changed API behaviour and reserved slugs (a client
that edited a system agent's prompt through the API now gets a 403),
the GET edit policy and getPlatformAgent, and the removal of
SYSTEM_AGENT_PROTECTED_FIELDS. t-724's entry no longer says the form
still accepts these edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep a locked field's help usable; hide Available on locked bindings (§116 t-725)
From looking at the platform-agent form on the dev server:
- A disabled fieldset also disables every <FieldHelp> button inside it,
so no locked field's help opened. Each locked control now takes its
own disabled instead; KnowledgeAccessSection and
ReasoningEffortSelect gain a disabled prop for it.
- The fieldset faded the controls a second time over their own
disabled style, leaving locked values at about 30% opacity. Gone
with the fieldset.
- The banner listed registry labels ("Monthly budget (USD), ...,
Provider config"). It now names them as prose from the same API
list, and leaves out provider config, which has no control on the
form.
- On a platform-bound agent the capabilities tab listed every
available capability with nothing to do with it. The Available
column is now dropped and Attached takes the full width;
mcp-system keeps both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show the platform-agent banner on every tab; make a disabled slider look disabled (§116 t-725)
From a second look at the form:
- The banner sat inside the General tab, so the greyed controls on
Model and Instructions had no explanation. It now sits above the
tabs.
- The locked system-instructions field still said "Changes are saved
when you click Save changes." It says "Set by the platform." there.
- The temperature slider was disabled but looked live: Slider's
disabled styles were :disabled classes on a thumb that Radix marks
with data-disabled instead, so they never applied. The root now
styles data-[disabled] (the shadcn pattern). No other slider in the
app is ever disabled, so nothing else changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Find only the system instance by platform slug in workflows, quiz scores and Learn (§116 t-725)
Review round 1: chat's loadAgent filtered a platform slug to system
rows, but the workflow agent_call and chat_turn steps, the quiz-score
POST's quiz-master lookup and the Learn page's pattern-advisor /
quiz-master lookup did not, so an org's own agent under a platform
slug still answered there.
platformSlugWhere(slug) in the guard is the one rule: { isSystem: true }
for a registered platform slug, nothing otherwise. chat, agent_call and
chat_turn spread it; the two fixed-slug lookups filter on isSystem.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Lock the embed appearance and instruction revert; clear a blank rate limit (§116 t-725)
Review round 1:
- The Embed tab edited a system agent's widget appearance, which the
API refuses. EmbedConfigPanel takes appearanceLocked and shows a note
in its place; the tokens stay.
- The instructions history offered Revert, which the revert route
refuses for system agents. It takes readOnly and keeps the diffs.
- On a locked binding the rate limit is all Configure sends, and a
blank one was sent as undefined, so the body was empty and refused.
Blank now sends null, which is what "leave blank to inherit" says,
and clears an override (it never could before).
- The binding rule (isEnabled and customConfig are the platform's) moves
from the route into the guard beside the agent rule, and widget-config
asks the registry through assertPlatformOwnedFieldsUnchanged rather
than hard-coding isSystem. The guard's header no longer claims
version restore goes through it: restore reads the registry directly.
Declined: backup restore dropping a system agent's org fields (the
exporter never writes system agents); the banner without a policy and
the redundant isActive clause (every caller reads the agent from GET,
which always sends the policy; the clause predates this PR).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check platform slugs where they are validated, not only where they run (§116 t-725)
Review round 2: the runtime lookups found only a platform slug's
system row, but the checks in front of them did not, so an org's own
agent under a platform slug passed validation and then failed at run
time: the orchestrator's available agents (handed to the planner, then
refused by agent_call), the workflow validator's agent_call check, the
judge check on evaluation-run create, and the consumer chat and
invite-token routes ahead of the chat handler.
platformSlugsWhere(slugs) is the list form of platformSlugWhere: it
excludes an org's own agent under any platform slug in the list. The
Learn page asks the list API for isSystem=true instead of filtering a
10-row page it sorts system rows to the bottom of.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Leave a platform agent's binding config to the org; clear a blank config (§116 t-725)
Review round 2: customConfig was locked on the grounds that the
reconcile writes it back, but the reconcile never touches it (only the
binding set and isEnabled). Locked, a config set before this change
could never be changed or cleared by anyone. It is the org's now, as
customRateLimit is; the Configure dialog edits both again.
A blank config now sends null and clears it, as a blank rate limit
does since round 1.
Grant sets compare by set size, so a body repeating an id is not a
change (the write path skips duplicates). The isActive switch uses
the edit policy alone, as every other locked control does.
Declined: a no-op restore on a system agent writing a version row (it
changes and reverts nothing, and the row is accurate); widget-config
comparing against the stored null (writing the resolved config would
itself change it); the banner's phrase list (deliberate prose, falls
back to the registry label); folding the binding reads (two indexed
reads on an admin save).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Clear a binding config explicitly; apply the slug rule everywhere it is checked (§116 t-725)
Review round 3:
- Configure sends customConfig: null to clear a config (since round
2). The binding PATCH now writes that as Prisma.JsonNull, the attach
route's explicit form. The review said a literal null would make
Prisma throw; measured on the dev DB, Prisma 7 accepts it and stores
JSON null exactly as JsonNull does, so this is consistency, not a
crash fix.
- A sweep of every agent lookup by slug found the checks still outside
the platform-slug rule: the admin chat rate-limit pre-check, the
experiment verdict judge check, the workflow and evaluation cost
estimates, and the agent names on an execution trace. All now resolve
the system row for a platform slug.
- widget-config compares with the resolved stored config, so a save
that changes nothing is not refused, and skips the write when nothing
changes (no stored null turned into an explicit copy of the defaults).
- The agent PATCH decides whether grants changed with the guard's own
sameGrantSet, so a repeated id neither passes the guard nor bumps a
version; the two used to disagree.
- The grant lists are optional in PlatformOwnedCurrentValues, so
widget-config no longer passes made-up empty ones; two comments that
still called a binding's config the platform's are corrected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…877) * Give every org its own copy of the patterns knowledge (§116 t-726) The pattern advisor and quiz master search the patterns document, which was seeded once into the install org, so another org's instances had nothing to search and its pattern explorer was empty. - chunkKey is unique per (orgId, chunkKey), not globally: the seeded keys are fixed, and every org now holds them. Uploads key chunks by document id, so they never collided. - The seeder writes into the calling org's own default knowledge base, in one transaction, and is idempotent by slug. A copy of an earlier chunks.json is left in place and logged, as the seeder always has (replacing it would drop the org's embeddings). Its own grant of the tag to the two agents goes: the reconcile owns grants. - The platform-agent reconcile writes the org's copy before its agents, since that creates the tag they are granted. A failure is logged, the agents are reconciled anyway, and the marker waits for the next run. - The registry digest includes the document's slug, so the maintenance job reconciles every existing org once and each gets its copy; seed 021 re-runs when the chunk file or seeder changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prove a new org searches its own copy of the patterns knowledge (§116 t-726) smoke:tenancy-isolation [12], at multi as the restricted role: an org made by createOrg holds one copy of the patterns document in its own default knowledge base, tagged in it, with all 191 chunks carrying its org; one fixed chunk key is held once in each of three orgs; its embed run embeds its copy and nobody else's; and its quiz master's search, with another org's identical copy embedded beside it, reads only chunks from its own copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the per-org patterns knowledge: seeder, reconcile, digest (§116 t-726) - the seeder writes the calling org's copy into its own default knowledge base, is idempotent by slug, leaves an earlier version in place, replaces a failed copy, writes everything in one transaction through the caller's client, and refuses to run outside an org - PATTERNS_DOCUMENT_SLUG is the slug the committed chunk file produces - the reconcile writes the copy in the org's scope before reading tags, so its tag is granted on the same run; a failure or a race holds the marker back without stopping the agents - the registry digest moves with the patterns knowledge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test that the default knowledge base writes through the given client (§116 t-726) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document the per-org patterns knowledge (§116 t-726) platform-agents.md gains the patterns-knowledge section (before the agents, the org's own knowledge base, without embeddings, idempotent by slug, never refreshed, a failure never stops the agents) and the digest now covering it; its "not yet" list and the multi-tenancy gaps drop the patterns item. knowledge.md, seeding.md, models.md and admin-api.md follow: per-org default knowledge bases, the tag granted by the reconcile, chunkKey unique per org, and the embed route embedding the caller's org only (the seed flow never called it, as the old text said). CHANGELOG: the uniqueness change and per-org copies under Changed, the new seeder exports and the optional client under Added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test seed 021's report of the patterns knowledge (§116 t-726) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the patterns knowledge in the maintenance task's CHANGELOG entry (§116 t-726) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Find a renamed earlier copy; write the chunks in one statement (§116 t-726) From code review round 1: - An earlier copy was found by its name, which PATCH /knowledge/documents/:id lets an admin change. A renamed one was missed, the new copy met its fixed chunk keys, and the org's reconcile failed on every run. Earlier copies are now found by scope alone: only this seeder writes scope system. Oldest first, so the one reported is stable. - The 191 chunks were one INSERT each, now on the org-creation request. They are one createMany, which the chokepoint stamps with the org, so the raw INSERT goes (raw-SQL allowlist: seeder 3 -> 2). - The two agent definitions name the tag through PATTERNS_TAG_SLUG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count a written copy as work; take a violation for a race only with the copy there (§116 t-726) From code review round 2: - The platformAgents job counted only agent writes as work, so a run whose only write was an org's patterns copy (every existing org after this deploy) looked idle, and the idle gate could arm mid-rollout. - Every unique violation from the copy was logged at info as a race. One that is not a race (another document holding the fixed chunk keys, a second default knowledge base) repeats every run and was never raised. It is now a race only when the org's copy is there after it; otherwise it is logged as an error. - seeding.md said a fork that skips db:seed still gets each org's copy from the job; the reconcile refuses to run without the service account unit 001 creates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave a failed copy alone; settle the write race in the seeder (§116 t-726) From code review round 3: - A scope-system copy with status failed was deleted and rewritten without embeddings. The old seeder never left one failed (it wrote status ready directly); what does is a re-chunk that failed, which keeps its embedded chunks. Now any held copy is left, whatever its status, and is the admin's to retry. - The race to write a copy was settled in the reconcile only, so the admin seed route could 500 against a concurrent reconcile, and the re-read used the constant slug rather than the one written. The seeder now re-reads its own slug after a unique violation and reports present; anything else is thrown, and the reconcile logs every thrown failure as an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…nstall org (§116 t-733) (#880) * Keep the pattern advisor, quiz master and patterns knowledge in the install org (§116 t-733) Owner ruling 2026-09-29: they help the install's app admins build their app (the Learn page), not a tenant product. - pattern-advisor and quiz-master are install-only. The digest moves, so the next reconcile deactivates (never deletes) the instances it placed in other orgs: the existing retired-agent path. - The reconcile writes the patterns knowledge only in an org one of whose definitions declares the tag, which in core is the install org. Keyed on the definitions, so a fork's agent that declares it keeps working. - scripts/seed-embeddings.ts runs as the install org; at multi it threw for want of one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prove a new org gets no patterns knowledge; keep the per-org checks (§116 t-733) smoke:tenancy-isolation at multi: [11] counts 12 per org and 4 install-only, with a judge as the evaluation subject in place of the now install-only pattern advisor. [12] asserts an org made by createOrg holds no copy while the install org holds its own; a copy loaded by hand into two orgs still proves the per-org chunk key, the embed run's scope, and that a search reads only its own org's chunks with the other's identical copy embedded beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document the pattern advisor, quiz master and patterns knowledge as install-only (§116 t-733) platform-agents.md: the roster (12 every-org, 4 install-only) and why; the patterns knowledge goes where an agent declaring its tag goes. multi-tenancy.md, knowledge.md, seeding.md, models.md and admin-api.md no longer say every org gets a copy. CHANGELOG: t-724's install-only list names the two, and t-726's entry describes the net change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test that the embeddings script runs as the install org (§116 t-733) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the org in embedChunks; say why an agent was narrowed out (§116 t-733) From code review round 1: - embedChunks relied on RLS alone to confine its raw SQL to the caller's org, so under the owner DSN (a seed or script) it embedded every org's chunks, and alreadyEmbedded could go negative. Both queries now name the org, and it refuses to run outside one. - An agent switched off because its audience narrowed was versioned as "Removed from the platform agent registry"; it is still registered. The summary now says its audience changed. - The smoke again runs search_knowledge_base as a restricted agent in a tenant org (B's own agent, granted the patterns tag), not only a plain search: the grant-resolution path the advisor used to cover. - Stale wording: two test titles still said fourteen; seed 021's docblock said every org gets a copy; the docs said §111 hides the Learn page, which it will once built. platform-agents.md now says the Learn page and the MCP patterns resource find nothing outside the install org until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave embedChunks to RLS; check the tag grant resolves in the org (§116 t-733) From code review round 2: - Round 1 made embedChunks name the org, on the premise that a script may run it on a connection RLS does not apply to. That premise is wrong for this design: at multi DATABASE_URL is the restricted app role, and raw SQL is where RLS does the scoping (the allowlist's rule). The change also split it from the embedding-status counts and skipped NULL-org rows. Reverted; the script still enters the install org, which is what it needed. - The smoke's restricted-agent search could not fail on grants, since the copy is system-scope and passes through regardless. It now checks that the agent's tag grant resolves, in B, to B's copy and not A's, and shares one helper between its two search checks. - A deactivation's version summary states the actual reason: removed, now install-only, or otherwise no longer this org's. - Stale comments: the patterns module header, the digest docblock, the knowledge doc's embed paragraph, the dashboard's agent count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Use a chat agent as the smoke's subject; bind the searching agent (§116 t-733) From code review round 3: - The evaluation subject was a judge, which POST /evaluations/runs refuses; it is cleanup-agent, a chat agent, now. - B's embed run is again checked against the install org's unembedded copy as well as A's (and says so when a rerun leaves nothing to catch there). - B's agent is bound to search_knowledge_base (an upsert: the fixture may already hold it), so the check passes under CAPABILITY_BINDING_MODE=strict. - The deactivation reason has two cases, not a third it cannot reach. - Seed 021 no longer logs "patterns knowledge skipped" for an org that simply has no agent using it. - Stale wording: the chunkKey schema comment, a test title, the patterns module header, and the digest docblock (a run for an org with no copy rewrites its marker). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Review on #942: putting `^CLAUDE\.md$` in the one `.trufflehog-exclude.txt` also switched off every non-Postgres detector for CLAUDE.md on every PR and push scan, though only the initial commit's copy ever held a DSN. The history-only paths move to `.trufflehog-exclude-history.txt`, which the workflow appends to the main list on `schedule` and `workflow_dispatch` runs. `--exclude-paths` takes a single file and the action mounts the workspace into its container, so appending to the checked-out copy is simpler than choosing a generated file with an expression. Verified with trufflehog 3.97.9 over the full history: main + history list exits 0; main list alone still reports CLAUDE.md at 316963f, so PR and push scans keep scanning it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code-review finding: the history-list comment read as if the tripwire still covered CLAUDE.md's history. It reads only the current tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ute them only to a verified address (t-767) (#965)
* Erase a person's contact-form messages when their account is erased (t-767)
ContactSubmission has no FK to User, so the erasure cascade never reached it,
while the subject-access export already hands these rows over as the
subject's personal data. eraseUser() now deletes them inside the erasure
transaction, before the user row, matched exactly on the trimmed,
lower-cased address.
The matcher is shared with the export source (contactSubmissionsOf) so the
two cannot drift: an ILIKE (mode: 'insensitive') here would delete a
stranger's messages wherever the subject's address holds _ or %.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Prove the contact delete spares a stranger's row against real Postgres (t-767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document the contact-form step in erasure, and log it in the CHANGELOG (t-767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Mock and assert the contact delete in the account-deletion route tests (t-767)
Both routes run the real eraseUser() over a mocked Prisma boundary, which had
no contactSubmission model, so the happy paths returned 500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pin the shared contact-submission matcher with its own test (t-767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Match contact rows on the stored address, log the count, tighten the tests (t-767)
Review round 1 (/code-review high):
- The delete matched the caller's userEmail, which can be stale (a cached
session from before an email change). It now reads the address from the
user row inside the transaction, as the export does.
- The deleted count goes on the 'User erased' log line: these rows are erased
by address, not FK, so the log is their only trace.
- The transaction test now hands the callback a tx distinct from prisma, so a
delete on the outer client would fail it.
- The smoke's always-true fixture check is replaced by one that can fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say the contact match reads the stored address (t-767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Attribute contact messages only to an account that verified the address (t-767)
Owner ruling: the contact form proves nothing about who typed an address,
and with email verification off anyone can open an account under someone
else's. Matching on the address alone let that account download a stranger's
enquiries (already true) and, with this task, delete them. Now both export and
erasure attribute contact messages only when the account's emailVerified is
true; an unverified account gets none and loses none, and the operator answers
that request by hand. No change on the default production setup, where every
account is verified.
The fork collector still receives exactly AppSubjectQuery ({ userId, email }):
that type lives in the fork-owned lib/app scaffold.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document the verified-address rule for contact messages (t-767)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Warn when erasure leaves contact messages for an unverified address (t-767)
Review round 2 (/code-review high):
- An unverified account's erasure skipped its contact messages and logged
contactSubmissionsDeleted: 0, indistinguishable from having none. It now
logs a warning so an operator can answer the rest of the request by hand.
- The route tests mocked the stored address equal to the one the route
passes in, so they could not catch a return to the stale caller address.
- The docs said nothing changes in production; pending sign-ups there are
unverified too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Warn about contact messages left by an unverified erasure only when there are some (t-767)
Review round 3 (/code-review high, the last):
- The warning fired on every unverified erasure, including the usual case
of no messages at all, which would teach operators to ignore it. Erasure
now counts what it left, through a new ungated contactSubmissionsUnder()
(for counting only), and warns only above zero, with the count.
- The warning left the operator no way to find the rows. The docs now give
the route: the receipt's subjectEmailHash hashes the same normalised form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cans-green Make the weekly secret sweep and dependency audit pass again
…s an agent (t-779) (#966) * Save an agent version whenever a revert or import changes an agent (t-779) Instructions revert, agent bundle import and backup import changed an agent's versioned config without writing an AiAgentVersion, breaking #360's invariant that the newest version equals the live config. Every chat turn pinned to the newest version (#811) was then credited to a config it did not run. Each writer now calls ensureBaselineVersion before its write and recordAgentVersion after it, in the same transaction. The helpers read the agent row and grants back, so the snapshot is the live config, and skip a no-op. The chat_turn executor reads the agent and its newest version in one REPEATABLE READ transaction via readAgentConsistently, since the include is a second SELECT without relationJoins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Expose the agent mock in the inbound loop test's transaction (t-779) The chat_turn executor now reads its agent inside a transaction, so the test's mocked tx needs the same aiAgent delegate as the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document that revert and import now add agent versions (t-779) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: version bulk actions, 409 on version races, shared equality (t-779) - Bulk activate/deactivate/delete changes isActive, a versioned field, so each changed agent now gets a version (found by the writer sweep). - Revert, agent import, bulk and backup import map a P2002 to a retryable 409, as PATCH does, instead of a 500. - Import, backup and bulk transactions get a 60s timeout; each agent now costs several version round trips. - The version helpers read sequentially: pg deprecates concurrent queries on the one connection an interactive transaction holds. - One snapshot equality rule (key-order insensitive), shared with the history diff, and field-level change summaries as PATCH writes. - CHANGELOG no longer overclaims what an unchanged backup writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document version writes on bulk agent actions (t-779) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cover the non-conflict error path in revert and bulk (t-779) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review round 2: precise version-conflict check, maxWait, narrower diff (t-779) - 409 only for a collision on AiAgentVersion (the adapter reports the model in meta.modelName); any other unique violation keeps the shared handler's answer instead of a misleading 'please retry'. - The chat_turn consistent read waits 10s for a pool connection, as a plain query does, not Prisma's 2s interactive-transaction default. - recordAgentVersion compares only fields both snapshots record, so a snapshot older than a registry change does not version a no-op. - Bulk actions version only the agents whose isActive actually changes. - Docs: deleting an agent writes no version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review round 3: exact bulk write set, keep missing fields as changes (t-779) - Bulk updateMany targets exactly the agents it versions, so a concurrent edit cannot slip an unversioned agent into the write. - A field the stored snapshot lacks counts as a change again (round 2 over-corrected): the newest version must record it to equal live. Fields the registry dropped are still ignored. A malformed stored snapshot now yields a version rather than none. - Comments: a non-version unique violation goes to the shared handler (a 400), not a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Pin streaming chat assistant messages to the latest agent version The streaming handler accepted agentVersionId on PersistMessageParams but no call site supplied it, so every chat turn's assistant message had a NULL version pin while the workflow chat-turn executor pinned its own. Load the latest AiAgentVersion id with the agent once per turn and stamp it on every assistant message (terminal, budget-exceeded, pending-approval, error marker). User and tool messages stay unpinned, as with the other provenance scalars. Fixes #811 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hmq9dAxXJENe1rxKpT3wQ * Correct provenance doc: chat turns now carry the agentVersionId pin Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hmq9dAxXJENe1rxKpT3wQ * Address review: pin user rows, share the version include, cover every pin site Pin the user message too so streaming chat and the workflow chat_turn executor follow one rule (user and assistant rows pinned, tool rows not). Pass agentVersionId as a plain scalar like modelId/providerSlug, move the latest-version include next to the other agent-versioning helpers, add tests for the budget-exceeded and both pending-approval persists, and fix the two doc lines that still described the old null-on-chat behaviour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hmq9dAxXJENe1rxKpT3wQ --------- Co-authored-by: Claude <noreply@anthropic.com>
…4.0 (t-781) (#968) * Patch mysql2 to 3.24 to clear its security alerts (t-781) Overrides mysql2 to ^3.23.1 for GHSA-3f6p-5ww8-9rcr (high) and GHSA-rgwj-5xj2-c3m3 (medium). It arrives only through better-auth's and prisma's MySQL adapters, which a Postgres install never loads, and both parents still pull 3.15.3. Same major; the lockfile change is mysql2 and its own dependencies only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Note the mysql2 override and the accepted alerts in the CHANGELOG (t-781) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Address review: scope the mysql2 override to prisma, fix its reason (t-781) - Only the prisma CLI pins mysql2 (exactly 3.15.3); better-auth declares it as a ^3.0.0 peer. Scoping the override to prisma leaves a fork's own mysql2 dependency alone. Resolving main's lockfile against it gives the same lockfile, mysql2 3.24.5. - The reason and removal condition now name prisma's pin, not adapters. - The t-758 CHANGELOG bullet no longer lists mysql2 as an open high, and the new bullet drops its hand-kept inventory of accepted alerts, which belongs in the release notes at cut time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Cut Sunrise 0.14.0 (t-763) Bumps SUNRISE_VERSION, package.json and the lockfile root to 0.14.0, and moves [Unreleased] under a dated 0.14.0 heading with a release blockquote saying what a fork has to do, plus both link definitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Correct the 0.14.0 notes from review (t-763) - Platform agents are reconciled by the maintenance tick within 15 minutes of deploy, not only on db:seed: clone customised prompts before deploying (blockquote and the §116 entry). - Name the two migrations whose locks scale with table size. - At multi, say non-install orgs are refused from the deploy until granted, and to script the grants post-deploy. - Spell out the judge_call cases that fail on every run. - Give the accepted alerts' severities, parents and why they're unreachable; fix the t-758 bullet implying mysql2 parents braces. - The pre-upgrade template query now matches the migration's filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…eful MCP transport gone Merges v0.14.0 (77 commits, v0.12.1..v0.14.0, two releases: 0.13.0 and 0.14.0) in one stage. Sunrise suggests two stages for a large database; this install's tables are small enough to take the tag at once. 111 conflicts, resolved by taking upstream's change and keeping the fork's additions on top. - 56 test files conflicted only on the `@see` path the fork had renamed; upstream now uses a relative path, so those take upstream's. Seven of them also carried fork edits elsewhere, which are kept. - Stateful MCP transport removed upstream: the four files the fork had only renamed (`sunrise://` to `resparkable://`) are deleted with it, and the fork's `maxSessionsPerKey` defaults are dropped. - RESPARKABLE_VERSION is 0.14.0. The MCP server name and the platform agents' prompts keep the Resparkable name, now in lib/orchestration/agents/platform-agent-definitions/. - Platform jobs: upstream gives every job a scope. The fork's transferJobs and transferArchiveExpiry run under the system scope, because TransferJob belongs to a person, not an org. - Erasure: the fork's transfer-archive cleanup and upstream's contact-message delete both stay, in the service, its doc and the smoke. - The TENANCY_MODE=multi guard in lib/db/client.ts is gone; upstream now implements multi. - Lockfile seeded from v0.14.0's and reconciled with --package-lock-only: it matches upstream plus the fork-only packages. No version floated. - CHANGELOG: fork [Unreleased], then upstream's 0.14.0 and 0.13.0 verbatim, with upstream's compare links. - Doc tables union both sides. - model-graph.generated.ts regenerated for the merged schema. Not yet done: the fork's AiAgent slug lookups (in a follow-up commit), classifying the fork's models for tenancy, migrations, validate and the full test run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e per org Sunrise 0.13 replaced the global unique on AiAgent.slug with orgId_slug. The fork's four lookups (the binding resolver, two seeds, the triage eval script) name the org through requireOrgId(): seeds run inside the install org's scope, and at single a call outside any scope falls back to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… seeds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
model-classification.test.ts names every model with no orgId on neither allowlist. This tier's 31 models and TransferJob belong to a person, not an org, and scale.md rules multi-tenancy out, so they go on SYSTEM_MODELS under a FORK comment. GLOBAL_CONFIG_MODELS would turn every user write into a shared-settings write. Upstream #964 and #1041 ask for a seam and a class. process-state.test.ts scans lib/ for module-level holders; the four lookup maps in lib/portability are built once from code and declared no-tenant-data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sunrise 0.13 gave 38 core tables an orgId. The planner treated it as an ordinary reference to Org, which never transfers, so it would have been nulled, and injectOrgId does not stamp an explicit null. An imported agent would then sit in no org, where an orgId_slug lookup never finds it. The planner now sets every column that names an org from the importing context, the way it sets the owner column from the importing account, and an overwrite never moves a matched row. AiAgent merges on [orgId, slug] to match the new constraint. Org and OrgMembership are excluded from transfer, and AiConversation.embedVisitorId is declared not a row. transferJobs runs per-org rather than system: an import writes tenant-owned rows, and only a per-org run stamps them. At single that is one run in the install org. The prisma cast in import-lookup is no longer needed now that the client is a TenancyClient. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backup importer refuses system workflow slugs by name, where the row's isSystem flag cannot be seen. framework-resparkable/005-workflows.ts seeds seven, and system-workflow-slugs.test.ts failed on it. No seam exists, so the slugs go on the core list under a FORK comment (Sunrise ask #55). The exporter leaves the same slugs out of a backup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…GELOG entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-org scope ran transferJobs once per ACTIVE org, whatever TENANCY_MODE says, and createOrg has no single-mode guard. A second org's run would claim the next queued import and stamp its rows with an org that is not the importing user's, hiding their imported agents from the per-org lookups. The job is back on the system scope and its body runs as the install org, which is what the comment said it did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… agents The two-org smoke came from upstream with x-sunrise-* signing headers, which this fork's HMAC adapter does not read, so the inbound call got a 401. Its install-org check also counted every system agent, and this fork seeds ten of its own there, so it now counts only the registry's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings Resparkable up to Sunrise 0.14.0: tenancy plumbing, platform agents defined in code, and the stateful MCP transport removed.
What the fork had to change
@seepaths in tests. Upstream removed stateful MCP subscriptions, so the fork's subscription broadcasts go with them.AiAgent.slugis now unique per org, so the fork's four lookups (and their tests) look agents up by org and slug..context/framework/resparkable/scale.md), so its 32 models go onSYSTEM_MODELS. That is a core-file edit under aFORK (Resparkable)comment; filed as Sunrise ask fix(resparkable): harden phase 29 billing foundation #54.TENANCY_MODEsays, so a second org could claim someone's import and stamp it with the wrong org. Found in code review; covered by a new test.SYSTEM_WORKFLOW_SLUGS, another core edit under aFORKcomment (ask docs(resparkable): scope Sparkey/Workspace/Activity UI redesign and Present mode #55).Checks run locally
db:seedclean, all 69 drift probes pass.Before deploying to a real database
tpl-*schedule query in the 0.14.0 CHANGELOG entry under Changed. A schedule pointing at a built-intpl-*workflow will stop running.Known limits
TransferJobto record the requesting org.🤖 Generated with Claude Code