Repository navigation
docs(spec): MU1 multi-user control plane — owned sessions, per-user credentials - #238
Conversation
|
I think this should be moved to its own track |
…edentials Specs multi-user support as a trusted always-on control plane fronting the existing scale-to-zero data plane. Tracing the tree at b533d87 shaped three decisions the requirements did not anticipate: - The credential path reads the environment in THREE places, not one (run-turn.ts:306, :310-311, :313). Removing only the write-once-if-absent seed leaves the `||` fallbacks intact, so the /turn path would still fail open. All three go, on that path only — a narrow cut. - Redis is fully ephemeral (redis.yaml has no PVC and no appendonly), so credentials cannot live there. Per-user K8s Secrets under envelope encryption, in their own namespace, with no `list` verb on the serving path. - Requirement 4 has no reverse index to read: the lease ZSET is keyed by run id, not session id (sandbox-lease.ts:3-6). The harness self-reports its runtime record, and that record is display-only — never authz input. Two things are named rather than glossed. Slice 1 ships a SHARED sandbox pool, so isolation holds at the API, session store, and inference credential but not the sandbox; and the control plane holds both identity and credentials, which diverges from Z1 §2's "orchestrator holds no secrets" and is contained by a CredentialStore seam plus RBAC scoping until Z3/Z5 retire it. §3.5 sets the ownership boundary with the P5 session-isolation spec in PR rossoctl#228, whose implementation is a separate contributor's track on a different timeline. Z8 is adjacent to it, not blocked on it: Z8 owns /turn, P5 owns the leaf/CLI paths, the global reachability pins, and the scrub. Both touch run-turn.ts:306-313, so whichever lands second rebases; neither ordering invalidates the other. P5 reserves ADR-0032, so this takes ADR-0033. The tenant partition slice 2 needs is blocked on an already-deferred ADR-0028 decision at server.ts:308-320, where a workload's pool selector is ignored for kind:'prompt' leaves. Filed as rossoctl#237 rather than decided here, since it also affects the non-multi-user /runs path. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…th merged P5 The review asked for this to move to its own track. Rebasing onto main to do it surfaced that rossoctl#228 had merged in the meantime, and reading the merged P5 spec contradicted a load-bearing assumption in §3.4 — so this commit is both. Own track. Z8 becomes MU1, first entry in a new `MU` (multi-user service) track, with its own registry section and dependency notes. Phase 2 is a security architecture; this is a product surface with its own consumers and compatibility obligations. They meet only at the credential. MU2/MU3 are recorded so the former slice 2/3 have ids. §3.4 corrected. The draft said "pass the key explicitly and pi's per-session apiKey does the rest." P5 §3.3 and run-turn.ts:150-155 show there is no such seam: createAgentSession cannot inject a per-session key, and pi resolves the key by provider name from ANTHROPIC_API_KEY. What IS per-session is the model object, rebuilt each turn at :497 by applyModelGateway, which installs Authorization: Bearer from config and already prefers config over env at :306. Two consequences: MU1 touches no line of run-turn.ts, and deleting the :310-312 seed without P5's sentinel would break gateway mode outright. §3.5 rewritten from "ownership boundary on shared files" to composition. P5 step 1 already reserved Authorization for "may this caller use the harness" — this spec — and carries whose-work-it-is in X-SH-Subject. MU1 adopts that split unchanged and adds what P5 cannot: making the subject trustworthy instead of asserted. When a session token is present the subject is token.sub and an inbound X-SH-Subject is ignored; both present and conflicting is a 400, not a precedence rule, because a silent winner there is a cross-tenant bug. Pre-P5, MU1 enforces fail-closed by policy at two points it owns rather than by construction. §3.5, §8.1 and §9.3 test 1 now state which is in force instead of implying the stronger one; the test asserts the policy form today and tightens when the sentinel lands. Also: run-turn.ts seed cited as :310-312, matching P5 and the closing brace. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
16736a7 to
6ad717a
Compare
|
Done — moved to its own track. Rebasing to do it surfaced that #228 merged in the meantime, and reading the merged P5 spec contradicted something load-bearing in §3.4, so this push is both changes. Own track. §3.4 was wrong and is corrected. The draft said "pass the key as an explicit argument and pi's per-session Two consequences, both good for the split:
§3.5 is now composition, not a conflict boundary. P5 step 1 already reserved One honesty change worth flagging. Pre-P5, MU1 enforces fail-closed by policy at two points it owns (session creation and the exchange both refuse a subject with no resolvable credential), not by construction (nothing ambient reachable). My earlier draft implied the stronger property. §3.5, §8.1 and §9.3 test 1 now say which is in force, and test 1 asserts the policy form today and tightens to the sentinel form once P5's implementation lands — so the weaker assertion doesn't have to be deleted later. P5's own §5 makes the reason concrete: asserting the sentinel alone would stay green while an Also fixed the seed citation to Rebased onto Still open from the PR description: |
P5's design is merged, so it should not be rewritten to accommodate a later spec. The interactions between them therefore live here, in MU1. The one that would actually bite: the Bearer payload is one-or-the-other. P5 targets an inert placeholder swapped by RC1's static-inject; MU1's interim direct mode carries the real token. Same field, incompatible contents — a P5 implementation that unconditionally sets the placeholder would silently overwrite MU1's token and requests would fail with nothing configured to swap it. So TurnConfig carries a TAGGED credential, and the control plane declares the mode at exchange time, with placeholder mode winning wherever an injector exists. Adding an injector then strictly narrows what the harness may hold. The tag is not ceremony. A bare string makes both failure directions silent: a placeholder sent upstream with a misconfigured injector, or a real key rewritten by an injector added later. P5 §3.4 argues an ambient placeholder is as dangerous as an ambient key because the injector faithfully swaps in whichever tenant it names — which applies equally to a mislabelled one. Second, direct mode diverges from P5 §5's in-process invariant, not only from Z1 §2 / Z3. Named in both spec and ADR so P5's implementation does not assert something MU1 knowingly breaks; its lock-down assertion wants scoping to the environment, which MU1 never writes. Third, inbound X-SH-Subject becomes conditional on there being no session token, so P5 should not pin "always honoured" — operator and leaf paths keep it. Also aligns naming now that the track exists: former slices 2 and 3 are MU2 and MU3, and §2.2 no longer reads as though MU1 performs P5's deletions. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
|
Added §3.6 — What MU1 owes P5's implementation, since P5's design is merged and shouldn't be rewritten to accommodate a later spec. Three interactions, one of which would have bitten: The Resolution: Direct mode diverges from P5 §5, not just Z1/Z3. §5 asserts no real provider credential is reachable from the harness process; direct mode puts one there per turn. Now named in both the spec and ADR-0033's accepted-cost line, so P5's implementation doesn't assert an invariant MU1 knowingly breaks. Suggested there that the assertion be scoped to the environment, which MU1 never writes. Inbound Coordination note posted on #228 (#228 (comment)) rather than a P5 tracking issue — the issues replacing #220 don't exist yet, so that needs carrying into whichever one gets filed. Also aligned naming now the track exists: former slices 2 and 3 are MU2/MU3, and §2.2 no longer reads as though MU1 performs P5's deletions.
|
cwiklik
left a comment
There was a problem hiding this comment.
Approving. Docs-only, and unusually well-traced: every file:line citation in this spec checks out against source — the three credential reads (run-turn.ts:306, :310-312, :313), createAgentSession at :499-504, the model rebuild at :497, buildConfig and its four call sites, resolveRunWorkload at server.ts:300-324, :553, :482-494, leaf-job.ts:15-18, cli.ts:9-14, redis.yaml (no PVC / no appendonly / 128Mi), sandbox-lease.ts:3-6. The ADR-0028 deferral is quoted verbatim and correctly.
I had drafted this as REQUEST_CHANGES against 3d5ac7dc on four points; the last two commits resolved all four, so this is an approval of f29a83b5:
- §8.1's mechanism. The earlier row attributed "a turn runs on its own subject's key or not at all" to no env fallback on
/turn, which did not follow: with no credential,run-turn.ts:314returnsbaseModelunchanged rather than throwing, leaving pi'swithEnvApiKeyandservice.yaml:45-49's real key reachable — while the scrub that would close it was assigned to P5. §8.1 now says enforced by policy pre-P5, by construction once P5's sentinel lands, §3.5's "If P5's implementation has not landed" draws the cannot vs does not line explicitly, and ADR-0033 records it as an accepted cost. That is the honest form of the claim. - §9.3 test 1 previously set only
ANTHROPIC_AUTH_TOKEN— the one variable the change stops reading — so it would have stayed green whileANTHROPIC_API_KEYstill authenticated. Now two-phase, assertingANTHROPIC_OAUTH_TOKEN/ANTHROPIC_AUTH_TOKENabsent and citing the precedence. - The merge conflict from #228's
docs/adrs/README.mdcolumn reflow, and the four stale "#228, open againstmain" statements — both gone; noZ8or "whichever lands second" leftovers anywhere.
Two things worth calling out as better than what I asked for. §3.4's correction — that createAgentSession exposes no seam for a per-session key, so the per-turn model object is the only per-subject surface — is the right reading of the code and reverses a plausible-sounding first draft. And §3.6 catches a collision I had not raised: P5's inert placeholder and MU1's real token contend for the same Authorization: Bearer field, in which a bare string makes both failure directions silent. The tagged UpstreamCredential with placeholder-mode-wins is the correct resolution, and "adding an injector strictly narrows what the harness may hold" is the property that makes it safe.
Four non-blocking comments inline: two suggestions (§8.2's #237 dependency looks inverted; §6.2's descriptor has no field for the base URL §5.3 returns) and two nits.
Author: pdettori (MEMBER — maintainer)
Areas reviewed: Docs/specs, ADRs
Agent/IDE config (.claude/.vscode): none
Commits: 3, all signed off
CI status: passing (12/12)
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com
| label, and a lease can only ever match its own partition. This preserves the warm pool and the P2/P3 | ||
| density work, at the cost of a minimum idle pod count per active tenant. | ||
|
|
||
| It is blocked on the deferred ADR-0028 decision recorded in §2.6: the resolver **ignores** a workload's |
There was a problem hiding this comment.
suggestion — I think this dependency is inverted, and the source cited two paragraphs up says so directly. server.ts:308-311:
A prompt leaf DOES lease a pool sandbox now, and honors an envelope
sandboxPoolSelector(ADR 0028 amendment, 2026-09-01) — but a workload-addressed one still ignores the workload's own selector.
Two different selectors. #237 governs only the workload-addressed one — whether a workload's own sandboxSelector should bound its prompt leaves. But the mechanism this section proposes is the envelope: "the control plane supplies a sandboxPoolSelector per tenant through the seam at server.ts:553/:300-324". That path already works for prompt leaves — sandboxEnvironment() (run-leaf.ts:124-128) is not gated on kind, and runPromptLeaf reaches it at :390.
If the control plane injects the tenant selector into the leaf envelope after the :553 scrub — which is exactly what that comment describes as the intended post-boundary path — the partition is enforceable today. Worth re-checking, because it may unblock MU2's headline item entirely.
Same claim in §11.1 and in docs/specs/README.md (the MU2 row's "gated on #237", and the MU2 is gated bullet), so it propagates to the track table.
There was a problem hiding this comment.
You're right, and it's the more useful kind of wrong — it unblocks MU2's headline item. Traced it before changing anything:
sandboxEnvironment()(run-leaf.ts:124-128) checks onlyenv.sandboxPoolSelector, not gated onkind;runPromptLeafreaches it at:390.selectPoolSandboxtakes its environment as an argument (select-sandbox.ts:75), so the selector is already request-scoped rather than process-global — which is exactly what a per-tenant partition needs, and I'd assumed had to be built.
So #237 governs only the workload-addressed selector, as you say. §8.2 now carries the two-selector table with your quoted source, and #237 stays open on its own merits for the /runs + workloadId path.
One correction to the conclusion, though — the partition isn't quite enforceable today on MU1's path. sandboxPoolSelector is a LeafEnvelope field on the /runs path; /turn never leases from the pool at all — run-turn.ts:57 resolves a single pod via resolveSandboxConfig, never selectPoolSandbox. Since MU1's interactive path is /turn, MU2's work is making that path take a request-scoped selector, which is the same substitution runPromptLeaf already made and which its own comment at run-leaf.ts:385-387 calls "a superset, not a behavior swap" (no selector set ⇒ identical single-pod fallback).
Net: not a deferred cross-cutting decision, just plumbing on a path this spec already touches. Propagated to §11.1, the MU2 registry row, and the MU2 is not gated bullet.
| ```jsonc | ||
| PUT /v1/credentials/github-work | ||
| { | ||
| "kind": "bearer", // registry entry: bearer | basic | api-key | oauth2-token | sigv4 | … |
There was a problem hiding this comment.
suggestion — the descriptor has no field for the gateway base URL, but §5.3's exchange returns one:
◀── { anthropicAuthToken, anthropicBaseUrl }
destination.hosts is a host allow-list (["api.github.com", "github.com"]), not a base URL — applyModelGateway needs a full origin plus path, e.g. https://litellm.internal/v1. So for consumer: inference it is unclear where anthropicBaseUrl is stored, or whether it is deployment-wide rather than per-credential.
This matters because of the || at run-turn.ts:313: if config.anthropicBaseUrl comes back undefined, gatewayBase falls through to process.env.ANTHROPIC_BASE_URL, and if that is also unset the model gets Authorization: Bearer <subject token> with no baseUrl override — the subject's gateway token sent to the default endpoint. Either add an optional base-URL field to the descriptor, or state that it stays deployment-level and the exchange simply echoes it.
There was a problem hiding this comment.
Real gap, fixed. Added an optional endpoint field to the descriptor, meaningful only for consumer: inference, with the reasoning you gave: destination.hosts is a host allow-list, not an origin, so it can't serve as the gateway address applyModelGateway needs.
Resolution order is credential endpoint → deployment default → refuse with endpoint_unresolved. The refusal is the substantive part, and it's your :313 trace that makes the case: with config.anthropicBaseUrl undefined, gatewayBase falls through to process.env.ANTHROPIC_BASE_URL, and with that unset the model gets Authorization: Bearer <subject's token> and no baseUrl override — one user's gateway token sent to the default Anthropic endpoint. That's a misdirected secret rather than a degraded request, so it fails closed instead of defaulting.
Per-credential rather than deployment-only because a user may hold keys for different gateways, and endpoint is non-secret metadata so it lists without decryption like the rest of the descriptor.
Threaded through §5.3 (the exchange now returns {mode, anthropicAuthToken, anthropicBaseUrl} and never an undefined base), §6.2, §9.1's taxonomy, and §9.2's fail-closed list.
|
|
||
| `createAgentSession` (`run-turn.ts:499-504`) exposes **no seam** to pass a per-session key into the | ||
| session's `ModelRegistry`. Pi resolves the request key **by provider name** — | ||
| `authStorage.getApiKey('anthropic')` → `getEnvApiKey('anthropic')` → `process.env.ANTHROPIC_API_KEY` |
There was a problem hiding this comment.
nit — the chain is missing a step that this section's conclusion depends on. getApiKeyEnvVars("anthropic") returns ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] — OAuth first, so it outranks the API key.
That is load-bearing for the diagram's ANTHROPIC_API_KEY = P5's inert sentinel ← satisfies pi's existence check: the sentinel only ends up being what pi resolves if ANTHROPIC_OAUTH_TOKEN is absent, which is precisely why P5's step 3 deletes it rather than only overwriting ANTHROPIC_API_KEY. §9.3 test 1 already has this right ("asserting the sentinel alone would stay green while an OAuth token outranked it") — worth one clause here so the two sections agree.
There was a problem hiding this comment.
Added. §3.4 now states that getApiKeyEnvVars('anthropic') returns ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] in that order — OAuth first — so the sentinel is only what pi resolves once ANTHROPIC_OAUTH_TOKEN is deleted, which is why P5's step 3 deletes rather than merely overwrites. Also added the deletion to the diagram, so §3.4 and §9.3 test 1 now say the same thing rather than one of them carrying the load alone.
| mints session identity **and** holds the credential store. | ||
|
|
||
| That is a real concentration of risk, accepted for one reason: the alternative — per-subject | ||
| resolution at the inference injector — lives in `kagenti-extensions`, outside this repo, and would |
There was a problem hiding this comment.
nit — kagenti-extensions has been renamed and now redirects to rossoctl/cortex. Pre-existing (the merged P5 spec and ADR-0032 both carry the old name, so this is a shared docs nit rather than anything introduced here), but these are new lines, so worth using the current name in them — also in ADR-0033's Context and Consequences, and in the MU3 row of docs/specs/README.md.
There was a problem hiding this comment.
Updated to rossoctl/cortex on the lines this PR adds — §3.3 of the spec, and ADR-0033's Context and Consequences. The MU3 registry row now says rossoctl/cortex too.
Left the merged P5 spec and ADR-0032 alone, and also the pre-existing kagenti-extensions mention in the Phase-2 note in docs/specs/README.md — that one predates this PR, and renaming it here would mix an unrelated docs sweep into a spec review. Worth a separate pass if you want the whole tree consistent.
…te MU2, plus the gateway endpoint Four review comments, all verified against source before acting. The rossoctl#237 dependency was inverted, and the reviewer is right. There are two selectors, and server.ts:308-311 distinguishes them in the same sentence: the ENVELOPE sandboxPoolSelector, which prompt leaves DO honour, and the WORKLOAD-ADDRESSED one, which they ignore. rossoctl#237 governs only the second. MU2's partition uses the first, and sandboxEnvironment (run-leaf.ts:124-128) is not gated on kind — runPromptLeaf reaches it at :390, and selectPoolSandbox takes its env as an argument (select-sandbox.ts:75), so the selector is already request-scoped. What remains is smaller and different: /turn does not lease from the pool at all (run-turn.ts:57 resolves a single pod via resolveSandboxConfig). So MU2's work is the substitution runPromptLeaf already made — described by its own comment at run-leaf.ts:385-387 as "a superset, not a behavior swap". §8.2, §11.1, the MU2 registry row and the MU dependency bullet all corrected; rossoctl#237 stays open on its own merits for the workload-addressed path. The descriptor had no home for the gateway base URL while §5.3's exchange returned one. destination.hosts is a host allow-list, not an origin, so an optional `endpoint` field is added for consumer: inference. Resolution is credential → deployment default → REFUSE with endpoint_unresolved. Refusing is the point: run-turn.ts:313's `||` would otherwise fall through to the environment and, with that unset, hand the model the subject's token and no baseUrl override — sending one user's gateway token to the default Anthropic endpoint. That is a misdirected secret, not a degraded request. §5.3, §6.2, §9.1 and §9.2 updated together. Two nits: §3.4 now states that getApiKeyEnvVars returns OAuth first, so the sentinel is only what pi resolves once ANTHROPIC_OAUTH_TOKEN is deleted — which is why P5 step 3 deletes rather than overwrites, and makes §3.4 agree with §9.3 test 1. And kagenti-extensions is now rossoctl/cortex on the lines this PR adds (the merged P5 spec and ADR-0032 keep the old name; not touched here). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… nit §4.4's correction (post-final-Exec refill is D standbys, not zero, aged out by StandbyIdle) hadn't propagated to three downstream sites that still asserted the retracted "zero" claim: - §7.4 prediction 5's third clause - §8's reclamation-path unit test description - §9's Release deferral bullet, which also still called the idle thresholds "crash backstops" and repeated the retracted 90s/RAM rationale — collapsed into a pointer at §4.4 instead of a second copy of the argument Also: the Config struct still carried WorkspaceIdle's superseded 2h value (everywhere else in the doc moved to 30m interim, §4.5 owns final); and the MU2 reference resolved to no such issue, reworded to name MU1 (rossoctl#238) instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
What
Specs MU1 — Multi-User Control Plane: an always-on trusted tier fronting the existing
scale-to-zero data plane, so many users can share one deployment while each authenticates, holds
their own credentials, and sees only their own sessions.
Docs only — no code changes. Adds:
docs/specs/2026-09-08-multi-user-control-plane-design.mddocs/adrs/0033-multi-user-control-plane.mddocs/specs/README.mdanddocs/adrs/README.mdShape of the design
Deployment(@sh/control-plane), deliberately not Knative — it holds an OAuth client, mints tokens for cron-fired runs with no client present, and is the trusted tiersub+sidand no secret. The harness holds only the public key, so a compromised harness can verify but never mint — the trust tier dictates the algorithmlistverb on the serving path. Open registry keyed by consumer tier, destination-boundassertOwner()choke point; 404 (not 403) across tenants, to avoid an existence oracleThree findings from tracing the tree at
b533d87These changed the design mid-flight and are the spec's main value:
run-turn.ts:306(||fallback),:310-311(the write-once-if-absent seed),:313. Removingonly the seed leaves the
||fallbacks intact, so/turnwould still fail open. All three go.deploy/knative/redis.yamlhas no PVC and noappendonly, socredentials cannot live there. That is what moved them to Secrets.
id (
sandbox-lease.ts:3-6), so the "view my compute resources" requirement had nothing to read.The harness self-reports a runtime record, which the spec marks display-only, never authz input.
Two limitations named rather than glossed
inference credential — not the sandbox. The demo narration says so. The tenant-labelled
partition is slice 2 and is blocked on Decide whether a workload's sandbox pool selector should bound its
kind:'prompt'leaves #237.Z1 §2's "orchestrator holds no secrets".
Accepted in ADR-0033 to avoid blocking on
kagenti-extensions, contained by aCredentialStoreseam plus RBAC scoping, and retired by Z3/Z5 in slice 3.
Relationship to #228
MU1 is adjacent to, not blocked on, the P5 session-isolation spec in #228, whose implementation is
a separate contributor's track on a different timeline. §3.5 sets the ownership boundary:
/turnandbuildConfig's signature.ScaledJoband CLI paths, the reachability pins for the four inert globals,and the scrub.
Both touch
run-turn.ts:306-313, so whichever lands second rebases; neither ordering invalidates theother's design. MU1's cut is a strict subset of P5's step 2. P5 reserves ADR-0032, so this takes
ADR-0033 — note that leaves 0032 unused if #228 does not land.
Filed alongside
kind:'prompt'leaves #237 — decide whether a workload's pool selector should bound itskind:'prompt'leaves. Deferredat
server.ts:308-320under ADR-0028; it gates slice 2's tenant partition, and it also affects thenon-multi-user
/runspath, so the spec records it as owed rather than deciding it.Open questions for review
Status: Proposedin both the spec and the registry cell (design (proposed)) — bump togetheron acceptance.
Verification
make lintpasses (prettier, shellcheck, hadolint, gitleaks, yaml). No code touched, so no test runapplies.
🤖 Generated with Claude Code