Skip to content

fix(service-storage): answer an upload's organization change with 409 RESOURCE_CONFLICT, not an outage 500 - #22216

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22175-upload-org-change-4xx
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-22175-upload-org-change-4xx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22175
Clause-②: no

The commit door and the chunked-completion door of @objectstack/service-storage now answer an uploader whose active organization changed after starting an upload with 409 RESOURCE_CONFLICT, in the ADR-0112 envelope, with a message that names the organization change. They used to answer 500 INTERNAL with the store's data-engine outage text. The operator log gets one warning that names the cause. Which organization an upload belongs to does not change, and the tenant scope of every write is unchanged.

Rulings carried (triage 6052856225, verbatim)

What changed

  • metadata-store.ts: a new module-level function, organizationOutOfWriteReach(store, row, context). It is not re-exported from the package entry, and the public face is unchanged. It answers one question about the by-id write the store would issue under context: can that write reach row? If it cannot, the function returns the organization the row was started in. Otherwise it returns null.
    • It returns null for the engine-absent stand-in, which does not scope its writes. A module-private WeakSet filled in the constructor records which stores are engine-backed.
    • It returns null when no organization is threaded. It reuses writeOptionsFor, the same rule that decides whether the write carries a tenantId.
    • It returns null for a row stamped with no organization, because the driver keeps org-less rows in reach.
    • Otherwise, a row is in reach exactly when its organization is the acting one.
    • It composes no predicate. The statement's tenant term stays the driver's.
  • storage-routes.ts: a new gate, requireStartingOrganization(writeContext, rows, …). It runs right after the ownership rule from fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the uploader #22170 and before any write, so only the uploader ever reaches it. It asks the store about every row the door is about to write. When a row is out of reach, the gate logs one warn line naming the door, the upload id, the starting organization and the active one. It then answers 409 / RESOURCE_CONFLICT with a constant message that names no organization.
    • Commit door (POST …/upload/complete): it asks about the sys_file row. The write context is now bound once and passed both to the question and to updateFile, so the two cannot differ.
    • Chunked-completion door (POST …/upload/chunked/:uploadId/complete): it asks about the upload session and its file, and it asks before the expiry stamp, which is the first scoped write. The file row that the ownership rule reads is now bound to a variable rather than read inline.
  • Changeset: .changeset/22175-upload-org-change-4xx.md, a patch for @objectstack/service-storage.

Code choice. RESOURCE_CONFLICT is the standard-catalog member that HTTP 409 derives, so no ledger entry and no packages/spec change were needed. The request conflicts with where the upload stands, and the caller can resolve it: the same call succeeds after switching back, and the wall pin shows this. PERMISSION_DENIED would merge this answer with the ownership rule's one refusal, and this caller is the uploader. PRECONDITION_REQUIRED (428) means the request is missing a precondition, and nothing is missing here.

Before and after, on the booted organization wall

Measured with packages/plugins/organizations: bootStack with OrganizationsPlugin and StorageServicePlugin, posture isolated, and the uploader switched through the product's own set-active door.

door before (1e5d322c) after (af2811c51c)
commit, organization changed 500 INTERNAL, "StorageMetadataStore: sys_file update failed against the data engine … Restore the data engine …", cause "Record … not found in sys_file" 409 RESOURCE_CONFLICT, "This upload was started in a different organization than your active one. …"; row unchanged; switching back commits it (200)
chunked completion, organization changed 500 INTERNAL, the same text for sys_upload_session 409 RESOURCE_CONFLICT; session and file rows unchanged; switching back completes it (200)
commit / chunked completion, same organization 200 200 (control)
progress, organization changed 200 200 (unchanged)

H1, measured: the scoped miss is a throw, not zero rows. The SQL driver's scoped UPDATE … WHERE id touches no row and its scoped read-back returns null. The engine then raises "Record ID not found in OBJECT", and engineOp wraps that as StorageMetadataStoreError.

Why the question lives in the store, not as a bare comparison at the door

A comparison written only at the door would also refuse on the engine-absent stand-in. The stand-in records the organization on insert but does not scope its writes, and it finishes this upload today (200). Refusing there would narrow an accept set that is published (registerStorageRoutes and StorageMetadataStore are both public), and that reads as Clause-②: no (narrowing) against the ruled Clause-②: no. A public getter on StorageMetadataStore would widen the public face instead. The module-level function changes neither, and it keeps the stand-in's answers unchanged, which is H4.

Tests

  • storage-routes.metadata-outage.test.ts: a new block, an organization change is not an outage, with 14 cases on that file's existing fake engine. The double was reused and no new double was pinned.
    • At both doors: 409 with the code and first sentence, rows unchanged, and the warn line naming the upload and both organizations.
    • Same-organization control (200).
    • Same-organization real engine failure still answers 500 INTERNAL with "Restore the data engine", at both doors.
    • No active organization (200), an org-less row (200), an org-less session whose file is out of reach (409), and the question asked before the expiry write.
    • The ownership rule runs first: a non-uploader in another organization still gets 403 PERMISSION_DENIED, which says nothing about organizations.
    • The progress door is unchanged, and the stand-in is unchanged (200 from either organization).
  • metadata-store.test.ts: 6 cases on organizationOutOfWriteReach.
  • error-envelope.conformance.test.ts: one case for the new code, parsed against the declared envelope.
  • packages/plugins/organizations/src/storage-upload-organization-change.wall.test.ts (new): the premise, both doors' negative pins with switch-back, both same-organization controls, and the progress door unchanged.

Ablation (from committed af2811c51c)

The mutation went through scripts/ablation-replace.mjs in wrap mode, run under the verify lock. It replaced the question with const started = null (marker ABLATION-22175).

  • On-disk evidence: anchor count went 1 to 0, marker 0 to 1, blob fd950d45ec2a to eba43b779bd2.
  • Both suites resolve the subject from src: the organizations package's vitest alias maps @objectstack/service-storage to src/index.ts, and the unit tests import relatively. So no dist leg applied.
  • Wall: the 2 negative pins turned red. Each received 500 INTERNAL with the outage text, the original defect reproduced. The other 4 stayed green.
  • Unit: 5 cases turned red, the conformance case among them. The fake engine does not scope, so the doors answered 200, or 410 on the expiry case.
  • Restore: proven by the tool (blob equals HEAD fd950d45ec2a, git diff HEAD empty) and re-read afterwards (anchor 1, marker 0).

Gates (at af2811c51c)

  • Derivation. Run from this worktree with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, after git fetch origin main (origin/main 13aea18959, merge base 1e5d322c1). It derived 68 commands. That is the seat's 66 plus check:dispatcher-error-vocabulary and check-dev-prereqs.mjs --self-test.
  • Reconciliation. --ran was run with every exit code recorded. It printed: "68 derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".
  • Exit 0: 67 commands. One of them, check:i18n, first answered PREREQUISITE NOT MET (exit 3). After its stated build closure, it printed: "check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)".
  • NOT MEASURED: check:dual-build-cjs-loads. Its prerequisite is a whole-workspace build (33 packages have no dist/). In its place I ran a narrow probe on the only package whose source this diff touches: the rebuilt service-storage loads through require('./packages/services/service-storage/dist/index.cjs') (exit 0). The new function is absent from dist/index.d.ts. CI runs the full gate.
  • Error-envelope and error-code families.
    • From the derivation: check:route-envelope passes, and check:dispatcher-error-vocabulary answered "OK — 54 unregistered code-stamping site(s), all classified".
    • From the roster families the derivation lists but cannot place: check:error-code-casing ("no unlisted lowercase error codes in 7791 scanned file(s)"), check:error-status-conformance ("every derivable runtime status is documented, and every documented status is reachable") and @objectstack/spec check:error-code-provenance (OK).
    • Also run beyond the derivation, all exit 0: check:authz-resolver, check:filter-alias-parity, check-changeset-fixed.mjs, check:tenant-chokepoint and check:durability-log-level.
  • Package suites at af2811c51c. @objectstack/service-storage: 43 files, 704 tests pass. @objectstack/organizations: 11 files, 147 tests pass. typecheck exits 0 for both, with check:test-typecheck: OK for each.
  • Lint, narrowed. I ran eslint --no-inline-config --format json over the 6 touched TypeScript files: files=6, errors=0, warnings=0. Population: there was no "file ignored" warning, so all 6 were linted under eslint.config.mjs. Invariance: that config never enables type-aware linting (no parserOptions.project, no typed rules), so this diff cannot change the verdict on an untouched file. The full pnpm lint is CI's.

Acceptance notes

  • Same family, outside this card's two doors. These were measured on the same wall with a throwaway probe that was deleted after the run:
    • the chunk door (PUT …/upload/chunked/:uploadId/chunk/:chunkIndex), after the uploader switches organization, answers 500 INTERNAL with the sys_upload_session outage text;
    • the progress door, for a session past its expires_at, answers the same 500, because its expiry stamp is a scoped write. From the starting organization it answers 200 with expired.
    • The store question this PR adds would answer both of these scoped writes. They are reported to the seat as one finding and are not fixed here, because the dispatch fenced the two doors.
  • The dispatch said the card had 3 comments. The REST read shows 2, the triage and the claim. The newest Claim: names this branch.

Generated by Claude Code

claude added 2 commits October 8, 2026 06:06
…an organization change with 409, not an outage 500

An uploader whose active organization changed after starting an upload
names a row the doors' scoped by-id write cannot reach. The engine's miss
came back as 500 INTERNAL carrying the store's "Restore the data engine"
text. Both doors now ask the store (organizationOutOfWriteReach) before
any write and answer 409 RESOURCE_CONFLICT naming the change, with one
operator warning naming the cause. The engine-absent stand-in does not
scope and keeps its answers.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…dapter so a same-organization completion assembles

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 8 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/error-catalog.mdx (via RESOURCE_CONFLICT (literal, a string literal in ORGANIZATION_CHANGED_CODE))
  • content/docs/api/error-handling-server.mdx (via RESOURCE_CONFLICT (literal, a string literal in ORGANIZATION_CHANGED_CODE))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via RESOURCE_CONFLICT (literal, a string literal in ORGANIZATION_CHANGED_CODE))
  • content/docs/releases/v17/17-7.mdx (via RESOURCE_CONFLICT (literal, a string literal in ORGANIZATION_CHANGED_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 7d7943dd0dfba6c98afa2200a08983ec85f9849a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bcd3417b3c44a821688f56636faf8d4ca560fae0 — the merge of head af2811c51c814a5cca6ce315b3ff8fe0ca01c88e into base 7d7943dd0dfba6c98afa2200a08983ec85f9849a, 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 bcd3417b3c44a821688f56636faf8d4ca560fae0 && git checkout bcd3417b3c44a821688f56636faf8d4ca560fae0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d7943dd0dfba6c98afa2200a08983ec85f9849a af2811c51c814a5cca6ce315b3ff8fe0ca01c88e && git checkout -B drift-repro 7d7943dd0dfba6c98afa2200a08983ec85f9849a && git merge --no-ff af2811c51c814a5cca6ce315b3ff8fe0ca01c88e

node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7d7943dd0dfba6c98afa2200a08983ec85f9849a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 06:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 06:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 7ebbc01 Oct 8, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22175-upload-org-change-4xx branch October 8, 2026 07:54
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… and the progress door's expiry stamp with 409 RESOURCE_CONFLICT, not an outage 500 (objectstack-ai#22251)

Fixes objectstack-ai#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 objectstack-ai#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)

- "Direction: the same 409 that PR objectstack-ai#22216 gives, asked before the write"
- "⛔ No change to which organization owns an upload (objectstack-ai#22046's ruling). ⛔
No catch-all that would hide a real engine failure."

## What changed

Only `packages/services/service-storage/src/storage-routes.ts` changed
in source. `metadata-store.ts` is untouched: PR objectstack-ai#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.
- The resume token stays first. It is this door's proof that the caller
may act on the upload, and the door has no uploader rule (objectstack-ai#22046's
ruling covers the commit, chunked-completion and progress doors). A
caller without the token still gets `403 INVALID_RESUME_TOKEN` and
learns nothing about organizations.
- Before this change, a refused chunk was sent to the backend first. Its
bytes landed, and only the session write after it failed.
- **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 objectstack-ai#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 objectstack-ai#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 (5fe0c1a) 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: objectstack-ai#22216's "UNCHANGED — the
progress door still answers…" pin, which received `409` | 2 red: "asked
only when the stamp is due" and objectstack-ai#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

- **The seed helper's org-less arm was vacuous; fixed here, because the
new pins depend on it.** The helper in
`storage-routes.metadata-outage.test.ts` took `sessionOrganization:
string | undefined = 'org_a'`, and `seed(store, undefined)` takes the
default. So PR objectstack-ai#22216's case "a session row with no organization still
answers 409 when its FILE row is out of reach" seeded the session in
`org_a`. It passed for that reason, not for the reason its title gives.
- Measured here: the two new org-less cases first answered `409`, not
`200`, in the first run at `3be6d508d4`.
- The helper now takes `null` for "no organization". Each org-less case,
the objectstack-ai#22175 one included, asserts its premise (`organization_id`
undefined), and all three pass.
- **Release text:** PR objectstack-ai#22216's pending changeset
(`.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](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

2 participants