Skip to content

fix(mcp,storage): say which upload link a lock refuses; close the upload-link relay's code - #33142

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-wp6iex
Nov 13, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-wp6iex

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #32783

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:create_upload_link 的工具说明和 connect-mcp 文档页,现在说清楚"锁定记录、只读字段不发链接"只针对字段目标;附件链接只问附件本身的两个问题,所以审批锁定中的记录照样能拿到附件链接并成功附上。另外,上传链接页面和上传接口转发拒绝时,error.code 改为平台闭合错误码,应用自定义的未注册码放到旁边的 declaredCode,与同文件提交接口的做法一致。
  • 为什么改:代理按工具说明决定要不要给用户发链接;旧说法会让它以为审批中的记录不能加附件,从而错误地拒绝用户。
  • 风险与回滚:patch 级。说明文字只改措辞,不改判定规则;错误码归一只影响抛出未注册码的应用钩子(平台自己抛的码都已注册,答复不变)。回滚即还原本 PR。
  • 先看:packages/services/service-storage/src/storage-routes.ts 的 relayUploadLinkRefusal;packages/mcp/src/mcp-http-tools.ts 的 create_upload_link 描述;content/docs/ai/connect-mcp.mdx 的 create_upload_link 条目。
  • 两分钟验证:pnpm --filter @objectstack/service-storage exec vitest run src/storage-routes.upload-link-refusal-relay.test.ts,预期 7 个全部通过。
  • 需要你做的:无,席位落地。

What changes

S1, the description says which link each refusal applies to. Measured at origin/main 67188849 (RecordFileAccess.createUploadLinkAs, record-file-access.ts):

Description sentence Branch that serves it
The user must be able to edit the record Both targets: a field link asks mayEditAttachmentParent (PERMISSION_DENIED 403); an attachments link asks assertMayAttach, the sys_attachment create permission plus the same parent-edit verdict (ATTACHMENT_PARENT_ACCESS 403).
A field link needs a field they may write Field branch only: getWritableFields (FIELD_NOT_ACCESSIBLE 403).
A readonly field gets no link Field branch only: fieldDef.readonly === true (403).
A field whose readonlyWhen holds now gets no link Field branch only, since #32768: engine.judgeFieldWriteLock (403, naming the lock).
A record a pending approval locks gets no link Field branch only: approvals.getRecordLock (RECORD_LOCKED 409).
A locked record still takes attachments (new) The attachments branch asks no lock, and attaching writes a sys_attachment row and the sys_file row, never the parent, so the approvals lock (a beforeUpdate on the parent) never runs. Measured, see Verification.
An upload refused when it arrives Field link: the strict write (strictReadonlyWrites) refuses a field that became read-only, and the lock hook refuses a record locked after minting (RECORD_LOCKED 409, pinned in record-file-access.integration.test.ts).
The new-record limb (#32686) Unchanged: createUploadLinkForNewRecordAs asks create permission, the writable field and readonly; readonlyWhen is judged by the create.

The description and content/docs/ai/connect-mcp.mdx now say the lock and the read-only refusals belong to a field link, and that an attachments link to a locked record is issued and attaches. "for this record and field only" now reads "for this record and this field (or its attachments) only". The served skill text (packages/mcp/src/skill-md.ts and its console mirror) does not restate the lock or the read-only refusals, so it is not changed.

N2, the relay's code is closed. relayUploadLinkRefusal (storage-routes.ts) answered the thrown spelling under an as ErrorCode cast. It now answers resolveThrownHttpError(err).code, with demotedDeclaredCode(thrown) beside it as declaredCode, as relayAttachFailure in the same file does. The ruling read: the module note of packages/types/src/thrown-http-error.ts, "[#9106] That limb was ruled (maintainer, 2026-08-16): error.code is a closed vocabulary at every door". The 503 arm still relays only a code spelled SERVICE_UNAVAILABLE: it reads the spelling (thrown.declaredCode), because an unregistered spelling at 503 derives SERVICE_UNAVAILABLE and reading the closed code would widen the arm.

N3, the docblock names all three declaredStatus limbs: status, then statusCode, then the validation shape, which declares 400. It also says a throw with no string code is not relayed.

Tests

packages/services/service-storage/src/storage-routes.upload-link-refusal-relay.test.ts (new) mounts the storage family over a link door that throws a chosen refusal and drives the page GET and the upload PUT:

  • an unregistered 4xx spelling answers the code its status derives (a registered one) with the spelling as declaredCode, on both doors (ablated);
  • a 503 spelled with an unregistered code stays the route's 500, and a 503 SERVICE_UNAVAILABLE relays (ablated);
  • a registered spelling answers verbatim with no declaredCode (control);
  • statusCode 409 RECORD_LOCKED answers 409;
  • the validation shape, with no status or statusCode, answers 400 VALIDATION_FAILED (N3);
  • a throw declaring no status is the route's 500 (control).

Acceptance notes

Verification

On head 9292068c (one merge of origin/main f054a0b3, no conflict) unless stated.

Measurement at origin/main 6718884: a locked record, two targets

A throwaway case (not committed) in record-file-access.integration.test.ts, the record locked through the fixture's lock answer and its beforeUpdate lock hook:

MEASURE attach on locked => {"landed":{"fileId":"84b4ed19-…","size":13,"attachmentId":"zkWBtwqYRFhhINTb"},"parent":true}
MEASURE field on locked => {"code":"RECORD_LOCKED","status":409,"message":"You cannot attach a file to claim/… now: it is locked while an approval is in progress."}
Tests  1 passed | 51 skipped (52)

The attachments link was issued and its upload landed a sys_attachment on the locked record; the field link was refused 409. The file was restored with git checkout HEAD --, and git diff HEAD was empty.

Ablations (src, read directly by the suite)

Both ran through scripts/ablation-replace.mjs, which proved the mutation landed (anchor 1 to 0, blob changed) and restored the blob (e1f86c0c… before and after). git diff HEAD was empty after each.

  • N2: sendError(res, status, thrown.code, …, declaredCode) put back to sendError(res, status, e.code as ErrorCode, message, { userMessage }). Red, 2 failed / 5 passed: GET and PUT "a 4xx refusal spelling an unregistered code answers a registered one", expected 'CLAIM_DESK_SAYS_NO' to be 'RESOURCE_CONFLICT'.
  • The 503 arm: thrown.declaredCode === 'SERVICE_UNAVAILABLE' replaced with thrown.code === 'SERVICE_UNAVAILABLE'. Red, 1 failed / 6 passed: "a 503 relays only as spelled SERVICE_UNAVAILABLE", expected 503 to be 500.
Tests, typecheck, lint
  • pnpm --filter @objectstack/service-storage test: Test Files 83 passed (83), Tests 1554 passed (1554).
  • pnpm --filter @objectstack/mcp test: Test Files 63 passed (63), Tests 1104 passed (1104).
  • pnpm --filter @objectstack/service-storage --filter @objectstack/mcp typecheck: exit 0.
  • pnpm lint: exit 0.
  • The new suite alone: Tests 7 passed (7).
Derived gates: 102 of 102, exit 0

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 102 commands from 5 paths; each ran with stdin from /dev/null and its exit code was written to disk. Three first exited 3 (PREREQUISITE NOT MET: check:skill-examples needs the client SDK built, check:i18n the CLI closure, check:dual-build-cjs-loads a full build). Each exited 0 after the build it names. --ran with every exit recorded: "102 derived famil(ies) accounted for — 102 run, 0 NOT-MEASURED (a DERIVED zero — all 102 recorded an exit code and none of them is 3)". check-nul-bytes: exit 0.

Dogfood: n/a, since the title has no ! and Clause-② has no (narrowing) arm.

…k relay's code

create_upload_link's description and the connect-mcp page said a record a
pending approval locks gets no link for any target; only a field link is
refused, and an attachments link to a locked record is issued and attaches.

The upload-link doors' refusal relay now answers the resolver's closed code,
with an unregistered spelling beside it as declaredCode, as the commit door's
relay does, and its docblock names all three declared-status limbs.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwbZycGiSpGfzuw3UC
@github-actions github-actions Bot added size/m 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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/connect-mcp.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/api/client-sdk.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/api/error-catalog.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/api/index.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/automation/webhooks.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/deployment/self-hosting.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/kernel/runtime-services/email-service.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/permissions/authentication.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/permissions/authorization.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/permissions/sharing-rules.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/protocol/kernel/error-handling.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/ui/print-pages.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))

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

  • content/docs/releases/v17/17-0.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/releases/v17/17-3.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/releases/v17/17-4.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/releases/v17/17-7.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))
  • content/docs/releases/v18/18-2.mdx (via SERVICE_UNAVAILABLE (literal, a string literal in relayUploadLinkRefusal))

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

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: VALIDATION_FAILED (literal, 45 pages)
  • 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 — 19 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 840372560ad52ca7aaa522a0bfca9672d2011611 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 840372560ad52ca7aaa522a0bfca9672d2011611

⚠️ 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 840372560ad52ca7aaa522a0bfca9672d2011611 → 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

维护者速读 · PR #33142 @ 9292068c · 2026-11-13T18:53Z

席位核对

  • 形态: draft → main,正文 Fixes #32783,合并后应当关卡;正文其余地方没有挨着别的 open 卡号的闭合关键词。Clause-②: no、Metadata-protocol: none。served skill(skill-md.ts)没有复述这条说明,所以不欠 Skills-docs 行。changeset 32783-upload-link-description-and-relay.md 把 @objectstack/mcp 和 @objectstack/service-storage 都定为 patch。没有改 content/docs/releases/。文件面 = 认领 6860179214,connect-mcp.mdx 是认领里写明的 docs 页。
  • 前提实测: dev 在 origin/main 67188849 上逐句测了说明(报告 6861467985)。Upload links: refuse at issue a field whose readonlyWhen holds now (carries the issue-time readonlyWhen half of #32734) #32768 已经把 readonlyWhen 写进说明,剩下的过宽说法只有锁定。附件链接在锁定记录上能发、能附,测过;只有字段链接被拒 409。
  • 门禁 check-runs @ 9292068c: 53 个,38 success、15 skipped、0 失败。本地 102/102 个门禁 exit 0(3 个先补了前置构建),pnpm lint 0,storage 1554/1554,mcp 1104/1104。N2 两条分支都消融转红,已恢复。
  • 契约复审: 不适用。Clause-② no,没有 narrowing、schema 或受管路径,所以没有契约面。席位自读了 diff:
  • 接受的偏差: 无。
  • 范围外去向: dev 在 Acceptance notes 里记了两处,都不立卡:
    • record-file-access.ts 模块注释里 readonlyWhen 那句已过时,只是注释漂移;
    • 该 relay 回 VALIDATION_FAILED 时不带 details.fields。这与本 PR 之前的行为相同,卡也没有要求,页面只显示句子。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z)。非受管面(check-governed-merges --pr 33142:0/5 命中);222 行 ≤ 3000。K = 1(PRBASE 84037256,取自 Test Core 计划的 Affected-set diff base;origin/main bf02d33b),< 15,无需先 merge main。mcp-http-tools.ts 与本席位的 fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134、fix(runtime): MCP list_actions marks a confirmation-gated action unavailable for an AI service account #33144 同文件不同段,按顺序落地。没有新的语种包。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 13, 2026 18:54
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit 932eab5 Nov 13, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-wp6iex branch November 13, 2026 19:18
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/m 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.

create_upload_link: say which target and which read-only declaration get no link; close the upload-link relay's code and docblock

2 participants