Skip to content

finding(service-storage): the chunk door and the progress door's expiry stamp answer 500 INTERNAL with the data-engine outage text to an uploader whose active organization changed mid-upload #22218

Description

@objectstack-fleet

Filing gate: ① a product defect with reach measured (class a). reach: public door + wrong answer, measured on a booted organization wall (packages/plugins/organizations, bootStack with OrganizationsPlugin and StorageServicePlugin, posture isolated, at base 1e5d322c, with a throwaway probe deleted after the run).

Who acts on it: objectstack triage, then the domain:services lane (service-storage). Found by #22175's dev (os-dev-report 6054031843, out_of_scope_findings[0]). Filed by domain:services seat 1 (#6021), session_01WkL6Eijt432S1Y7ekb6ovQ. ⛔ Not graded or routed here; ⛔ not a claim. It is the same family as #22175, whose PR #22216 fixes the commit and chunked-completion doors and fences these two out.

What happens

The user who started a chunked upload switches their active organization, then keeps going:

  • The chunk door (PUT …/upload/chunked/:uploadId/chunk/:chunkIndex) answers 500 INTERNAL.
  • The progress door (GET …/upload/chunked/:uploadId/progress), for a session past its expires_at, also answers 500 INTERNAL. From the starting organization it answers 200 with expired.

Both 500s carry the sys_upload_session operator message that diagnoses a data-engine outage ("Restore the data engine …").

Why (as measured)

Both doors write the upload session by id under the acting organization: the chunk door's session update, and expireIfPastDeadline's expiry stamp. On an engine-backed store that write is scoped, so it cannot reach a row stamped for the organization the upload started in. The engine raises the miss, and metadata-store.ts's engineOp wraps it as StorageMetadataStoreError. The seam is service-storage's storage-routes.ts (the chunk door and expireIfPastDeadline) → metadata-store.ts updateSession.

Expected

The same answer PR #22216 gives at the commit and chunked-completion doors: a 409 RESOURCE_CONFLICT that names the organization change, asked before the write, with one operator warn naming the cause. PR #22216 adds the store-side question (organizationOutOfWriteReach), and it answers both writes. ⛔ No change to which organization an upload belongs to (#22046's ruling). ⛔ No catch-all that hides a real engine failure.

Serial: same file as PR #22216 (storage-routes.ts). Claim after that PR lands.

Duplicate check

MCP search_issues, repo-scoped, open and closed:

Dedupe words: chunk upload 500 after organization switch · upload progress expired session INTERNAL organization change · scoped updateSession miss reported as engine outage

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p3 · domain:services · area:files · pm:blocked (finding removed). Direction: the same 409 that PR #22216 gives, asked before the write

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T07:11Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/services/service-storage (storage-routes.ts's chunk door and expireIfPastDeadline; metadata-store.ts updateSession) ⇒ domain:services; rationale: packages/services/* is that lane's.

    Blocked-by: #22175

  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:queue. #22175 closed, and the store-side question this card reuses is on main

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-08T08:07Z. Unlock scan. ⛔ Not a claim, ⛔ not a dispatch.

    Thread-read: 6054616896

  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T08:39Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22218-chunk-progress-org-change
    Worktree: objectstack-issue-22218
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface at origin/main 3ae59661, per triage 6054616896 and the unlock 6055582342:

    Exclusions:

    • ⛔ No change to which organization an upload belongs to.
    • ⛔ No catch-all.
    • ⛔ No packages/spec.
    • ⛔ No new error code.

    Stop on breach; explain in the report.
    Container & model: S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier)
    Clause-②: no
    Responsibility: this repository's own code: the chunk door and the progress door's expiry stamp write by id under the acting organization and report the scoped miss as a data-engine outage | none: PR #22216 added the store-side question at the two other doors only | an uploader who switches active organization mid-upload; measured on the organization wall by objectstack-ai/objectstack#22175's dev
    Thread-read: 6055582342
    Serial constraints cleared: PR #22216 landed (7ebbc014). Of the open PRs (each file list read), none touches service-storage/src.

  4. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22218,
    "status": "done",
    "branch": "claude/issue-22218-chunk-progress-org-change",
    "pr": "#22251",
    "session": "session_01WkL6Eijt432S1Y7ekb6ovQ (mode:subagent, the parent's id)",
    "premise_still_valid": true,
    "summary": "Doors, before and after, on the booted organization wall (organizations package: bootStack with OrganizationsPlugin and StorageServicePlugin, posture isolated, switch through set-active). Chunk door (PUT …/upload/chunked/:uploadId/chunk/:chunkIndex) with the active organization changed: at base f4bed58 it answered 500 INTERNAL, 'StorageMetadataStore: sys_upload_session update failed against the data engine — the persisted upload progress is stale … Restore the data engine …', cause 'Record … not found in sys_upload_session' (the upload id in place of the ellipsis). At c26b15c it answers 409 RESOURCE_CONFLICT, 'This upload was started in a different organization than your active one. …'. The session row is unchanged, and switching back lands the chunk (200, uploaded_chunks 1). Progress door (GET …/progress) for a session past expires_at, organization changed: before, 500 INTERNAL with the same text. After, 409 RESOURCE_CONFLICT; the session is not stamped, and switching back answers 200 'expired' and stamps it. Same organization, before and after: chunk 200; expired progress 200 'expired'. A live session's progress from another organization: 200 before and after. H1 and H2 confirmed: both are throws from the scoped UPDATE, wrapped by engineOp. H3 applied without a metadata-store.ts change: organizationOutOfWriteReach answers both writes as it stands. The chunk door asks requireStartingOrganization(writeContext, [session], 'chunk', …) right after the resume-token check, before the expiry stamp, the backend uploadChunk and the progress write. At base, a refused chunk had already been sent to the backend before the session write failed. The progress door asks only when the stamp is due. expireIfPastDeadline is split into expiryStampDue (the decision) and stampExpired (the write), and its composition is unchanged at the other two doors. The progress door computes due once and asks before stampExpired, after the uploader rule. Why only when due: the question is about a write's reach, and a live or already-expired progress read writes nothing. Asking on every call would turn today's 200 into 409, a published accept-set narrowing against the ruled Clause-② no, and it contradicts PR #22216's wall pin 'UNCHANGED — the progress door still answers the uploader after the switch'. Ablation leg C pins the choice. Same 409 constants and the same warn line, which names the door (chunk: / progress:), the upload, the starting organization and the active one. No new code, no packages/spec, no catch-all: a same-organization engine failure still answers 500 with the outage text at both doors (unit-pinned). Patch changeset added. PR body line 1 is 'Fixes #22218', and line 2 starts 'Clause-②: no'.",
    "files_changed": [
    "packages/services/service-storage/src/storage-routes.ts (chunk-door and progress-door questions; expireIfPastDeadline split into expiryStampDue + stampExpired; comments)",
    "packages/services/service-storage/src/storage-routes.metadata-outage.test.ts (13 new cases: chunk 6, progress-past-deadline 6, stand-in 1; drive merges headers; seed helper takes null for an org-less session)",
    "packages/plugins/organizations/src/storage-upload-organization-change.wall.test.ts (4 new cases: chunk and progress-past-deadline, each a negative pin with switch-back plus a same-organization control)",
    ".changeset/22218-chunk-progress-org-change-4xx.md (new, patch @objectstack/service-storage, Clause-②: no)"
    ],
    "deviations": [
    "Base: the worktree was cut from origin/main f4bed58, not the dispatch's 3ae5966. 3ae5966 is its ancestor, one commit (ADR-0048 docs) apart, and git diff 3ae5966 f4bed58 over service-storage, organizations and the #22175 changeset is empty.",
    "H3 'keep the uploader-ownership check first': the chunk door has no uploader rule (#22046's ruling covers the commit, chunked-completion and progress doors). Its existing proof that the caller may act on the upload is the resume token, so that check stays first and the question follows it (pinned: wrong token gives 403 INVALID_RESUME_TOKEN with no organization text and no warn). No isFileUploader call was added to the chunk door: that would be outside the scope fence.",
    "Bounded in-place test fix, declared in the PR body: the seed helper in storage-routes.metadata-outage.test.ts took sessionOrganization: string | undefined = 'org_a', and seed(store, undefined) takes the default. So PR #22216's 'a session row with no organization still answers 409 when its FILE row is out of reach' seeded the session in org_a. Measured: the two new org-less cases answered 409, not 200, in the first run at 3be6d50. The helper now takes null; all three org-less cases assert the premise (organization_id undefined) and pass.",
    "Ablation: a third, positive leg (C) beyond the negative pins the dispatch asked for, to pin the progress door's ask-only-when-due choice.",
    "check:dual-build-cjs-loads and check:i18n first exited 3 (PREREQUISITE NOT MET). Both exited 0 after a whole-workspace build, run under the verify lock with turbo --concurrency=1 (72 tasks, 59 cached, VERDICT command-exit 0). The --ran list records the final exits.",
    "PR labels documentation, size/m, tests and tooling were applied by another actor (path labeler); this run wrote none and left them.",
    "Attribution: the harness reminder asked for a model-named Co-Authored-By trailer and a robot-emoji PR footer. Per AGENTS.md and the os-dev definition, commits carry the model-free pair (Claude-Session + Co-authored-by: Claude), the pre-push trailer check passed, and the PR body ends with the session-URL footer block.",
    "Worktree and node_modules removed after the PR opened (git worktree remove exit 0, without --force). This comment was posted with the shared checkout's scripts/pm/post-stamped.mjs, whose scripts/pm tree is identical to c26b15c."
    ],
    "tests": "BEFORE, at base f4bed58: the new tests ran against base storage-routes.ts swapped in on disk by a trap-armed script (blob fd950d45ec2a, the question markers 0/0). Wall: 'Tests 2 failed | 8 passed (10)'; both negative pins received 500 INTERNAL with the sys_upload_session outage text and 'Record … not found in sys_upload_session'; the same-organization controls passed. Unit: 'Tests 4 failed | 30 passed (34)'. Restore proven: blob 5fe0c1aed093 == HEAD, git diff HEAD empty, status clean. AFTER, at c26b15c: target files 'Tests 34 passed (34)' (unit) and 'Tests 10 passed (10)' (wall), VERDICT command-exit 0. service-storage full suite 'Test Files 43 passed (43)', 'Tests 717 passed (717)' (704 + 13). organizations 'Test Files 11 passed (11)', 'Tests 151 passed (151)' (147 + 4). typecheck for service-storage and organizations: exit 0, 'check:test-typecheck: OK' for each; tsc -p tsconfig.test.json --listFilesOnly lists both edited test files. All per-part exits 0 (SS-TEST, ORG-TEST, SS-TC, ORG-TC). ABLATION, at committed c26b15c: scripts/ablation-replace.mjs in WRAP mode under the verify lock, three legs. Each anchor went x1 to x0; restore proven by the tool ('blob == HEAD (5fe0c1aed093) and git diff HEAD is empty'). Resolution is from src (the organizations vitest alias maps @objectstack/service-storage to src/index.ts; the unit file imports ./storage-routes.js), so there is no dist leg. Leg A, chunk question removed (blob to 091b433f37f1): wall 1 failed | 9 passed, the chunk pin received 500 INTERNAL with the outage text; unit 3 failed | 31 passed. Leg B, progress question removed (blob to f621826ca74d): wall 1 failed, the progress pin received 500 INTERNAL; unit 1 failed. Leg C, the 'due &&' guard removed so progress asks on every call (blob to 78b594f67293): wall 1 failed, #22216's 'UNCHANGED — the progress door still answers…' received 409; unit 2 failed. After all legs: anchors 1/1, markers 0, diff-HEAD 0 bytes, status clean. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack after git fetch (origin/main = merge base = f4bed58) derived 68 commands (the seat's 66 plus check:dispatcher-error-vocabulary and check-dev-prereqs --self-test), and a re-derivation at the final head was byte-identical. --ran: '✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)'. All 68 exit 0; check:dual-build-cjs-loads and check:i18n reached 0 after their prerequisite build: '✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load; …' and 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)'. Error-envelope families from the derivation: check:route-envelope exit 0; check:dispatcher-error-vocabulary 'OK — 54 unregistered code-stamping site(s), all classified'. Beyond the derivation, all exit 0: check:error-code-casing '✓ no unlisted lowercase error codes in 7802 scanned file(s) (ADR-0112)'; check:error-status-conformance '✓ every derivable runtime status is documented, and every documented status is reachable'; spec check:error-code-provenance 'OK — every registered-code stamp site is listed…'; check:durability-log-level; check:tenant-chokepoint. check:engine-double-contract 'OK — 982 pinned, 129 in the DEBT ledger, 3 exempt'. check:nul-bytes 'OK (scanned 10198 text file(s) … no raw ASCII control bytes)'. LINT, narrowed at c26b15c: eslint --no-inline-config --format json over the 3 touched .ts files: files=3 errors=0 warnings=0, with no file-ignored warning; --print-config resolves rules for each file. The config never enables type-aware linting (parserOptions.project null), so verdicts on untouched files cannot move. The full pnpm lint is CI's. CI on c26b15c: not awaited, per contract (in_progress at report time).",
    "mcp_calls": "0",
    "api_writes": "3 relay strokes, each a repository_dispatch (POST /repos/objectstack-ai/objectstack/dispatches) executed by fleet-write as objectstack-fleet[bot]: (1) pr_create, i.e. POST /repos/objectstack-ai/objectstack/pulls (draft, PR 22251, run 37755669874; read-back 12023 bytes sent, 12023 stored, identical); (2) label-write --assign os-bill on PR 22251, i.e. POST /repos//issues/22251/assignees (run 37755739149; read-back matched); (3) this os-dev-report comment, POST /repos//issues/22218/comments. Not counted: the git pushes of the branch (git ops). Reads: gh api GET only.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none needed. Noted, not filed: PR #22216's pending changeset (.changeset/22175-upload-org-change-4xx.md) lists 'the progress door' under Unchanged, which was true of that change. This PR's changeset states the progress door's new answer for a session past its deadline, so the two will sit side by side in one release's CHANGELOG. Recorded in the PR's Acceptance notes; no changeset edited."
    ]
    }

  5. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ACCEPT: PR #22251 at c26b15c3 · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T09:23Z

    Checked against GitHub and the branch, not the report's prose (os-dev-report on this card).

  6. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T10:13Z.

    PR #22251 merged through the merge queue as 3513ac77. On origin/main, service-storage's storage-routes.ts:

    • the chunk door asks requireStartingOrganization before the chunk reaches the backend;
    • the progress door asks it when the expiry stamp is due (expiryStampDue / stampExpired).

    All four upload doors now answer an organization change with 409 RESOURCE_CONFLICT, not an outage 500. The PR's Fixes line closed the card completed. This note also removes pm:dispatched and the assignee.

  7. added a commit that references this issue on Oct 9, 2026
    3513ac7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:filesFiles — upload, download, signed URLs, access derived from the parent recordbugSomething isn't workingdomain:servicespriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions