Repository navigation
Conversation
… caller (N49)
Board row N49. POST /api/batches/shared/:batchId/claim took agentId from the
request body with no check against the caller, so any key holder could file
claims, and their payment amounts, under any identity. The dashboard sent the
fixture identity 'demo-user'.
Gateway (routes/batches.ts):
- The claim and release routes run requireAuth. The claimant is req.userId,
which apiGate sets from the API key's operatorId or the wallet session.
Without one the route returns 401.
- A body agentId is accepted only when it equals the caller. Anything else
is 403 agent_mismatch, so older clients that echo their own id keep working.
- Only the claimant can release a claim (403 not_claimant). Before, any
caller could release anyone's claim.
- slotCount must be a positive integer. A negative count used to produce a
claim with a negative amount. preferredIndices must be distinct integers
in range.
- Binding the real identity would have exposed it, so every read (detail,
open list, availability) shows a claimant's identity and sample labels only
to that claimant.
Dashboard (BatchBoardPage):
- The claim body is { slotCount } only (claimRequestBody); no identity.
- batch-board-logic.ts maps the server shape to the page's view: the claim
array becomes a count and the price becomes a number. The page rendered the
claim array as a React child, which crashed it once a batch had a claim, and
called toFixed on a string price. An unknown price shows a dash, not $0.00.
Tests: 9 gateway route tests behind apiGate with real API keys (7 of them fail
on the previous route), 6 dashboard logic tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…on-owner views, legacy batch routes bound to the caller Answers coord-watch's DO-NOT-SHIP on #375 (sol, #2939) and gateway's LOW-1/LOW-2 (#2830). - Shared create requires auth and records the creator (shown only to the creator). totalSlots must be an integer 1..1536, minSlotsToRun 1..totalSlots, the price a non-negative amount with at most 6 decimals (a number from the dashboard is normalised), currency 3-5 capitals, closesAt an ISO date, text fields at most 200 chars. Binding the batch to the kernel operator is N55 (needs requireKernelOperator from #335/WP-C). - Claim: supplied preferredIndices must be an array of slotCount distinct in-range integers (malformed input is refused, never auto-assigned). sampleLabels: at most slotCount strings of at most 200 chars; the rest are padded. - A non-owner sees a claim as slotIndices, status and own:false only: no claim id, amount, escrow or time. - Legacy batch-manifest routes: adding a sample needs auth and binds userId to the caller (403 agent_mismatch otherwise), with validated fields. List, detail, events and by-job show other users' samples without owner, label, job or result refs. An unknown batch is 404. - Dashboard mapper keeps agentId only on the viewer's own claims. Tests: route 21/21 (13 new or changed; all 13 fail against 815e554), dashboard mapper 7/7 (the new case fails on the old mapper). Gateway 188 files, 3002 passed / 6 skipped / 0 failed. Dashboard 227/227. tsc exit 0 for both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
Owner
Author
|
Round 2 @
Scoped out, with reasons:
Tests:
|
…ob is no oracle; strict create input (N49 r3) Round 3 of #375, for the cross-family DO-NOT-SHIP on 4a0f412 (the weakest link was the legacy batch privacy boundary). Legacy batch manifests: - viewSlot gives a non-owner only position and status: no slot id, sample type or acquisition times, through an explicit projection. - viewEvents shows a viewer's own sample events in full, and leaves out every event about anyone else's sample. Batch-level events keep their type and time and only an allowlist of aggregate fields (slotCount, completed/failed). - by-job: the operator of the job's kernel sees that kernel's batches holding the job, and anyone else only the batches holding a sample of theirs from it. An unauthorised caller gets the same empty list as a job with no batches. - Adding a sample: jobs record no buyer, so the relationship that can be checked is the kernel's. Only the operator of the batch's kernel may add, and only for a jobId and stepId on that same kernel; a sealed batch is 409. The tracker's error text is never forwarded. Shared batches: - A number price that would need rounding is refused (1.1234567, 0.0000001), and stored prices are canonical ("0010.500000" -> "10.5"). - closesAt must be a real ISO 8601 UTC calendar time, in the future and at most 30 days away. A batch past it takes no claims (409 batch_closed) and leaves the open list. - An explicit null minSlotsToRun or currency is refused, not defaulted, and whitespace-only text is refused. - Releasing someone else's claim answers exactly like a missing claim (404), so there is no existence oracle. - The in-memory store is bounded: 20 open batches per creator, 10,000 in total. - The viewClaim comment states that omitted fields are not protection against inference (price x slot count; occupancy timing). Verified: batch tests 28/28, of which 10 fail on 4a0f412; gateway 188 files, 3,009 passed, 0 failed; tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…allowlists; honest amount, real labels, pruned store (N49 r3) Cross-family (gpt-5.6-sol) r3 on b1673e4: DO-NOT-SHIP. F1 (CRITICAL) — the shared batch stream (/sse/stream/batch/:batchId) forwarded the entire raw BatchEvent to every authenticated subscriber, leaking another tenant's slotId, timing, resultHash and resultRef. A new pure projectBatchStreamEvent (sse/batch-stream-projection.ts) is now the only thing published: batch-LEVEL events only, and only their aggregate fields (batch_sealed:slotCount, batch_completed:completed/failed). Per-sample events never reach the shared stream; they stay on the authenticated, owner-projected HTTP surface (viewEvents). F2 (CRITICAL) — viewManifest spread the whole manifest, so runConfig (free-form, may hold proprietary protocol params or customer metadata) leaked to the public list/detail routes. It is now an explicit allowlist (id, kernelId, deviceId, capabilityId, status, timing, projected slots); runConfig/methodId are never in the public view. F4 (MEDIUM) — shared-claim labels now require isText (a whitespace-only label is rejected), matching legacy labels. F5 (MEDIUM) — the shared-batch store prunes no-longer-claimable batches past a retention window before the cap check, so short-lived batches can't permanently exhaust it (batch_store_full). F6 (MEDIUM) — the claim amount was float*toFixed(2) (0.000001*n -> "0.00"). It is now an honest full-precision display string via integer micro-units (displayAmountMicros), documented display-only, never settlement. F3 (HIGH) is N55 (the kernel-operator gate on shared-batch creation): reproduced, NOT fixed here — it is blocked on WP-C/#335 and cannot land on this SHA. Tests: F1 projection (per-sample dropped, batch-level aggregate-only), F2 (no runConfig in detail/list), F4 (whitespace labels rejected), F6 ("3" and 0.000001*3 -> "0.000003"). F2/F4/F6 fail on b1673e4's route and pass here; batch suite 33/33, tsc clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…am boundary (N49 r5)
Reviewer finding (r4 verdict, CRITICAL): raw sensor readings reached the
shared batch SSE topic. services.ts put {type:"batch", id: reading.batchId}
in the sensor fan-out and published the whole reading; /sse/stream/batch/:id
checks authentication only (no ownership) and setupSSE wrote every payload
verbatim. The N49 r3 projection lived only in the BatchTracker forwarder, so
any other publisher (the sensor pipeline, the mock BatchProducer, a future
producer) bypassed it. An authenticated subscriber on /sse/stream/batch/victim
received the full sensor_reading of someone else's sample: sampleId, job and
step ids, value, free-form tags.
Fix, at the shared-topic boundary instead of one producer:
- topic-sse.ts: setupSSE takes an optional per-stream projector, run on EVERY
event the stream delivers (live and Last-Event-ID replay). A null drops the
event; a projector that throws drops it too (fail closed). The batch
endpoint passes projectBatchStreamEvent. Job, kernel and device streams are
unchanged.
- services.ts: raw readings are no longer put on the batch topic at all (no
subscriber needs them there: every other streamHub consumer subscribes to
the global topic, and the dashboard reads readings from the kernel stream).
The onBatchEvent projection stays; it is now redundant but harmless.
- batch-stream-projection.ts: now total, because it judges whatever any
publisher put on the topic. A prototype-key event type ("constructor",
"__proto__") is not a listed type (it used to throw), a non-object payload
counts as no payload (it used to throw), and an aggregate field passes only
as a whole-number count.
Tests (batch-sse-boundary.test.ts, a real HTTP SSE subscription against
app.listen({port:0}) with SSE_AUTH_REQUIRED on): the reviewer's end-to-end
case; a raw reading, odd payloads and prototype-key types published straight
on the topic; the real mock BatchProducer; Last-Event-ID replay; the
batch_completed aggregate keeping only allowlisted fields; the real
BatchTracker flow; kernel and job streams unchanged. 7 of the 11 SSE tests and
3 projection tests fail at 9606d66.
Not changed (separate pre-existing rows): /sse/stream/kernel/:id and
/sse/stream/device/:id still carry raw sensor_reading payloads to any
authenticated subscriber; there is no ownership check on them on master.
Agent: implementer-november
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…r-creator limit counts every retained batch (N49 r5) Reviewer finding (r4 verdict, MEDIUM): pruning measured retention from closesAt, not from when a batch stopped taking claims, and the per-creator limit counted only claimable batches. One principal could create one-slot batches with closesAt 30 days out, fill each one, and hold the store at its global cap (batch_store_full, 503) for about 30 days: advance two hours and creation still returned 503. - A batch now records when it stopped being claimable: setStatus is the only way a status changes, and it stamps the moment a batch leaves open/filling (full today; running, completed and cancelled are covered the same way) and clears the stamp when a released claim reopens it. The stamp lives in a WeakMap beside the batch, not on it, because viewBatch spreads a batch into the response; a test pins the public key set. - Retention runs from the earlier of that stamp and closesAt, so a batch that filled an hour ago is prunable now however far off its closesAt is, and an expired-open batch is kept an hour after closesAt as before. - The per-creator limit counts every batch of the creator's that is still retained (claimable, or stopped less than an hour ago), not only the claimable ones. The global cap is kept. The error code stays too_many_open_batches so clients that match it keep working. - Test seam: _setSharedBatchLimitsForTests lowers the bounds so a test can hit the cap without creating 10,000 batches; _clearSharedBatchesForTests restores the defaults. Production never calls either. Tests use a faked Date (no real waiting). At the old retention logic two of the five new tests fail: after two hours creation still returns 503, and the 21st creation by one creator returns 200 instead of 409. The other three pin unchanged behavior (a released claim reopens the batch and it is not pruned; an expired-open batch is kept an hour after closesAt; the public batch view keeps its key set). Not changed: N55 (shared-batch creation accepts any kernelId without checking the caller operates it) stays open and blocked on the gateway ownership work. Agent: implementer-november Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…-only estimate, not an amount owed (N49 r5) Reviewer finding (r4 verdict, MEDIUM): the claim response field was claim.amount, and the exported spec type BatchSlotClaim documented it as "Amount owed for this claim", while the gateway filled it with a non-authoritative display calculation from the posted price. A comment does not separate that from accepted economics; a consumer reading the typed `amount` would treat the estimate as money owed. - spec: BatchSlotClaim.amount is removed and replaced by displayAmount, whose doc says it is a display-only estimate (posted pricePerSlot x claimed slot count), NOT an amount owed, and that settlement decides what is owed. A types-only compile-time guard beside the type stops the package compiling if `amount` returns or displayAmount goes. - gateway: the claim response field is displayAmount. The route's inline copy of BatchSlotClaim is gone: claims are the public spec type, so the response cannot drift from the contract (a claim literal carrying `amount` no longer compiles). Comments updated. - Consumers: a repo-wide search of packages/* and apps/* found no other reader of the shared-batch claim's `amount`. The dashboard batch board counts slotIndices and reads the batch pricePerSlot; it never read the claim's amount. The only readers were three assertions in the gateway test, updated. Tests: three existing assertions now read displayAmount; new tests check that the claim, the claimant's own read and the release response carry displayAmount and no amount, that someone else's claim shows neither, and (expectTypeOf) the public type. At 9606d66 those fail (displayAmount is undefined) and the spec guard fails to compile (TS2344 twice: displayAmount absent, amount present). gateway tsc resolves @pcc/spec through its built dist, so rebuild spec before type-checking the gateway, as with any spec type change. Agent: implementer-november Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP
…r ids; SSE framing guard (N49 r6) The r5 review (gpt-5.6-sol, on 6e19623) found that the SSE envelope still bypassed the batch projection. setupSSE wrote the publisher's raw event.id into every frame, so an allowlisted batch_sealed or batch_completed could carry a sample id, a job id or a result hash in its id, and an id holding CR/LF could inject whole frames. Replay matched Last-Event-ID against the same publisher ids, so a guessed id was an oracle: found, it replayed what came after; not found, it replayed everything. - StreamHub now assigns each topic its own monotonic cursor at publish (1, 2, 3, ... per topic, so a cursor says nothing about any other topic). It keeps a cursor-tagged replay window beside the existing buffer, whose shape the visualizer reads. Every callback, live or replayed, receives the event's cursor. subscribe() with {cursor: "seq"} reads Last-Event-ID as that cursor and replays only what follows it; any other value replays nothing. - The batch stream writes only that cursor as the frame id (no cursor, no id line) and subscribes in cursor mode. - Every stream guards its frame. An event whose type holds CR, LF or NUL, or is empty or over 256 characters, is dropped, and a publisher id like that is left out of the frame. Data goes through JSON.stringify, which escapes all three. Tests: 8 new tests (7 over real HTTP SSE, 1 hub unit) fail at 6e19623 and pass now. The 11 r5 boundary tests were adapted to find frames by payload and order instead of by publisher id; they pass both before and after. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
…ows (N49 r7) The r6 review (gpt-5.6-sol, on 8a545cd) closed the envelope CRITICAL and found one MEDIUM. The hub numbered every publication on a batch topic, including the private events the batch projection then drops, so the gaps in the public cursor counted hidden events: N dropped events showed up as a jump of N, and replay kept those positions. That is a covert channel. - StreamHub takes a cursor policy. An event the policy does not admit gets no cursor on that topic and stays out of the cursor window. It is still delivered, and still kept in the publisher-id buffer the visualizer reads. A policy that throws admits nothing. - The gateway's hub admits on a batch topic only what the batch stream can show: isPublicBatchStreamEvent, the same projection the stream applies. - A cursor stream never writes an event that has no cursor, so the policy and the projection cannot disagree visibly. Tests: 5 new tests (3 over real HTTP SSE, 2 hub unit) fail at 8a545cd and pass now. The 20 earlier boundary tests are unchanged and pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
…h, and the stream writes exactly that (N49 r8) The r7 review (gpt-5.6-sol, on 609d886) found that the cursor-gap MEDIUM was not fully closed. The batch projection ran twice on the same mutable event: once when the hub numbered it, and again at the SSE boundary. When the two judged differently (a payload getter, or a mutation after publish), a numbered event was never shown, leaving a gap that counted it, or a shown event lost its cursor. - StreamHub takes a CursorView, called ONCE per event and topic at publish. Null (or a throw) means no cursor. A view means a cursor, and the frozen view is stored in the cursor window and handed out with the cursor, live and on replay. - batchStreamView (batch-stream-projection.ts) returns the frozen projection; the gateway's hub uses it for batch topics. Only batch topics carry cursors. - setupSSE writes a cursor stream's frame from cursor.view alone and never reads the event again. The per-stream projector is gone. Tests: 4 wire tests (a read-once payload shown as first judged with no gap; replay shows the stored view; a mutation after publish doesn't change replay; a payload that throws on first read gets no cursor and leaves no gap) and 2 hub unit tests (the view is decided once and is the same object on replay; the batch view is frozen). On 609d886's source, 3 of the wire tests and both hub tests fail; the read-throws-first case is a control. The r7 hub unit test now uses the view API. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBTXKQ2uqtcrsUtPnQdtf8
This branch has not been deployed
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.
Board row N49: a shared-batch slot claim belongs to the authenticated caller
Raised by product-steward as #2557 and assigned to refvertical (66f65db1) by the steward in #2638. Verified at
ac86a404.The hole
POST /api/batches/shared/:batchId/claim(packages/gateway/src/routes/batches.ts:144-152atac86a404) tookagentIdfrom the request body and never checked it against the caller. Any key holder could therefore file claims, each carrying a paymentamount, under any identity.BatchBoardPage.tsx:266sent the fixture identity"demo-user"on every real claim.These gaps sat in the same routes:
DELETE …/claim/:claimIdlet any caller release anyone's claim.slotCountproduced a claim with a negative amount.Gateway:
routes/batches.tsrequireAuthpreHandler.req.userId, which apiGate sets from the API key'soperatorIdor the wallet session.agentIdis accepted only when it equals the caller, so a client that echoes its own id still works. Anything else,"demo-user"included, gets 403agent_mismatch.not_claimant.slotCountmust be a positive integer (400 otherwise).preferredIndicesmust be distinct integers in[0, totalSlots)./availability) shows a claimant'sagentId, which is an email or wallet, and itssampleLabelsonly to that claimant. Other callers seeagentId: null, own: falseand no labels. The dashboard never rendered other claimants' ids, so nothing visible changes.Dashboard:
BatchBoardPage{ slotCount }only (claimRequestBody): no identity.batch-board-logic.ts, following thekernel-leaderboard-logic.tspattern, maps the server shape to the page's view.claimedSlotsis now a count..toFixedonpricePerSlot, which is a string when a batch is created through the API. The price is now a number, and an unknown price renders as "—" rather than an invented "$0.00".Verification (DGX Spark; deps from
/mnt/sparkbulk/pnpm-store)vitest run src/__tests__/batches-shared-claim.test.tsprovisionApiKey, plus one bare-app test without apiGate.batches.tsagentIdis still accepted.@pcc/gatewaysuite@pcc/gatewaytsc --noEmitvitest run src/pages/__tests__/BatchBoardPage.test.ts@pcc/dashboardsuite@pcc/dashboardtsc --noEmitThe typechecks were run after building the workspace dependencies (
pnpm --filter "@pcc/dashboard^..." buildand the same for gateway).Not in scope
POST /api/batches/shared(create) has no creator or kernel-owner binding: anyone can open a batch on any kernel at any price. That needs a separate row.operatorIdis self-asserted at key provisioning (board row N2) until gateway's WP-A lands.Review routing (board rule: auth or identity)
🤖 Generated with Claude Code
https://claude.ai/code/session_01FFEKdAcSGUnfrgiwtP21GP