Skip to content

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

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22218-chunk-progress-org-change
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22218-chunk-progress-org-change

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22218
Clause-②: no

The chunk door and the progress door of @objectstack/service-storage now answer an uploader whose active organization changed after starting an upload with the same 409 RESOURCE_CONFLICT that PR #22216 gives at the commit and chunked-completion doors. They used to answer 500 INTERNAL with 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.ts changed in source. metadata-store.ts is untouched: PR #22216's organizationOutOfWriteReach answers both writes as it stands.

  • Chunk door (PUT …/upload/chunked/:uploadId/chunk/:chunkIndex): it calls requireStartingOrganization(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.
  • Progress door (GET …/upload/chunked/:uploadId/progress): the expiry decision is split out of expireIfPastDeadline as expiryStampDue(session), and the write as stampExpired(session, context). expireIfPastDeadline is now the composition of the two, so its behaviour at the chunk and chunked-completion doors is unchanged. The progress door computes due once. When the stamp is due, it calls requireStartingOrganization(writeContext, [stored], 'progress', …) and then stamps. The call comes after the uploader rule, as at the other doors.
  • requireStartingOrganization, the 409 constants 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 no packages/spec change.
  • Changeset: .changeset/22218-chunk-progress-org-change-4xx.md, a patch for @objectstack/service-storage.

Why the progress door asks only when the stamp is due (H3's decision)

  • The question is about the reach of a write. A progress read of a live session, or of one already expired, writes nothing, so there is no reach to miss. It answers 200 today from any organization, and the card does not ask to change that.
  • Asking on every call would turn that 200 into 409. That narrows a published accept set (Clause-②: no (narrowing)), against the ruled Clause-②: 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".
  • due is 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.
  • This choice is pinned. Ablation leg C (below) removes the due && guard, and the pins go red.

Before and after, on the booted organization wall

Measured in packages/plugins/organizations with bootStack, OrganizationsPlugin, StorageServicePlugin and posture isolated. The uploader switches organization through the product's own set-active door. To put a session past its deadline, expires_at is moved into the past as SYSTEM, in the session's own organization.

door before (base f4bed58341) after (c26b15c37c)
chunk, organization changed 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_chunks 1)
progress past expires_at, organization changed 500 INTERNAL, the same text 409 RESOURCE_CONFLICT; the session is not stamped; switching back answers 200 expired and stamps it
chunk, same organization 200 200 (control)
progress past expires_at, same organization 200 expired 200 expired (control)
progress of a live session, organization changed 200 200 (unchanged)

H1 and H2 were measured as stated. Both are throws from the scoped UPDATE, wrapped by engineOp as StorageMetadataStoreError. The "before" column comes from the new wall file run against the base's storage-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.
    • Chunk door (6 cases):
      • From another organization: 409 with the code and first sentence. The backend uploadChunk is never called, the session row is unchanged, and the warn names chunk:, the upload and both organizations.
      • Asked before the expiry write: from another organization the session is not stamped, while the same organization gets 410 and the stamp.
      • Same-organization control: 200.
      • A same-organization real engine failure still answers 500 INTERNAL with "Restore the data engine".
      • An org-less session stays in reach (200).
      • The resume token runs first: 403 INVALID_RESUME_TOKEN, with no mention of organizations.
    • Progress door past its deadline (6 cases):
      • From another organization: 409, not stamped, and the warn names progress:.
      • Same-organization control: 200 expired, stamped.
      • A same-organization real engine failure on the stamp still answers 500 with "Restore the data engine".
      • Asked only when due: an already expired session answers 200 from the other organization.
      • An org-less session is stamped from any organization (200).
      • The ownership rule runs first: 403 PERMISSION_DENIED.
    • Stand-in: the engine-absent store answers both doors from any organization, as before.
  • 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.mjs in 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) and git diff HEAD is empty"). Afterwards: anchors 1/1, markers 0, status clean. Both suites resolve the subject from src: the organizations vitest config aliases @objectstack/service-storage to src/index.ts, and the unit file imports ./storage-routes.js. So no dist leg applies.

leg mutation wall unit
A the chunk door's question removed (blob → 091b433f37f1) 1 red: the chunk pin, which received 500 INTERNAL with the outage text 3 red: 409, expiry-first, token-then-409
B the progress door's question removed (blob → f621826ca74d) 1 red: the progress pin, 500 INTERNAL with the outage text 1 red
C the due && guard removed, so the question is asked on every progress call (blob → 78b594f67293) 1 red: #22216's "UNCHANGED — the progress door still answers…" pin, which received 409 2 red: "asked only when the stamp is due" and #22216's progress-read pin

Gates (at c26b15c37c)

  • Derivation: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run from this worktree after git fetch origin main (origin/main and the merge base are both f4bed58341). It derived 68 commands: the seat's 66 plus check:dispatcher-error-vocabulary and check-dev-prereqs.mjs --self-test.
  • Reconciliation: "✓ 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. Two of them first answered PREREQUISITE NOT MET (exit 3), check:dual-build-cjs-loads and check:i18n. After a whole-workspace build (turbo run build --filter='!@objectstack/docs' --concurrency=1, 72 tasks, VERDICT command-exit 0), both exit 0:
    • "✓ check:dual-build-cjs-loads — 106 published require entry point(s) across 66 package(s) load; …"
    • "check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)"
  • Error-envelope families named by the derivation:
    • check:route-envelope: exit 0.
    • check:dispatcher-error-vocabulary: "OK — 54 unregistered code-stamping site(s), all classified".
  • Also run beyond the derivation, all exit 0:
    • 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-provenance
    • check:durability-log-level
    • check:tenant-chokepoint
  • Other gates: check:engine-double-contract "OK — 982 pinned, 129 in the DEBT ledger, 3 exempt". check:nul-bytes "OK (… no raw ASCII control bytes)".
  • Package suites at c26b15c37c:
    • @objectstack/service-storage: 43 files, 717 tests pass (704 before, plus 13 new).
    • @objectstack/organizations: 11 files, 151 tests pass (147, plus 4).
    • typecheck exits 0 for both, with check:test-typecheck: OK.
    • tsc -p tsconfig.test.json --listFilesOnly lists both edited test files.
  • Lint, narrowed: eslint --no-inline-config --format json over the 3 touched TypeScript files gave files=3, errors=0, warnings=0.
    • Population: no "file ignored" warning, and --print-config resolves rules for each file under eslint.config.mjs.
    • Invariance: that config never enables type-aware linting (parserOptions.project is null), so this diff cannot move the verdict on an untouched file.
    • The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

claude added 2 commits October 8, 2026 08:47
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 73a0a6bf1db3216a0c82bee1503011040c6a1394 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4d16d328027a84dba0db1d8e02d535e316f5b09b — the merge of head c26b15c37c67dff7581c8cfe676bb2bdab4d514f into base 73a0a6bf1db3216a0c82bee1503011040c6a1394, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 09:51
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 09:51
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 3513ac7 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22218-chunk-progress-org-change branch October 8, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants