Skip to content

fix(service-storage)!: enforce the storage settings Limits group (max_upload_mb, presigned_ttl, session_ttl) at the upload doors - #22311

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22283-storage-limits-honoured
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22283-storage-limits-honoured

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

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): StorageServicePlugin reads the group on every settings pass and every upload door honours it, on the plugin's own mount and on a host's mountStorageRoutes mount.

What changed

  • storage-limits.ts (new, package-internal logic). The one place the three keys are READ out of a getNamespace('storage') payload (readStorageLimits) and RESOLVED against a mount's own options (resolveStorageLimits). A numeric string is admitted because the settings service admits it for a number specifier; a present value that is not a positive number is not applied and is logged at warn naming the key and value.
  • storage-service-plugin.ts. applySettings reads 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 the SwappableStorageService the plugin registers as storage, and composeStorageRoutes binds 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. One info line 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 gate, before any row is written or byte stored: presigned door (declared size), chunked start (declared totalSize), local raw PUT (declared content-length before the body is read, then the bytes read), chunk door (bytes held so far plus this chunk, the same two ways).
    • The chunk door counts the latest recorded size per chunk index: both adapters replace a re-sent part, so a retried chunk is not counted twice. Each stored parts entry now records its size; a session whose parts predate that is judged by its running uploaded_size, the upper bound.
    • TTLs: presigned upload URLs and non-gated download URLs take the resolved presignedTtl; new chunked sessions take the resolved sessionTtl. Already-issued URLs and sessions keep the lifetime stamped at issue.
  • swappable-storage-service.ts: setLimitsSnapshot() / getLimitsSnapshot(). index.ts: type exports StorageLimitsSnapshot, StorageLimitReading. mount-storage-routes.ts: option docs. README section, changeset.

Measurements (H1 to H5 from the dispatch)

  • H1 holds. At 238222d8, non-test readers outside service-settings: max_upload_mb 0, presigned_ttl 0, session_ttl 0; controls in the same manifest local_root 4 files, s3_bucket 1 file. A settings change reaches the plugin through settings.subscribe('storage', ...), which re-runs applySettings; the three keys now ride that path.
  • H2. Manifest defaults vs the built-in defaults: presigned_ttl 3600 = 3600; session_ttl 86400 = 86400; max_upload_mb 100 vs no limit. So the one observable change with nothing saved is the 100 MB (104,857,600 bytes) cap; the changeset states it.
    • Precedence: the seat's lead is falsified, and the measured behaviour is followed. The adapter keys do NOT let the constructor win over a save: an authored save rebuilds the adapter over the constructor's (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 / sessionTtl on the constructor or on mountStorageRoutes), which wins over the namespace default, which wins over the built-in default. max_upload_mb has no host option.
  • H3, the code: the lead PAYLOAD_TOO_LARGE is not usable inside the fence. The ledger lists it under @objectstack/rest only; @objectstack/service-storage's row does not, so stamping it here would make check:error-code-provenance red, and the row lives in packages/spec (fenced out). The refusal is 413 with the standard member the platform itself derives for a 413 (standardErrorCodeForHttpStatus(413) is VALIDATION_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.
  • H3, the doors. There is no single-request multipart door in 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).
  • H4, H5: pinned below.

Each door, before and after (status, code)

Door Before (no reader) After, over the resolved limit Stored on refusal
POST upload/presigned (declared size) 200, pending sys_file row, URL minted 413 VALIDATION_ERROR nothing: createFile and getPresignedUpload not called
POST upload/chunked (declared totalSize) 200, file + session rows, backend multipart 413 VALIDATION_ERROR nothing: createFile, createSession, initiateChunkedUpload not called
PUT _local/raw/:token (body) 200, bytes stored 413 VALIDATION_ERROR (by content-length before reading, and by bytes read) nothing: upload not called, key absent on disk
PUT upload/chunked/:id/chunk/:n (held + chunk) 200, chunk stored 413 VALIDATION_ERROR (same two ways) nothing: uploadChunk not called, uploaded_size unchanged

The 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_ttl 120 and session_ttl 600: before 3600 / 86400 (saved value unread); after 120 (expiresIn and the token's exp, upload and non-gated download URLs) and 600 (expiresAt). A session issued before a save keeps its original expiresAt.

Tests (final head 46d7554dc8, origin/main 799eb000c7 merged in)

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: 44 files, 736 passed; this PR adds 19 of them (18 in the new storage-limits.test.ts, 1 in the envelope file).
  • pnpm --filter @objectstack/service-storage typecheck: exit 0, including check:test-typecheck: OK (both tsc programs list all 44 test files, the new one included, counted with --listFiles).
  • Dogfood, real boot (storage-upload-limits.dogfood.test.ts, 2 cases; re-run at the final head with field-file-collection, attachments-permission-matrix and route-ledger-live-mount-parity: 34 passed, 1 declared skip; @objectstack/dogfood typecheck 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 refuses 1 MiB + 1, the URL carries 120 s, and the raw PUT refuses a 1 MiB + 1 body and accepts exactly 1 MiB. 2 passed.
  • Consumer sweep at 73ce441fe8 (before the second merge), direction ...@objectstack/service-storage (downstream), storage-touching files only: organizations 2 wall files 20 passed; plugin-dev 9 files 86 passed; cli unit 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.ts sections: 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 through applySettings (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: false control); section 4, mountStorageRoutes honours the snapshot and resolves the TTL against its host option. error-envelope.conformance.test.ts gains the 413 case.

Ablations (each committed first; restore proven against HEAD; run at 071760b844..102ce46155, which the two main merges did not touch)

Mutations went through scripts/ablation-replace.mjs (anchor must hit once; blob change and restore proven on disk; git diff HEAD empty after every leg). The unit pins import src/ relatively, so no build or dist preflight is involved there.

Leg Mutation Red
A1 presign size check removed 5: presign door, envelope 413 case, defaults-hold, saved-values, host door
A2 chunked-start check removed 2: chunked start, save-after-boot
A3 / A3b raw PUT bytes check / content-length pre-check removed 1 each
A4 / A4b chunk bytes check / content-length pre-check removed 1 each
A5 plugin no longer stores the snapshot 4 (section 3 except the bindToSettings: false control)
A6 composition no longer binds the snapshot 5 (section 3 and section 4)
A7 saved value no longer beats the host option 2
R1 chunk door counts the running total again 1: the retry case
R2 legacy-session branch removed 1: the legacy case
D1 (dogfood, through dist/) size gate answers "within" for everything; built; ablation-dist-preflight marker present in dist/index.js and dist/index.cjs 2 of 2 dogfood cases ("expected 200 to be 413"); restored, rebuilt, preflight --absent exit 0, rerun 2 passed

D1'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/objectstack at 46d7554dc8: 71 commands, the same list as at 73ce441fe8 and 102ce46155. All were run at the final head, plus pnpm --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 json over all 11 changed paths at 46d7554dc8. 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.mjs enables no type-aware linting (no parserOptions.project, no projectService), so this diff cannot move the verdict of an untouched file. The full pnpm lint run is CI's.

Acceptance notes

  • S3 presigned uploads are judged by declared size only. The bytes go to the bucket directly; binding the size into the signature needs a StorageUploadOptions field in the spec contract (fenced out here). The local adapter's byte door judges real bytes. Not filed: not measured against a live bucket.
  • Pre-existing: the chunk door's uploaded_size / uploaded_chunks double-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 reports uploadedSize 1228800 and percentComplete 100 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.
  • The commit door (POST upload/complete) does not re-measure stored bytes; the declared size was judged at presign.
  • Pre-existing, untouched here: a resumed chunked upload can commit without its earlier chunks. Measured on this branch's build through the doors, in the order the SDK's resumeUpload drives them (progress, then the chunks from uploadedChunks on, then completion listing only those parts): a 2-chunk upload whose chunk 0 (5 MiB) was stored in the first pass commits with 200 and size 5242890 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 through storage.upload server-side; it is not an upload door and is not capped.
  • Out of the fence and untouched: which organization an upload belongs to, the uploader-ownership checks, the organization-change answers, packages/spec, the settings manifest.

Seat's append (domain:services seat 1, #6021), carried verbatim from the dev's round-2 report 6063587901 (with the dev's one stated correction: 25 to 32 minutes); the dev never edits a PR body.

Round 2 (seat review, R1)

  • R1: check:error-status-conformance was red on 46d7554dc8. Per the ruling, this PR ships 413 VALIDATION_ERROR (option A). The switch to PAYLOAD_TOO_LARGE is 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 how MISSING_REQUIRED_FIELD documents its 422.
    • content/docs/api/error-catalog.mdx: a callout in the VALIDATION_ERROR section, 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.
    • Every added line names the storage upload doors and max_upload_mb, so the switch removes exactly these lines. Once VALIDATION_ERROR is no longer emitted at 413, the gate's direction B goes red on any line left behind.
  • Which page the gate needs. Measured at 736713afc8 with proven-restore ablations: either page's claim is enough on its own.
    • Catalog row removed: green, 29 pairs.
    • Protocol status line reduced to 400: green, 29 pairs.
    • Both removed: red, 1 finding, 28 pairs.
    • Before the docs (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.
  • Why the derivation did not name this gate. dispatch-gates places check:error-status-conformance only by four file literals: errors.zod.ts, the two doc pages and its baseline.
    • The gate walks packages/** through const 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.
    • So for a diff under 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.
    • Reported to the seat as a finding; no gate added.
  • CI.
    • On 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 recent main runs. The aggregate Test Core then reported a missing shard attestation, not a test failure.
    • On 736713afc8: Lint & Repo Gates, TypeScript Type Check, Build Core, Dogfood Regression Gate, Temporal Conformance and Governed Surface Queue Guard succeeded, as did Test Core 2 to 6. Test Core (1/6) was still in progress at report time.
  • Local verification at 736713afc8.
    • Derivation: 97 families (the docs added 26); all run, plus check:error-code-provenance, 98 of 98 exit 0. --ran reconciliation: 97 of 97, 0 NOT-MEASURED.
    • service-storage: 736 passed. Typecheck: exit 0. Dogfood storage-limits file: 2 passed.

Generated by Claude Code

claude added 6 commits October 8, 2026 13:35
…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>
…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>
@github-actions github-actions Bot added 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

This PR changes 1 package(s): @objectstack/service-storage, touching 46 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-storage/README.md, packages/services/service-storage/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE), max_upload_mb (literal, a string literal in LIMIT_KEYS; a string literal in describeStorageLimits))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/api/plugin-endpoints.mdx (via chunkIndex (symbol, a field of interface StoredChunkPart), storage.uploadPart (sdk, the route ledger binds it to PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex), uploadPart (sdk, the bare tail of client method storage.uploadPart, bound to PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/kernel/contracts/storage-service.mdx (via eTag (symbol, a field of interface StoredChunkPart))
  • content/docs/kernel/runtime-services/storage-service.mdx (via eTag (symbol, a field of interface StoredChunkPart))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE), max_upload_mb (literal, a string literal in LIMIT_KEYS; a string literal in describeStorageLimits))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))

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

  • content/docs/releases/v17/17-1.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_CODE))
  • content/docs/releases/v17/17-7.mdx (via VALIDATION_ERROR (literal, a string literal in UPLOAD_TOO_LARGE_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
  • 2 changed file(s) yielded no anchor (packages/services/service-storage/README.md, packages/services/service-storage/src/index.ts) — pages documenting those are invisible to this run
  • 4 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 dc4a5c630844f04587e790c21ca807f306239518 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d5653ea864240e2d31bda4dae86599b13912761 — the merge of head 736713afc811e257c7e22cf49ba1ce6c164714d9 into base dc4a5c630844f04587e790c21ca807f306239518, 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 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

⚠️ 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 dc4a5c630844f04587e790c21ca807f306239518 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 15:56
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 15:56
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge October 8, 2026 16:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 59d993c Oct 8, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22283-storage-limits-honoured branch October 8, 2026 16:35
os-sales pushed a commit that referenced this pull request Oct 8, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
… 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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…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>
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/xl tests tooling

Projects

None yet

2 participants