Skip to content

feat: a session's sandbox tier — control plane, exchange, mocactl (P6.3 PR 2, #425) - #444

Merged
pdettori merged 12 commits into
rossoctl:mainfrom
pdettori:feat/425-slice3-pr2
Oct 6, 2026
Merged

pdettori merged 12 commits into
rossoctl:mainfrom
pdettori:feat/425-slice3-pr2

Conversation

@pdettori

@pdettori pdettori commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

PR 2 of 3 for #425 (P6.3, slice 3 of epic #426). The control plane now records a session's sandbox tier, the exchange returns it, and the turn runs in that tier. PR 1 (#442) built the data-plane side: tier filtering and session-to-sandbox affinity in selectPoolSandbox. This PR connects it to sessions. PR 3 adds deployment wiring and acceptance.

Spec: docs/specs/2026-10-04-p6-on-kubernetes-slice3-design.md, now at v1.3. §0.3 records the corrections this PR made.

What changed

  • Control plane reads and advertises the tiers.
    • SH_SANDBOX_TIERS and SH_SANDBOX_DEFAULT_TIER are parsed at boot. An invalid list or default refuses to start.
    • packages/control-plane/src/sandbox-tiers.ts is a copy of the harness parser. The same fixture (harness/test/fixtures/sandbox-tiers-cases.json) tests both copies.
    • GET /v1/discovery returns sandboxTiers: { names, default } | null.
  • A session's tier is chosen once, at creation.
    • POST /v1/sessions accepts sandbox: { tier }. If the tier is omitted, the deployment default is recorded. Changing the default later never moves an existing session to another tier.
    • These requests get a 400 invalid_request that names the declared tiers: sandbox is not an object; the tier is not a string; the tier is undeclared; or the deployment declares no tiers but the request names one.
    • Session list and get show sandboxTier, the tier the session actually runs in: the recorded tier, or the deployment default. The default applies to a session recorded with '' (created while the deployment was untiered) and to a record written before P6.3. Such sessions follow the deployment default on every turn, so a later change to the default does move them. A session created with tiers declared never moves.
    • /internal/credentials returns sandboxTier only when it is non-empty.
  • The data plane carries the tier into the turn. TurnAuth.sandboxTier holds the tier, and buildConfig passes it on as TurnConfig.sandboxTier. Only a non-empty string from the exchange is trusted.
  • The resources view shows where a session runs.
    • GET /v1/sessions/{id}/resources gains session.sandboxTier and a top-level placement: { sandboxId, tier, workspaceReset: { at, from } | null } | null.
    • Fix: the control plane's runtime-field allow-list now accepts PR 1's placement fields. Before, they were dropped on write. A leased turn with no tier now writes sandboxTier: '', so a stale tier never sits beside a new sandboxId.
    • Fix: a turn that throws after a workspace reset still records its placement. The new ExecuteTurnInput.onPlacement callback reports it, and both /turn paths use it.
  • API documentation and mocactl.
    • OpenAPI documents all of the above.
    • mocactl adds a sandboxTier session option. It is available in the New Session overlay, in presets, and as run --new --option sandboxTier=<name>. The server default is preselected.
    • In a non-interactive run --new, an omitted tier is left to the server, not refused.
    • Against a control plane that predates discovery, the option is skipped.
    • The Sessions list shows each session's tier.
    • A server refusal is now matched to its option by error code (refusalCode), not by the order of the options.

Compatibility

  • With SH_SANDBOX_TIERS unset:
    • discovery returns sandboxTiers: null;
    • the exchange response is byte-identical to before;
    • session views show sandboxTier: null;
    • mocactl shows no tier option.
  • A session record written before P6.3 has no stored tier, so it is given the current default.
  • A new mocactl works against an older control plane: if discovery has no sandboxTiers, or returns a 404, there is no tier option.

Test results

make lint       EXIT:0
make typecheck  EXIT:0
make test       EXIT:0   (control-plane 490, knative-server 434, mocactl 574, harness 579, …)

packages/knative-server/test/run-leaf-route.test.ts › "400s on a malformed envelope" failed once locally (it got a 404) and passed on every rerun. This branch does not touch that file, so it looks like an existing flaky test.

🤖 Generated with Claude Code

…discovery (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… returned by the exchange (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… (Task 9 review)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ded for its session (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…cement (rossoctl#425)

projectResources gains a top-level placement (sandboxId, tier, workspaceReset) from the
data plane's turn-end report, and session.sandboxTier by the session view's rule
(sessionTier: the stored tier, or today's default for a record written before P6.3).

The runtime hash allow-list now keeps sandboxId, sandboxTier, workspaceResetAt and
workspaceResetFrom: putRuntime dropped them on write, so placement could never have
appeared.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… reports where it ran (rossoctl#425)

The turn-end runtime report read the placement from result?.sandbox only, so a turn that
fell back to a fresh sandbox and then threw recorded no sandboxId, tier or workspace reset,
and the resources view never showed that the session lost its workspace.

executeTurn gains onPlacement, called once inside the lease's try as soon as a leased turn
has a placement (before the workspace_reset frame), with the shape withPlacement attaches.
Both authenticated /turn paths capture it and report result?.sandbox ?? placement.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…time, pre-P6.3 view test (rossoctl#425)

- resources: workspaceResetAt goes through num(), so a garbage self-reported time is
  workspaceReset: null rather than at: NaN (and '' stays absent).
- harness: the onPlacement test asserts its order against workspace_reset in one log.
- handlers-sessions: the pre-P6.3 view test deletes the field outright and creates the
  session in a non-default tier, so it can pass only through sessionTier's fallback.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…tier API (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ontrol plane; order-free refusal mapping; placement schema (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…t runs in (rossoctl#425 final review)

The session and resources views showed null for a record stored with ''
(created while no tiers were declared), though the data plane gives such a
session the deployment default -- so /resources showed session.sandboxTier
null beside placement.tier 'container'. Both views now use viewTier: the
stored tier, else today's default, null only on an untiered deployment.
sessionTier and the exchange are unchanged.

Docs (OpenAPI Session.sandboxTier, spec §0.3 item 4, §3.3, §3.4, §6) say
the same; the stale "'' or undefined means no tier filter" bullet now says
the data plane applies the default on a tiered deployment. Also drops a
comment copied from the harness that is wrong in the control plane's copy
of the tier parser.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…nal review

- mocactl: a malformed discovery sandboxTiers (anything but
  { names: string[], default: string }) counts as no tiers instead of
  breaking session creation with a TypeError.
- mocactl run: a --option value not among the choices is named as rejected
  ("unknown sandbox tier 'gpu'; declared: container, microvm"), sanitized,
  for any session option field.
- knative-server: runtimeFieldsForTurn writes sandboxTier even as '', so an
  untiered turn clears the previous turn's tier in the merged runtime hash
  (putRuntime keeps empty strings; pinned by an ownership test).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This connects PR 1's data-plane tier filtering to sessions cleanly. The tier is validated and recorded once at creation, carried through the exchange, and shown in the session, resources and mocactl views. Things I checked that hold up:

  • The session hash (sh:cp:session:<id>) and the runtime hash (…:runtime) are separate keys. The new runtime sandboxTier allow-list entry therefore can't overwrite the tier recorded at creation.
  • The onPlacement capture covers turns that throw, on both /turn paths.
  • The mocactl resolution edge cases behave correctly: untiered with --option, a single tier with a wrong value, headless with multiple tiers, and a 404 from discovery.

One nit, outside the diff hunks: docs/api/openapi.yaml:421 still types the resources session as a bare { type: object }. So the new session.sandboxTier isn't documented there, although the PR body says OpenAPI covers everything.

Author: pdettori (MEMBER — maintainer)
Areas reviewed: TypeScript (control-plane, knative-server, harness, mocactl), OpenAPI/docs, tests
Agent/IDE config (.claude/.vscode): none
Commits: 11 commits, all signed off: yes
CI status: passing (14/14)

No blocking issues. Posted as COMMENT only because GitHub doesn't allow an author to approve their own PR.

Assisted-By: Claude Code

Comment thread packages/control-plane/src/exchange.ts Outdated
* field at all and gets TODAY's default: what it would have been given had it been created now.
*/
export function sessionTier(rec: SessionRecord, tiers: CpConfig['sandboxTiers']): string {
return rec.sandboxTier ?? tiers?.default ?? '';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: A record stored as '' makes this function (??) omit the tier, so the data plane falls back to its own SH_SANDBOX_DEFAULT_TIER. Meanwhile viewTier (||) shows the control plane's default. The view only matches the actual placement if both processes read the same default.

Using || here as well would keep the untiered response byte-identical (when tiers is null this still returns ''). It would also make the exchange the one source of truth for '' sessions, and sessionTier and viewTier would differ only in returning '' vs null. Turn behaviour wouldn't change: '' sessions would still follow the current default on every turn.

Leaf paths still read the data-plane env, so PR 3 must wire both sides from one value regardless. This just removes one place where they can disagree.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 12a65d9. sessionTier is now rec.sandboxTier || tiers?.default || '', so for a session stored as '' on a tiered deployment the exchange names today's default instead of leaving it to the data plane's SH_SANDBOX_DEFAULT_TIER.

  • viewTier is now sessionTier(rec, tiers) || null, so the two differ only in '' vs null.
  • Untiered, it still returns '', so the exchange response is unchanged (the existing strict toEqual still pins it).
  • Added an exchange test: a session created untiered, then exchanged on a tiered deployment, gets sandboxTier: 'microvm'. A mutation back to ?? fails it and two unit tests.
  • Updated the sessionTier, ExchangeResponse and SessionRecord comments, the OpenAPI /internal/credentials description, and spec §0.3 item 4, §3.3 and §3.4. The §3.4 fallback now covers only the unauthenticated and leaf paths, which, as you note, PR 3 must wire from the same value.

… recorded no tier; document the resources session (rossoctl#444 review)

sessionTier now treats a stored '' like a pre-P6.3 record: on a tiered deployment the exchange
names today's default instead of leaving the data plane to apply its own SH_SANDBOX_DEFAULT_TIER,
so the views (viewTier = sessionTier || null) and the placement come from one value. Untiered,
it still returns '' and the exchange response stays byte-identical.

docs/api/openapi.yaml types the resources response's session object, including sandboxTier, and
the /internal/credentials sandboxTier description follows the new rule. Spec §0.3 item 4, §3.3
and §3.4 updated.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori

pdettori commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Review nit (docs/api/openapi.yaml:421, the untyped resources session): fixed in 12a65d9. The session object now has its properties (id, state, createdAt, lastTurnAt, turns, sandboxTier), all required, matching projectResources. sandboxTier is documented by the same rule as Session.sandboxTier. Both contract tests pass.

Checks on 12a65d9: control-plane 492 passed, knative-server 434 passed, mocactl contract tests passed, make typecheck EXIT:0, make lint EXIT:0.

@pdettori
pdettori merged commit 70f708d into rossoctl:main Oct 6, 2026
14 checks passed
@pdettori
pdettori deleted the feat/425-slice3-pr2 branch October 6, 2026 01:09
pdettori added a commit to pdettori/moca that referenced this pull request Oct 7, 2026
…nd 11 claims marked historical (rossoctl#454 review)

The P6.3 spec's Status is Implemented (PRs rossoctl#442, rossoctl#444 and rossoctl#454; accepted on the KVM rig and OpenShift
4.20.8), its two mocactl --tier mentions are --option sandboxTier=..., and the README's P6.3 row is
implemented. The slice-2 spec's 'both tiers together are refused' carries the P6.3 superseded pointer,
and its '11 claims' is the count of slice 2's time (P6.3 adds claim 12).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant