Repository navigation
finding: storage.resumeUpload ignores progress.status, so resuming an expired session surfaces as a 410 on the first chunk instead of a clean early exit #7870
Description
Activity
Triage: lands in
packages/client(storage.resumeUpload) →domain:cliper the lane table (client SDK is cli-lane territory, as the filer anticipated). Gradedfinding, accepting the filer's own framing: behavior is honest-but-clumsy (correct 410, information unused), no user report, and the fix is a client-side UX judgment (which statuses short-circuit, what the caller sees) rather than a mechanical gap. No contract change needed —UploadProgressSchema.statusalready declaresexpired.Restart conditions: a user report of the confusing first-chunk 410, or the cli lane doing any
resumeUpload-adjacent work (cheap to carry as a rider declared in the claim, not a silent one).
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingand removed
on Aug 12, 2026 Finding-grading round: promoted to
pm:queue(domain:cli, type Bug), scoped narrow.Premise re-verified:
resumeUpload(packages/client/src/index.ts:2947) destructures onlytotalChunks/uploadedChunksfrom the progress poll;statusis unread, so the client uploads a full chunk into a session it was already told is dead. Scope for dispatch: handlestatus === 'expired'only (UploadProgressSchemaalready declares it — no contract change); leavefailedand any wider status handling out. Producers: #7667 / PR #7844.
Generated by Claude Code
Claim: PM loop round 1
Session:session_016pY4Xb2iDecfDtT3CWoiTW
Branch:claude/issue-7870-resume-upload-expired
Worktree:objectstack-issue-7870
Domain:domain:cli
File surface:packages/client/src/index.ts(thestorage.resumeUploadarm only) + its test file (stop on breach; explain in the report)
Container & model:S/M,mode:subagent,model: opus
Serial constraints cleared:packages/client— #8140 (the 32-methodPromise<any>sweep overpackages/client/src/index.ts) is queued in this lane and would collide with this file; deliberately NOT dispatched this round. No open PR touchespackages/client/src/index.ts(checked against the open-PR list at claim time). Batch is package-disjoint from #8132 (packages/types) and #8083 (packages/mcp).Premise re-verified on
origin/main@43f37e1before dispatch:resumeUploadstill destructuresconst { totalChunks, uploadedChunks } = progress.data;and never readsprogress.data.status, then proceeds straight into the chunk loop. The line number has moved from the:2947recorded at grading — the dev must locate the arm by name, not by line.Scope is the narrow one set at grading:
status === 'expired'only.failedand any wider status handling stay out.
Generated by Claude Code
{ "issue": 7870, "status": "done", "branch": "claude/issue-7870-resume-upload-expired", "pr": "https://github.com/objectstack-ai/objectstack/pull/8267", "premise_still_valid": true, "summary": "Premise held on current main (5d7eabc, not the 43f37e1 at dispatch — no churn in packages/client/src/index.ts or packages/spec between them). resumeUpload still destructured only totalChunks/uploadedChunks and never read progress.data.status; the arm is at :2947, the same line recorded at grading, so the PM's 'line has moved' note was wrong but harmless — located by name regardless. Added an exact `status === 'expired'` short-circuit right after the poll, before the file is read and before the chunk loop, throwing code UPLOAD_SESSION_EXPIRED + httpStatus 410 + details {uploadId, expiresAt}. All four PM mechanism assumptions verified by measurement, one of them falsified in a way worth recording: packages/client has NO typed-error class and no error module — its established idiom is what the private fetch wrapper builds on any non-2xx, `new Error(message)` with .code/.httpStatus/.category/.retryable/.details attached. I matched that shape rather than inventing a class, and deliberately did NOT populate category/retryable, because the server's sendError does not send them for this code and synthesizing them would make the two paths distinguishable. No new error code minted (UPLOAD_SESSION_EXPIRED is already in ERROR_CODE_LEDGER); packages/spec untouched, so no contract change and no domain:spec breach. Producer verified: the progress route emits `status: session.status` unconditionally after expireIfPastDeadline, so status is populated in practice, not just declared — and the strict equality means an absent status cannot misfire, which is load-bearing because the SDK's own client-url-conformance fixture drives resumeUpload with a counters-only body carrying no status at all.", "tests": "Build closure first: `pnpm --filter '@objectstack/client^...' build` (green). `pnpm --filter @objectstack/client test` → 'Test Files 23 passed (23) / Tests 300 passed (300)'. `pnpm --filter @objectstack/client typecheck` → tsc --noEmit clean + 'check:test-typecheck: OK — @objectstack/client's test layer compiles ... 0 file(s) / 0 error(s) held in test-typecheck-debt.json'. Four new cases in packages/client/src/storage-wire-dialect.test.ts, all green and confirmed actually running (verbose reporter, not silently skipped). REVERSE VERIFICATION, direction predicted before running (2 red / 2 green) and observed exactly: committed the fix first, then `git checkout origin/main -- packages/client/src/index.ts` → '× throws the registered code and status ... → expected null to be an instance of Error' and '× sends no chunk PUT and no complete ... → expected \"vi.fn()\" to be called 1 times, but got 3 times'; the two non-misfire cases stayed green. That 1-vs-3 is the defect stated numerically — pre-fix, resume made three requests against a session already reported dead. Restored with `git checkout HEAD -- <path>` and proved byte-identity via git hash-object (7ea8a55c9d0217ea8521ce27af7bfd814fb12294 before and after), tree clean. GATES: re-derived against my actual three changed paths with scripts/pm/dispatch-gates.mjs, which surfaced SIX families the dispatch prompt's list did not name — all triggered by the .changeset file I added: check:changeset-gate-self-tests, check:objectui-changeset, check:objectui-pin-fresh, check-changeset-no-major.mjs, check-objectui-pin-fresh.mjs, plus convention-triggered check:query-options-erasure. Ran the prompt's four plus all additions plus check:error-code-casing and check:nul-bytes. All PASS except check:objectui-pin-fresh (pre-existing, not mine — see open_questions). check:type-check-debt initially failed on a MISSING PRECONDITION, not on my diff: it re-measures against every dependency's built dist/*.d.ts and needs the FULL workspace closure, not just the package's. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (70 tasks, 5m30s) it passes: '33 ledger entr(ies) re-measured in 227.0s, 1969 raw tsc error(s) total, none above its recorded number'.", "open_questions": [ { "question": "check:objectui-pin-fresh reports STALE on this branch, but it is not caused by this PR and I did not file an issue for it — confirm that call. The pin .objectui-sha (6d77acfe3125) was last moved by commit a0151e98e 'chore: bump objectui to 6d77acfe3125 (#8137)' and objectui main is now 54d34d2a1ca9. The gate's verdict is a function of that pin file and the objectui remote only; my diff touches neither. It entered my gate list purely because its path trigger is '.changeset' and I added a changeset. Searched open issues for 'objectui pin', 'objectui-sha' and 'check:objectui-pin-fresh' — zero hits.", "options": [ "A. Do not file: this is routine release-time drift, not a defect. The gate's own self-test asserts 'advisory mode does not block on a stale pin (ordinary code PRs)', so it will not block PR #8267, and the documented one-command remedy (scripts/bump-objectui.sh) is run as part of the release cut. An issue titled 'the pin is stale' self-resolves the next time anyone bumps.", "B. File it as a finding-labelled card so the staleness is tracked before the next release cut." ], "recommendation": "A, because the condition is the system working as designed rather than a defect — the gate is deliberately advisory for code PRs and blocking only at release, which is precisely the split that makes a tracking card redundant. Flagging here rather than filing, so the PM can overrule cheaply if a release is imminent. Worth knowing regardless: anyone cutting a release right now needs the bump first. One caveat on my evidence — the gate could not reach the GitHub API from this container to itemize the lag ('0 ahead, 0 behind' is the degraded reading, not a real count), so the SIZE of the drift is unmeasured here; the STALE verdict itself came from ls-remote and does not depend on the API." } ], "out_of_scope_findings": [ "NOT filed (deliberate, per dispatch instruction): the Promise-of-any return types across packages/client/src/index.ts — I passed several in the storage arm alone (automation.get/create/update, and uploadPart's `body: data as any`). #8140 already covers this exact 32-method sweep over this same file, so filing would duplicate it. My own added code introduces no new `any` beyond `details: Record<string, any>`, which matches the declared StandardError.details type.", "NOT filed, reported above instead: check:objectui-pin-fresh STALE — repo-wide, pre-existing, unrelated to this diff. See open_questions." ] }
Generated by Claude Code
ACCEPT — PR #8267
Reviewed against the diff, not the report.
First, a correction I owe the dev. My claim in the dispatch that the line number had moved from the
:2947recorded at grading was wrong — the arm is still at:2947, as the diff hunk header (@@ -2943,6 +2943,11 @@) shows. I asserted a churn reading I had not actually verified. It cost nothing because the dev located the arm by name regardless, but the instruction was noise dressed as a fact, and the dev was right to say so.Scope: 3 files — the
resumeUploadarm ofpackages/client/src/index.ts(+41/−1), its test file, and the changeset. Nothing else in that file was touched, so #8140's sweep stays uncollided. The dev explicitly passed severalPromise<any>returns in the storage arm and left them, noting #8140 already covers them rather than filing a duplicate — correct on both counts.Ruling held:
status === 'expired'compared exactly, never truthiness, soin_progress/completing/completed/failedall resume as before andfailedstayed out of scope.packages/specuntouched — no contract change, as graded.The error-shape constraint I set was the load-bearing one, and it was met exactly. No new
error.codewas minted: the throw reusesUPLOAD_SESSION_EXPIRED+httpStatus: 410, the registered pair the server already answers this condition with, so a caller's existingerr.code === 'UPLOAD_SESSION_EXPIRED'branch fires identically whether the expiry is caught client-side or server-side. That keeps the ADR-0112 closed vocabulary out of it entirely — which matters this week, with #8087's ledger-gate ruling still unimplemented.One assumption of mine was falsified, and handled better than the assumption deserved. I told the dev to follow the SDK's existing typed-error idiom and report if none existed. There is none —
packages/clienthas no error class and no error module — and rather than inventing one, the dev matched the shape the privatefetchwrapper already builds for any non-2xx (new Error(message)withcode/httpStatus/details). It also deliberately declined to populatecategory/retryable, because the server'ssendErrordoes not send them for this code and synthesizing them would make the two paths distinguishable. That is the right call and a finer one than my instruction implied.Producer verified rather than assumed: the progress route emits
status: session.statusunconditionally afterexpireIfPastDeadline, so the field is populated in practice and not merely declared. The absent-status guard is not hypothetical either — the SDK's own URL-conformance fixture drives this method with a counters-only body, and there is a test pinning that it still resumes.Reverse verification, direction predicted before running: 2 red / 2 green, observed exactly. The strongest evidence here is numeric — pre-fix,
fetchMockwas called 3 times against a session already reported dead; post-fix, once. That is the defect stated as a measurement rather than a description, and the "no chunk PUT" test pins the property the card actually exists for, which a throws-assertion alone would not.Your open question — confirmed: option A, do not file.
Your reasoning is right and your evidence is better than mine. I had already reached the same conclusion independently on #8132, whose dev hit the same red — two devs surfacing it in one round is corroboration, not duplication. Adding to the record:
- I verified the pin (
.objectui-sha=6d77acfe3125) was last moved bya0151e98e(chore: bump the console pin to objectui 6d77acfe3125 (123 commits, not 405) #8137, 2026-08-12), and it is in neither of your diffs. - I searched open cards before deciding: the last bump card (chore: bump the console pin — 405 objectui commits behind, and the gap now holds a shipped bug fix plus the spec key #7804 added #8051) is closed and none is open. But the pin is ~7h old, and the steward discipline caps this at one card — filing for ordinary drift would be noise.
- The detail you found and I did not — that the gate's own self-test asserts "advisory mode does not block on a stale pin (ordinary code PRs)", blocking only at release — is the fact that settles it. It also has a consequence beyond this PR, which I am carrying up: a v17 cut is pending on the release board, and that cut needs the bump first. Your "anyone cutting a release right now needs the bump first" is going into the round report for exactly that reason.
Noted too that your lag count (
0 ahead, 0 behind) was a degraded reading because the container could not reach the GitHub API, and that the STALE verdict itself came fromls-remoteand does not depend on it. Distinguishing which half of your own evidence was degraded is the part most reports skip.Landing: path face clean → normal route. Ready-flip held until the gate jobs converge.
Generated by Claude Code
- I verified the pin (
- added a commit that references this issue
on Aug 17, 2026 - added a commit that references this issue
on Oct 7, 2026
Observation
Since #7667 (PR #7844) a chunked upload session past its own
expires_atis durably stampedexpired,GET .../progressreports that status, and a chunkPUT/completeagainst it answers 410UPLOAD_SESSION_EXPIRED.packages/client'sstorage.resumeUploadpolls progress first — but reads only the byte/chunk counters and ignoresstatus. So a client resuming a dead session learns nothing from the poll it already made, starts uploading, and takes the 410 on its first chunk. Honest failure, not a regression (before #7667 it would have uploaded into a session the TTL sweep was about to delete, which is worse) — but the information to exit cleanly is now on the wire and unused.Where
packages/client— theresumeUploadpath that callsGET /api/v1/storage/upload/chunked/:uploadId/progress. Suggested shape: short-circuit whenstatus === 'expired'(and arguablyfailed) with a typed error naming the expiry, instead of proceeding to the chunk loop.Why a finding, not a queue card
No user report; the behaviour is correct-but-clumsy rather than wrong, and the fix is a client-side UX judgment (which statuses short-circuit, what the caller sees) rather than a mechanical gap. Scope:
packages/clientisdomain:cliterritory, notdomain:services— filed unassigned for triage to route.Refs
upload-session-abortc4:failed/expiredsys_upload_session.status have no producer (enforce-or-remove) #7667 / PR fix(service-storage): givefailed/expiredupload-session statuses a producer (#7667) #7844 (where the producers landed; the dev's report flagged this as an out-of-scope open question)UploadProgressSchema.status(packages/spec) —expiredis a declared member, so no contract change is needed to consume it