Repository navigation
fix(service-storage): a file field's accept/maxSize refusal answers 400 ERR_FILE_CONSTRAINT - #21751
Conversation
…00 ERR_FILE_CONSTRAINT FileConstraintError declared a registered code but no status, so the data API's declared-status passthrough never fired and the refusal left as a sanitised 500 INTERNAL_ERROR with the field and reason withheld. It now declares status 400, as FileFieldBulkWriteError in the same module does, and carries the field and the violated constraint as members. Unit pins cover create (accept, maxSize) and update; a dogfood pin drives the real /api/v1/data door for both refusals, the small-PDF control, the update refusal and its control, with read-back of rows and file ownership. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…le-constraint-400
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 — 7 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 90837fd5bc30e338a3b7d07668947501dd104bd2 && git checkout 90837fd5bc30e338a3b7d07668947501dd104bd2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ea7ff394b6d802de9ad0448130bef86d4407760d aa33b2e3bc2d268c7614dbf9feca26e873159080 && git checkout -B drift-repro ea7ff394b6d802de9ad0448130bef86d4407760d && git merge --no-ff aa33b2e3bc2d268c7614dbf9feca26e873159080
node scripts/docs-audit/affected-docs.mjs --json ea7ff394b6d802de9ad0448130bef86d4407760d
|
… with 400 ERR_FILE_CONSTRAINT The Media Types paragraph said file constraints are configured at the storage-provider layer, not on the field. FieldSchema declares accept and maxSize for the media family and the storage service enforces them on write, so the paragraph now states both properties in the spec's own terms and the 400 ERR_FILE_CONSTRAINT refusal, keeping the true half: storage itself is configured at the storage-provider layer. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…le-constraint-400
…cision in words instead of a tracker number (stage 13) (objectstack-ai#21763) Part of objectstack-ai#20749 Clause-②: no Stage 13 of this card, and the fourth area of class (e): the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes two whole directories, `contracts/` and `conversions/`. Their 97 test-title and test-string literals carried 108 tracker ids citing 78 records. 107 ids in 96 literals now either state what their record decided, in words (form D), or are dropped where the title already says it. One id stays, for the reason given below. Text only: no assertion, identifier, test count or code comment changes. ## Census at the base (`866b4393d0`, the claim's base) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`), byte-identical to the copies stages 10 to 12 used. A literal counts as a test title when its folded message is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` / `.only` chains included. Everything else is an "other" string. Both instruments read **1509 messages / 1606 ids in 348 files at the base**, which is stage 12's head reading exactly. `contracts/` reads 63 / 74 and `conversions/` 34 / 34, also stage 12's figures. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 392 / 415 | 374 / 397 | 18 / 18 | | `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 | | `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 | | (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 | | **`contracts/`** (this PR) | 25 | **63 / 74** | 59 / 70 | 4 / 4 | | **`conversions/`** (this PR) | 9 | **34 / 34** | 34 / 34 | 0 | | `security/` | 8 | 28 / 28 | 28 / 28 | 0 | | `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 | | `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 | | `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 | | `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 | | `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 | | **total** | **348** | **1509 / 1606** | **1422 / 1515** | **87 / 91** | - **Controls.** Lit, a title: `contracts/data-engine.test.ts:575` reads one message with four ids. Lit, an `expect` message: `contracts/metadata-service-roundtrip-conformance.test.ts:142` reads one message. Dark: the comment at `contracts/core-service-contracts.test.ts:3` ("[objectstack-ai#4127] The map claims a binding per slot") reads 0. Planted in a scratch copy: an id put back into a title reads 1 / 1, and an id in an added comment reads 0. - **A wider pattern** (any `#` plus digits) reads 63 / 75 under `contracts/` and 35 / 36 under `conversions/` test files at the base. The extras are `(batch objectstack-ai#76)` in the `resume-failure-report.pin.test.ts:137` title, and `PD objectstack-ai#12` (Prime Directive 12, contract-first) in two `conversions.test.ts` titles, `:180` and `:785`. None matches the gate's 3-to-5-digit pattern. - **At the head:** 1413 messages / 1499 ids in 315 files. `contracts/` reads 1 / 1 (the needle below), `conversions/` 0 / 0. Nothing else moved. The wider pattern reads that needle and the two `PD objectstack-ai#12` titles, and nothing else. ## How the area was chosen Stage 10's rule: rank whole first-level directories by ids, and take the busiest within about 10% of the ~100-id bound. `data/` (501), `ui/` (415), `api/` (201) and `system/` (165) each exceed it alone, and the files directly in `src/` (120) are 20% over. No single remaining directory fits except smaller ones, and `contracts/` with `conversions/` reads exactly 108, within 10% of the bound. That is the pairing the stage-10 ACCEPT named, so the rule needed no second pass. **Named for the next stages:** `data/` (about five stages, by subdirectory or file group; `data/driver/` alone is 52), `ui/` (about four), `api/` (two), `system/` (two), the files directly in `src/` (one, 120), and `security/`, `ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together (one, 96). ## What each id became 32 ids in 21 literals now state a decision in words. 75 ids in 75 literals are dropped where the title already says what the record decided. Every cited record was read with its comments through REST: 71 answer 200, and 7 answer 404 (objectstack-ai#6345, objectstack-ai#6523, objectstack-ai#11741, objectstack-ai#12010, objectstack-ai#12248, objectstack-ai#16559, objectstack-ai#16786). For those seven the decision was read from what landed: the landing commit and the CHANGELOG entry. | record(s) | literal | now reads | |:--|:--|:--| | objectstack-ai#16293 | `action-confirmation-contract.pin.test.ts:63` | "action-confirmation contract — an unconfirmed gated action is refused". The ruling: a gated action without an explicit confirmation is refused loudly, with the way to confirm. | | objectstack-ai#10331 | `approval-service.test.ts:23` | "approval rows declare the organization_id they are stamped with". The finding's first reading landed: both row types declare it, optional and nullable. | | objectstack-ai#19846 | `automation-context-caller-param-keys.pin.test.ts:49` | "AutomationContext.callerParamKeys — the keys the caller supplied". The ruling replaced the headless-screen inference with this explicit signal. | | objectstack-ai#18235 | `automation-service.test.ts:422` | "FlowRuntimeState — carries the reason a flow is unbound", so a policy-disabled flow reads differently from a broken binding. | | objectstack-ai#15937 | `confirmed-blueprint-identity-contract.pin.test.ts:52` | "confirmedBlueprintIdentity — declared on the protocol ToolExecutionContext". The maintainer chose option 1: declare it in the protocol, not only on cloud's augmentation. | | objectstack-ai#11493 (2) | `data-driver.test.ts:290`, `data-engine.test.ts:455` | "introspectSchema — an optional driver member at the spec shape" and "introspectDatasource — answers the spec introspection shape". Both ruled steps. | | objectstack-ai#12248, objectstack-ai#11833 | `data-engine.test.ts:514` | "datasource resolution members — declared, and optional". Fork 1 of the objectstack-ai#11833 ruling, option A, landed as `8425c17cc`. | | objectstack-ai#12248, objectstack-ai#12010, objectstack-ai#12805, objectstack-ai#11833 | `data-engine.test.ts:575` | "datasource lifecycle members — declared at the shape the engine keeps". Item 4 of the objectstack-ai#11833 ruling put `ConnectionEngineLike`'s members on the contract (`8425c17cc`, `77b91bd`), and objectstack-ai#12805 caught the declared def up to what the engine retains. | | objectstack-ai#12482, objectstack-ai#12010, objectstack-ai#11833 | `data-engine.test.ts:679` | "syncObjectSchema — declared, since two services already call it". | | objectstack-ai#5040 | `http-server.test.ts:183` | "optional setFallbackHandler — a not-found hook, not a wildcard route". The design's option C, so a declared endpoint never shadows a registered route. | | objectstack-ai#9835 | `http-server.test.ts:316` | "optional afterResponse — a transport-agnostic response observer". | | objectstack-ai#6617 | `job-service.test.ts:126` | "JobHandler degraded-outcome channel — optional and additive". | | objectstack-ai#14766, objectstack-ai#14501 | `job-service.test.ts:263` | "IJobService.replay force option — a succeeded window replays only when forced". The A + a2 ruling. | | objectstack-ai#4127 | `notification-service.test.ts:153` | "inbox — declared on the contract, and optional". | | objectstack-ai#5928 | `objectql-engine-hook-scope.test.ts:41` | "IObjectQLEngine.registerHook scope faces — global minus excluded objects". The ruled A shape, `excludeObjects`. | | objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:29` | "getObject return contract — a structured answer, not `unknown`". Fork 3. | | objectstack-ai#12481, objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:106` | "getSchema return contract — the same answer as its alias getObject". Fork 3, one member over. | | objectstack-ai#20157, objectstack-ai#19995 | `objectql-engine.test.ts:163` | "judgeFilter contract — the engine judges a filter without running it". Ruling C. | | objectstack-ai#4539 | `sharing-service.test.ts:35` | "recipient vocabularies, each under its own name". The same-name, different-form exports were split by renaming. | | objectstack-ai#5858 | `sharing-service.test.ts:194` | "HierarchyScopeContext tenancy authority — organizationId is authoritative". | **Dropped only (75 ids):** objectstack-ai#3903, objectstack-ai#4045, objectstack-ai#4127 (2), objectstack-ai#4158, objectstack-ai#4251, objectstack-ai#4343, objectstack-ai#4347 (2), objectstack-ai#4401, objectstack-ai#4456 (2), objectstack-ai#4538 (2), objectstack-ai#4827, objectstack-ai#4829, objectstack-ai#4923 (5), objectstack-ai#5011, objectstack-ai#5122, objectstack-ai#5125, objectstack-ai#5126, objectstack-ai#5493 (3), objectstack-ai#5777, objectstack-ai#5817, objectstack-ai#5945 (4), objectstack-ai#6345 (2), objectstack-ai#6428, objectstack-ai#6430, objectstack-ai#6523, objectstack-ai#6775 (2), objectstack-ai#6776, objectstack-ai#7378 (3), objectstack-ai#7616 (2), objectstack-ai#8321, objectstack-ai#11122 (2), objectstack-ai#11741, objectstack-ai#11832, objectstack-ai#13700, objectstack-ai#13937, objectstack-ai#14103, objectstack-ai#14244, objectstack-ai#14384, objectstack-ai#14945, objectstack-ai#14969, objectstack-ai#15389, objectstack-ai#15429, objectstack-ai#16231, objectstack-ai#16495, objectstack-ai#16559, objectstack-ai#16693, objectstack-ai#16786, objectstack-ai#19620, objectstack-ai#20323, objectstack-ai#20390, objectstack-ai#20740, objectstack-ai#20935, objectstack-ai#20940, objectstack-ai#21005, objectstack-ai#21220, objectstack-ai#21458. - Each title already states the pinned decision. In `conversions/` most titles are the conversion entry's own id ("action-aria-removed", "app-hidden-to-unpublished"), which is the decision that landed. - **Qualified references** were read before dropping: "objectstack-ai#13937 shape 4" (keep the consume order and add an operator exit verb; the title already names the repairable run), "objectstack-ai#4923 house rule" (equal values dedupe, different values keep both; the title already says "keeps BOTH"), "objectstack-ai#4127 batch 3" (the ledger extends past the enum; the title says "beyond the enum"), "objectstack-ai#4251 B3" (`IObjectQLEngine` widens `IDataEngine`; the title says it), "objectstack-ai#5122 shape" (a wrapper that forwards only required members; the title says it), and "objectstack-ai#7378 row 1" / "row 3" (both refusal messages already state their row's ruling). - **The 404 records:** objectstack-ai#6345 (`e2798fa`: one driver vocabulary, `mongo` converged to `mongodb`), objectstack-ai#6523 (`aa4b90d`: enforcement takes the full `ExecutionContext`), objectstack-ai#11741 (`b706af9`: an optional `organizationId` on both email inputs), objectstack-ai#16559 (`c7aca0dce`: the resume failure declared once, carried by a success answer), objectstack-ai#16786 (`6059b29`: `updateById` declares the record or `null`). Each title already carries what landed. - **`(batch objectstack-ai#76)`** in `resume-failure-report.pin.test.ts:137` goes with `objectstack-ai#16559` in the same literal. It names the decision batch whose ruling the title already states ("the resume failure a success answer carries"). It is outside the gate's pattern, and it is declared here. - **`PD objectstack-ai#12`** stays in `conversions.test.ts:785`. It is a Prime Directive reference in AGENTS.md, not a tracker number, and the untouched sibling title at `:180` spells it the same way. ## The one id that stays `contracts/approval-service.test.ts:274` is `expect(doc).toContain('objectstack-ai#16495')`. It is not a title: it is the expected value of an assertion that reads the `continueRestoredRun` docblock in `contracts/approval-service.ts` and pins that the docblock names the sibling it was ruled to copy. Changing it needs a code comment and assertion logic, which this claim excludes. It moves with that docblock when the comment lane rewrites `approval-service.ts:999`. ## Readers - **Test-name filters:** none. A tracked-tree search for `-t` and `--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t "owner-scoped"`), which is unrelated. - **Snapshots:** none. Neither directory has `__snapshots__`, and no `.snap` file is tracked under `packages/spec`. - **Projects:** none of the 34 files is listed in `packages/spec/vitest.repo-tests.json`, so all run in the `local` project. - **By substring:** every old literal, plus a window around each id (241 needles), was searched across the tracked tree outside its own file. No gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads one. The 17 needle hits land on 11 lines: - Sibling test strings in other lanes' packages: `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts:232` (the objectql driver of the same table, `register must REFUSE this write (objectstack-ai#7378)`); `packages/plugins/plugin-security/src/get-queryable-fields.test.ts:136` and `:165` (`[objectstack-ai#20935]`), `resolve-permission-sets-for-context.pin.test.ts:103` (`[objectstack-ai#7616]`), `authored-row-write-verdict.test.ts:350` (`[objectstack-ai#5493]`); and `packages/metadata-core/src/artifact-forward-conversion.test.ts:277` (`(ADR-0113, objectstack-ai#16693)`). - Code comments: `packages/spec/src/contracts/automation-service.ts:1061` (`objectstack-ai#13937 shape 4`), `packages/spec/src/contracts/index.ts:44` (`(objectstack-ai#4127)`), `packages/cli/src/utils/view-container-names.ts:11` and `packages/objectql/src/view-container-name-refusal.ts:13` (`objectstack-ai#7378 row 1`). ## Text-only proof Stage 10's scratch tool (`textonly10.cjs`, md5 `d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file on three legs: 1. **Skeleton:** the full AST, with string pieces masked. It must be identical. 2. **Comments:** every comment, byte-equal. 3. **Strings:** each changed string leaf must sit in a test-call title position or on a declared line, must carry a tracker id before, and must carry no `#` plus digits after. The declared lines are the three in `metadata-service-roundtrip-conformance.test.ts`: the reference double's two refusal messages (`:68`, `:75`) and the `expect` message at `:142`. No test asserts on the dropped text: the refusal checks assert `code`, `status` and the write's coordinates. - **Result:** 33 of 34 files SAME on all three legs. `conversions.test.ts` passes the skeleton and comment legs and is flagged on one string, `:785`, because its rewritten title keeps `PD objectstack-ai#12`. That was predicted in writing before the run. With `PD objectstack-ai#12` spelled `PD-12` in both the base and head copies of that one line, the file reads SAME with 19 changed titles. - **Totals:** 96 changed literals, 93 titles and 3 declared. The diff's `+` lines are exactly the 96 planned lines, and every file keeps its line count. - **Controls (10 of 10 as predicted, on scratch copies, each anchor hit once):** identifier rename DIFF; numeric literal DIFF; comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten title given a new id VIOLATION; a title that was id-free at base edited VIOLATION; one title reverted to base SAME; a declared string keeping an id VIOLATION; an undeclared `expect` message changed VIOLATION; a title re-split into a `+` chain DIFF. **Test counts:** the 34 files were run at the base (in a separate base worktree at `866b4393d0`) and at the head, in the `local` project. Both sides read 595 / 595 passed, with the same count and status sequence per file in 34 of 34. 354 full test names change, and each equals the base name with the planned replacements applied. One full name repeats on both sides: two `sqlite` rows of the `stored.test.ts` `it.each` table share the `%s` name. That predates this PR. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the 34 touched files are in it, and no `*.test.ts` at all. The controls `src/shared/expression.zod.ts` and `dist/contracts/index.js` are in it. - In `dist/`, four new phrases and three old ones each read in 0 files. The control `Unrecognized key(s) on` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `72513933ee`) - `pnpm turbo run build` over all packages: 71 / 71. - `@objectstack/spec`: - `vitest run --project local`: 614 files, 18285 passed, 1 todo. - `typecheck` exit 0, including `check:test-typecheck`. Its program holds all 34 touched files, counted with `tsc --listFilesOnly`. - **Gates:** `dispatch-gates --commands` derived 79 families (stage 12's 80 without `check:future-spec-major`, which no touched file feeds), and all 79 exit 0. `--ran` reconciles: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN. - The five roster families whose rosters sit under a touched directory were also run, and each exits 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - **ESLint, a proven narrowing:** `--no-inline-config` over the 34 files, 0 errors and 0 warnings. The population comes from ESLint's own config: 34 configured, 0 ignored. No `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 192 changed lines. ## Acceptance notes - **The `objectstack-ai#16495` needle** at `approval-service.test.ts:274` stays, with the `approval-service.ts:999` docblock it pins. The comment lane owns both. - **Code comments still carry ids** in these 34 files and in the two directories' sources. Comments are not this card's share and are untouched here. - **Sibling test strings in other packages** repeat ids this PR dropped: the objectql driver of the round-trip table, three `plugin-security` test files and one `metadata-core` test (listed under Readers). Each is its own lane's test-string stage. - **`origin/main` moved** five commits past the base before this PR opened (objectstack-ai#21752, objectstack-ai#21754, objectstack-ai#21753, objectstack-ai#21751, objectstack-ai#21743). None touches `packages/spec`, so nothing was merged. objectstack-ai#21756, which also edits `contracts/security-service.test.ts`, has no PR yet; whichever lands later merges `origin/main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21730
Clause-②: no
What changed
FileConstraintError(packages/services/service-storage/src/file-reference-lifecycle.ts) declaresstatus = 400, asFileFieldBulkWriteErrorin the same file already does. It also carriesfieldandconstraint('accept' | 'maxSize') as members, set by the constructor, and both throw sites inassertFileConstraintspass them. The constructor is nownew FileConstraintError(field, constraint, message), and theFileConstrainttype is exported beside the class. The message text is byte-identical.A file field's
accept/maxSizerefusal now answers400 ERR_FILE_CONSTRAINTwith a sentence naming the field and the constraint. Before, it answered the sanitised500 INTERNAL_ERROR. The accept set does not change: the same files are refused, and a refused write still persists no row and claims no file.Measured at the
/api/v1/datadoorReal stack (
bootStack+StorageServicePlugin), objectffc_docwithdoc: { type: 'file', accept: ['.pdf'], maxSize: 10 },sys_filerows seeded under a system context.ff29410e)415514d0)text/plainfile500 {"error":"Internal server error","code":"INTERNAL_ERROR"}400 {"error":"File type 'text/plain' is not permitted by the accept list declared for 'doc' (.pdf)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}500, same body400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"ffc_doc"}500, same body400, same body as the POST201201Read-back on both sides: neither refused POST wrote a row, and no refused write claimed its
sys_file(ref_object/ref_id/ref_fieldstay null).The PM's mechanism hypotheses, measured
ERR_FILE_CONSTRAINTis registered atpackages/spec/src/api/error-code-ledger.zod.ts:506. No ledger edit.error,codeandobject.fieldandconstraintare declared on the error, but the declared-status passthrough (packages/types/src/data-error-classification.ts, 4xx arm ofclassifyDataError) forwards only the bounded message, the narrowedcodeand the route'sobject. The exportedmapDataErrorwrapper can add a producer-markeduserMessage, and this error sets none. So on the wire, the field and the constraint are named by the message only. Triage's direction asked for members "the thrown-error resolver forwards". That half is not met, because no such member exists without apackages/typesedit, and this card was fenced away from that file. See Acceptance notes.error === thrown messagefor accept, maxSize and the update). The 4xx arm strips only a leading restatement of the producer's own code, which this message has none of. It then applies the 500-character truncation bound. No leak heuristic runs on this arm.applyCopyOnClaim, called from bothbeforeInsertandbeforeUpdate). Both are pinned, at the unit seam and at the door.Tests
packages/services/service-storage/src/file-reference-lifecycle.test.ts: the accept and maxSize create refusals and a new update refusal each assert{ code: 'ERR_FILE_CONSTRAINT', status: 400, field: 'image', constraint }, that the message names the field, and that no row was written or file claimed. 51/51 pass. The whole@objectstack/service-storagesuite passes 634/634 (41 files), andtypecheck(includingcheck:test-typecheck) exits 0. Both at9251e230.packages/qa/dogfood/test/file-field-constraint-refusal.dogfood.test.ts(new): the door-level pin from the table above, in 5 cases. Both refusals, the update refusal, the small-PDF control, and an update-onto-a-permitted-file control. Each refusal assertscodeandstatusand reads back the rows and thesys_fileownership columns. 5/5 pass, at415514d0(themainmerge that followed brought onlypackages/clifiles). The dogfoodtsc --noEmitexits 0 and its program lists this file.readonly ablation21730 = true;in place ofreadonly status = 400;onFileConstraintError, usingscripts/ablation-replace.mjs(anchor 1 → 0, blob632a803a→487a00f9). Then I rebuiltservice-storage.ablation-dist-preflightproved the marker present in 2 built files. Results:expected Error: File exceeds the maximum size decl… to match object { code: 'ERR_FILE_CONSTRAINT', …(3) }.{"error":"Internal server error","code":"INTERNAL_ERROR"}: expected 500 to be 400. Both controls stayed green.632a803aequals HEAD,git diff HEADis empty, the whole-treegit status --porcelainis empty, and the rebuild's preflight in--absentmode shows the marker absent from all 6 built files. The unit (51/51) and dogfood (5/5) re-runs passed.rowsNamed/ownerreaders return the row and its claimed file.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat9251e230derived 70 commands. I ran every one, recording each exit code.--ranreports:70 derived famil(ies) accounted for — 68 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).pnpm check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET, because 33 packages outside this diff's closure have nodist/. Supplementary reading:require()ofservice-storage/dist/index.cjsandimport()ofdist/index.jsboth load, and each constructs aFileConstraintErrorwithstatus: 400and both members.pnpm check:i18n, reason: PREREQUISITE NOT MET (unbuilt config imports). This diff touches no translation bundle or label.node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3, because its pinned fixture commit was absent from this shallow clone. It exited 0 aftergit fetch --depth=1of that commit.Acceptance notes
docs/qa/platform-checklist/areas/attachments-storage.json, itemattachments-storage.field-accept-maxsize-server-enforced, carries a knownGap and clause 2. Both predict the sanitised 500 and say a measured 4xx carryingERR_FILE_CONSTRAINTmeans "revise this clause with the measurement". TheFOLLOW-UPS.mdrow D13 describes the same 500. The item's own clause routes that revision to the next QA run. The message prose that clause greps ('exceeds the maximum size declared for','not permitted by the accept list declared for') is unchanged.field/constraintas structured body members would take apackages/typeschange (anERR_FILE_CONSTRAINTarm instructuredCodeAnswer, or a generic declared-4xx detail channel). That file belongs to another lane, so the seat routes it. Nothing measured here reads such a member.[REST] Unhandled errorwith a stack (logUnexpectedRouteErrortreats only 403/404/409/502/503 and a few query codes as expected). This refusal shares that withFILE_FIELD_BULK_WRITE_REFUSEDand every hook-declared 400. It is log noise only, and the wire is unaffected. Carrier: none.content/docs/data-modeling/field-types.mdx(Media Types) says file constraints "are configured at the storage-provider layer, not on the field".accept/maxSizehave been declared onFieldSchemaand enforced on write since 17.0. This was already false before this change, which does not touch it. Carrier: none.patch. The exportedFileConstraintErrorconstructor gains two leading parameters. The only constructor in the repository is the module's own throw sites.Patch round 1
Appended by the seat (
domain:services#1,session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report5981408643, after seat review5981327964.content/docs/data-modeling/field-types.mdx, "Media Types" paragraph (f18345a4). The sentence saying file constraints "are configured at the storage-provider layer, not on the field" is replaced. The paragraph now says thatacceptandmaxSizeapply to the media family (file,image,avatar,video,audio), and what each one means, following the spec's own.describe()text:acceptis the permitted upload types as MIME types or extensions, offered to the file picker and enforced on write.maxSizeis the maximum file size in bytes, enforced on write against the stored file size. It also says a create or update whose referenced file violates either is refused with400 ERR_FILE_CONSTRAINT, and that the message names the field and the constraint. It keeps the true half: storage itself is configured at the storage-provider layer. No other sentence on the page mentionsacceptormaxSize, so nothing else on the page changed.mainmerged ataa33b2e3. It brought 5 commits, and none of them touches this PR's files.aa33b2e3.dispatch-gates --commandsderives 95 commands. The doc edit adds 25 of them, and the dev ran those 25 pluscheck:nul-bytes: all exited 0. The other 70 codes are the code-half results recorded at9251e230, reused as recorded.--ranreports: 95 derived, 93 run, 2 NOT-MEASURED (check:dual-build-cjs-loadsandcheck:i18n, each exit 3 with its prerequisite not met, unchanged from the code half), 0 UNRUN.field-types.mdxobservation above is resolved by this round.Generated by Claude Code