Repository navigation
fix(service-storage): answer an organization change at the chunk door and the progress door's expiry stamp with 409 RESOURCE_CONFLICT, not an outage 500 - #22251
Conversation
…ry stamp for the starting organization before writing The chunk door and the progress door's expiry stamp write the upload session by id under the acting organization; for an uploader who switched organization that scoped write misses and used to answer 500 INTERNAL with the data-engine outage text. Both now ask requireStartingOrganization first and answer 409 RESOURCE_CONFLICT. The progress door asks only when the expiry stamp is due. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
An undefined argument takes the seed helper's default and stamped the session org_a, so the org-less-session pins pinned nothing. Each now asserts its premise. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4d16d328027a84dba0db1d8e02d535e316f5b09b && git checkout 4d16d328027a84dba0db1d8e02d535e316f5b09b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 73a0a6bf1db3216a0c82bee1503011040c6a1394 c26b15c37c67dff7581c8cfe676bb2bdab4d514f && git checkout -B drift-repro 73a0a6bf1db3216a0c82bee1503011040c6a1394 && git merge --no-ff c26b15c37c67dff7581c8cfe676bb2bdab4d514f
node scripts/docs-audit/affected-docs.mjs --json 73a0a6bf1db3216a0c82bee1503011040c6a1394 |
Fixes #22218
Clause-②: no
The chunk door and the progress door of
@objectstack/service-storagenow answer an uploader whose active organization changed after starting an upload with the same409 RESOURCE_CONFLICTthat PR #22216 gives at the commit and chunked-completion doors. They used to answer500 INTERNALwith the store's data-engine outage text. The progress door asks only when the session's expiry stamp is due. Which organization an upload belongs to does not change, and the tenant scope of every write is unchanged.Rulings carried (triage
6054616896, verbatim)What changed
Only
packages/services/service-storage/src/storage-routes.tschanged in source.metadata-store.tsis untouched: PR #22216'sorganizationOutOfWriteReachanswers both writes as it stands.PUT …/upload/chunked/:uploadId/chunk/:chunkIndex): it callsrequireStartingOrganization(writeContext, [session], 'chunk', …)right after the resume-token check. That is before the expiry stamp, before the chunk goes to the storage backend, and before the progress write. The door writes only the session row, so only that row is asked about.403 INVALID_RESUME_TOKENand learns nothing about organizations.GET …/upload/chunked/:uploadId/progress): the expiry decision is split out ofexpireIfPastDeadlineasexpiryStampDue(session), and the write asstampExpired(session, context).expireIfPastDeadlineis now the composition of the two, so its behaviour at the chunk and chunked-completion doors is unchanged. The progress door computesdueonce. When the stamp is due, it callsrequireStartingOrganization(writeContext, [stored], 'progress', …)and then stamps. The call comes after the uploader rule, as at the other doors.requireStartingOrganization, the409constants and the warn line are reused unchanged. The warn names the door (chunk:/progress:), the upload id, the starting organization and the active one. No new error code and nopackages/specchange..changeset/22218-chunk-progress-org-change-4xx.md, apatchfor@objectstack/service-storage.Why the progress door asks only when the stamp is due (H3's decision)
expired, writes nothing, so there is no reach to miss. It answers200today from any organization, and the card does not ask to change that.200into409. That narrows a published accept set (Clause-②: no (narrowing)), against the ruledClause-②: no. It would also contradict PR fix(service-storage): answer an upload's organization change with 409 RESOURCE_CONFLICT, not an outage 500 #22216's own wall pin, "UNCHANGED — the progress door still answers the uploader after the switch".dueis computed once and drives both the question and the stamp. The question and the write therefore cannot disagree about a deadline that passes between two clock reads.due &&guard, and the pins go red.Before and after, on the booted organization wall
Measured in
packages/plugins/organizationswithbootStack,OrganizationsPlugin,StorageServicePluginand postureisolated. The uploader switches organization through the product's ownset-activedoor. To put a session past its deadline,expires_atis moved into the past as SYSTEM, in the session's own organization.f4bed58341)c26b15c37c)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"409 RESOURCE_CONFLICT, "This upload was started in a different organization than your active one. …"; session row unchanged; switching back lands the chunk (200,uploaded_chunks1)expires_at, organization changed500 INTERNAL, the same text409 RESOURCE_CONFLICT; the session is not stamped; switching back answers200expiredand stamps it200200(control)expires_at, same organization200expired200expired(control)200200(unchanged)H1 and H2 were measured as stated. Both are throws from the scoped
UPDATE, wrapped byengineOpasStorageMetadataStoreError. The "before" column comes from the new wall file run against the base'sstorage-routes.ts. That file was swapped in on disk inside a trap-armed script and restored to the HEAD blob afterwards. Result: "Tests 2 failed | 8 passed (10)", and the failing two are exactly the two negative pins.Tests
storage-routes.metadata-outage.test.ts: two new blocks inside the existing finding(service-storage): an uploader whose active organization changed after starting an upload gets 500 INTERNAL from the commit and chunked-completion doors, with an operator message that diagnoses a data-engine outage #22175 block, plus one stand-in case. All use that file's existing fake engine; no new double was pinned.409with the code and first sentence. The backenduploadChunkis never called, the session row is unchanged, and the warn nameschunk:, the upload and both organizations.410and the stamp.200.500 INTERNALwith "Restore the data engine".200).403 INVALID_RESUME_TOKEN, with no mention of organizations.409, not stamped, and the warn namesprogress:.200expired, stamped.500with "Restore the data engine".expiredsession answers200from the other organization.200).403 PERMISSION_DENIED.storage-upload-organization-change.wall.test.ts: 4 new cases. For both doors: the negative pin with switch-back, and a same-organization control.Ablation (from committed
c26b15c37c)Three legs, each through
scripts/ablation-replace.mjsin WRAP mode under the verify lock. Each anchor hit x1 and went to x0, and each restore was proven by the tool ("blob == HEAD (5fe0c1aed093) andgit diff HEADis empty"). Afterwards: anchors 1/1, markers 0, status clean. Both suites resolve the subject fromsrc: the organizations vitest config aliases@objectstack/service-storagetosrc/index.ts, and the unit file imports./storage-routes.js. So nodistleg applies.091b433f37f1)500 INTERNALwith the outage text409, expiry-first, token-then-409f621826ca74d)500 INTERNALwith the outage textdue &&guard removed, so the question is asked on every progress call (blob →78b594f67293)409Gates (at
c26b15c37c)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run from this worktree aftergit fetch origin main(origin/main and the merge base are bothf4bed58341). It derived 68 commands: the seat's 66 pluscheck:dispatcher-error-vocabularyandcheck-dev-prereqs.mjs --self-test.check:dual-build-cjs-loadsandcheck:i18n. After a whole-workspace build (turbo run build --filter='!@objectstack/docs' --concurrency=1, 72 tasks, VERDICT command-exit 0), both exit 0:check:route-envelope: exit 0.check:dispatcher-error-vocabulary: "OK — 54 unregistered code-stamping site(s), all classified".check:error-code-casing("no unlisted lowercase error codes in 7802 scanned file(s)")check:error-status-conformance("every derivable runtime status is documented, and every documented status is reachable")@objectstack/spec check:error-code-provenancecheck:durability-log-levelcheck:tenant-chokepointcheck:engine-double-contract"OK — 982 pinned, 129 in the DEBT ledger, 3 exempt".check:nul-bytes"OK (… no raw ASCII control bytes)".c26b15c37c:@objectstack/service-storage: 43 files, 717 tests pass (704 before, plus 13 new).@objectstack/organizations: 11 files, 151 tests pass (147, plus 4).typecheckexits 0 for both, withcheck:test-typecheck: OK.tsc -p tsconfig.test.json --listFilesOnlylists both edited test files.eslint --no-inline-config --format jsonover the 3 touched TypeScript files gave files=3, errors=0, warnings=0.--print-configresolves rules for each file undereslint.config.mjs.parserOptions.projectis null), so this diff cannot move the verdict on an untouched file.pnpm lintis CI's.Acceptance notes
storage-routes.metadata-outage.test.tstooksessionOrganization: string | undefined = 'org_a', andseed(store, undefined)takes the default. So PR fix(service-storage): answer an upload's organization change with 409 RESOURCE_CONFLICT, not an outage 500 #22216's case "a session row with no organization still answers 409 when its FILE row is out of reach" seeded the session inorg_a. It passed for that reason, not for the reason its title gives.409, not200, in the first run at3be6d508d4.nullfor "no organization". Each org-less case, the finding(service-storage): an uploader whose active organization changed after starting an upload gets 500 INTERNAL from the commit and chunked-completion doors, with an operator message that diagnoses a data-engine outage #22175 one included, asserts its premise (organization_idundefined), and all three pass..changeset/22175-upload-org-change-4xx.md) lists "the progress door" under Unchanged. That 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 entries will sit side by side in one release's CHANGELOG. Neither changeset is edited here.Generated by Claude Code