Repository navigation
fix(control-plane): #713 review follow-ups — correct the refusal docs, bound the dequeue turn id - #714
Conversation
…e list Review follow-ups from rossoctl#713, none of which changes behavior. The 404 description in openapi.yaml and the comment at errors.ts both claimed the claim refusals are "deliberately indistinguishable" and give "no existence oracle". Neither holds as written: turn_not_found, session_not_found and 409 turn_not_claimable are three different codes, and the last one's message names the state. Only the status matches on two of the three, and a worker needs them apart to record why it dropped a queue entry. What is actually true is narrower and enough: every caller on this route authenticated with the deployment-wide SH_EXCHANGE_TOKEN, and a successful call hands back the credential itself, so no refusal reveals anything the same caller could not already obtain. Both places now say that, and both note this is NOT spec §8.1's 404-rather-than-403 rule, which governs the owner-scoped /v1 routes a session token reaches. http-api.md's code list also missed codes the same 1.3.0 changelog introduced: 429 async_quota_exhausted and a 501 group (async_tenancy_unsupported, workload_runs_unavailable). Refs rossoctl#713, rossoctl#388 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
Review follow-up from rossoctl#713. POST /internal/turn-credentials took any string as its turnId and built a Redis key from it -- HGETALL sh:cp:turn:<input>, with TurnStore.tag free to append :${keyTag} besides. submitTurn is the only minter and its ids are always t-${randomUUID()}, so the shape is knowable and anything else can be refused before Redis is touched. This closes no hole on its own: get only reads, and the handler re-checks the record's owner against its session. It bounds the key space a shared-bearer route can be made to address, which is the cheaper default there. 400 rather than 404 because this caller holds the deployment-wide exchange token and gets the credential on success, so a distinguishable "you sent nonsense" tells it nothing new and names the actual bug. isMintedTurnId lives in turn-record.ts beside turnKey rather than reusing sandbox-handlers.ts's unexported UUID_RE, since the t- prefix is this module's own minting contract. The owner-scoped /v1 turn routes deliberately do not use it: there a malformed id must stay a 404, indistinguishable from another subject's turn, or the code becomes the oracle assertOwner exists to deny. Verified load-bearing by mutation: with the gate removed, exactly one test fails and it is the new one. server.test.ts's unknown-turn case now uses a well-formed id, or the gate would answer first and it would stop covering the 404 at all. Refs rossoctl#713, rossoctl#388 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
Review follow-ups on this PR's own two commits.
The shape check the dequeue exchange now performs was enforced in code and
absent from the contract: `openapi.yaml` still described `turnId` as a bare
string and mapped the 400 to the generic `BadRequest`. This repo already
decided against that — `credentialName` and `sandboxId` state their
code-enforced patterns and `openapi-contract.test.ts` asserts it, because
two sources of truth for a validation rule is how a documented API starts
lying. `MINTED_TURN_ID_RE` is now exported, stated on the request body,
pinned by a contract test, and the route carries its own 400 description
telling a worker to ack and drop rather than retry a corrupt id.
The gate rests on an invariant nothing enforced: `isMintedTurnId` encodes
`t-${deps.newId()}` with newId = randomUUID, but newId is injectable. Point
it at anything shorter and every submission still answers 202 while every
claim 400s — enqueued, never claimable, redelivered until it dead-letters,
with no test failing. One test now submits with the production newId and
asserts the minted id passes the gate. Verified by mutation: shortening the
minter fails that test, the new contract test and the whole e2e suite.
Also: the argument for why distinguishable refusals cost this caller nothing
was written out four times, and this PR exists because one duplicated doc
claim went stale in two places. It now lives in `openapi.yaml` alone, with
one-line pointers from `errors.ts`, `handlers.ts` and `turn-record.ts` — all
four would otherwise have to change together when M2 stops returning a real
key. The `model`/`sandboxTier` pinning comment pointed at rossoctl#389 in code and
rossoctl#388 in docs; rossoctl#389 cannot pin what the record does not carry, so both now
say rossoctl#388. The 501 group was appended after 503 in an otherwise ascending
list, which is how it came to be missing in the first place. And the two new
`isMintedTurnId` lists are merged: `t-*` and `t-?` were rejected on shape,
never on the glob metacharacter the comment credited.
Control-plane suite: 1158 passed, 3 skipped against real Redis, no Errors.
tsc clean, prettier clean, check-links 198 files / 0 broken.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: cwiklik <cwiklikj@gmail.com>
clawgenti
left a comment
There was a problem hiding this comment.
Summary
Follow-ups to #713. The OpenAPI 404 description and the errors.ts comment now correctly say that the claim refusals are distinguishable. They also make clear that this is not the §8.1 404-not-403 rule. POST /internal/turn-credentials now refuses any turnId outside the minted t-<uuid> shape with 400 invalid_request before Redis is read. MINTED_TURN_ID_RE matches what submitTurn mints with the production newId (randomUUID, main.ts:523), and a contract test pins it against openapi.yaml. The coupling test uses the production newId, and the 404 case in server.test.ts was moved to a well-formed id, so the 404 path is still covered. Editing the 1.3.0 changelog entry is fine: the latest release (v0.10.0) still ships API 1.2.0. No blocking issues.
One nit outside the diff (no inline anchor): the comment at turn-exchange.ts:58 still says the 404 is "Shaped like every other not-found answer". That is the same framing this PR corrects in errors.ts. Also, exchangeTurnCredential is re-exported from index.ts without the shape gate, so a direct library caller skips it. That is fine as long as the gate is meant to be HTTP-only. All checks pass. Ready for human review.
Author: cwiklik (CONTRIBUTOR — returning external; every changed file was read in full)
Areas reviewed: TypeScript (control-plane src + tests), Docs/OpenAPI, Security
Agent/IDE config (.claude/.vscode): none
Commits: 3 commits, all signed-off: yes
CI status: passing (17/17)
Generated by RepoMan github-pr-review@235a7f7 (https://github.com/rossoctl/agent-skills/blob/235a7f7620139ee3e4ab0872b8439591fe52e8c1/skills/github-pr-review/SKILL.md).
Review nit on this PR's own commits (clawgenti, no inline anchor). `turn-exchange.ts:58` still said the 404 is "Shaped like every other not-found answer" -- the exact framing this PR corrects in `errors.ts` and `openapi.yaml`. It is deliberately distinguishable from the other claim refusals, and not the 404-not-403 of spec 8.1. Replaced with a pointer to where that argument lives, matching the one-statement-plus-pointers shape the rest of the PR adopted. The second nit -- `exchangeTurnCredential` is re-exported from `index.ts` without the shape gate -- is intended rather than a gap, so the doc comment now says so: the gate bounds caller input reaching a Redis key on a route authenticated by one deployment-wide bearer, which is a property of the route and not of the function. A library caller has already passed whatever gate it answers to, and repeating the check would only turn its own 404 into a 400. Nothing outside the package imports it today (grepped `packages` and `harness`); only `handlers.ts` and the package's own tests call it. Comments only, no behavior change. Control-plane suite: 1158 passed, 3 skipped against real Redis, no Errors. tsc and prettier clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
|
Both nits addressed in ae71734 (comments only, no behavior change).
Control-plane suite: 1158 passed, 3 skipped against real Redis, no Assisted-By: Claude Code |
clawgenti
left a comment
There was a problem hiding this comment.
Clawgenti Code Review
Summary
No must-fix issues. The shape gate goes in at the right point: the handler checks it after the empty check and before exchangeTurnCredential touches Redis. MINTED_TURN_ID_RE matches what submitTurn mints (t-${randomUUID()}, main.ts:523), and the contract test pins the OpenAPI pattern to it. The corrected refusal docs now match the codes in errors.ts, and the new 429/501 entries in http-api.md are all real codes in CP_ERROR_CODES. One suggestion inline: the coupling test restates the production newId instead of using it.
Author: cwiklik (CONTRIBUTOR — returning external; I read every changed file in full)
Areas reviewed: TypeScript (control-plane src + tests), OpenAPI YAML, Docs
Agent/IDE config (.claude/.vscode): none
Commits: 4 commits, all signed off: yes
CI status: passing (17/17)
All checks pass. Ready for human review.
Generated by RepoMan github-pr-review@235a7f7 (https://github.com/rossoctl/agent-skills/blob/235a7f7620139ee3e4ab0872b8439591fe52e8c1/skills/github-pr-review/SKILL.md).
| // anything shorter and every submission still 202s while every claim 400s -- the turn is | ||
| // enqueued, never claimable, and redelivered until it dead-letters. So this test uses the | ||
| // PRODUCTION newId rather than the fixture's counter, which the gate rightly refuses. | ||
| const d = turnDeps({ newId: () => randomUUID() }); |
There was a problem hiding this comment.
suggestion — This test passes newId: () => randomUUID() itself, so it restates the production wiring instead of exercising it. If main.ts:523 ever switches newId to a different generator (nanoid, ulid, a prefixed id), this test still passes while every real claim 400s. That is the exact dead-letter failure the comment describes, and it contradicts "the only test that fails when it breaks". To make the coupling real, put the production id factory in one exported place (e.g. export const newTurnUuid = randomUUID beside MINTED_TURN_ID_RE), have main.ts import it, and use it here.
There was a problem hiding this comment.
Fixed in 13fb679. The finding is correct, and it contradicted the test's own comment —
() => randomUUID() restated the wiring instead of exercising it, so a switch to nanoid
or a prefixed id would have kept it green while every claim 400s.
One change to the proposed shape: the binding is defaultNewId in handler-util.ts,
not newTurnUuid beside MINTED_TURN_ID_RE. newId has three shape-dependent
consumers and only one is turns — session ids are their own leafSessionId sanitisation
(gap #10), and sandboxId needs 26 random hex and refuses a shorter source. Putting
it in turn-record.ts would move the session and sandbox contract into the turn module.
handler-util.ts is also the existing home for this shape: defaultAuditLog and
defaultRunKubectl are the same thing for their CpDeps fields.
depsFromEnv now wires it — the only production CpDeps producer; I grepped packages
and harness for another newId: and there is none. Three tests inject the same
binding rather than rebuilding it: the coupling test, turn-e2e (which claims every
turn it submits, and was the suite silently carrying this invariant), and
turn-record's accepts exactly what submitTurn mints.
Verified by mutation — defaultNewId → nid-${randomUUID()} fails 8 tests: the
coupling test, all six turn-e2e tests, and the turn-record unit test. Before this
commit the same mutation left every one of them green, which is precisely the gap.
Control-plane suite: 1158 passed, 3 skipped against real Redis, no Errors. tsc and
prettier clean.
…state it Review suggestion on f206e8b (clawgenti, turn-handlers.test.ts:109), and it is right: the coupling test passed `newId: () => randomUUID()` itself, so it restated the production wiring rather than exercising it. Switch main.ts:523 to nanoid, ulid or a prefixed id and that test still passes while every real claim 400s -- the dead-letter failure its own comment describes, and a direct contradiction of "the only test that fails when it breaks". The production id factory is now one exported binding, `defaultNewId` in handler-util.ts, following `defaultAuditLog` and `defaultRunKubectl` -- the shape this repo already uses for a production default of an injectable CpDeps field. `depsFromEnv` wires it (the only production CpDeps producer; grepped `packages` and `harness` for another `newId:`), and the three tests that depend on the id's SHAPE inject the same binding instead of rebuilding it: turn-handlers (the coupling test), turn-e2e (claims every turn it submits), and turn-record (`accepts exactly what submitTurn mints`). handler-util.ts rather than beside MINTED_TURN_ID_RE, because `newId` has three shape-dependent consumers and only one is turns: session ids are their own leafSessionId sanitisation (gap rossoctl#10), and `sandboxId` needs 26 random hex and refuses a shorter source. Naming it `newTurnUuid` in turn-record.ts would have put the session and sandbox contract inside the turn module. Verified by mutation: `defaultNewId` -> `nid-${randomUUID()}` fails 8 tests -- the coupling test, all six turn-e2e tests, and the turn-record unit test. Before this commit the same mutation left every one of them green, which is exactly the gap the suggestion identified. Control-plane suite: 1158 passed, 3 skipped against real Redis, no Errors. tsc and prettier clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
clawgenti
left a comment
There was a problem hiding this comment.
Clawgenti Code Review
Summary
No must-fix issues. The new head commit (13fb679) takes the earlier suggestion. turn-handlers.test.ts, turn-record.test.ts and turn-e2e.test.ts now inject defaultNewId, which is the same binding main.ts wires. Before, they restated () => randomUUID(). So if the production minter drifts from MINTED_TURN_ID_RE, the coupling tests now fail instead of staying green. Moving randomUUID into handler-util.defaultNewId doesn't change behavior. submitTurn still mints t-${deps.newId()}, and the shape gate in handlers.exchangeTurnCredential still runs after the empty check and before Redis. The 429/501 codes added to http-api.md all exist in CP_ERROR_CODES with those statuses. The earlier nit on the turn-exchange.ts not-found comment was fixed in ae71734.
Author: cwiklik (CONTRIBUTOR — returning external; I read every changed file in full)
Areas reviewed: TypeScript (control-plane src + tests), OpenAPI YAML, Docs, Security
Agent/IDE config (.claude/.vscode): none
Commits: 5 commits, all signed off: yes
CI status: pending (14 passing; check, deploy-scripts, k8s-kind-e2e still running)
All checks pass. Ready for human review.
Generated by RepoMan github-pr-review@235a7f7 (https://github.com/rossoctl/agent-skills/blob/235a7f7620139ee3e4ab0872b8439591fe52e8c1/skills/github-pr-review/SKILL.md).
ingpaolodettori-dev
left a comment
There was a problem hiding this comment.
Summary
These are follow-ups to #713. The new head 13fb679 adds one test/refactor commit on top of ae71734, and it fixes the coupling-test nit from the previous round. main.ts now wires an exported defaultNewId (handler-util.ts) as the production CpDeps.newId, following the existing defaultAuditLog/defaultRunKubectl pattern. The three shape-dependent tests (turn-handlers coupling, turn-e2e, turn-record) inject that same binding, so a production switch to nanoid or a prefixed id now fails them. Runtime behaviour is unchanged (defaultNewId is still randomUUID()). Imports are clean: randomUUID was dropped from main.ts and turn-handlers.test.ts, and the files that still use it keep it. The f206e8b/ae71734 substance still holds. One nit is left from the previous round, and nothing blocks merge.
Author: cwiklik (MEMBER — maintainer)
Areas reviewed: TypeScript (control-plane src + tests), Docs/OpenAPI
Agent/IDE config (.claude/.vscode): none
Commits: 5 commits, all signed-off: yes (DCO passing)
CI status: passing so far; check/deploy-scripts/deploy-scripts-bash32/k8s-kind-e2e still in progress at draft time
Assisted-By: Claude Code
| anything else is 400 `invalid_request`, refused before Redis is read. | ||
| - New error codes `turn_not_cancellable` (409, cancelling a turn that is not queued), | ||
| `turn_not_claimable` (409, claiming a cancelled or terminal turn), `turn_not_found` (404), | ||
| `async_quota_exhausted` (429, `MOCA_ASYNC_QUEUED_PER_OWNER` non-terminal turns already held) and |
There was a problem hiding this comment.
nit (carried over from the previous round, not yet addressed): The 1.3.0 "New error codes" bullet still leaves out workload_runs_unavailable (501). It is already in openapi.yaml's code enum and in http-api.md's 501 group, which the PR body describes as codes "the same 1.3.0 changelog introduced". Add it here.
There was a problem hiding this comment.
Fixed in 330b07a. The bullet now reads:
- New error codes `turn_not_cancellable` (409, cancelling a turn that is not queued),
`turn_not_claimable` (409, claiming a cancelled or terminal turn), `turn_not_found` (404),
`async_quota_exhausted` (429, `MOCA_ASYNC_QUEUED_PER_OWNER` non-terminal turns already held),
`async_tenancy_unsupported` (501, async under `MOCA_TENANCY=multi`) and
`workload_runs_unavailable` (501, a submission naming a `workloadId`). Either 501 is answered
before the quota is touched, so a request that can never be served is never reported as a 429 and
leaves no record, no queue entry and no quota slot behind (§4.2).
Verified it belongs to 1.3.0 rather than to an earlier version before adding it: in the control
plane the only route that emits the code is submitTurn (turn-handlers.ts:104), which this release
added. knative-server/src/server.ts:972 answers it too, but that is the harness's own data-plane
document, not this one.
The trailing sentence is deliberate. Three of 1.3.0's new codes describe the same route — two 501s
and a 429 — and the order they are answered in is specified rather than incidental (spec §4.2, and
submitTurn's own doc block says so): both 501s precede reserveOpenSlot, so a request that can
never be served is never reported as a quota problem. That is the fact which makes three codes on one
route unambiguous, and the changelog is where an API consumer would look for it.
One thing I did not do, and want to flag rather than quietly skip: nothing pins this list against
CP_ERROR_CODES, which is why the omission survived two rounds. I considered a contract test in the
spirit of the openapi-contract.test.ts ones, and decided against it here — codes accumulate across
releases, so the test would need to know which version introduced each code, and that attribution
exists nowhere in the tree. Inventing it in a test would be a third source of truth rather than a
check on the two. Better as its own issue; say the word and I will open one.
Carried over from the previous review round and verified: the 1.3.0 "New error codes" bullet left out `workload_runs_unavailable` (501) while every other place that documents the async surface already carries it -- openapi.yaml's code enum, and http-api.md's 501 group, which this PR itself resorted. A reader reconciling the changelog against the OpenAPI document finds a code in one and not the other, which is the same class of drift the rest of this PR set out to remove. It belongs to 1.3.0 and not to an earlier version: in the control plane the only route that emits it is `submitTurn` (turn-handlers.ts), added by this release's async submission work. knative-server also answers it, but that is the harness's own data-plane document, not this one. The added sentence is not decoration. Two of 1.3.0's new codes are 501 and one is a 429 quota refusal, and the submission order is specified rather than incidental (spec §4.2, restated in submitTurn's doc block): both 501s are answered before `reserveOpenSlot`, so a request that can never be served is never reported as a 429 and leaves no record, no queue entry and no quota slot behind. That ordering is the reason three codes can describe the same route without ambiguity, and the changelog is where an API consumer reads it. No contract test pins this list, which is why the omission survived two rounds. One is not added here on purpose: codes accumulate across releases, so a test would have to know which version introduced each code, and that attribution exists nowhere in the tree. Worth its own issue rather than a guess. Verified: prettier clean; `make check-links` 198 files, 0 broken; openapi-contract, errors and turn-handlers suites 62 passed. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
The six inline review notes on #713, which merged on the approval before they were walked. None of them changes behavior on the claim path; four are doc corrections and one is a guard clause. Kept separate from #389 so the drain loop does not bury them.
The doc claims were wrong, in two places
openapi.yaml's 404 description and the comment aterrors.tsboth said the claim refusals are "deliberately indistinguishable" and give "no existence oracle" (@clawgenti, @ingpaolodettori-dev). That does not hold as written.turn_not_found,session_not_foundand 409turn_not_claimableare three different codes, and the last one's message names the state — only the status matches on two of the three. A worker legitimately needs them apart to record why it dropped a queue entry.Both now say what is actually true, which is narrower and sufficient: every caller on this route authenticated with the deployment-wide
SH_EXCHANGE_TOKEN, and a successful call hands back the credential itself, so no refusal reveals anything the same caller could not already obtain. Both also note this is not spec §8.1's 404-rather-than-403 rule, which governs the owner-scoped/v1routes a session token reaches.openapi.yaml:310's existence-oracle claim is about that rule and is correct — left alone.This also resolves @clawgenti's note that the refusal paths pass
turn.sessionIdinto the error body. That behavior stays: a refused worker wants the session id in its log line, and the caller already holds the exchange token. The fix was the doc.The turn id is now bounded before Redis
POST /internal/turn-credentialstook any string as itsturnIdand built a key from it —HGETALL sh:cp:turn:<input>, withTurnStore.tagfree to append:${keyTag}besides (@ingpaolodettori-dev onhandlers.ts:1109).submitTurnis the only minter and its ids are alwayst-${randomUUID()}, so anything else is refusedinvalid_requestbefore Redis is touched.This closes no hole on its own —
getonly reads, and the handler re-checks the record's owner against its session. It bounds the key space a shared-bearer route can be made to address. 400 rather than 404 because this caller gets the credential on success, so a distinguishable "you sent nonsense" tells it nothing new and names the actual bug.isMintedTurnIdlives inturn-record.tsbesideturnKeyrather than reusingsandbox-handlers.ts'sUUID_RE, which is unexported and does not carry thet-prefix that is this module's own minting contract. The owner-scoped/v1turn routes deliberately do not use it: there a malformed id must stay a 404, indistinguishable from another subject's turn, or the error code becomes the oracleassertOwnerexists to deny.Verified load-bearing by mutation — with the gate removed, exactly one test fails and it is the new one.
server.test.ts's unknown-turn case now uses a well-formed id; left ast-nobodythe gate would answer first and it would stop covering the 404 at all.Two smaller doc items
http-api.md's code list gains 429async_quota_exhaustedand a 501 group (async_tenancy_unsupported,workload_runs_unavailable) — codes the same 1.3.0 changelog introduced.http-api.md's "a turn already running keeps its model" gains the queued-async-turn caveat. A queued turn is a next turn: it picks up aPATCHed model whenever a worker claims it, because the exchange readsmodelfrom the live session. @ingpaolodettori-dev also asked for an explicit checkbox to pinmodel/sandboxTieron the record — that goes on Coding-agent subagents B3: grants minted at dequeue from the turn record (no bearer in the queue) #388's M2 list, since pinning means adding both to the turn record.Verification
packages/control-planesuite: 1157 passed, 3 skipped, against real Redis on 6379. NoErrorsline.make typecheck: clean.make check-links: 198 files, 0 broken links.make lintfails two hooks for environmental reasons only, neither a finding against this diff:hadolintneeds the Docker daemon, and theprettierhook's own pnpm env writes anallowBuildsstub intopnpm-workspace.yamlunder pnpm 11 (reverted, not shipped).Refs #713, #388
Assisted-By: Claude Code