Skip to content

feat(storage,mcp): one upload link takes several files on a multi-file field - #33151

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-4i7bf9
Nov 13, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/project-thread-4i7bf9

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #32747

Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: updated here — the served MCP skill (packages/mcp/src/skill-md.ts, the create_upload_link lines) and content/docs/ai/connect-mcp.mdx say a multi-file field's link takes up to 10 files and explain count; content/docs/permissions/system-context.mdx row 47f names the reserved ids. No file under skills/** teaches upload links (git grep -i "upload.link\|create_upload_link" -- skills/ finds nothing). The Console's skill download (objectstack-skill.ts) renders the no-scope skill, which carries no file tools, so its byte-identical mirror needs no change and its drift pin stays green.

维护者速读

  • 改了什么:多文件字段(multiple: true)的上传链接,一次可以选并提交多个文件(最多 10 个),每个文件各得一个文件 id;单文件字段和附件链接仍然只收一个文件。
  • 为什么改:用户说"这是我的三张发票"时,以前要开三个链接、传三次;现在一个链接、一次选择就够了(裁决 A)。
  • 风险与回滚:一次提交全部成功或全部不留:任一文件超限或字段不接受,整批拒绝、链接不被消耗;落盘或写记录失败,整批文件都被清掉,记录保持原样。回滚就是还原这个 PR,现有单文件链接的行为和拒绝码不变(成功应答只多了 fileIds)。
  • 先看:record-file-access.ts 的 consumeUploadLinkFiles(检查顺序、一次消耗、失败清理),以及记录尚未创建时 count 预留 id 的那一段。
  • 两分钟验证:跑 record-file-access.integration.test.ts 里 #32747 那一组(19 个用例),和 dogfood 的 showcase-mcp-record-files(一个链接、三张 PDF)。
  • 需要你做的:无,席位落地。

What changes

The storage door: one submit, several files, all or nothing
  • consumeUploadLinkFiles(token, uploads[], caller) is the one path; consumeUploadLink wraps it with a one-element array, so a one-file send takes the same steps in the same order as before, except on a multi-file field's record link, where the field's accept / maxSize is now judged before the spend (see Acceptance notes).
  • How many files a submit takes (fileRangeOf):
    • an attachments link: 1;
    • a record's field link: up to UPLOAD_LINK_MAX_FILES (10) when the field declares multiple: true, else 1;
    • a link for a record not yet created: exactly its count.
      The spec has no max-items key for a file field, so the cap is the platform constant. A wrong count is refused 400 VALIDATION_ERROR through sendError before the link is spent, and says how many the link takes.
  • Before the spend, every file is judged on the workspace's upload size limit (413), and, on a link that takes several (a multi-file field's, or a link for a record not yet created), on the field's own accept / maxSize, however many files the send carries. A refusal stores nothing and leaves the link usable. A link that takes one file is judged as before (to a record, the record's write judges the field's constraints).
  • After the spend, the files are stored and committed one by one, then the record's field gains them all in one write ([...current, ...ids]). Any store failing, or that write refusing (a pending approval's lock, say), discards every file of the send, rows and bytes, so the record shows what it held before.
  • A link for a record not yet created takes count (1–10, more than 1 only on a multiple: true field). The mint reserves the extra ids as pending rows named upload-link (reserved), owned by the minting user, and answers fileIds: [link id, ...reserved]. The upload fills each id with the same pre_record_upload binding a single upload's id gets (that user, that object and field, one record, one hour). A failed mint takes the link and every reserved row back. A link whose stored reserved ids repeat one, or name the link's own id, is the one unusable link.
  • The PUT /upload-links/:token door reads a several-file send as one multipart/form-data form carrying count and file0 … fileN-1 (indexed names, since a form parser that keeps one value per name keeps only the last of a repeated one), marked by the header x-upload-link-form: several, which the page sends with it.
    • The door reads a body as that form only when it is multipart/form-data, carries the header, and the link takes several files: for such a body it reads the link first (a read that writes nothing), and the form declares at most as many files as that link takes, each within the upload limit, plus 64 KiB of framing.
    • Any other body, on any link, is the file itself, whatever its content type, under the one-file limit, with the same checks in the same order as on main: a multipart body without the header (on a multi-file field's link too), and the page's form sent to a link that takes one file (a single-file field's, an attachments link, a count: 1 link). So does a marked form to a link the door cannot read: its spend answers the refusal it answered on main, in main's order.
    • A count over what the link takes is refused 400 VALIDATION_ERROR, and a form whose parts disagree with count (or misses a part) 400 INVALID_REQUEST, both before any part's bytes are read.
    • Every success answer now also carries fileIds.
  • When a store throws after the spend, its key is deleted with the rest of the send, as a single upload's is.
  • Also touched to carry the new member: file-attachment-service.ts (pass-through), storage-service-plugin.ts (the door resolver requires it), upload-link-wire.ts (the cap, its code and status, the form's field names).
The page, the MCP tool, docs
  • upload-link-page.ts: <input type="file" multiple> where the link takes several; the script checks the pick count and sends the form. New sentences in upload-link-copy.ts for every locale it serves (pick up to N; pick exactly N; N files attached / received; wrong count).
  • create_upload_link (MCP): the description says a multi-file field's link takes up to 10 files, sent together, and that one refused file refuses the send. Optional count (1 to 10 in the schema) is offered where links for a record not yet created are; count with a recordId is refused before the bridge is asked. No new tool.
  • packages/runtime/src/domains/mcp.ts: the bridge member's target type takes count.
  • Docs: content/docs/ai/connect-mcp.mdx, content/docs/permissions/system-context.mdx row 47f. Changeset: .changeset/32747-upload-link-multiple-files.md (patch: service-storage, mcp, runtime).

Tests

New pins and what each refuses

packages/services/service-storage/src/record-file-access.integration.test.ts, describe #32747 — one link, several files (19 cases), on a fixture field claim.scans (multiple: true, PDF only):

  • a record link takes 3 PDFs: three ids, the field holds all three;
  • a link for a record not yet created, count: 3: three ids, one submit fills them, the create takes all three;
  • before the upload, a reserved id is refused to every write (the uploader's own, a reviewer, system), the download door answers 404, and it is not a link;
  • the cap: 11 files 400 VALIDATION_ERROR before the spend, then 10 land;
  • exact count: 2 of 3 refused before the spend, then 3 land;
  • CONTROL: for a single-file field link and an attachments link, the spend refuses two files (400 VALIDATION_ERROR, "takes one file"; the door never reads a form for them), then the one lands, then the link is gone (404); and the mint refuses count of 0, 11 or 1.5, and above 1 on a single-file field (400 VALIDATION_ERROR);
  • another signed-in user gets the one unusable-link 404 and spends nothing;
  • file 2 of 3 the field does not take: the whole send is refused before the spend (ERR_FILE_CONSTRAINT), the record unchanged, the link usable;
  • file 2 of 3 failing in the storage write after the spend: 500, the record unchanged, no row and no bytes of the send left, and the key whose upload threw deleted too (a record link and a pre-record link);
  • the record's write refusing after the spend (a lock): 409, the record unchanged, none of the files kept;
  • a form that lost a part: 400 INVALID_REQUEST, nothing spent;
  • a multi-file field's record link and one PNG (as a form, and as the request body): 400 ERR_FILE_CONSTRAINT before the spend, nothing stored, the link then takes a PDF;
  • a single-file field link and an attachments link take a multipart/form-data body as the file, as on main: declared 1 MiB + 1 under a 1 MiB limit is 413 with main's words (a form's allowance would have taken it), and the body is stored with its own length as the size;
  • a single-file field link and an attachments link take the page's own marked form as the file too: declared 1 MiB + 1 is 413 with the same words;
  • a multipart body without the header, even one shaped like the form, is the file on a multi-file field's record link and on a count: 1 link: 413 with main's words over the one-file limit, else 200 with the body's length as the size;
  • the page's form to a used link, declared over the one-file limit: 413 with main's words, then 404;
  • a link whose stored reserved ids name a file twice, or the link's own id: the one unusable-link 404, nothing stored;
  • a form declaring 2 MiB under a 1 MiB limit is accepted on a multi-file field link;
  • a form refused before any part is read: count 11 and count 999 (400 VALIDATION_ERROR), parts fewer than count and a missing file1 (400 INVALID_REQUEST), each with zero reads; a good form reads each part once;
  • the page: multiple on a multi-file link, the exact-count sentence on a count link, no multiple on a single-file link, and its form sent with x-upload-link-form: several.

packages/mcp/src/mcp-file-tools.test.ts, describe [#32747] create_upload_link: one link, several files (5 cases): count passes to the bridge and the answer names every id; no count asks the bridge as before; count with a recordId is refused before the bridge; count is offered only where a link can name no record, and the description says a multi-file link takes several; the served skill teaches both.

packages/qa/dogfood/test/showcase-mcp-record-files.dogfood.test.ts, describe [#32747] one link, three PDFs (dogfood_upload_claim.invoices) (3 cases, a real FormData through the served stack): a claim link takes three PDFs and read_file reads each; count: 3 and create_record takes the three ids; one PDF of three over the size limit refuses the send (413, nothing stored), then the same link takes the three.

Two existing expectations in the integration file now include the additive fileIds; one dogfood expectation for #32686 does too.

Ablations: each new negative pin, with the forbidden behaviour put back

Each through node scripts/ablation-replace.mjs (the anchor must hit, the mutation is verified on disk, the file is restored to HEAD and git diff HEAD is empty after each):

Mutation Red
drop the file-count check before the spend cap, exact count, CONTROL (3 failed)
judge accept / maxSize before the spend only for a pre-record link file 2 of 3 the field does not take (1 failed)
pre-record failure discards only the last id storage write fails on file 2 (1 failed)
record failure discards only the last file storage write fails on file 2; the lock after the spend (2 failed)
reserved rows stored committed instead of pending the reserved-id refusals and 3 more (4 failed)
drop the link's same-user check the stranger pins, old and new (2 failed)
a single-file field link takes up to 10 CONTROL, the page pins (6 failed)
mint count above 1 on a single-file field CONTROL (1 failed)
mint count above 10 CONTROL (1 failed)
a form's part count and missing-part checks off the lost-part pin (1 failed)
MCP: count with a recordId passed to the bridge that MCP pin (1 failed)
(round 1) a multi-file record link's one-file send judged after the spend the one-PNG pin (1 failed)
(round 2) every link reads a form, a one-file link included the one-file multipart pin (1 failed: 400 INVALID_REQUEST instead of 413)
(round 3) any multipart body read as the form, no header needed the unmarked-multipart pin (1 failed)
(round 3) a link the door cannot read refused at the inspect the used-link 413 pin (1 failed: 404 before 413)
(round 3) reserved ids not checked for repeats or the link's own id the forged-reserved-ids pin (1 failed)
(round 1) count over the link's cap not refused in the door the zero-reads pin (1 failed)
(round 1) each part read while the form is still being checked the zero-reads pin (1 failed)
(round 1) a key whose upload threw left in place, pre-record and record the storage-write pin (1 failed each)
(round 1) MCP count without .max(10) the MCP schema pin (1 failed)
size limit judged on the first file only (rebuilt dist, dogfood) one PDF of three over the limit (1 failed: 200, three files stored)

Acceptance notes

  • Condition 1, no narrowing of existing links. Every body main took as the file is still the file, on every link, except a multipart/form-data body carrying x-upload-link-form: several (a header main never defined) sent to a link that takes several files, which is read as the form. Every other body takes the same path, checks, limit and order as on main, with the same codes and statuses. The one answer change is additive: every success answer also carries fileIds.
  • The one judgement that moved (accepted as the cost of F1). A one-file send through a multi-file field's record link is now judged on the field's accept / maxSize before the spend, with the same code and status the record's write answered (400 ERR_FILE_CONSTRAINT), and the link stays usable instead of spent.
  • Condition 2, a reserved id before its upload. The uploader's own write naming it is refused by the incomplete-upload arm (400 VALIDATION_FAILED, invalid_value, "its upload was never completed"); any other user's or a system write by the pre-record arm (not_yours, the unreadable-file words), since the reserved row is recognised as a link's row. The download door answers 404 FILE_NOT_FOUND (a pending file is not served), read_file reads only files a record holds, and the row is pending, so sys_file's retention (status: 'pending', 7 days) reaps it if the link is never used. The link's own id keeps its existing refusals.
  • Condition 3, partial failure after the spend. Pinned by failing file 2 of 3 in the storage write: the record is unchanged and no sys_file row or stored bytes of the send are left, for a record link and a pre-record link. A record write refusing after the spend (a lock) is pinned the same way.
  • Attachments links stay one file (the seat's Q2); a link for a record not yet created takes exactly count (Q1).
  • The Console skill mirror is unchanged: it renders the skill a no-scope caller gets, which has no file tools, and its byte-identical test is green.
  • Other places that say "one file" about links (mcp-http-tools.ts, storage-routes.ts, upload-link-copy.ts, pre-record-upload.ts, skill-md.ts) were read; the ones describing a link's use are updated, the rest describe a single upload or file and stay.

Verification

Commands and exit codes
  • pnpm --filter @objectstack/service-storage exec vitest run — 83 files, 1573 tests pass (at 03dcadc6).
  • pnpm --filter @objectstack/mcp exec vitest run — 63 files, 1109 tests pass.
  • pnpm --filter @objectstack/dogfood exec vitest run test/showcase-mcp-record-files.dogfood.test.ts — 21 of 21 pass.
  • pnpm --filter <pkg> run typecheck for service-storage, mcp, runtime and dogfood — exit 0 each.
  • node scripts/pm/dispatch-gates.mjs --commands — 106 derived commands plus the 4 artifact-roster gates whose roster sits under a changed directory (check:changeset-fixed, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census): all 110 exit 0, each run with </dev/null. check:docs, check:skill-examples (exit 1 / 3: json-schema and declarations older than the merged packages/spec/src) and check:dual-build-cjs-loads (exit 3: packages not built yet) were rerun after pnpm --filter @objectstack/spec build and the builds, exit 0. --ran reconciliation: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint — exit 0.
  • Round 3 at 03dcadc6: the same 106 derived commands and 4 roster gates, all 110 exit 0; --ran 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN; pnpm lint exit 0; typecheck service-storage and dogfood exit 0; dogfood file 21 of 21 after rebuilding service-storage.
  • Round 2 at 5cc65412: the same 106 derived commands and 4 roster gates, all 110 exit 0; --ran 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN; pnpm lint exit 0; typecheck service-storage exit 0; dogfood file 21 of 21 after rebuilding service-storage.
  • Round 1 at 501eafef: the same 106 derived commands and 4 roster gates, all 110 exit 0 first time; --ran 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN; pnpm lint exit 0; typecheck service-storage and mcp exit 0; dogfood file 21 of 21 after rebuilding service-storage and mcp.
  • origin/main merged once before this PR (merge 6f09953b; no file in common with this change).

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ

…e field

A link for a multiple: true file field takes up to ten files in one
submit, judged before the link is spent and landed in one write or not
at all. A link for a record not yet created takes count and answers
every id at once.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/mcp, @objectstack/runtime, @objectstack/service-storage, touching 63 documentable anchor(s).

53 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 47f6c939f132df9185e1259740e2cbe4156802e1.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: recordId (4 routes)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 42 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 47f6c939f132df9185e1259740e2cbe4156802e1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 47f6c939f132df9185e1259740e2cbe4156802e1

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 6a6df23affbd19a8d4842f8746eed3e78e005ebd
Local-runs: none

① Derived judgments

Measured against ruling A (6844223218), #30892 ruling B (a signed-in user, one user, an expiry) and #32686 ruling A (bound ids: same user, that object and field, one record, 1h).

  • Multipart door: right, with one deviation. storage-routes.ts PUT /upload-links/:token → uploadLinkFormFiles → RecordFileAccess.consumeUploadLinkFiles. The ruling B gates are unchanged and still run first: uploadLinkCaller gives 401, and openLink checks the secret digest, the expiry, state.user_id === caller and owner_id === state.user_id. The door picks the form arm from the request's content-type, not from the link's range. So a one-part form (count=1, file0) is accepted on a single-file field link and on an attachments link. Accepted shape item 2 said that case is refused 400. See ③ F2.
  • Cap: right. UPLOAD_LINK_MAX_FILES = 10 (upload-link-wire.ts). refuseFileCount answers 400 VALIDATION_ERROR (UPLOAD_LINK_FILE_COUNT_*) in consumeUploadLinkFiles. That check runs after openLink and before the size checks, the user resolve and the spend. This matches the accepted shape item 3. The mint applies the same cap: createUploadLinkForNewRecordAs takes an integer 1–10, and openLinkStateOf rejects reserved_ids.length >= 10.
  • One spend per submit: right. There is one updateFileIfUnchanged(open → used) per call. Every pre-spend judgement covers all N files: the count, the per-file uploadSizeRefusal, assertFileConstraints when newRecord || typed.length > 1, and isOpenReservation for each reserved id.
  • count input: right. MCP create_upload_link lists count only where newRecordLinkFn exists. count together with recordId is refused before the bridge. The service refuses a non-integer, <1, >10, and >1 on a field without multiple: true, each 400 VALIDATION_ERROR. An attachments link cannot carry count: without a recordId it needs field, and with a recordId the count arm refuses it.
  • fileIds / fileId / files answer: right, additive. The raw arm adds fileIds: [fileId] and keeps fileId and size. The form arm answers fileId (the first id), fileIds (in pick order) and files[{fileId,size}], but no top-level size (nit N1). The pre-record mint answers fileIds: [link.id, ...reserved].
  • Reserved rows: right against [Decision] How should an MCP agent create a record whose required file field must be filled (e.g. an expense line with a required e-invoice)? #32686 A. Each row is pending, owned by the minting user, named upload-link (reserved), has no secret and no record, and carries kind: upload_link, target: field. On upload, filesForNewRecordThroughLink gives every id the same preRecordBindingMetadata (user, object, field, org, claim_until = upload + 1h). A failed mint deletes the link row and every reserved row.

② Semver level

  • The changeset puts patch on @objectstack/service-storage, @objectstack/mcp and @objectstack/runtime. These are the three released packages the diff moves; packages/qa/dogfood is test-only. With Metadata-protocol: none, AGENTS.md §Post-Task 3 calls for patch, so the level is right. No packages/spec change.
  • What the diff publishes is additive only:
    • McpFileBridge.createUploadLinkForNewRecord and McpRecordFileService.createUploadLinkForNewRecordAs gain an optional count;
    • NewRecordUploadLinkTarget gains an optional count;
    • success answers gain fileIds.
      UploadLinkDoor, which gains the required consumeUploadLinkFiles, is not exported from service-storage/src/index.ts, and the only implementer is FileAttachmentService. So nothing breaking is published.
  • Clause-②: yes (widening) is right. The accept-set widens: a multi-file link takes N files, a pre-record link takes count. The raw-body wire of single-file and attachments links loses no input.
  • Metadata-protocol: none is right. No authorable key changes.
  • Skills-docs: is verified:
    • git grep over skills/ and ui/skills/ finds no upload-link text;
    • skill-md.ts emits the file-tool lines only under fileTools === true (if (options.fileTools !== true) return '');
    • the Console mirror ui/apps/console/src/pages/developer/objectstack-skill.ts mirrors the no-scope render and has no file or upload text, and skill-md.test.ts pins the two renders byte-equal.
      So "the mirror needs no change" holds.
  • Changeset and docs sentences vs the code: see F1.

③ Boundary flags

Seat conditions:

  • C1, single-file and attachments links unchanged: holds on the raw-body wire. consumeUploadLink delegates with [upload]. The order is unchanged: open, then filename, then bytes, then size (413), then the user resolve (403), then the pre-record constraints, then the spend. The count check cannot refuse one file on a range that admits 1. The added pre-spend accept/maxSize runs only when typed.length > 1. No refusal code or status changes for a raw PUT. The multipart wire on these links changed: see F2.
  • C2, reserved ids before upload: holds.
    • Writes: the uploader's own write hits the applyCopyOnClaim incomplete-upload arm (!isCompletedUpload). Another user's write, and a system or context-less write (writerUserId === null), get not_yours from preRecordReferenceRefusal, because the row's metadata satisfies isPreRecordLinkRow. The system lookup findReferenceableFile reaches the row.
    • Reads: the download door refuses a pending row (isServableForDownload), and isUploaderOnlyFile denies others. readRecordFileAs reads only ids the record holds, and no write can make a record hold a reserved id.
    • Not a link: openLinkStateOf requires name === 'upload-link' and secret_sha256. A reserved row has neither, so it never opens, and the pin uses the real secret.
    • Reaping: the sys_file retention (status: pending, 7d) reaps it.
  • C3, partial failure after spend: holds.
    • Record link: fillFieldThroughLink tracks created and stored per file, writes the field once ([...current, ...ids]), and on any throw discards every file it created.
    • Pre-record link: filesForNewRecordThroughLink discards every id, including rows not yet reached (bytesStored = i < stored).
    • The tests pin a store failure on file 2 for both link kinds, and a 409 lock after the spend.

Break attempts:

  • Multipart on a single-file or attachments link: 2 or more parts give 400 VALIDATION_ERROR before the spend. A malformed form gives 400 INVALID_REQUEST before openLink. A one-part form lands and spends the link (F2).
  • Duplicated, reordered or missing parts: the door reads exactly file0…file{count-1}. Every file-valued entry, and every key matching /^file\d+$/, must total count. A non-file fileN or a missing index is refused. A repeated name collapses in the parser and then mismatches count. Order follows the index. No more than N files are stored, and no uncounted file is stored.
  • accept/maxSize before spend: yes for every file of an N>1 submit and of every pre-record submit. A one-file record submit is judged by the record write after the spend, as before (C1).
  • A second user claiming an id: refused. openLink rejects the stranger (404, nothing spent, pinned). Their write naming an id gets not_yours, both before and after the upload. A system write naming an unheld bound id also gets not_yours.
  • count range: validated at the MCP schema (int().min(1)) and in the service (1–10). Refused above 1 on a single-file field. Not accepted on an attachments link.
  • Locales: all 11 CopyLocale tables carry all six new keys (wantedSeveral, wantedExactly, filesLabel, attachedSeveral, receivedSeveral, pickCount). Record<CopyLocale, UploadLinkCopy> makes them compiler-required, and upload-link-copy.test.ts pins them non-empty and carrying the number.
  • Ablations (12 reported), judged from the code: each of the 12 mutations reaches an assertion that the pin makes. The guards that no pin covers:
    • the pre-spend isOpenReservation loop;
    • the openLinkStateOf reserved_ids shape check (no duplicate or self-id check, N2);
    • the form-arm declared cap formLimit;
    • the one-part form on a one-file link.
  • Body cap maxUploadBytes × 10 + 64 KiB: checked on the parsed form in the PUT handler, so it is not a pre-read guard for this door. uploadLinkFormFiles then copies every part to a Buffer, for any count up to 999, before the service's count check (F3).

Dev flags (PR body Acceptance notes; os-dev-report 6864752482):

  • C1–C3, Q1 (exact count), Q2 (attachments stay one file) and the mirror claim are confirmed as above, except for F2 under C1.
  • The "other one-file wording read" claim holds for the touched surfaces.
  • The report's security_findings: "withheld" carries no content on the card. Not judged here.

Check-runs at head (50 read): 25 success, 15 skipped, 10 in progress (Test Core 1–8/8, Type Check · workspace, Lint · scoped · slot lookup & corpus), 0 failure. Dogfood Regression Gate succeeded, but its shard jobs were skipped. This verdict is on the contract only; the gate verdict is still pending.

Findings:

  • F1, should-fix: .changeset/32747-upload-link-multiple-files.md ¶2 and content/docs/ai/connect-mcp.mdx (multi-file paragraph) say every file of a send is checked against accept/maxSize before the spend, and that a refused file leaves the link usable. For a one-file send on a multi-file record link, the check happens in the record write after the spend, so the link is spent. Narrow both to "a send of more than one file, or a link for a record not yet created".
  • F2, should-fix: storage-routes.ts PUT handler, form arm chosen by isUploadLinkForm(content-type). A one-part form on a single-file or attachments link is admitted, and its declared cap widens from maxUploadBytes to 10× + 64 KiB. This deviates from accepted-shape item 2 and is unpinned. Either refuse a form on a link whose range max is 1, before the spend, or amend the shape and pin the behaviour.
  • F3, should-fix: uploadLinkFormFiles and the comment "refused before it is read". The form cap is not a pre-buffer guard, and the door materialises up to 999 parts. Bound count to UPLOAD_LINK_MAX_FILES before any arrayBuffer(), and correct the comment.
  • N1, nit: the form-arm answer has no top-level size, while the raw arm has one.
  • N2, nit: openLinkStateOf does not reject duplicate reserved_ids, or one equal to the link id. That is reachable only through the owner's own row metadata, and it can orphan bytes in their own scope.
  • N3, nit: discard(..., bytesStored = false) no longer deletes the key of the file whose storage.upload threw. On main the single-file path did delete it. Bytes from a partial adapter write can be left.
  • N4, nit: the MCP count schema has no .max(10). The description states the cap, and the service refuses above it.

Implemented-by: claude/project-thread-4i7bf9
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

…d, refuse a form to a one-file link, read no part before the form is judged

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
…e-file link takes any body as the file, as before

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 5cc654121039c794c8998b72f823f8397334752a
Local-runs: none

Round 2 is a delta review: 501eafef..5cc65412, plus 6a6df23a..501eafef read as round-1 context, plus the whole net diff against main (merge-base 128b6571, unchanged) for the narrowing question. Labels: F = must-fix (FAIL), S = should-fix, N = nit. Round-1 ids appear as r1-F1…r1-N4.

① Derived judgments

  • The door's path choice: right for one-file links, wrong on main's multi-file field links (F-1). For a multipart/form-data body, the door now asks the link first (storage-routes.ts:1899-1900 → severalFilesOf :2472-2484, inspectUploadLink, which does a read and writes nothing). Only files.max > 1 (:2483) reads the form. A single-file field link, an attachments link, or a link for a record not yet created with count: 1 takes the raw body (:1936-1946). That path has main's declared cap (:1914) and main's order. This answers the coordinator's round-1 ask for one-file links. But a multiple: true field's record link existed on main and took any body as its one file (main storage-routes.ts:1890-1905). It now parses every multipart body as the indexed form (F-1).
  • The cap (10, 400 VALIDATION_ERROR): right, unchanged. The door now also refuses count > linkFiles.max before reading any part (:2536). Both refusals share one spelling, uploadLinkFileCountMessage (upload-link-wire.ts).
  • One spend per submit: right, unchanged (record-file-access.ts:1210).
  • Pre-spend accept/maxSize: widened to every send on a link that takes several (record-file-access.ts:1192, newRecord || range.max > 1). A one-file raw send to a multi-file field's record link is now judged before the spend, and the link stays usable. The refusal code and status are the ones the record write answered on main, but its precedence moved (S-1).
  • count input: right. MCP now declares .max(10) (mcp-http-tools.ts:3431, pinned). The service range is unchanged.
  • fileIds / fileId / files: right, additive. The one-file raw answer is main's plus fileIds (storage-routes.ts:1955-1961). The form answer still has no top-level size (:1927-1933, N-1).
  • Reserved rows: unchanged since round 1. They still hold against [Decision] How should an MCP agent create a record whose required file field must be filled (e.g. an expense line with a required e-invoice)? #32686 A.

② Semver level

  • patch on service-storage, mcp and runtime with Metadata-protocol: none: right. Nothing new is exported.
  • Clause-②: yes (widening) is not accurate while F-1 stands. A request main accepted is now refused 400: a multipart/form-data body sent to a usable multiple: true field's record link, when the body is not the indexed form. If F-1 is kept rather than fixed, the PR body and changeset must declare Clause-②: yes (narrowing), with the BREAKING banner and the ADR-0087 disposition (AGENTS.md §Post-Task 3).
  • Skills-docs:: unchanged and still true. The Console mirror stays no-scope.
  • The changeset's last paragraph and connect-mcp.mdx:306 now match the code for single-file and attachments links, and for "however many" on multi-file links.

③ Boundary flags

(a) Can a one-file link refuse or reorder anything main answered? No, for a single-file field link, an attachments link, or a count: 1 link.

  • The order is 401 (:1877), then a missing door 404 (:1881), then a side-effect-free inspect, then the declared 413 against maxUploadBytes (:1914), then rawBody (:1938), then consumeUploadLink (:1946). That is main's sequence, with an inspect read added that never answers.
  • When inspect throws (expired, used, another user's, unknown, no engine), catch → undefined (:2480) takes the raw path, so 413 still precedes 404, as on main.
  • In the service, a one-file send on these links passes the count check (record-file-access.ts:1156). Constraint judgement stays in the record write (:1192, range.max === 1).
  • The only answer change is the added fileIds. Outside this set, F-1 and S-1 concern multiple: true field links, which also took one file per use on main.

(b) Can the inspect read be raced into a wrong path? No, in a way that matters.

  • A one-file link takes the raw path whatever the race does.
  • A multi-file link spent or expired between inspect and spend reaches openLink inside consumeUploadLinkFiles, which answers main's 404 RESOURCE_NOT_FOUND. The conditional spend (:1210) still allows exactly one use.
  • The residual: a racing multi-file multipart body declared between max and max × N answers 404 where main answered 413. Both are refusals of a used link. N-level, not filed.

(c) Round-1 items

  • r1-F1: resolved, by a code change and docs. The changeset ¶2 and connect-mcp.mdx:306 now match record-file-access.ts:1192; see S-1 for its cost.
  • r1-F2: superseded by the coordinator's ruling. A one-file link takes the body raw (a).
  • r1-F3: resolved at this door. The cap is maxUploadBytes × linkFiles.max + 64 KiB (storage-routes.ts:1902-1905). count > max and every part-shape refusal return before any arrayBuffer() (:2530-2556 vs :2564). Pinned with counted reads (record-file-access.integration.test.ts:1525). The comment at :1884-1893 is now accurate.
  • r1-N3: resolved. attempted is set before storage.upload (record-file-access.ts:1275, :1383), and discard deletes a key whose upload was tried (:1436). Pinned (…integration.test.ts:1424).
  • r1-N4: resolved (mcp-http-tools.ts:3431, pinned).
  • r1-N1: still open (N-1).
  • r1-N2: still open (N-2, record-file-access.ts:568-571).

(d) Is the new pin genuine? Yes.

  • …integration.test.ts:1490-1512: for both link kinds, a multipart body declared at 1 MiB + 1 under a 1 MiB limit gets 413 with main's uploadSizeRefusal words (:1501). Under the limit it is stored with its own length (:1506). The link's second use gets 404 (:1508).
  • The ablation is genuine. If every link reads a form, the cap becomes 1 MiB + 64 KiB, which exceeds the declared length, so there is no 413. useLink carries no body, so uploadLinkFormFiles({}) answers 400 INVALID_REQUEST (storage-routes.ts:2530). The pin goes red at :1501.
  • The pin does not cover the inspect-throws fallback (S-2).

PR body (rewritten) vs code.

  • "What changes" and Acceptance C1 match the code for single-file and attachments links. Acceptance line 114 discloses the multi-file pre-spend judgement.
  • Line 112, "no narrowing of existing links", is false while F-1 stands.
  • Line 24 ("a one-file send takes the same steps in the same order as before") contradicts lines 30 and 114 for multi-file links.
  • The case counts "13 cases" (lines 17 and 56) are stale (N-3).

Check-runs at head (57 read): 40 success, 17 skipped, 0 failure, 0 in progress. Test Core 1–8/8 and Type Check · workspace are success. Dogfood Regression Gate succeeded, but its shard jobs were skipped, so CI did not run the touched dogfood file.

Findings:

  • F-1, must-fix: narrowing on main's multi-file field links. storage-routes.ts:1899-1900, :2483, :1920, :2530. A usable multiple: true field's record link that receives a multipart/form-data body answers 400 INVALID_REQUEST (or 400 VALIDATION_ERROR for a count it does not take) whenever the body is not count plus file0…. On main (storage-routes.ts:1890-1905) that same link stored the body as its one file, subject to the field's accept. This is the class the coordinator ruled a narrowing in round 1, now on the links that existed on main. The page never sends such a body, but neither did it in round 1. Cure one of three ways:
    • (i) the page marks its several-file send with its own header, and a multipart body without the header keeps main's raw path on every link;
    • (ii) keep main's raw path when the parsed form carries no count;
    • (iii) declare Clause-②: yes (narrowing) with the BREAKING banner, the ADR-0087 disposition, and a pin.
  • S-1, should-fix (disclosed, PR body line 114): refusal precedence moved for a one-file send on a multi-file field's record link. record-file-access.ts:1192 judges accept/maxSize before the spend. On main the same judgement ran inside the record write (main :970 was if (newRecord) only). When the file also meets a spend race (404), a RECORD_NOT_FOUND, a readonlyWhen strict refusal or a lock, the answer is now 400 ERR_FILE_CONSTRAINT where main answered the other refusal. No request main accepted is refused here, and the link stays usable (a widening). Either the coordinator accepts this explicitly as the r1-F1 cure under ruling A, or the code reverts to typed.length > 1 and the docs are narrowed instead.
  • S-2, should-fix: the inspect-throws fallback is unpinned. storage-routes.ts:2480. Rethrowing there instead of return undefined stays green: the pin's used-link step (…integration.test.ts:1508) declares no length and answers 404 either way. Add the case: a used or expired one-file link, a multipart body declared over the limit, expecting 413 with main's words.
  • N-1, nit (r1-N1): the form answer has no top-level size (storage-routes.ts:1927-1933).
  • N-2, nit (r1-N2): openLinkStateOf accepts duplicate reserved_ids, or one equal to the link id (record-file-access.ts:568-571).
  • N-3, nit: PR body: "13 cases" (lines 17 and 56) are stale, and line 24 overstates "same steps in the same order" for multi-file links.

Implemented-by: claude/project-thread-4i7bf9
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: CHANGES

… any other body is the file on every link

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibzVFJSNs6XZjC8jkNJ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 03dcadc63561eba754b2d93991e4877e1473a475
Local-runs: none

Round 3 is a delta on round 2 (comment 6866608002). I read git diff 5cc65412..03dcadc6, then the whole net diff against main (merge-base 128b6571, unchanged) for the narrowing question.

Labels: F = must-fix (FAIL), S = should-fix, N = nit. Round-2 ids appear as r2-F-1 and so on.

① Derived judgments

② Semver level

  • The level is unchanged and right: patch on service-storage, mcp and runtime, with Metadata-protocol: none.
  • UPLOAD_LINK_FORM_HEADER is a package-internal export of upload-link-wire.ts, not exported from the package index.
  • Clause-②: yes (widening) is now accurate:
    • main read every body raw;
    • head reads every body raw, except a body that opts in with a header main never defined.
  • The changeset's last paragraph names the header and the any-other-body rule. It matches storage-routes.ts:2494-2502.

③ Boundary flags

(a) Main's answer holds for every unmarked body, on every link.

  • I diffed the PUT handler between main (storage-routes.ts:1869) and the head (:1876). For an unmarked body, linkFiles is undefined with no inspect, and the path is main's line for line: 401, missing door 404, declared 413 (:1917), rawBody (:1940-1941), consumeUploadLink (:1949). The only addition is fileIds in the answer.

  • The hono claim holds. On that path the handler reads req.body only at :1942, and only when req.rawBody is absent. That branch is main's code, unchanged. Both adapters answer their parse refusal only when req.body is actually read:

    • plugin-hono-server/src/adapter.ts:858 sets refusedBodyRead inside the getter, and :1019 answers it;
    • adapters/hono/src/index.ts:461 does the same through refusedOnRead.

    So a malformed unmarked multipart body is never refused by this route. Looking for count in req.body instead would have refused it, so the dev's reason for choosing the header is sound.

  • A marked form to a one-file link (single-file field, attachments, count: 1) is inspected and gets max === 1. A marked form to a link that cannot be read hits catch → undefined (:2483). Both take the raw path, in main's order.

  • In the service, a one-file send runs in main's order (round 1). The one exception is S-1 on multi-file record links, which was accepted.

(b) The header reaches nothing main refused, beyond the accepted widening.

  • The header changes the outcome only for a usable link with max > 1. Even there:
    • the declared cap becomes max × link max + 64 KiB (:1905-1909);
    • count is at most the link's max and is refused before any part is read (:2549);
    • every part is judged against the upload limit and, before the spend, against the field's accept/maxSize;
    • the same openLink gates apply (session user, owner, expiry, secret).
  • The header does nothing on a non-multipart body, a one-file link or an unusable link.
  • The header's one other effect: a marked malformed multipart body sent to a several-file link now gets the transport's 400 INVALID_REQUEST. That narrows only requests that opt in with the new header, which main's clients never sent. Not filed.

(c) Served teaching.

  • Only the changeset describes the wire (.changeset/…:18), and it names x-upload-link-form: several.

  • None of these teach a client to send a form. Each describes a user picking files on the link's page:

    • connect-mcp.mdx (:278, :305, :337);
    • skill-md.ts (:226, :240);
    • the create_upload_link description and its instructions.
  • content/docs/api/plugin-endpoints.mdx:165 (untouched) still says the PUT "spends the link on the request body". That remains true, and a client following it sends one file and gets one file.

    So no served text leads a non-page client to send a form that would silently land as one raw file. See N-2 below.

(d) Pins.

  • The S-2 pin is genuine (…integration.test.ts:1547-1559). A used single-file link receives a marked form declared at 1 MiB + 1, expecting 413 with main's uploadSizeRefusal words (:1553-1554), then 404 (:1558).
    • If catch → undefined (storage-routes.ts:2483) rethrows instead, the inspect's unusable refusal is relayed as 404 before the size check, and the pin goes red at :1553.
    • If the fallback treats an unreadable link as a several-file link, the form path's cap passes 1 MiB + 1, the spend answers 404, and the pin is red again.
  • The N-2 pin is genuine for repeats only (:1561-1574).
    • Without new Set(ids).size (record-file-access.ts:575), the twice forge passes isOpenReservation on both slots and the submit lands. The pin goes red at :1570.
    • The own forge is already refused without the new ids.includes(row.id): isOpenReservation (:600) requires the row name upload-link (reserved), and the link row's name is upload-link. So the own half does not discriminate its own guard. That guard is defence in depth (N-1 below). Round 2's "or one equal to the link id" overstated that gap.
  • The unmarked-multipart pin is genuine (:1519-1545). A body shaped like the form but carrying no header is stored with the raw length. Ablation B1 ("any multipart body read as the form") turns it red.

(e) PR body vs code.

  • The case count of 19 is right (the #32747 describe at …integration.test.ts:1196-1627).
  • What-changes lines 33-35, Tests 70-77 and the ablation rows 106-108 match the code.
  • Acceptance Condition 1 (body line 118) is inaccurate (S-1).
    • "Every body main took as the file is still the file, on every link" ignores the one exception it then names. Main took a marked multipart body sent to a several-file link as a raw file; head reads it as the form.
    • The sentence after it, "Such a body takes the same path, checks, limit and order as on main", grammatically refers to the marked form just named. That body takes the new form path and the max × N cap, which is the opposite of what is meant.
  • Line 24 ("a one-file send takes the same steps in the same order as before") still overstates the multi-file record link case (r2-N-3; N-3 below).

Check-runs at head (57 read):

  • 39 success, 17 skipped, 1 in progress (Check Changeset), 0 failure.
  • Dogfood Regression Gate succeeded, but its shard jobs were skipped, so CI did not run the touched dogfood file.

Findings:

  • S-1, should-fix: the body's Condition 1 (line 118). Suggested wording: "Every body main took as the file is still the file, on every link, except a multipart/form-data body carrying x-upload-link-form: several (a header main never defined) sent to a link that takes several files, which is read as the form. Every other body takes the same path, checks, limit and order as on main, with the same codes and statuses."
  • N-1, nit: the own half of the N-2 pin (…integration.test.ts:1561-1574) is already refused by isOpenReservation (record-file-access.ts:600). It does not pin ids.includes(row.id) (:575), and no other pin does.
  • N-2, nit: content/docs/api/plugin-endpoints.mdx:165 could name the marked several-file form for a multi-file link. It is accurate as it stands.
  • N-3, nit (r2-N-3, partly open): body line 24 overstates "same steps in the same order" for a multi-file field's record link. The stale "13 cases" is fixed.
  • N-4, nit (r2-N-1, kept by ruling): the form answer has no top-level size (storage-routes.ts:1930-1936).

Implemented-by: claude/project-thread-4i7bf9
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33151 @ 03dcadc6 · 2026-11-13T23:17Z

  • 改了什么:多文件字段(multiple: true)的上传链接,现在一次可以选并提交最多 10 个文件,每个文件各得一个文件 id。记录尚未创建时,可以用 count 先预留好 id。单文件字段的链接和附件链接仍然只收一个文件。
  • 为什么改:用户说「这是我的三张发票」,以前要开三个链接、传三次;现在开一个链接、选一次就够了(裁决 A,6844223218)。
  • 权限与边界:多文件表单只在请求带上页面专用的标头 x-upload-link-form: several、且链接本身能收多个文件时才按表单读取。其余任何请求体,在任何链接上都按 main 原来的路径、检查、上限和顺序处理。预留的 id 在上传之前,任何写入、读取或下载都会被拒。
  • 风险与回滚:一次提交要么全部成功,要么什么都不留。有一个文件超限或字段不接受,整批拒绝,链接也不消耗。落盘或写记录失败,整批文件都清掉,记录保持原样。回滚就是 revert;成功应答只多了一个 fileIds。
  • 先看:record-file-access.ts 的 consumeUploadLinkFiles(检查顺序、只消耗一次、失败清理);storage-routes.ts 的 PUT 分流(按链接和标头选路径,不按 content-type)。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。共三轮契约复审(TIER_DEFAULT,widening 面)。第二轮抓出一处收窄:main 上多文件字段的链接收任何请求体,改动后却把未标记的 multipart 当表单读。第三轮改成标头分流后 PASS。CI 无失败,没有碰受管面,改动 1610 行。

席位核对

  • 形态: draft → main。正文写 Fixes #32747,合并后应当关卡;正文其他地方没有挨着别的 open 卡号的闭合关键词。
    • 标注 Clause-②: yes (widening)、Metadata-protocol: none,三个包都是 patch。
    • Skills-docs 已在本 PR 内更新:served skill-md.ts、connect-mcp.mdx、system-context.mdx 47f 行。Console 镜像渲染的是无 scope 的 skill,没有文件工具,所以不受影响。
    • 文件面 = 认领 6863322469 加修订 6863416257。
  • 前提实测: 形态在写代码之前已经确定(out/32747-proposed-shape.md),并附三个条件:
    • 现有链接不新增拒绝;
    • 预留 id 在上传前不可用;
    • 部分失败不留下任何行。
      dev 报告:6864752482。
  • 门禁:
    • check-runs @ 03dcadc6:40 success、19 skipped、0 失败;正文修订后重跑的 2 个检查仍在进行,自动合并会等它们。
    • dogfood 分片在 pull_request 上会跳过,所以本 PR 改到的 dogfood 文件要到合并队列(merge_group)才首次在 CI 上跑。dev 本地在 rebuild 之后跑过,21/21。
    • 本地 @ 03dcadc6:storage 1573/1573;typecheck exit 0;110 个门禁全部 exit 0;--ran 对账 106 推导、106 已跑、0 NOT-MEASURED;pnpm lint exit 0。
  • 契约复审: 同一位复审者做了四次:
    • 首轮 6864941240 @ 6a6df23a:PASS,附应修 F1–F3、nit N1–N4。
    • 第二轮 6866608002 @ 5cc65412:CHANGES。必须修 F-1:main 上多文件字段的链接把任何 multipart 原样收为一个文件,改动后对未标记的 multipart 回 400,属于收窄。
    • 第三轮 6867332959 @ 03dcadc6:PASS。核对结果:
      • 未带标头的请求体在每条链接上都走 main 的路径;
      • hono 适配器只在真正读取 req.body 时才回拒绝,所以按 count 判断本身就会造成收窄,dev 改用标头是对的;
      • S-2 和 N-2 的 pin 都是真的;
      • 正文 Condition 1 的措辞已由席位按复审建议改正(正文已重发,读回一致)。
  • 接受的偏差:
    • S-1:多文件字段的记录链接上,一次只传一个文件时,accept / maxSize 改到消耗链接之前判断。因此,如果这个请求同时还会撞上竞态、锁或 readonlyWhen,现在先回 400 ERR_FILE_CONSTRAINT。main 接受的请求没有一个被拒,而且链接保持可用。
    • nit:
      • 表单成功应答没有顶层 size;
      • N-2 pin 的「链接自己的 id」那一半,其实已被 isOpenReservation 拒掉,所以没有测到新加的那道守卫;
      • plugin-endpoints.mdx:165 可以补一句带标记的表单(现有内容仍然准确);
      • 带了标头但格式错误的 multipart,发到多文件链接上,回传输层的 400 INVALID_REQUEST。只有主动带上 main 从未定义的标头的请求才会碰到。
  • 范围外去向:
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z)。
    • 非受管面:check-governed-merges --pr 33151 判定 0/19 命中。1610 行 ≤ 3000。
    • K = 1(PRBASE 47f6c939,取自 Test Core 计划的 Affected-set diff base;origin/main 28a2f0f5),小于 15。
    • 唯一新进 main 的提交是本席位的 fix(mcp,runtime): describe_object serves readonly: true where a write never sets the value #33150。它和本 PR 共用 mcp-http-tools.ts 与 runtime/src/domains/mcp.ts,但改的是不同的工具;git merge-tree 无冲突,合并队列会在合并后的树上再跑一遍。
    • 没有新的语种包(只在已有语种里加了句子)。

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

storage + mcp: one upload link on a multiple: true file field accepts several files (same user, record, field, one use, same expiry) — ruled A

2 participants