Repository navigation
fix(service-storage)!: enforce the storage settings Limits group (max_upload_mb, presigned_ttl, session_ttl) at the upload doors - #22311
Conversation
…d doors The storage settings namespace declares presigned_ttl, session_ttl and max_upload_mb, Setup renders and saves them, and nothing read them. The plugin now reads the Limits group on every settings pass (ahead of the adapter gate) and carries it on the swappable storage service both mounts are composed over; each upload door resolves it per request: a saved value wins over the host option, the host option over the namespace default. A presigned upload's declared size, a chunked upload's declared total, the local raw PUT body and each chunk's running total are refused over the resolved limit with 413 before anything is stored. Presigned URLs and new upload sessions take the resolved TTLs. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
Each upload door refuses over the saved max_upload_mb with 413 and stores nothing, with its control at exactly the limit; the saved TTLs reach presigned URLs and new sessions; saved > host option > namespace default; nothing saved keeps the declared defaults; a save after boot reaches the next request while an issued session keeps its deadline; the host door honours the same snapshot. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…Limits group The changeset declares the narrowing (an upload over the resolved max_upload_mb, 100 MB when nothing is saved, used to land and is refused) with its ADR-0087 disposition. The README documents the three keys, their defaults and the precedence. The dogfood file drives the real settings cascade, PUT /api/settings/storage and the HTTP upload doors. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…orage-limits-honoured
…nst the limit Both adapters replace a re-sent part, so the chunk door's size check now sums the latest recorded size per chunk index (each stored part entry records its size) instead of the running uploaded_size, which a retry inflates. A session whose parts predate the recorded sizes is judged by the running total, the upper bound. Measured before this commit: with max_upload_mb 1, a 1 MiB upload whose 600 KiB chunk 0 was re-sent once was refused 413 while holding 600 KiB. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…orage-limits-honoured
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
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 3d5653ea864240e2d31bda4dae86599b13912761 && git checkout 3d5653ea864240e2d31bda4dae86599b13912761
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dc4a5c630844f04587e790c21ca807f306239518 736713afc811e257c7e22cf49ba1ce6c164714d9 && git checkout -B drift-repro dc4a5c630844f04587e790c21ca807f306239518 && git merge --no-ff 736713afc811e257c7e22cf49ba1ce6c164714d9
node scripts/docs-audit/affected-docs.mjs --json dc4a5c630844f04587e790c21ca807f306239518
|
… storage upload doors The storage upload doors answer 413 VALIDATION_ERROR for an upload over the File Storage settings' max_upload_mb, and no page published that status for the code, so check:error-status-conformance was red. Both pages now carry it as an exception to the code's 400, mirroring MISSING_REQUIRED_FIELD's 422: a sentence in the code's section and an exception row in each status table, scoped to the storage upload doors so the later switch to the ledger's own 413 code retires exactly these lines. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
…le that binds an HTTP status, judged from content A sibling CHANGE_KIND_GATES entry with its own predicate, emitsAnHttpStatus: a status or statusCode bound to a 4xx/5xx value, the positional sendError door, or a SCREAMING_SNAKE status constant. Measured at the bytes of main 28bff18, it reaches all 128 files that carry a derived producer, while the ADR-0112 code predicate misses 19 of them (86 sites), so the gate does not simply join the vocabulary entry. The gate's REFUSE-WIDE bare-root row stands as recorded. Self-test cases pin the PR #22311 emit site, the docs-only, README and no-emission controls, and the rendered line. Claude-Session: https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q Co-authored-by: Claude <noreply@anthropic.com>
… upload holds, and a re-sent chunk is counted once (objectstack-ai#22313) (objectstack-ai#22330) Fixes objectstack-ai#22313 Clause-②: no (narrowing) ## Summary A chunked upload now completes with the file it was sent, or not at all. - **The completion door** (`POST /storage/upload/chunked/:uploadId/complete`) assembles the upload's own record of the chunks it holds: every chunk the chunk door stored, with the eTag the backend answered for it. It no longer assembles the request's `parts` list. The list is checked against the record, and the record is checked against the declared `totalChunks` / `totalSize`. When any check fails, the door answers **`409 RESOURCE_CONFLICT`** before anything is assembled. `error.details` names the chunk indexes: `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks`, plus `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`, so the uploader can send what is missing and complete again. - **The chunk door** (`PUT …/chunk/:chunkIndex`): a re-sent index replaces its slot in `sys_upload_session.parts`. `uploaded_chunks` counts distinct chunks. `uploaded_size` is the sum of each chunk's latest size, which uses the per-part size PR objectstack-ai#22311 records. A session whose record predates those sizes keeps its running total, as PR objectstack-ai#22311 judges it. - **`packages/client` is unchanged.** Because the door completes from the stored parts, the SDK's `resumeUpload` is correct as it is: it lists only the parts its own pass sent. The pins below drive the doors in exactly that order. ## Measured: before (main `4e4111ca`) and after The pins were committed first (`ab583f369d`, `4508df0ec0`) and run on unfixed main before `storage-routes.ts` was touched. | Pin | main `4e4111ca` | this branch | |---|---|---| | Resumed 2-chunk upload (chunk 0 = 5 MiB stored in a first pass; the resume sends chunk 1 = 10 B and completes listing only chunk 1) | `200`, `size` 5242890, stored object **10 bytes** (unit and over HTTP) | `200`, stored object 5242890 bytes, byte-equal to chunk0 + chunk1 (unit, and read back through `GET /storage/files/:id/url` over HTTP) | | Upload that never received chunk 0, completed | `200` | `409 RESOURCE_CONFLICT`, `details.missingChunks` = `[0]`; session `in_progress`; the backend assembly is not called | | Chunk 0 (600 KiB) of a 1 MiB session sent twice, then progress read | `uploadedSize` 1228800, `uploadedChunks` 2, `percentComplete` 100 | 614400 / 1 / 59, identical to the reading after one send | | CONTROL: a clean one-pass upload listing every part | `200`, full file | `200`, full file (unchanged) | ## Why the door completes from stored parts rather than refusing every short list (dispatch H2 / H4) Two measurements decided this. - **The server holds what assembly needs.** The record stores, for every chunk, the eTag the backend itself answered at upload time. S3's `CompleteMultipartUpload` needs part numbers and those eTags; the local adapter reads part files by number and ignores eTags. So the server can fill any part the request omits. - **The client cannot list what the record holds.** The progress answer (`UploadProgressSchema`) carries no parts or eTags, so `resumeUpload` cannot list the parts an earlier pass sent. Exposing them would be a `packages/spec` change, which is fenced. Refusing every list that omits a stored part would therefore refuse every real SDK resume. Assembling the record still refuses a short assembly. The door refuses any upload whose record lacks a declared chunk or does not add up to the declared size. Triage's pin is "completes with the full file or is refused", and both halves hold. ## Files outside the dispatch's file list (a declared fence deviation) - `packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts` and `storage-upload-organization-change.wall.test.ts`: three POSITIVE legs (one, plus two) completed a 10-byte chunked upload that held none of its bytes and expected `200`. That `200` is this defect, and the door now refuses it. Each leg now sends the declared 10 bytes as chunk 0 before completing. The refusals these files pin (ownership `403`, organization-change `409`) run before the new check and are unchanged. - In `service-storage`, five existing tests did the same zero-byte completion and were updated the same way. `tenant-audit-update-delete-half-repairs.test.ts`'s recording engine double now applies a by-id `update` to its row, as the real engine does. Without that, the completion could not read back the record the chunk door wrote. ## Tests, ablation and gates (final union at `0342e008b0`) - `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2`: 45 files / **749 passed**, including 13 in the new `src/chunked-upload-integrity.test.ts`. - `pnpm --filter @objectstack/service-storage typecheck` (tsc, scripts and test layer): exit 0. - `pnpm --filter @objectstack/organizations exec vitest run` on the two wall files: **20 passed**. `pnpm --filter @objectstack/organizations typecheck`: exit 0 (at `b0b1ca0d83`). - `pnpm --filter @objectstack/dogfood exec vitest run --project isolated` on `test/storage-chunked-resume-integrity.dogfood.test.ts` and PR objectstack-ai#22311's `test/storage-upload-limits.dogfood.test.ts`: **6 passed**. The new dogfood file compiles with 0 errors in `tsc --noEmit --listFiles`. The package-wide dogfood typecheck was NOT MEASURED locally: 84 TS2307 errors come from packages not built in this tree, none in this diff. - **Ablation** (`scripts/ablation-replace.mjs`, wrap mode; each leg's mutation and restore were proven by blob hash against HEAD). The unit suite imports `./storage-routes.js` from source, so no dist was involved. - A1, assemble the request's list instead of the record: the resumed-upload pin and 3 others go red. - A2, refusal disarmed: all 7 refusal pins go red. The first A2 leg was a no-op that the tool refused (its replacement was a substring of the anchor); it was re-run with a distinct replacement. - A2b, eTag check removed: only the mismatched-eTag pin goes red. - A2c, size check removed: the size and legacy-total pins go red. - A3, running total kept: the re-sent-progress pin goes red. - A4, append instead of replace: the re-sent-progress and replace-slot pins go red. - The dogfood file's red leg is its run on main above. - Gates at `0342e008b0`: `node scripts/pm/dispatch-gates.mjs --commands` derived 72 families (unchanged after the last merge). All 72 were run, plus `pnpm check:error-status-conformance` by hand, and every one exited 0. The error-status gate printed "every derivable runtime status is documented, and every documented status is reachable". `pnpm check:dispatcher-error-vocabulary` printed OK, `pnpm check:engine-double-contract` printed OK with 983 pinned, and `--ran` reconciled 72 of 72. (In the first run, before the last merge, `check:dual-build-cjs-loads` and `check:i18n` exited 3 with PREREQUISITE NOT MET because this tree was not fully built. Both were measured green in this run.) - Lint: `eslint --no-inline-config` on the 9 touched `.ts` files reported 9 files, 0 errors, 0 warnings (JSON count). `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. ## Acceptance notes - **Concurrent chunk PUTs lose a record entry (an existing defect, reported to the seat rather than fixed here).** The chunk door reads, modifies and writes `parts`. Two concurrent PUTs (chunks 0 and 1) both answered `200`, and the record kept only chunk 0. With this change, that upload's completion is refused `409` with `missingChunks` = `[1]`, so re-sending chunk 1 finishes it. On main, the same race corrupted the progress silently. No in-repo client uploads chunks in parallel: the SDK's `resumeUpload` is sequential, and objectui uses the presigned flow. - A resumed upload with a hole that is not a prefix (chunk 1 held, chunk 0 not) gets a `409` naming chunk 0 from the SDK, because `resumeUpload` resumes from the `uploadedChunks` count. The refusal's `details.missingChunks` is enough for a client to resume exactly; teaching the SDK to read it is a separate client change. - The chunk door still accepts a chunk index at or beyond `totalChunks`. The completion now refuses such an upload (`unexpectedChunks`), so no extra bytes reach an assembled file. - A `parts` value that is not a list of `{ chunkIndex, eTag }` is now refused `400 INVALID_REQUEST`. It used to throw a `500`. An absent `parts` is still an empty list. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…le that binds an HTTP status, judged from content (objectstack-ai#22329) Fixes objectstack-ai#22320 Clause-②: no ## What `scripts/pm/dispatch-gates.mjs` now names `check:error-status-conformance` for a file that binds an HTTP status to an error response, judged from the file's CONTENT. That is the route the seat verdict on the card chose: the `CHANGE_KIND_GATES` content trigger that already reaches `check:dispatcher-error-vocabulary`, another REFUSE-WIDE gate. - `scripts/pm/dispatch-gates.mjs` has three changes: - A new exported predicate, `emitsAnHttpStatus`, with three limbs. (1) `status`/`statusCode` bound to a 4xx/5xx literal or a SCREAMING_SNAKE name. (2) The positional `sendError(res, STATUS, …)` door. (3) A SCREAMING_SNAKE constant bound to a 4xx/5xx literal. - A sibling `CHANGE_KIND_GATES` entry naming the gate, with its deletion criterion. - 35 new self-test cases, plus a census-guard pin that the family is live. - `scripts/check-error-status-conformance.mjs` and `scripts/pm/bare-root-worklist.mjs` are back to their `main` bytes. The round-1 `ROOT_DIR_WATCH_HINTS` declaration, its self-test battery and the REFUSE-WIDE to DECLARED-NARROWER re-decision are reverted by a new commit (`11e2a5299b`); history is not rewritten. `git diff origin/main` on both files is empty, so neither needed a touch, and the gate's REFUSE-WIDE row stands as recorded. - The extractor's bare-word refusal is untouched. No gate is added. ## Sibling entry or join? Measured Measured at `11e2a5299b`, whose bytes equal `main` `28bff18d0c`. Each file was run through the gate's own deriver (`deriveRuntimeStatuses`) one at a time: | | files | |---|---| | carry a derived (code, status) producer | 128 (418 sites) | | producers the existing ADR-0112 code predicate (`stampsAnErrorCodeLiteral`) misses | **19 (86 sites)** | | producers `emitsAnHttpStatus` misses | 0 | The two questions diverge, so the gate gets a **sibling entry** with its own predicate rather than joining the code entry. All 19 misses fall into two shapes: - **The positional four-argument door.** For example, `settings-routes.ts` and `share-link-routes.ts` call `sendError(res, 403, 'SETTINGS_FORBIDDEN', …)`, with no `code` token near the code. - **An assignment pair whose code is an enum member.** For example, `turso-driver.ts` and `filter-refusal.ts` write `err.code = StandardErrorCode.enum.X; err.status = 400`. Precision and residues on the same tree: - **Precision:** `emitsAnHttpStatus` matches 185 of the 3247 non-test TypeScript files, every one inside the gate's own scanned population, and 128 of them carry a producer (69%). The code predicate names 283 files for its own gate. - **Residue 1, unresolved-only files:** 9 files hold only UNRESOLVED pairs (a status read from a runtime value), and 6 of them do not match. The gate reports those and never fails on them, so as written they cannot red it. - **Residue 2, cross-file constants:** 4 files resolve a producer elsewhere through a constant they declare. The status-constant limb reaches 3 of them: `anonymous-deny.ts`, `upload-ownership.ts` and `webhook-secret.ts`. The fourth, `packages/mcp/src/metadata-completeness.ts`, declares only a CODE constant, so the code entry reaches it and this one does not. A code-constant limb would have cost 45 more matches (185 to 230) for that one file, and the entry declines that trade. The docblock records both residues. ## The harm, reproduced on the same paths Command: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack PATH`. Before is `11e2a5299b` (the bytes of `main` `28bff18d0c`); after is head `28bf1d4448`. Every run exited 0. | path | before | after | |---|---|---| | `packages/services/service-storage/src/storage-routes.ts` (the PR objectstack-ai#22311 emit site) | 53 commands, family absent | 54; the only added line is `pnpm check:error-status-conformance` | | `content/docs/ai/agents.mdx` (docs-only control) | 41, absent | 41, absent | | `packages/services/service-storage/README.md` | 46, absent | 46, absent | | `packages/services/service-storage/src/index.ts` (a `.ts` with no status and no code) | 51, absent | 51, absent | `discoverFamilies()` gives byte-identical records, at `main` and at head, for every family whose files include `dispatch-gates.mjs` or `check-dispatch-gates.mjs`. So none of the new path strings in the self-test, or in the module-body `why`, became a hint. ## Pins (dispatch-gates self-test, 1976 to 2011 cases) - **Limbs, through an injected reader:** - Hits: a status-and-body terminal, an error class declaring its status, an enum-member assignment pair, the positional door, a SCREAMING status name, and a status constant. - The enum-pair and door cases are each paired with the code predicate missing the same text. That divergence is the reason for a sibling entry. - Misses: a quoted status word, a 2xx status, a comparison, a runtime-value status, a comment, a `.ts` with no status and no code, a test file, a `.d.ts`, a non-TS file, and an unreadable path. - **Live tree:** the objectstack-ai#22311 emit site is reached. The docs-only page, the package README and the package barrel `index.ts` are not reached; the barrel is reached by neither content entry. Discrimination holds at 185 of 3247. - **Rendered:** exactly one entry names the gate. The emit-site path renders the section and the runnable line. The `why` states REFUSE-WIDE, DOCUMENTING as the repair, the ⛔ MAINTAINER-ONLY baseline remedy, and "needs NO build". Through the whole table, the specimen derives the gate and the docs-only, README and barrel paths do not. An undiscoverable name renders STALE. - **Census guard:** `check:error-status-conformance` is a live family. ## Ablations All three ran through `scripts/ablation-replace.mjs` against committed head `28bf1d4448`, with the dispatch-gates self-test as the wrapped command. Each run was stopped once the status section and the first case after it had streamed, by killing only the wrapper's own descendants. Each mutation was proven on disk (anchor 1 to 0, blob changed). Each restore was proven by `blob == HEAD` (`5447f770ee5c`) and an empty `git diff HEAD`. 1. **Door limb removed:** exactly 1 red, "the positional four-argument sendError door is a hit". 2. **Entry made inert (`matches: () => false`, the base behaviour):** 7 red, the rendered half. Those are the section, the runnable line, the three `why` pins, "through the whole table, the specimen derives the status gate", and STALE. The negatives stay green. 3. **Predicate widened to every TS file:** - The 8 content negatives red: quoted word, 2xx, comparison, runtime value, comment, no-status `.ts`, live barrel, and barrel through the table. The discrimination case also reds, at 3247 of 3247. - The population filter holds: test, `.d.ts`, non-TS, docs and README stay green. - Collateral: three existing exact-count pins went red, because a widened entry adds a line to their specimen paths. They are the root-program, i18n and metadata-form section counts. With the real predicate they are green. ## Gates (all at head `28bf1d4448`) - **Derived list:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; change set from the merge base `28bff18d0`, one file) derived 29 commands. All 29 exited 0, with exit codes captured before any pipe. - **`--ran` reconciliation:** "29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED (a DERIVED zero — all 29 recorded an exit code and none of them is 3)". - **`pnpm check:pm-dispatch-gates`:** "dispatch-gates self-test: 2011 cases pass", exit 0. It ran detached, 1111s, before the ablations, on the clean tree. - **The other 28 derived commands:** all exit 0. The list: ci-filter-parity, closing-keyword-parity (+self-test), comment-mask-corpus, declaration-mirrors (+self-test), scripts-symbol-anchors (+self-test), self-test-wired (+self-test), self-test-workflow-commands (+self-test), whole-set-label-write (+self-test), agent-test-spelling, bash32-floor, cli-command-ids, cross-package-test-inputs, declared-population-live, driver-memory-census, entry-guard, gitlink-declared, nul-bytes, parse-guard, pnpm-filter-targets, ratchet-remedy-authority, refd-timer-probe, watch-hint-literal. - **Not derived, run anyway:** `node scripts/pm/bare-root-worklist.mjs --self-test` (it imports the derivation's predicates) passed with OK. `pnpm check:error-status-conformance` passed. - **Lint, narrowed and declared as narrowed:** `eslint --no-inline-config --format json scripts/pm/dispatch-gates.mjs` returned 1 file, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is left to CI. ## Acceptance notes - **`main` was merged once** (`591dc5ad2a`, to `28bff18d0c`). It has since moved one commit, to `e36ee5351b` (objectstack-ai#22330). That commit touches nothing under `scripts/`. It does edit the live specimen, and on its bytes the specimen still matches `emitsAnHttpStatus` and the barrel `index.ts` still matches neither predicate, so the branch was not merged again. CI runs the merge ref. - **Three other roster-silent REFUSE-WIDE families stay as recorded.** At `4e4111ca0`, for the storage-routes path, `check:error-code-casing`, `check:engine-double-contract` and `check:authz-resolver` were also roster-silent and underived. None has a measured consumer, so none is touched or filed. If one gains a consumer, the same content-trigger route applies. - **A side observation, not acted on:** the code predicate misses the `StandardErrorCode.enum.X` assignment form, because its SCREAMING_SNAKE limb requires the name to start the value. Whether `check:dispatcher-error-vocabulary` can fail on that form is not measured here. - **`skip-changeset`:** root `scripts/` publishes nothing. --- _Generated by [Claude Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…D_TOO_LARGE (objectstack-ai#22359) Fixes objectstack-ai#22314 Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) The upload size refusal that PR objectstack-ai#22311 added (card objectstack-ai#22283) now answers `413` with the registered code `PAYLOAD_TOO_LARGE` at all four `service-storage` upload doors, in place of the status-derived `VALIDATION_ERROR`. The error-code ledger lists `PAYLOAD_TOO_LARGE` under `@objectstack/service-storage`, beside `@objectstack/rest`. This is ruling B from the `domain:services` seat in PR objectstack-ai#22311's ACCEPT, carried on the card. It is not a new code. The status (`413`), the message, the four doors, and what is stored or refused are all unchanged. ## What changed - **`packages/spec/src/api/error-code-ledger.zod.ts`:** one provenance row, `'PAYLOAD_TOO_LARGE'`, under the `@objectstack/service-storage` owner key. - It follows the shape the ledger's other multi-emitter rows use (`PACKAGE_DELETE_PARTIAL` and `WRITABLE_PACKAGE_REQUIRED` under `@objectstack/runtime`): a comment naming the wire path and the other emitter, then "Provenance, not identity". - `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, because the union dedupes. - Every sentence of the docblock at `standardSynonymOf` stays true, including "a phrase for a status the catalog does not name (`PAYLOAD_TOO_LARGE`, 413) is NOT a synonym". - **`packages/services/service-storage/src/storage-routes.ts`:** the one constant changes from `UPLOAD_TOO_LARGE_CODE: StandardErrorCode = 'VALIDATION_ERROR'` to `UPLOAD_TOO_LARGE_CODE: RegisteredErrorCode = 'PAYLOAD_TOO_LARGE'`. Its docblock is rewritten to state the new choice and the reason for it. - **Tests:** they now expect `PAYLOAD_TOO_LARGE` and still assert both `code` and `status` at every door. - `storage-limits.test.ts`: the shared `expectTooLarge` helper asserts `413` + `PAYLOAD_TOO_LARGE`, and every door case calls it: - presigned; - chunked start; - local raw PUT, by bytes read and by `content-length`; - chunk PUT, by bytes read (three cases) and by `content-length`. - `error-envelope.conformance.test.ts`: the case for the over-limit upload. - `packages/qa/dogfood/test/storage-upload-limits.dogfood.test.ts`: three assertions over a real boot. - **Text that describes this refusal:** - the `service-storage` README limits row; - `content/docs/api/error-catalog.mdx`: the `VALIDATION_ERROR` 413 callout moves to a new `PAYLOAD_TOO_LARGE` entry, the quick-reference 413 row changes, and the wire-code count goes from 51 to 52; - `content/docs/protocol/kernel/error-handling.mdx`: the 413 row and the `VALIDATION_ERROR` entry. - **Changesets:** - `.changeset/22314-storage-payload-too-large-provenance.md` is new: `@objectstack/spec` `minor`, carrying the Clause-② line above. - The pending `.changeset/22283-storage-limits-honoured.md` gets a one-sentence correction (see the next section). ## Pending release note corrected: confirmation requested (`Check Changeset` is red by design) `.changeset/22283-storage-limits-honoured.md` is objectstack-ai#22283's pending, unreleased note (`@objectstack/service-storage` `minor`). This PR changes one sentence in it: - **Before:** "is refused with `413` and the standard code `VALIDATION_ERROR`, in the usual error envelope," - **After:** "is refused with `413` and the registered code `PAYLOAD_TOO_LARGE`, in the usual error envelope," Nothing else in that note changes. With this edit, the release that first ships the refusal names one code. `check-empty-changeset` refuses a changed foreign changeset by design. This PR is the DELIBERATE CORRECTION class: the gate's own remedy is to say so on the PR and have it confirmed. Do not restore the old sentence, which would put the false code back. Do not apply `skip-changeset`. Please confirm the correction here. The `service-storage` code change rides on that note. This PR's own changeset declares `@objectstack/spec` only. No `check-changeset*` gate asked for `@objectstack/service-storage` in an added changeset: - `check-changeset-no-major` exits 0. - `check-adr-0087-registration` exits 0. - `Require a changeset` counts the added `22314` changeset. ## Release coupling, checked before building - npm `@objectstack/service-storage`: - `latest` is `17.7.0`, published 2026-10-06T12:09Z. - Its tarball has 0 dist files matching `limitsSnapshot` or `UPLOAD_TOO_LARGE_CODE`. The control, `upload/presigned`, matches in 4 dist files. - PR objectstack-ai#22311 merged on 2026-10-08T16:35Z, and its changeset is still pending on `main`. - The Version Packages PR objectstack-ai#21988 is open. So no published version ships the `VALIDATION_ERROR` refusal. ## Measurements (the dispatch's hypotheses) - **H1 holds.** - `PAYLOAD_TOO_LARGE` was listed under `@objectstack/rest` only, which answers it for both import routes' row ceilings (`import-prepare.ts`, 5,000 and 50,000 rows). - `HTTP_REASON_PHRASE_STATUS` maps it to 413. - The new row is one entry in the existing multi-emitter shape. - **H2 holds, with the typed path that already exists.** - `sendError` (`@objectstack/types`) takes `ErrorCode` = `StandardErrorCode | RegisteredErrorCode`. - `RegisteredErrorCode` is the type `service-analytics` already uses for ledger-code constants (`read-scope-refusal.ts`, `dataset-refusal.ts`). - No shared type was widened, and no cast was added. - Reverse check: a misspelled code fails to compile (see Tests). - **H3: every test that pins this refusal now expects `PAYLOAD_TOO_LARGE`.** Sweep hits outside the tests, with line numbers at the base `41d0d4038`: - **Edited** (each describes this refusal): the service-storage README `:113`, `error-catalog.mdx` `:67` and `:920`, `error-handling.mdx` `:217`, `:405` and `:421`, the dogfood test `:68`, `:91` and `:102`, and the pending changeset `:15`. - **Not edited** (none describes this refusal): - `storage-adapter-list.conformance.test.ts` `:436` and `:447` (`VALIDATION_ERROR` for the list `limit`/`cursor`); - `import-mappings.mdx` `:224` and `import-job-integration.test.ts` `:202` (rest's import 413, already `PAYLOAD_TOO_LARGE`); - `references/api/contract.mdx` and `references/api/error-code-ledger.mdx` (generated union listings, which this change does not move); - `error-code-ledger.test.ts` `:87` (the synonym pin, still true). - The sibling `objectui` checkout has no branch on this refusal's code. - **H4 holds.** `check:error-code-provenance` is the gate that makes the row required: - **Before**, at the base `41d0d4038`: exit 0, with 334 stamp sites (315 listed, 19 waived). - **Ablation**, at commit `b5f9d332f`: the storage constant switched and the ledger row removed. The removal went through `scripts/ablation-replace.mjs`, which showed the anchor count go from 1 to 0 and the blob change, then restored the file to equal `HEAD`. Result: exit 1, `@objectstack/service-storage stamps 'PAYLOAD_TOO_LARGE' (constdef) at packages/services/service-storage/src/storage-routes.ts:93 — not listed under its own owner key`. - **After**, at `5b5b812ce`: exit 0, with 335 stamp sites (316 listed, 19 waived). - **Generated artifacts:** none moves. After `pnpm --filter @objectstack/spec build`, `check:generated` reports all 15 generated artifacts up to date, and `git status` is clean. - **Doc ⇄ runtime status reconciliation:** `check:error-status-conformance` now reconciles `PAYLOAD_TOO_LARGE`. Its scope line reads "4 ledger code(s) a doc page publishes a status for (INVALID_REQUEST, PAYLOAD_TOO_LARGE, UNIQUE_VIOLATION, VALIDATION_FAILED)", and the derived producer is `413 storage-routes.ts:315`. `VALIDATION_ERROR` keeps no 413 producer and no 413 doc claim. Exit 0. ## Tests At `5b5b812ce`, each run under the shared verify lock: - `pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2`: 45 files and 749 tests passed. - The targeted spec run (`src/api/` plus every spec test that reads the ledger) with `vitest run --project local --maxWorkers=2`: 59 files and 1724 tests passed. - `pnpm --filter @objectstack/service-storage run typecheck`: exit 0. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. At `b5f9d332f`. The only change from there to `5b5b812ce` is comment and changeset wording in three files (5 lines changed): - `pnpm --filter @objectstack/spec test`: 626 files passed, with 18740 tests passed and 1 todo. - The dogfood file over a real boot, `storage-upload-limits.dogfood.test.ts`: 1 file and 2 tests passed. The `service-storage` dist it loaded had 1 `PAYLOAD_TOO_LARGE` match. - Reverse type check: the constant was set to `'PAYLOAD_TOO_LARGE_ABLATED'` through `ablation-replace.mjs`, and `tsc --noEmit` failed with `TS2820 ... Did you mean '"PAYLOAD_TOO_LARGE"'?`. The file was then restored to equal `HEAD`, with an empty `git diff HEAD`. ## Gates At `5b5b812ce`: - `dispatch-gates --commands` derived 115 commands. All 115 ran: - 114 exited 0. - `node scripts/check-empty-changeset.mjs --base origin/main` exited 1, by design (the DELIBERATE CORRECTION above). - The `--ran` verdict: `✓ dispatch-gates --ran: 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED`. - Also run: `check:generated`, plus the roster gates `check:authz-resolver` and `check:filter-alias-parity`. All exited 0. ESLint was narrowed to the touched files, with this proof: 1. **What eslint checks:** `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`. The touched TypeScript files are the five listed in the next point. The `.md` and `.mdx` files match no `files` glob. 2. **What ran:** `pnpm exec eslint --no-inline-config --format json` over those 5 files at `5b5b812ce` reported 5 files, 0 errors and 0 warnings. 3. **Why other files are unaffected:** type-aware linting is not enabled (no `parserOptions.project` and no typed rules; the config says so at `:327`). The config's only file reads are two baselines this diff does not touch. So this diff cannot change the lint result of any untouched file. ## Acceptance notes - **Landing beyond the claim's file surface:** two hand-written docs pages, the `service-storage` README, and the dogfood test. The H3 sweep named each one as describing this refusal, and each is edited only where it does. - The catalog's new `PAYLOAD_TOO_LARGE` entry also names `@objectstack/rest`'s import row ceilings. The entry catalogs the code, not one door. - Documenting a status for the code makes it a reconciled wire code. That is what requires the heading and the count of 52, by `error-catalog-docs.test.ts`. - `content/docs/references/**` is unchanged and regenerates byte-identically. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22283
Clause-②: no (narrowing)
Setup's File Storage page renders a "Limits" group (
max_upload_mb,presigned_ttl,session_ttl) and an admin's save persisted it, but nothing read it. This PR implements triage's direction ("honour the three keys at the doors that own them"; implement, not retire):StorageServicePluginreads the group on every settings pass and every upload door honours it, on the plugin's own mount and on a host'smountStorageRoutesmount.What changed
storage-limits.ts(new, package-internal logic). The one place the three keys are READ out of agetNamespace('storage')payload (readStorageLimits) and RESOLVED against a mount's own options (resolveStorageLimits). A numeric string is admitted because the settings service admits it for anumberspecifier; a present value that is not a positive number is not applied and is logged atwarnnaming the key and value.storage-service-plugin.ts.applySettingsreads the Limits group on every pass, AHEAD of the adapter gate (that gate returns early when no adapter key is authored, and a save that touched only a limit must still land). The snapshot is held on theSwappableStorageServicethe plugin registers asstorage, andcomposeStorageRoutesbinds it for the doors from that service, so both mounts read it from the one place it is written, the same path an adapter save already takes. Oneinfoline names the snapshot when it changes.storage-routes.ts. Each request resolves the limits once (never hoisted to registration), so a save reaches the next request without a restart.size), chunked start (declaredtotalSize), local raw PUT (declaredcontent-lengthbefore the body is read, then the bytes read), chunk door (bytes held so far plus this chunk, the same two ways).partsentry now records itssize; a session whose parts predate that is judged by its runninguploaded_size, the upper bound.presignedTtl; new chunked sessions take the resolvedsessionTtl. Already-issued URLs and sessions keep the lifetime stamped at issue.swappable-storage-service.ts:setLimitsSnapshot()/getLimitsSnapshot().index.ts: type exportsStorageLimitsSnapshot,StorageLimitReading.mount-storage-routes.ts: option docs. README section, changeset.Measurements (H1 to H5 from the dispatch)
238222d8, non-test readers outsideservice-settings:max_upload_mb0,presigned_ttl0,session_ttl0; controls in the same manifestlocal_root4 files,s3_bucket1 file. A settings change reaches the plugin throughsettings.subscribe('storage', ...), which re-runsapplySettings; the three keys now ride that path.presigned_ttl3600 = 3600;session_ttl86400 = 86400;max_upload_mb100 vs no limit. So the one observable change with nothing saved is the 100 MB (104,857,600 bytes) cap; the changeset states it.buildAdapterFromValues, "settings value wins over any constructor-provided local.rootDir"), while a manifest default never moves it (the authored-source gate). Followed per key: saved value (admin save or env override) wins over the host option (presignedTtl/sessionTtlon the constructor or onmountStorageRoutes), which wins over the namespace default, which wins over the built-in default.max_upload_mbhas no host option.PAYLOAD_TOO_LARGEis not usable inside the fence. The ledger lists it under@objectstack/restonly;@objectstack/service-storage's row does not, so stamping it here would makecheck:error-code-provenancered, and the row lives inpackages/spec(fenced out). The refusal is413with the standard member the platform itself derives for a 413 (standardErrorCodeForHttpStatus(413)isVALIDATION_ERROR; the catalog names no 413 member). No code was added. The message names the measured size, the limit in MB and bytes, and the setting key.registerStorageRoutes; the single-request path is presign plus the local adapter's raw PUT. On S3 the presigned PUT goes straight to the bucket, so that upload is judged by its declared size only (Acceptance notes).Each door, before and after (status, code)
POST upload/presigned(declaredsize)sys_filerow, URL mintedVALIDATION_ERRORcreateFileandgetPresignedUploadnot calledPOST upload/chunked(declaredtotalSize)VALIDATION_ERRORcreateFile,createSession,initiateChunkedUploadnot calledPUT _local/raw/:token(body)VALIDATION_ERROR(bycontent-lengthbefore reading, and by bytes read)uploadnot called, key absent on diskPUT upload/chunked/:id/chunk/:n(held + chunk)VALIDATION_ERROR(same two ways)uploadChunknot called,uploaded_sizeunchangedThe Before column is read from main's code (no reader of the key) and from ablations A1 to A4 below, where each door with its check removed answers 200 to the same over-limit request. At exactly the limit each door answers 200 (control in the same test). With no settings namespace bound (bare kernel,
bindToSettings: false) no door limits (control: a 1 GiB declared size answers 200).TTLs: with nothing saved, presigned URL 3600 s and session 86400 s before and after. With a saved
presigned_ttl120 andsession_ttl600: before 3600 / 86400 (saved value unread); after 120 (expiresInand the token'sexp, upload and non-gated download URLs) and 600 (expiresAt). A session issued before a save keeps its originalexpiresAt.Tests (final head
46d7554dc8,origin/main799eb000c7merged in)pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: 44 files, 736 passed; this PR adds 19 of them (18 in the newstorage-limits.test.ts, 1 in the envelope file).pnpm --filter @objectstack/service-storage typecheck: exit 0, includingcheck:test-typecheck: OK(both tsc programs list all 44 test files, the new one included, counted with--listFiles).storage-upload-limits.dogfood.test.ts, 2 cases; re-run at the final head withfield-file-collection,attachments-permission-matrixandroute-ledger-live-mount-parity: 34 passed, 1 declared skip;@objectstack/dogfoodtypecheck exit 0): nothing saved enforces 100 MB and 3600 s;PUT /api/settings/storage {max_upload_mb: 1, presigned_ttl: 120}reaches the doors without a restart, the presign refuses1 MiB + 1, the URL carries 120 s, and the raw PUT refuses a1 MiB + 1body and accepts exactly 1 MiB. 2 passed.73ce441fe8(before the second merge), direction...@objectstack/service-storage(downstream), storage-touching files only:organizations2 wall files 20 passed;plugin-dev9 files 86 passed;cliunit tier, 4 storage files, 32 passed (its integration tier is declared to CI); dogfood, the 9 storage-touching files plus the new one, 93 passed, 1 skipped (the declared enterprise-package skip).storage-limits.test.tssections: section 1, each door refuses over the limit with nothing stored, plus the retry and legacy-session chunk cases; section 2, TTLs and the precedence table; section 3, the plugin end to end throughapplySettings(defaults hold, saved values reach the doors, a save after boot reaches the next request while an issued session keeps its deadline, constructor TTL vs saved,bindToSettings: falsecontrol); section 4,mountStorageRouteshonours the snapshot and resolves the TTL against its host option.error-envelope.conformance.test.tsgains the 413 case.Ablations (each committed first; restore proven against
HEAD; run at071760b844..102ce46155, which the twomainmerges did not touch)Mutations went through
scripts/ablation-replace.mjs(anchor must hit once; blob change and restore proven on disk;git diff HEADempty after every leg). The unit pins importsrc/relatively, so no build or dist preflight is involved there.content-lengthpre-check removedcontent-lengthpre-check removedbindToSettings: falsecontrol)dist/)ablation-dist-preflightmarker present indist/index.jsanddist/index.cjs--absentexit 0, rerun 2 passedD1's first attempt was void and is not counted: its marker was a comment (stripped by the bundler) and its replacement broke the DTS build, so
dist/never carried it. It was redone with a string-literal marker that type-checks.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat46d7554dc8: 71 commands, the same list as at73ce441fe8and102ce46155. All were run at the final head, pluspnpm --filter @objectstack/spec run check:error-code-provenance(the reason for the code choice). Verdicts are in the dispatch report on the card. The error-envelope and code families (check:route-envelope,check:dispatcher-error-vocabulary,check:error-code-provenance) are green.Lint, narrowed and proven:
eslint --no-inline-config --format jsonover all 11 changed paths at46d7554dc8. eslint's own config lints 9 of them and ignores the two markdown files ("no matching configuration"). Result: 9 linted, 0 errors, 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move the verdict of an untouched file. The fullpnpm lintrun is CI's.Acceptance notes
StorageUploadOptionsfield in the spec contract (fenced out here). The local adapter's byte door judges real bytes. Not filed: not measured against a live bucket.uploaded_size/uploaded_chunksdouble-count a retried chunk index. Measured on this branch's build with no limit: a 1 MiB session whose 600 KiB chunk 0 is sent twice reportsuploadedSize1228800 andpercentComplete100 while holding 600 KiB. This PR fixed only the part it touched: the size limit no longer counts a retry twice (without that fix the retry was refused 413 while holding 600 KiB). The progress accounting itself is unchanged and is handed to the seat as a finding.POST upload/complete) does not re-measure stored bytes; the declared size was judged at presign.resumeUploaddrives them (progress, then the chunks fromuploadedChunkson, then completion listing only those parts): a 2-chunk upload whose chunk 0 (5 MiB) was stored in the first pass commits with200andsize5242890 while the stored file is 10 bytes. The completion door assembles only the parts the request lists. Handed to the seat as a finding.plugin-email's inbound attachment storage writes throughstorage.uploadserver-side; it is not an upload door and is not capped.packages/spec, the settings manifest.Round 2 (seat review, R1)
check:error-status-conformancewas red on46d7554dc8. Per the ruling, this PR ships413VALIDATION_ERROR(option A). The switch toPAYLOAD_TOO_LARGEis carried by spec(error-code-ledger), service-storage: the upload size refusal (PR #22311) answers 413 VALIDATION_ERROR; register the existing PAYLOAD_TOO_LARGE under @objectstack/service-storage and stamp it, before the release that ships #22311 #22314. The 413 is now documented as an exception to the code's 400, mirroring howMISSING_REQUIRED_FIELDdocuments its 422.content/docs/api/error-catalog.mdx: a callout in theVALIDATION_ERRORsection, and a 413 exception row in the HTTP Status Quick Reference ("this row is an exception to the 400 row, not a second home for the code").content/docs/protocol/kernel/error-handling.mdx:**HTTP Status:** 400 — with one documented exception, which answers **413** (see below), a "the one 413" bullet under Where it comes from, and a 413 row in the HTTP Status Codes table.max_upload_mb, so the switch removes exactly these lines. OnceVALIDATION_ERRORis no longer emitted at 413, the gate's direction B goes red on any line left behind.736713afc8with proven-restore ablations: either page's claim is enough on its own.46d7554dc8) the gate was red with the CI finding. After:✓ every derivable runtime status is documented, and every documented status is reachable, 29 (code, status) pairs.dispatch-gatesplacescheck:error-status-conformanceonly by four file literals:errors.zod.ts, the two doc pages and its baseline.packages/**throughconst SCAN_ROOT = 'packages'(scripts/check-error-status-conformance.mjs:222, walked at:2099). That is a bare single-segment literal, and the hint extractor refuses those by design.packages/**the gate sat in the artifact-roster block, silent and outside the 71 commands. With this round's doc paths it is matched, and it is now one of the 97.46d7554dc8,Test Core (1/6)was cancelled at 15:20:41 when the round-2 push superseded the run. It had run 19 minutes; the shard takes 25 to 32 minutes on recentmainruns. The aggregateTest Corethen reported a missing shard attestation, not a test failure.736713afc8:Lint & Repo Gates,TypeScript Type Check,Build Core,Dogfood Regression Gate,Temporal ConformanceandGoverned Surface Queue Guardsucceeded, as did Test Core 2 to 6.Test Core (1/6)was still in progress at report time.736713afc8.check:error-code-provenance, 98 of 98 exit 0.--ranreconciliation: 97 of 97, 0 NOT-MEASURED.service-storage: 736 passed. Typecheck: exit 0. Dogfood storage-limits file: 2 passed.Generated by Claude Code