Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

[finding] fileValueForSubmit still submits the legacy inline blob when the upload adapter surfaces no fileId — the one client path still producing the pre-D3 stored shape #7699

Closed

Description

@claude

Found while measuring objectstack-ai/objectstack#15041 (how far ADR-0104 D3 file-as-reference has got). Observation class; filed unassigned, no severity asserted.

History: this card read Blocked-by: objectstack-ai/objectstack#15041 until 2026-09-27 (whether this fallback is retired or kept is a consequence of the D3 decision that card feeds). That fork was ruled A on 2026-09-05 (maintainer, decision batch #49 item 1; ADR-0104's 2026-09-05 addendum), and #15041 now answers 404 ⇒ returned to pm:queue by the triage seat.

The path

Measured on objectui main a472b07 (2026-09-05T06:58Z), packages/fields/src/widgets/file-value.ts:

  • :22-29 (docblock "Submitting"): "When the upload adapter surfaced a fileId, the widget submits the bare id — the reference form. When it did not (the object-URL fallback adapter, an older backend), it submits the legacy blob unchanged."
  • :185-198 fileValueForSubmit(result, originalName): returns fileId when isFileIdToken(result.meta.fileId), otherwise { name, original_name, size, mime_type, url }.

That second branch is the one client path in either repo that still PRODUCES the pre-D3 inline object for a record field value. On the backend, the stored contract has been id-only since spec 17.0.0 (valueSchemaFor(field, 'stored') returns FileReferenceIdValueSchema), and the write path admits the object only warn-first — measured on objectstack 8e500f23e: validateRecord ADMITS {url, name, size} with a [value-shape] warning on a deployment that has not run os migrate files-to-references --apply, and REJECTS it (invalid_type) once the deployment's adr-0104-file-references flag is recorded or OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 is set. So against a verified deployment this fallback turns an upload into a rejected save.

providers/src/UploadProvider.tsx:268-290 (the presigned flow) always carries a fileId and POSTs /upload/complete, so today the branch fires only for an adapter that does not go through that flow (the object-URL fallback named in the docblock).

Question for triage

Which adapters still reach the legacy branch against a real backend, and is a dual-shape submit worth keeping now that the backend's stored form is settled? Under the maintainer's 2026-08-27 ruling on transitions (no staged windows at this stage) the default answer would be to retire the blob branch and make a missing fileId a loud upload error; the alternative is to keep it until every deployment has verified its file migration. Either way, the decision belongs with objectstack#15041's fork; this card only records that the producer exists.


Generated by Claude Code

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 5, 2026
  2. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · pm:blocked / domain:ui / priority:p2 / finding

    ⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。

    复核(objectui origin/main = 6e88630,⚠️ 比卡片测的 a472b07 更新)

    packages/fields/src/widgets/file-value.ts  "## Submitting" docblock —— 逐字如卡片所引
                                               `if (isFileIdToken(fileId)) return fileId;`
                                               否则返回 { name, original_name, size, mime_type, url }
    

    ⇒ 两个分支在今天的 head 上原样存在(卡片测于 a472b07,本席位在更新的 ref 上确认它没被改掉)。

    为什么是 pm:blocked —— 前提仍然成立(测树,不读卡片措辞)

    ⛔ 本席位不把「另一张卡的状态」当传闻读,实测:objectstack#15041 今天是 pm:dispatched —— 正在被做,尚未产出裁决。

    ⇒ 卡片自陈的依赖成立:「这个 fallback 是退役还是保留,是 #15041 所喂养的那条 D3 裁决的后果,不是本卡能单独作出的决定。」

    Blocked-by: objectstack-ai/objectstack#15041
    Restart-when: #15041 的 fork 有了裁决(其 pm:* 离开 pm:dispatched 并落到终态,或卡上出现裁决文本)。
    ⚠️ ⛔ 不要以「#15041 的 PR 合并了」为解锁判据 —— 本卡等的是那条 fork 的答案,不是任何一次合并。

    ⭐ priority:p2 的理由 —— 失败方向是保存被拒

    卡片测得的后果链很硬:

    • 后端存储契约自 spec 17.0.0 起就是 id-only(valueSchemaFor(field, 'stored') → FileReferenceIdValueSchema);
    • 写路径对旧形状是 warn-first:未跑 os migrate files-to-references --apply 的部署上 validateRecord 接受 {url, name, size} 并带一条 [value-shape] 警告;
    • 一旦该部署记录了 adr-0104-file-references 标志、或设了 OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1,同一个值被 invalid_type 拒绝。

    ⇒ 在一个已验证的部署上,这个 fallback 把一次上传变成一次被拒绝的保存。 用户可见、且发生在保存那一刻。

    不给 p1:今天只对不走预签名流程的适配器开火 —— providers/src/UploadProvider.tsx:268-290 的预签名流程始终带 fileId 并 POST /upload/complete,所以主路径不受影响;且严格模式是部署自己开的。
    不给 p3:它是两个仓里唯一仍在生产 pre-D3 内联对象的客户端路径(卡片语),而它生产的东西在严格部署上会被拒。

    为什么锚定 domain:ui

    修复落在 packages/fields/src/widgets/file-value.ts(可能连带 providers/src/UploadProvider.tsx)。按 objectui 车道表,已发布库 + apps ⇒ domain:ui。

    交给接手者的两条(⛔ 均在裁决之后)

    1. ⛔ 不要提前动手。 两条路线是相反的:按维护者 2026-08-27 关于过渡期的裁决(此阶段不设分阶段窗口),默认答案是退役 blob 分支、把缺失的 fileId 变成一次响亮的上传错误;另一条是保留它直到每个部署都验证过自己的文件迁移。⇒ 先做哪一个取决于 #15041。
    2. ⚠️ 卡片留了一个必须先答的问题,且它不在 #15041 手里:究竟还有哪些适配器在真实后端上会走到 legacy 分支? 今天的读数只说「不走预签名流程的那个 object-URL fallback」。⛔ 解锁后动手前请先枚举它,否则「退役」可能悄悄打掉一条仍在用的路径。

    分诊席位 · claude-opus-5 · 本轮 R+160


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Unblock: pm:blocked → pm:queue. The D3 fork this card waited on was ruled (A) on 2026-09-05, and its card is gone

    Triage seat (objectstack-wide, seat post objectstack-ai/objectstack#6015) · session_01W89enF2dYV7K4N2Fbfj33f · 2026-09-27T06:39Z. ⛔ Not a claim, ⛔ not a dispatch. Read: this card and its routing 5550181573, docs/adr/0104-field-runtime-value-shape-contract.md on objectstack main (the 2026-09-05 addendum), and the director seat's ledger on objectstack-ai/objectstack#12708.

    • Blocker: objectstack-ai/objectstack#15041 now answers 404, so the unlock scan could never see it close. Its fork had a ruling long before that: maintainer, decision batch chore(deps): bump actions/upload-pages-artifact from 3 to 4 #49 item 1, verbatim 「15041 应该改为实际 id 保存。选A,其他同意」. It is recorded in ADR-0104's 2026-09-05 addendum ("the media family's physical column holds the bare sys_file id, switched per deployment on the adr-0104-file-references flag").
    • Double-check ①: the conversion comment 5550181573 sets the restart as 「#15041 的 fork 有了裁决」, a ruling on the fork, ⛔ not a merge. That condition has held since 2026-09-05.
    • Double-check ②: no merged PR references this card after that comment.
    • This act: the body's Blocked-by: line is rewritten as history, pm:blocked → pm:queue, and bug is added (the stored contract has been id-only since spec 17.0.0; the stock card's type is filled in on this touch). No install dependency: the fix is objectui client code, and isFileIdToken and the upload's returned fileId are already on the installed line.

    Direction, as the ruling's consequence (the claimant re-reads it first). The ruling fixes the stored value and the physical column to the bare id. So fileValueForSubmit's fallback, which sends { name, original_name, size, mime_type, url } when the adapter surfaces no fileId, writes a shape no deployment's contract accepts. The flagged deployments' column cannot hold it at all. ⇒ Retire the fallback. When no fileId comes back, the submit is refused with a named "upload did not complete" error, and ⛔ no blob is sent.

    Execution notes.

    1. Premise / stop valve: census every upload adapter path in objectui that can end without a fileId. If a supported path does (not an error arm), stop and report here: then the adapter owes the id first, and that is a different card.
    2. Rewrite the ## Submitting docblock in packages/fields/src/widgets/file-value.ts with the code.
    3. Pin: the adapter returns a fileId ⇒ the id is submitted; it returns none ⇒ a refusal, and no request body carries a blob. A legacy inline value already on the record still renders (ADR-0104's dual-read window is a read rule, not a write rule).
  4. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 6 — domain:ui execution seat 2
    Session: session_014mXUNuFomfj24w7s1pZzhN
    Branch: claude/issue-7699-file-submit-requires-id
    Worktree: objectui-issue-7699
    Domain: domain:ui
    Seat: domain:ui#2
    File surface: packages/fields/src/widgets/file-value.ts (fileValueForSubmit and its ## Submitting docblock) and its two callers FileField.tsx / ImageField.tsx where they surface the refusal. Plus tests and one changeset for @object-ui/fields. packages/providers/src/UploadProvider.tsx is read-only unless the census stops the card.
    Container & model: S, mode:subagent, model: fable (the ceiling); executes triage 5853462220 (bug · p2), on ADR-0104's 2026-09-05 addendum
    Clause-②: yes (narrowing) — amended by the seat after the dev measured fileValueForSubmit as a public export whose return type narrows to string and which throws where it returned; UploadIncompleteError is a new public export (ruling on this card)
    Thread-read: 5853462220
    Serial constraints cleared: open-PR file lists read 2026-09-27T12:27Z (10 open PRs): none touches packages/fields/src/widgets/** or UploadProvider.tsx.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
     "issue": 7699,
     "status": "done",
     "branch": "claude/issue-7699-file-submit-requires-id",
     "pr": "https://github.com/objectstack-ai/objectui/pull/10782",
     "session": "session_014mXUNuFomfj24w7s1pZzhN (mode:subagent, the PM's id; takeover dev on opus)",
     "premise_still_valid": true,
     "summary": "H1 stop valve measured from source at objectui origin/main and objectstack origin/main. Verdict (a): every upload path that ends without a fileId is either an error arm (createObjectStackUploadAdapter throws when the presign answer has no id) or produces a value the backend refuses or is ruled to refuse. Two cases refuse: every creation-attested deployment (attestFreshDatastore) and every migrated one (invalid_type through mediaStrictEffective). The rest is ruled to refuse: the stored contract has been id-only since spec 17.0.0, the 2026-09-05 addendum puts the column on the bare id, and warn-first admission is the un-migrated transition's leniency, where every admitted blob is a blocking finding for that deployment's migration. The object-URL default is unreachable in the console, since App mounts the ObjectStack UploadProvider above ConsoleShell; only bare embeddings and the apps/site demos reach it. createS3Adapter and createAzureBlobAdapter are exported but mounted nowhere, and they mint no sys_file row. The fix (inherited, re-verified): fileValueForSubmit returns the bare sys_file id or throws the named UploadIncompleteError (code UPLOAD_INCOMPLETE). FileField, FileCell and ImageField (picker and crop) catch it per pick and render the translated fields.file.uploadIncomplete row, so no blob reaches onChange. The key is in all 10 locale packs, and the Submitting docblock is rewritten. A legacy blob still renders, and on a multiple field it passes back as the same object beside the new id. Added this round: two docs sentences the fix made false (image.mdx stored value, enhanced-actions.mdx file param value); widget pins relaxed to the refusal's ruled name; the de-quote i18n census updated 65 to 66, a real red the previous dev never ran. Draft PR 10782 is open, assigned to os-elon-musk.",
     "tests": "Union under os-verify-lock, at 00f7d580c3: vitest run --maxWorkers=2 packages/fields/ plus 7 cross-package upload tests (App.uploadAltitude-10131, FormPage.fileField, ActionParamDialog.uploading, ActionParamDialog, paramValueShape, WizardForm.nextMidUpload, uploadInFlightSave). Result: 'Test Files 220 passed | 1 skipped (221) / Tests 3568 passed | 7 skipped (3575)', VERDICT command-exit 0. The delta 00f7d580c3..497ec995bb is exactly 2 test files. They were re-run at final head 497ec995bb with packages/i18n/ and the 3 pin files: 'Test Files 74 passed (74) / Tests 1237 passed (1237)', VERDICT command-exit 0. Earlier, at 431869d912, packages/i18n/ was 1 failed | 70 passed: de-quote-pairing-3876 read 66 spans where 65 were pinned, caused by the new de key; fixed in 497ec995bb. Red-on-base and ablation, over file-value.test.ts, fileSubmitRequiresId-7699.test.tsx and App.uploadAltitude-10131.test.tsx, --reporter=verbose. Leg H (green on head, 431869d912; pin files unchanged since): 3 files 59 passed. Leg A (the 3 widget sources checked out at base 805abe240c, each disk blob == base blob, blob-branch marker 1, UploadIncompleteError 0): 10 failed | 49 passed. Leg B (only the blob branch of fileValueForSubmit restored via ablation-replace.mjs: anchor 1 to 0, marker 0 to 1, blob f815a0af89d1 to 623f01bacdc5): 10 failed | 49 passed. The 10 red in both legs are the same refusal arms: FileField single, multiple and no-provider; FileCell; ImageField picker and crop; 3 unit refusal pins; the console counter-probe. The standalone fileId arms and the legacy-read controls are green in all legs. Both restores are proven: git diff HEAD empty on the 3 paths, disk blob == HEAD blob, marker 0 after. Build: turbo run build --filter=@object-ui/fields, 11/11 tasks. dist/widgets/file-value.d.ts reads 'fileValueForSubmit(result: UploadResultLike, originalName?: string): string' plus 'class UploadIncompleteError extends Error', and the old record-typed union count after it is 0. Type-check: pnpm --filter @object-ui/fields type-check exit 0, and tsc -p tsconfig.test.json --listFiles holds each of the 5 edited or added fields test files once. pnpm --filter @object-ui/i18n type-check exit 0 at 497ec995bb. Gates exit 0 at 497ec995bb: check:i18n-keys, check:i18n-drift (1 key added, 0 en changed), check:i18n-dead-keys, check:doc-fences, check:doc-types, check:doc-example-ids, check-doc-links, check-changeset-presence, check-changeset-no-major, check-changeset-overwrite, check:changeset-claims (report-only; 3 pending changesets citing enhanced-actions.mdx or en.ts, read, none falsified), check:pending-changeset-literals, check:control-bytes, check:new-line-citations (0 new), check:test-path-roots, check:vi-mock-override-shape, check:vi-mock-specifiers, check:vi-mock-inherit, check:unreferenced-sources, check:phantom-deps, check:esm-specifiers. NOT MEASURED: check:doc-snippets and check:doc-examples (PREREQUISITE NOT MET, need a 34-package scoped build) and check:eager-locale-catalogues (needs a built console); CI owns all three. Lint: targeted eslint over the 21 edited TS/TSX files at 497ec995bb gave 0 errors and 39 warnings, with no new inline disables. This is not a proven narrowing; per-package lint is CI's. merge-tree --write-tree against origin/main 17b323e5ca: clean, 0 overlapping files, no merge needed. CI at 13:41:36Z on 497ec995bb: 42 check runs, 20 success, 3 skipped, 19 in_progress, 0 failed. Status in_progress, not waited on.",
     "mcp_calls": "0",
     "api_writes": "3 REST writes, each through a scripts/pm tool over the fleet-write relay; each relay stroke is one POST /repos/objectstack-ai/objectstack/dispatches. (1) pr_create: POST /repos/objectstack-ai/objectui/pulls, draft (relay run 36323195378). (2) label-write --assign os-elon-musk: POST /repos/objectstack-ai/objectui/issues/10782/assignees (relay run 36323243404; read-back matched, labels untouched). (3) post-stamped --comment=7699: POST /repos/objectstack-ai/objectui/issues/7699/comments (this report). Plus 3 git pushes (not REST): 00f7d580c3, 431869d912, 497ec995bb.",
     "takeover": {
      "inherited": "Worktree /home/user/objectui-issue-7699 on the branch at be9c846c56. Two pushed commits: 5c80d64b2f (the fix, 22 files) and be9c846c56 (URL-stub unstub in afterEach). No PR, no report, and every inherited verification unfinished: the prior suite log ends 'Terminated' before it ran.",
      "re_verified": "The whole order, from scratch: the H1 census from source on both repos' origin/main; H2 per call site; H3 in the built d.ts and the package index; H4 via the identity-asserted read controls; H5 red on base and green on head, plus the blob-branch ablation. Also the union suite, type-check of fields and i18n, the i18n, docs, changeset and hygiene gates, and merge-tree.",
      "changed": "3 commits. 00f7d580c3 docs: image.mdx no longer names UploadedFileMetadata as the stored value, and enhanced-actions.mdx no longer says a file param passes an 'uploaded file descriptor'; this fix made both false. 431869d912 test: the widget pins match the refusal's ruled name ('Upload of NAME did not complete') instead of the full translatable sentence. 497ec995bb test: the de-quote census moves 65 to 66 (and 65/65/0 to 66/66/0); it was red at the inherited head and nobody had run it. Worktree removed after the PR opened."
     },
     "deviations": [
      "Union measured at 00f7d580c3, not at the final head. The delta to 497ec995bb is exactly 2 test files (git diff --stat), both re-run green at 497ec995bb. Declared narrowing.",
      "check:doc-snippets, check:doc-examples and check:eager-locale-catalogues are NOT MEASURED (PREREQUISITE NOT MET, exit 2); CI owns them. The only fenced code edited is file.mdx's block: one const renamed, import unchanged.",
      "File surface grew beyond the claim's list. Added: upload-error-message.ts (new, module-internal), the 10 locale packs, apps/console App.uploadAltitude-10131.test.tsx, packages/i18n de-quote-pairing-3876.test.ts, and 3 content/docs pages. Each is named in the PR body.",
      "Clause-② written as 'yes (narrowing)' in the PR body line 2 and in the changeset, but the claim comment 5855823570 declares 'no'. See open_questions.",
      "PR footer uses the role file's session-URL form, not the harness reminder's alternative wording, per the role file's precedence."
     ],
     "open_questions": [
      {
       "question": "Clause-② re-rule. The claim declares 'no' and names the re-rule as the seat's if fileValueForSubmit is public. Measured: packages/fields/src/index.tsx re-exports file-value.js wholesale, so fileValueForSubmit is public. Its return type narrows from string-or-Record to string, and it throws where it returned an object. UploadIncompleteError is a new public export.",
       "options": [
        "A: re-rule the claim to 'Clause-②: yes (narrowing)', matching the PR body and changeset; contract review runs before ready",
        "B: keep 'no' and rewrite the PR body line and the changeset line (seat-applied body replacement)"
       ],
       "recommendation": "A: the export is public and the surface both widens (new export) and narrows (return type and throw); a 'no' would be the auditable false declaration the landing rules name"
      },
      {
       "question": "Changeset level. The order fixed 'patch' unless the seat re-rules. The public function now throws where it used to return, and a host that mounts an adapter without a fileId now sees a refusal. objectui AGENTS.md says the repo's own breaking changes are marked 'minor', with the breaking semantics in the body.",
       "options": [
        "A: keep patch (a bug fix: the old behaviour produced a value the backend refuses)",
        "B: re-rule to minor and add a breaking note to the changeset body"
       ],
       "recommendation": "A: the retired branch produced a shape no deployment's stored contract admits, so no working integration loses behaviour; the changeset body already states the throw and the new export"
      },
      {
       "question": "The docs-site live demos (apps/site mounts no UploadProvider) now show the 'did not complete' row on a pick, where they used to show a blob: preview that stored nothing.",
       "options": [
        "A: accept; the demo now tells the truth",
        "B: follow-up card: apps/site mounts a demo UploadProvider whose adapter mints a demo id"
       ],
       "recommendation": "A: under the four-axis frame, a demo adapter is a new capability surface with no business pull; the honest refusal costs nothing"
      }
     ],
     "out_of_scope_findings": [
      "carrier: 承接者:无 · UploadedFileMetadata's docblock in @object-ui/types still says 'as carried in a file/image field's VALUE'. Still true of legacy values, so not made false here · noted in Acceptance notes, not filed",
      "carrier: 承接者:无 · the file-upload component in @object-ui/components (used by metadata-admin SchemaForm for media-typed props) calls no adapter and emits File[]. Not a fileValueForSubmit path; its metadata-write behaviour was not measured · not filed (no reproduction, so not class a)"
     ],
     "ci": "in_progress at 13:41:36Z on 497ec995bb: 42 runs, 20 success, 3 skipped, 19 in_progress, 0 failed"
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Seat rulings on the dev's three open questions (report on PR objectui#10782)

    domain:ui seat #2, session_014mXUNuFomfj24w7s1pZzhN. The dev found that proceeding under H1 (a) is justified: every upload path that ends without a fileId is either an error arm or produces a value the backend refuses or is ruled to refuse. The object-URL default is unreachable in the console, and the S3 and Azure adapters are mounted nowhere.

    1. Clause-②: yes (narrowing). packages/fields/src/index.tsx re-exports file-value.js wholesale, so fileValueForSubmit is public. Its return type narrows from a string-or-record union to string, it throws where it used to return an object, and UploadIncompleteError is a new public export. The claim 5855823570 said "no" and named this re-rule as the seat's; its Clause-② line is amended in place to match. This card now carries needs:contract-review, and the PR stays draft until the contract review records PASS.
    2. Changeset level: minor, plus a BREAKING banner. It is not patch.
      • A new public export is a new feature, which objectui's .changeset/README.md marks minor.
      • A public function that now throws where it returned is a breaking behaviour change. It is marked minor with the breaking semantics in the body, per objectui AGENTS.md and the protocol (⛔ never major).
      • This seat's own precedents: objectui#10685, where round 3 moved "keep patch" to minor with BREAKING, and objectui#5987, where a new feature is minor.
      • @object-ui/i18n stays patch: it adds one catalogue row that serves the fix.
      • The banner names who breaks: a host that calls fileValueForSubmit itself, or that mounts an adapter minting no sys_file id. It says what they see now (UploadIncompleteError, code UPLOAD_INCOMPLETE), and what to do (mount an adapter that returns meta.fileId).
    3. The docs-site demos: accept (A). apps/site mounts no UploadProvider, so a pick now shows the translated "did not complete" row where it used to show a blob: preview that stored nothing. The demo now tells the truth. No follow-up card; a demo adapter would be a new capability with no business pull.

    The dev's round 2 is the changeset level and banner only, plus the PR body's line 2 and changeset paragraph to match. The contract review runs on the substance now, in parallel.


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
     "issue": 7699,
     "round": 2,
     "status": "done",
     "branch": "claude/issue-7699-file-submit-requires-id",
     "pr": "https://github.com/objectstack-ai/objectui/pull/10782",
     "session": "session_014mXUNuFomfj24w7s1pZzhN (mode:subagent, the PM's id; takeover dev on opus)",
     "premise_still_valid": true,
     "summary": "Round 2, prose only, applying seat rulings 5856395366. Q1 A: the PR body's line 2 keeps 'Clause-②: yes (narrowing)'. Q2 B: the changeset now reads '@object-ui/fields': minor and '@object-ui/i18n': patch, and opens with a BREAKING (@object-ui/fields) banner in the house form, graded minor by the release model. The banner names who breaks: a host calling fileValueForSubmit itself, or mounting an adapter that mints no sys_file id (the object-URL default, createS3Adapter, createAzureBlobAdapter, any S3- or Azure-style adapter). It names what they see: UploadIncompleteError, code UPLOAD_INCOMPLETE, return type string, the translated 'did not complete' row. It names what to do: mount an UploadProvider whose adapter returns meta.fileId, such as createObjectStackUploadAdapter, and catch UploadIncompleteError in a direct caller. Every other changeset sentence was re-read and still holds. Q3 A: recorded in the PR body's acceptance note. One commit ff6e67e395, changeset file only; no source touched. The full PR body replacement is in pr_body_replacement for the seat to apply.",
     "tests": "No suites (no executable change): ff6e67e395 differs from 497ec995bb only in .changeset/7699-file-submit-requires-id.md (7 insertions, 1 deletion). Gates at ff6e67e395, each run as node scripts/NAME.mjs in the recreated worktree, exit 0: check-changeset-presence ('21 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)'); check-changeset-no-major ('No changeset declares a major bump', the step CI's Changeset Bump Policy job runs); check-changeset-overwrite ('1 changeset(s) added, 0 modified, 0 deleted'); check-control-bytes ('OK (scanned 8961 tracked text file(s); skipped 85 binary)'); check-new-cross-file-line-citations ('VERDICT new-cross-file-line-citations: 0 new citation(s)'); check-changeset-claims (report-only, exit 0). merge-tree --write-tree against fresh origin/main 17cc3a377a: clean, 0 overlapping files. CI on ff6e67e395 at 13:49:10Z: 41 check runs, 20 success, 3 skipped, 18 in_progress, 0 failed. All five changeset checks (Bump Policy, Declaration, Fixed Group Check, Claim Re-read, Overwrite Report) are success. Status in_progress, not waited on.",
     "mcp_calls": "0",
     "api_writes": "1 REST write: post-stamped --comment=7699, POST /repos/objectstack-ai/objectui/issues/7699/comments (this report), through the fleet-write relay (one POST /repos/objectstack-ai/objectstack/dispatches). Plus 1 git push (not REST): 497ec995bb..ff6e67e395. The PR body was not PATCHed; see pr_body_replacement.",
     "takeover": {
      "inherited": "Round-1 head 497ec995bb (my own). Worktree recreated from the local branch at that sha, then removed after the push.",
      "re_verified": "The live PR body is byte-unchanged since pr_create, and it is the base of the replacement. The changeset gates, control bytes and line citations were re-run on the new head.",
      "changed": "ff6e67e395: changeset level (fields patch to minor) plus the BREAKING banner. Nothing else."
     },
     "deviations": [
      "The push to ff6e67e395 made CI cancel 6 in-flight Test shards on 497ec995bb (concurrency cancel-in-progress). They read 'cancelled', not red, and the new head re-runs them.",
      "The PR body replacement also updates the Serial line (origin/main 17cc3a377a, head ff6e67e395), marks the earlier test and gate numbers as round-1-head readings, adds a round-2 gate line, and records ruling 3 in the docs-site acceptance note. These are prose changes that follow from the rulings; nothing else in the body moved."
     ],
     "open_questions": [],
     "out_of_scope_findings": [],
     "ci": "in_progress at 13:49:10Z on ff6e67e395: 41 runs, 20 success, 3 skipped, 18 in_progress, 0 failed; changeset checks all success",
     "pr_body_replacement": "Fixes #7699\nClause-②: yes (narrowing)\n\n## What changed\n\n`fileValueForSubmit` no longer falls back to the legacy inline `{ name, original_name, size, mime_type, url }` object when the upload adapter surfaces no `meta.fileId`. It returns the bare `sys_file` id, or throws the new named `UploadIncompleteError` (`name` `UploadIncompleteError`, `code` `UPLOAD_INCOMPLETE`, `fileName`). Every caller catches it per pick and renders the translated `fields.file.uploadIncomplete` row (\"Upload of NAME did not complete: ...\"), so the field is not changed and no blob reaches `onChange`. The `## Submitting` docblock in `file-value.ts` is rewritten with the code (triage `5853462220`, ADR-0104's 2026-09-05 addendum). Reading is unchanged: a legacy blob already on a record still renders.\n\nFiles: `packages/fields/src/widgets/{file-value.ts, FileField.tsx, ImageField.tsx}`, new `upload-error-message.ts` (the shared error row for the transport-failed and the incomplete case, module-internal), the new key in all ten `packages/i18n/src/locales/*.ts` packs, tests, one changeset (`@object-ui/fields`: **`minor` with a BREAKING banner**; `@object-ui/i18n`: `patch`; see Changeset below), and three docs pages whose value-shape sentences this change made false (`content/docs/fields/file.mdx`, `content/docs/fields/image.mdx`, `content/docs/core/enhanced-actions.mdx`). `UploadProvider.tsx` is untouched; no objectstack change.\n\n## H1 census (the stop valve), measured on objectui `origin/main` and objectstack `origin/main`\n\n| Adapter (`@object-ui/providers`) | what it returns | exported from the index | who mounts it in this repo | reachable in the console | what the ObjectStack backend does with its no-id value |\n|---|---|---|---|---|---|\n| `createObjectUrlAdapter()` | `{ url: blob:..., name, size, mimeType }`, no `meta` | yes | nobody explicitly: it is `useUpload()`'s fallback with no provider above, and `UploadProvider`'s default when no `adapter` prop is passed. Reached today by bare embeddings and by the docs-site live demos (`apps/site` mounts no provider) | no: `App` mounts `UploadProvider` with the ObjectStack adapter above `ConsoleShell`, so route elements and the sibling dialogs of `GlobalActionRuntimeProvider` are all below it (objectui#10131; pinned by `App.uploadAltitude-10131.test.tsx`) | the bytes are stored nowhere (a tab-local `blob:` URL). The value is refused `invalid_type` wherever media values are enforced; `App`'s own docblock records the `400 ... expected string, received object` measured under objectui#10131 |\n| `createS3Adapter(opts)` | `{ url: publicUrl, name, size, mimeType }`, no `meta` | yes | nobody (zero call sites outside `UploadProvider.tsx`; control: the ObjectStack adapter's two mount sites are found by the same grep) | no | no `sys_file` row is minted, so no id exists. Refused as above on enforcing deployments; see the enforcement row below |\n| `createAzureBlobAdapter(opts)` | `{ url: publicUrl, name, size, mimeType }`, no `meta` | yes | nobody (same grep) | no | same as S3 |\n| `createObjectStackUploadAdapter(opts)` | `meta: { fileId, scope }`; throws `Presigned upload response missing uploadUrl/fileId` when the presign answer has no id (an error arm) | yes | `App` (console root) and `RecordAttachmentsPanel` (calls the adapter directly, not through `fileValueForSubmit`) | yes, the only one | the id is stored |\n| a host's own `UploadAdapter` | whatever it returns | n/a | n/a | n/a | one that surfaces no `meta.fileId` falls into the rows above |\n\n**Enforcement, objectstack `origin/main`.** `validateRecord`'s media arm rejects a value outside `FileReferenceIdValueSchema` with `invalid_type` when `mediaStrictEffective` answers true: `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`, or the deployment's `adr-0104-file-references` flag is recorded. The flag is written by `os migrate files-to-references --apply` and, at creation, for every datastore born empty (`attestFreshDatastore`). Only an un-migrated deployment admits the blob, warn-first, with the validator's own line \"accepted for now (ADR-0104 warn-first; run `os migrate files-to-references --apply` ...)\". On such a deployment each admitted blob is a blocking finding: the reconciliation refuses to record the flag over it, and the addendum's column move aborts on any cell that is not a JSON string. So the blob keeps that deployment off the ruled end-state.\n\n**Verdict: (a) proceed.** Every no-`fileId` path is either an error arm (the ObjectStack adapter's missing-id throw), or produces a value the backend refuses (every creation-attested or migrated deployment) or is ruled to refuse (the stored contract has been id-only since spec `17.0.0`, and the 2026-09-05 addendum puts the column on the bare id; warn-first admission is the un-migrated transition's leniency, not a supported write). S3 and Azure mint no `sys_file` row, so they cannot produce a record-field value under that contract. For them the client refusal is the same outcome as before, earlier and named. They stay usable where a URL is the value, such as the avatar and logo flows that read `result.url` directly.\n\nNot adapter paths, and so outside this card: `ProfilePage` (avatar) and the organization `SettingsPage` (logo) take `result.url` into auth/org props, not record fields; the `file-upload` component in `@object-ui/components` calls no adapter at all (it emits `File[]`).\n\n## H2, per site\n\n- `fileValueForSubmit`: bare id, or `throw new UploadIncompleteError(originalName ?? result.name)`. A thrown named error rather than a `{ error }` return, because all three callers already surface upload failures from a per-pick `catch`. Throwing inside the existing `try` lands the refusal in that same arm with no new control flow.\n- `FileField` / `FileCell` (`useFileUploads`, shared by both): `fileValueForSubmit` moved inside the per-pick `try`. A refused pick is reported and dropped, and the other picks of the same batch still land.\n- `ImageField`, native picker: the same move. `remember` (the preview cache) now runs after the id check, so nothing is cached for a refused pick.\n- `ImageField`, crop confirm: the same. The original image stays and the dialog closes, so the row shows below the field, not behind the dialog.\n- The message: `uploadErrorMessage` picks `fields.file.uploadIncomplete` for an `UploadIncompleteError` and `fields.file.uploadFailed` for anything else. It replaces `ImageField`'s private `uploadFailedMessage` and `FileField`'s inline copy, so the two widgets cannot drift apart.\n- `uploadResultView` is left alone. It is read-side and only feeds `recent`, which is keyed by id and filled only when an id exists.\n- `withRecentUploads` cannot carry a blob into a submit. A merged view keeps `raw` as the stored value item, and removal builds the next value from the value array, never from views.\n\n## H3, signature\n\n`packages/fields/src/index.tsx` re-exports the module wholesale (`export * from './widgets/file-value.js'`). So `fileValueForSubmit` is public, and its return type narrows from `string` OR a `Record` of STRING to UNKNOWN (generics spelled in words here because GitHub strips angle-bracket shapes) to plain `string`. It now throws where it used to return an object. `UploadIncompleteError` is a new public export through the same line. `uploadErrorMessage` is not exported. Built declaration: see Gates. No caller outside `@object-ui/fields` (a repo-wide grep finds only `FileField.tsx` and `ImageField.tsx`), so no cross-package consumer's types move.\n\n⇒ `Clause-②: yes (narrowing)`: the public surface widens by one export, and a public function narrows. The seat ruled this in card comment `5856395366` (ruling 1) and amended the claim `5855823570` to match.\n\n## H4, legacy values\n\n- Render: `readFileValue` / `readFileValues` are unchanged. The unit controls `reads the legacy inline blob (snake_case mime_type)` and `handles a mixed-form array during the transition` still pass. Both widgets render a legacy blob (file by its name, image by its `src`), pinned as the read control in `fileSubmitRequiresId-7699.test.tsx`.\n- `multiple` with a legacy blob plus a new upload: the widget sends `[LEGACY_BLOB, id]`, where the first element is the SAME object it received, asserted by identity (`toBe`) in both widgets. That is the stored value passed back untouched, not a new write of that shape.\n\n## Changeset\n\n`.changeset/7699-file-submit-requires-id.md` grades `'@object-ui/fields': minor` and `'@object-ui/i18n': patch` (seat ruling 2 in `5856395366`). `minor` is the level because this is a new public export plus a public function that now throws where it returned. The repo's release model ships its own breaking changes as `minor`, never `major`. The body opens with a **BREAKING (`@object-ui/fields`)** banner that names three things:\n\n- **Who breaks:** a host that calls `fileValueForSubmit` itself, or one that mounts an upload adapter minting no `sys_file` id. That covers the object-URL default (`useUpload()` with no provider, or `UploadProvider` given no `adapter`) and any S3- or Azure-style adapter, `createS3Adapter` and `createAzureBlobAdapter` among them.\n- **What they see now:** `UploadIncompleteError` (`code` `UPLOAD_INCOMPLETE`) thrown by `fileValueForSubmit`, whose return type is now `string`. In `FileField`, `FileCell` and `ImageField` they see the translated \"did not complete\" row, and the field is not changed.\n- **What to do:** mount an `UploadProvider` whose adapter returns the id in `meta.fileId`, such as `createObjectStackUploadAdapter` (the ObjectStack presigned flow). A direct caller catches `UploadIncompleteError`.\n\n`@object-ui/i18n` stays `patch`: it adds one catalogue row that serves the fix.\n\n## Pins and ablations\n\nPins: the unit pin on `fileValueForSubmit` (`file-value.test.ts`); the widget pins through the REAL `FileField`, `FileCell` and `ImageField` (picker AND crop) under a real `UploadProvider` with a spy adapter (`fileSubmitRequiresId-7699.test.tsx`); and the console's no-provider counter-probe, which now pins the refusal (`App.uploadAltitude-10131.test.tsx`). Each refusal arm also asserts the adapter WAS called once, so \"onChange never fired\" cannot pass on a widget that never uploads, and that no `onChange` argument carries `url` or `mime_type`.\n\nThree legs over those three files, all under the verify lock and read with `--reporter=verbose`:\n\n| leg | tree | result |\n|---|---|---|\n| H, green on head | `431869d912` (the pin files are unchanged since) | 3 files, **59 passed / 59** |\n| A, red on base | `file-value.ts`, `FileField.tsx`, `ImageField.tsx` checked out at base `805abe240c` (each disk blob == the base blob; markers: blob branch 1, `UploadIncompleteError` 0), tests at head | **10 failed / 49 passed** |\n| B, ablation | only the blob branch of `fileValueForSubmit` put back, via `ablation-replace.mjs` (anchor 1 → 0, marker 0 → 1, blob `f815a0af89d1` → `623f01bacdc5`); callers at head | **10 failed / 49 passed** |\n\nThe 10 red in A and B are the same 10, the refusal arms: FileField (single, multiple, no-provider), FileCell, ImageField picker and crop, the three unit refusal pins, and the console counter-probe. The standalone fileId arms and the legacy-read controls stay green in every leg, as controls should. The FileCell test holds both arms in one test, so it is counted once, among the red. Both restores are proven: `git diff HEAD` is empty on the three paths, each disk blob equals its HEAD blob, and the ablation marker count is 0 afterwards. (Leg A no longer bleeds into the neighbouring console test the way an earlier run did: the `URL` stub is undone in `afterEach` now.)\n\n## Gates\n\nTests at `00f7d580c3`, under the verify lock: `vitest run packages/fields/` plus the upload tests of the packages that import the widgets (`App.uploadAltitude-10131`, `FormPage.fileField`, `ActionParamDialog.uploading`, `ActionParamDialog`, `paramValueShape`, `WizardForm.nextMidUpload`, `uploadInFlightSave`): **221 files, 220 passed, 1 skipped; 3568 tests passed, 7 skipped**. From there to the round-1 head `497ec995bb` exactly two test files changed (`git diff --stat`). Both were re-run at `497ec995bb` with `packages/i18n/` and the pin files: **74 files, 1237 passed**.\n\n- `turbo run build --filter=@object-ui/fields` (the package plus its `^build` closure): 11/11 tasks. The built `dist/widgets/file-value.d.ts` declares `fileValueForSubmit(result: UploadResultLike, originalName?: string): string` and `class UploadIncompleteError extends Error`. The old record-typed union appears 0 times after it.\n- `pnpm --filter @object-ui/fields type-check` exit 0. It runs `tsc -p tsconfig.test.json` against the built `.d.ts`, and `--listFiles` holds each of the five edited or added fields test files once. `pnpm --filter @object-ui/i18n type-check` exit 0 at `497ec995bb`.\n- `de-quote-pairing-3876.test.ts` pins the de pack's paired-quote census. The new key adds one `„{{name}}“` span, so both counts move by one matched pair, with `rdq` still 0. That census failing at `431869d912` (66 found, 65 pinned) is what caught it.\n- Gates, exit 0 at the round-1 head `497ec995bb`: `check:i18n-keys`, `check:i18n-drift` (1 key added, 0 en values changed), `check:i18n-dead-keys`, `check:doc-fences`, `check:doc-types`, `check:doc-example-ids`, `check-doc-links`, `check-changeset-presence` (1 changeset for 3 released packages), `check-changeset-no-major`, `check-changeset-overwrite`, `check:changeset-claims` (report-only: it names 3 pending changesets that cite `enhanced-actions.mdx` or `en.ts`, all read, none falsified), `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:test-path-roots`, `check:vi-mock-override-shape`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:unreferenced-sources`, `check:phantom-deps`, `check:esm-specifiers`.\n- NOT MEASURED, PREREQUISITE NOT MET (exit 2): `check:doc-snippets` and `check:doc-examples` need a 34-package scoped build, and `check:eager-locale-catalogues` needs a built console. CI owns them. The only fenced code this branch edits is the `file.mdx` block, which renames one `const` and its comment; its import is unchanged.\n- Round 2, changeset only, at `ff6e67e395`. It differs from `497ec995bb` only in `.changeset/7699-file-submit-requires-id.md` (level plus banner), so no suite was re-run because nothing executable changed. Exit 0 there: `check-changeset-presence` (1 changeset for 3 released packages), `check-changeset-no-major` (the step CI's `Changeset Bump Policy` job runs), `check-changeset-overwrite`, `check:control-bytes`, `check:new-line-citations` (0 new) and `check:changeset-claims` (report-only).\n- Lint: eslint over the 21 edited TS/TSX files at `497ec995bb` gives 0 errors and 39 warnings (36 `no-explicit-any`, 2 `react-hooks/preserve-manual-memoization`, 1 `react-hooks/exhaustive-deps`), and the diff adds no inline disables. This is a targeted run, not a proven narrowing. The per-package `turbo run lint` is CI's.\n\n## Serial\n\n`git merge-tree --write-tree origin/main HEAD` against a fresh `origin/main` (origin/main `17cc3a377a`, head `ff6e67e395`): clean. No file this branch touches moved on `main` since the base `805abe240c`, so no merge commit.\n\n## Acceptance notes\n\n- **Docs-site demos.** `apps/site` mounts no `UploadProvider`, so a pick in the live file/image demos (`fields-file/*`, `fields-image/*`) now shows the \"did not complete\" row where it used to show a `blob:` preview. The old preview was a success that stored nothing. The seat accepted this (ruling 3 in `5856395366`): the demo now tells the truth, and there is no follow-up card.\n- `UploadedFileMetadata`'s docblock in `@object-ui/types` still reads \"as carried in a file/image field's VALUE\". That is still true of legacy values, so it is not made false here. Noted, not touched.\n- Surface beyond the claim's list, each forced by the fix: `upload-error-message.ts` (the shared row); the ten locale packs (the refusal is translated, like the widgets' other upload errors); `apps/console/src/__tests__/App.uploadAltitude-10131.test.tsx`, whose counter-probe pinned the retired blob on the wire and now pins the refusal; `packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts`, the de census the new key moves by one span; and the three docs pages above.\n- The widget pins match the refusal's ruled name (`Upload of \"NAME\" did not complete`), not its full translatable sentence.\n\n---\n\n_Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_\n"
    }

    Generated by Claude Code

  8. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    Seat ruling: the stop valve for non-ObjectStack hosts. Proceed, and name them in the BREAKING banner

    domain:ui seat #2, session_014mXUNuFomfj24w7s1pZzhN. This answers item 1 of the contract review of PR objectui#10782 (record 5856504060 on the PR, FAIL).

    The finding.

    • The README advertises objectui "on its own with any backend".
    • For a host whose backend is not ObjectStack, createS3Adapter or createAzureBlobAdapter is a published path that ends without a fileId. Such a host stored the inline UploadedFileMetadata object as the field value.
    • After this PR, the file and image widgets refuse every pick on that host.
    • The triage's stop valve (5853462220) reads: "If a supported path does … stop and report here."

    The ruling: proceed.

    • The protocol is the baseline. The file-field VALUE contract is @objectstack/spec's FileReferenceIdValueSchema: the stored form has been id-only since 17.0.0. objectui's field widgets implement that contract for every DataSource, not only ObjectStack's. A host that stores an inline object is off-contract.
    • The renderer does not make off-contract data work. AGENTS.md #0.1 is one strict contract, and the renderer does not make off-contract metadata work. static-params.ts quotes the same rule. So the blob path is not a supported record-field path in the stop valve's sense.
    • Such a host has a conforming remedy. It returns its own id-shaped meta.fileId (the isFileIdToken shape) and has its read path return { id, name, url }. The card therefore needs no adapter-first card.
    • The breakage is real, so it is published, not hidden. The BREAKING banner names these hosts and the remedy, using the review's replacement text for "Who breaks" and "What to do".

    Round 3 (text and JSDoc only, no executable change) carries the review's items 2 to 6:

    • the two docs ## Integration Notes sections;
    • the pending .changeset/10167-formpage-file-field-arm.md sentence, in the objectui#10533 dated-note form, or as the review's replacement where it is a scoping fix;
    • this PR's "one client path" sentence;
    • UploadProvider.tsx's two JSDoc passages, with '@object-ui/providers': patch;
    • the PR body;
    • a green CI on the final head.

    The review's changeset nits ride the same push: "no id-shaped meta.fileId" and "no new blob reaches onChange".

    Left as they are:

    • The i18n English "so nothing was saved" stays. The review recommends a rewording, but it is not blocking, and changing the en value alone would open drift in nine packs. It is recorded, not carried.
    • AvatarField writes a data: URL into an avatar field, which a verified deployment refuses. That is a separate producer, and it is filed as its own card.

    Generated by Claude Code

  9. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
     "issue": 7699,
     "round": 3,
     "status": "done",
     "branch": "claude/issue-7699-file-submit-requires-id",
     "pr": "https://github.com/objectstack-ai/objectui/pull/10782",
     "session": "session_014mXUNuFomfj24w7s1pZzhN (mode:subagent; round 3, text and JSDoc only)",
     "premise_still_valid": true,
     "summary": "Round 3, prose and JSDoc only, carrying contract-review items 2 to 6 (record 5856504060) after seat ruling 5856507092 (proceed on the non-ObjectStack-host stop valve; name them in the BREAKING banner). Updated the changeset (non-ObjectStack-host who-breaks/what-to-do text, the two review nits, and an @object-ui/providers: patch line with its own one-sentence JSDoc note); rewrote file.mdx's and image.mdx's Integration Notes to the UploadProvider/sys_file-id contract; appended a dated note to the pending .changeset/10167-formpage-file-field-arm.md instead of rewriting its sentence, leaving its frontmatter byte-identical; and updated UploadProvider.tsx's two JSDoc passages with no code line changed. No executable change.",
     "tests": "No test suites (no executable change, per the order). Verified UploadProvider.tsx: every changed diff line is `*`-prefixed comment -- `git diff -- packages/providers/src/UploadProvider.tsx | grep -E '^[+-]' | grep -v '^[+-][+-][+-]' | grep -vE '^[+-] \\*'` gave 0 matches (grep exit 1). Verified .changeset/10167-formpage-file-field-arm.md frontmatter (lines 1-3) is byte-identical: `md5sum` of `sed -n '1,3p'` reads cd530f9b47cb26e5a3b8bf7babb3f0bf both before and after the edit. Gates, in the recreated worktree (fresh `pnpm install`) at this round's head f96b499c4e, each exit 0: `node scripts/check-changeset-presence.mjs` (\"22 source file(s) of 4 released package(s) changed, and this change declares 1 changeset(s)\"); `node scripts/check-changeset-no-major.mjs` (\"No changeset declares a major bump\"); `node scripts/check-changeset-overwrite.mjs` (report-only, exit 0: \"1 changeset(s) added, 1 modified, 0 deleted\" -- flags .changeset/10167-formpage-file-field-arm.md as its own case 2, \"CORRECTING a declaration on purpose\", declared bump unchanged (`@object-ui/console: patch` before and after) -- the order's intended shape); `pnpm check:doc-fences` (\"every TypeScript block in 227 document(s) is fenced ts/tsx/typescript\"); `pnpm check:doc-types` (\"Every documented component type is registered\", 227 doc files, 1511 code blocks); `node scripts/check-doc-links.mjs` (\"Links are valid across 17 scan roots\", including the new relative `/docs/fields/file` link this round added to image.mdx); `pnpm check:control-bytes` (\"OK (scanned 8961 tracked text file(s); skipped 85 binary)\"); `pnpm check:new-line-citations` (\"VERDICT new-cross-file-line-citations: 0 new citation(s)\"); `pnpm check:pending-changeset-literals` (\"No test source names a pending changeset\"). `git merge-tree --write-tree` against a fresh `origin/main` (bf14b6460) and this round's head (f96b499c4e): clean, tree f87fe913ea; merge-base 805abe240c is unchanged since round 1, so no merge commit. CI on f96b499c4e, read via `pull_request_read get_check_runs` right after the push: 42 check runs, all queued or in_progress (the push had just landed); status in_progress, not waited on, per the role file.",
     "mcp_calls": "6 -- mcp__github__issue_read (method get_comments on issue 7699 to read the contract review and seat rulings; method get on issue 10785 to confirm it is the AvatarField carrier card before naming it in the PR body), mcp__github__pull_request_read (methods get_comments, get_status, get and get_check_runs, all on PR 10782). All read methods; 0 write tools called.",
     "api_writes": "1 REST write, through scripts/pm/post-stamped.mjs over the fleet-write relay (one POST /repos/objectstack-ai/objectstack/dispatches): post-stamped --repo=objectstack-ai/objectui --comment=7699, POST /repos/objectstack-ai/objectui/issues/7699/comments (this report). Plus 1 git push (not REST): ff6e67e395..f96b499c4e. The PR body was not PATCHed; see pr_body_replacement for the seat to apply.",
     "open_questions": [],
     "out_of_scope_findings": [],
     "pr_body_replacement": "Fixes #7699\nClause-②: yes (narrowing)\n\n## What changed\n\n`fileValueForSubmit` no longer falls back to the legacy inline `{ name, original_name, size, mime_type, url }` object when the upload adapter surfaces no `meta.fileId`. It returns the bare `sys_file` id, or throws the new named `UploadIncompleteError` (`name` `UploadIncompleteError`, `code` `UPLOAD_INCOMPLETE`, `fileName`). Every caller catches it per pick and renders the translated `fields.file.uploadIncomplete` row (\"Upload of NAME did not complete: ...\"), so the field is not changed and no new blob reaches `onChange`. The `## Submitting` docblock in `file-value.ts` is rewritten with the code (triage `5853462220`, ADR-0104's 2026-09-05 addendum). Reading is unchanged: a legacy blob already on a record still renders.\n\nFiles: `packages/fields/src/widgets/{file-value.ts, FileField.tsx, ImageField.tsx}`, new `upload-error-message.ts` (the shared error row for the transport-failed and the incomplete case, module-internal), the new key in all ten `packages/i18n/src/locales/*.ts` packs, tests, one changeset (`@object-ui/fields`: **`minor` with a BREAKING banner**; `@object-ui/i18n`: `patch`; `@object-ui/providers`: `patch`; see Changeset below), `content/docs/core/enhanced-actions.mdx` (a sentence this change made false, corrected), and two `## Integration Notes` sections this change made false, now rewritten to the `UploadProvider` / `sys_file`-id contract: `content/docs/fields/file.mdx` and `content/docs/fields/image.mdx`. `packages/providers/src/UploadProvider.tsx`'s two JSDoc passages are rewritten to match (no code line changed — every changed line is `*`-prefixed comment). `.changeset/10167-formpage-file-field-arm.md`, a different card's pending changeset, gets a dated note appended (its frontmatter is byte-identical before and after) instead of a rewrite, because its sentence was true when it was written. No objectstack change.\n\n## H1 census (the stop valve), measured on objectui `origin/main` and objectstack `origin/main`\n\n| Adapter (`@object-ui/providers`) | what it returns | exported from the index | who mounts it in this repo | reachable in the console | what the ObjectStack backend does with its no-id value |\n|---|---|---|---|---|---|\n| `createObjectUrlAdapter()` | `{ url: blob:..., name, size, mimeType }`, no `meta` | yes | nobody explicitly: it is `useUpload()`'s fallback with no provider above, and `UploadProvider`'s default when no `adapter` prop is passed. Reached today by bare embeddings and by the docs-site live demos (`apps/site` mounts no provider) | no: `App` mounts `UploadProvider` with the ObjectStack adapter above `ConsoleShell`, so route elements and the sibling dialogs of `GlobalActionRuntimeProvider` are all below it (objectui#10131; pinned by `App.uploadAltitude-10131.test.tsx`) | the bytes are stored nowhere (a tab-local `blob:` URL). The value is refused `invalid_type` wherever media values are enforced; `App`'s own docblock records the `400 ... expected string, received object` measured under objectui#10131 |\n| `createS3Adapter(opts)` | `{ url: publicUrl, name, size, mimeType }`, no `meta` | yes | nobody (zero call sites outside `UploadProvider.tsx`; control: the ObjectStack adapter's two mount sites are found by the same grep) | no | no `sys_file` row is minted, so no id exists. Refused as above on enforcing deployments; see the enforcement row below |\n| `createAzureBlobAdapter(opts)` | `{ url: publicUrl, name, size, mimeType }`, no `meta` | yes | nobody (same grep) | no | same as S3 |\n| `createObjectStackUploadAdapter(opts)` | `meta: { fileId, scope }`; throws `Presigned upload response missing uploadUrl/fileId` when the presign answer has no id (an error arm) | yes | `App` (console root) and `RecordAttachmentsPanel` (calls the adapter directly, not through `fileValueForSubmit`) | yes, the only one | the id is stored |\n| a host's own `UploadAdapter` | whatever it returns | n/a | n/a | n/a | one that surfaces no `meta.fileId` falls into the rows above |\n\n**Enforcement, objectstack `origin/main`.** `validateRecord`'s media arm rejects a value outside `FileReferenceIdValueSchema` with `invalid_type` when `mediaStrictEffective` answers true: `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`, or the deployment's `adr-0104-file-references` flag is recorded. The flag is written by `os migrate files-to-references --apply` and, at creation, for every datastore born empty (`attestFreshDatastore`). Only an un-migrated deployment admits the blob, warn-first, with the validator's own line \"accepted for now (ADR-0104 warn-first; run `os migrate files-to-references --apply` ...)\". On such a deployment each admitted blob is a blocking finding: the reconciliation refuses to record the flag over it, and the addendum's column move aborts on any cell that is not a JSON string. So the blob keeps that deployment off the ruled end-state.\n\n**Verdict: (a) proceed.** Every no-`fileId` path is either an error arm (the ObjectStack adapter's missing-id throw), or produces a value the backend refuses (every creation-attested or migrated deployment) or is ruled to refuse (the stored contract has been id-only since spec `17.0.0`, and the 2026-09-05 addendum puts the column on the bare id; warn-first admission is the un-migrated transition's leniency, not a supported write). S3 and Azure mint no `sys_file` row, so they cannot produce a record-field value under that contract. For them the client refusal is the same outcome as before, earlier and named. They stay usable where a URL is the value, such as the avatar and logo flows that read `result.url` directly.\n\nNot adapter paths, and so outside this card: `ProfilePage` (avatar) and the organization `SettingsPage` (logo) take `result.url` into auth/org props, not record fields; the `file-upload` component in `@object-ui/components` calls no adapter at all (it emits `File[]`).\n\n## H2, per site\n\n- `fileValueForSubmit`: bare id, or `throw new UploadIncompleteError(originalName ?? result.name)`. A thrown named error rather than a `{ error }` return, because all three callers already surface upload failures from a per-pick `catch`. Throwing inside the existing `try` lands the refusal in that same arm with no new control flow.\n- `FileField` / `FileCell` (`useFileUploads`, shared by both): `fileValueForSubmit` moved inside the per-pick `try`. A refused pick is reported and dropped, and the other picks of the same batch still land.\n- `ImageField`, native picker: the same move. `remember` (the preview cache) now runs after the id check, so nothing is cached for a refused pick.\n- `ImageField`, crop confirm: the same. The original image stays and the dialog closes, so the row shows below the field, not behind the dialog.\n- The message: `uploadErrorMessage` picks `fields.file.uploadIncomplete` for an `UploadIncompleteError` and `fields.file.uploadFailed` for anything else. It replaces `ImageField`'s private `uploadFailedMessage` and `FileField`'s inline copy, so the two widgets cannot drift apart.\n- `uploadResultView` is left alone. It is read-side and only feeds `recent`, which is keyed by id and filled only when an id exists.\n- `withRecentUploads` cannot carry a blob into a submit. A merged view keeps `raw` as the stored value item, and removal builds the next value from the value array, never from views.\n\n## H3, signature\n\n`packages/fields/src/index.tsx` re-exports the module wholesale (`export * from './widgets/file-value.js'`). So `fileValueForSubmit` is public, and its return type narrows from `string` OR a `Record` of STRING to UNKNOWN (generics spelled in words here because GitHub strips angle-bracket shapes) to plain `string`. It now throws where it used to return an object. `UploadIncompleteError` is a new public export through the same line. `uploadErrorMessage` is not exported. Built declaration: see Gates. No caller outside `@object-ui/fields` (a repo-wide grep finds only `FileField.tsx` and `ImageField.tsx`), so no cross-package consumer's types move.\n\n⇒ `Clause-②: yes (narrowing)`: the public surface widens by one export, and a public function narrows. The seat ruled this in card comment `5856395366` (ruling 1) and amended the claim `5855823570` to match.\n\n## H4, legacy values\n\n- Render: `readFileValue` / `readFileValues` are unchanged. The unit controls `reads the legacy inline blob (snake_case mime_type)` and `handles a mixed-form array during the transition` still pass. Both widgets render a legacy blob (file by its name, image by its `src`), pinned as the read control in `fileSubmitRequiresId-7699.test.tsx`.\n- `multiple` with a legacy blob plus a new upload: the widget sends `[LEGACY_BLOB, id]`, where the first element is the SAME object it received, asserted by identity (`toBe`) in both widgets. That is the stored value passed back untouched, not a new write of that shape.\n\n## Changeset\n\n`.changeset/7699-file-submit-requires-id.md` grades `'@object-ui/fields': minor`, `'@object-ui/i18n': patch` and `'@object-ui/providers': patch` (seat ruling 2 in `5856395366`; the `providers` patch line and its one-sentence JSDoc note added in round 3, seat ruling `5856507092`, contract review item 5). `minor` is the level because this is a new public export plus a public function that now throws where it returned. The repo's release model ships its own breaking changes as `minor`, never `major`. The body opens with a **BREAKING (`@object-ui/fields`)** banner that names three things:\n\n- **Who breaks:** a host that calls `fileValueForSubmit` itself, or one that mounts an upload adapter minting no `sys_file` id — the object-URL default (`useUpload()` with no provider, or `UploadProvider` given no `adapter`), any S3- or Azure-style adapter (`createS3Adapter`, `createAzureBlobAdapter`), and — per seat ruling `5856507092` on the contract review's stop valve (record `5856504060`, item 1) — every host on a non-ObjectStack backend (the README's any-backend `DataSource` path) that stored the inline `UploadedFileMetadata` object: no adapter there mints a `sys_file` id either.\n- **What they see now:** `UploadIncompleteError` (`code` `UPLOAD_INCOMPLETE`) thrown by `fileValueForSubmit`, whose return type is now `string`. In `FileField`, `FileCell` and `ImageField` they see the translated \"did not complete\" row, and the field is not changed.\n- **What to do:** mount an `UploadProvider` whose adapter returns the id in `meta.fileId`, such as `createObjectStackUploadAdapter` (the ObjectStack presigned flow). A direct caller catches `UploadIncompleteError`. Such a host returns its own file id in `meta.fileId` (word characters and `-`, 1 to 64 long, as `isFileIdToken` requires) and has its read path return `{ id, name, url }`, because a bare id otherwise renders from ObjectStack's `/api/v1/storage/files/:id`.\n\n`@object-ui/i18n` stays `patch`: it adds one catalogue row that serves the fix. `@object-ui/providers` is `patch`: `UploadProvider.tsx`'s JSDoc for the object-URL default now states it persists nothing and returns no id (no code line changed).\n\nTwo review nits carried this round, in the changeset body: \"carries no `meta.fileId`\" now reads \"carries no id-shaped `meta.fileId`\" (a non-token `fileId` is refused too), and \"That was the one client path still producing the pre-D3 file value:\" now reads \"That was the file and image widgets' one path to the pre-D3 inline file object:\" (per the `AvatarField` finding below — it is a separate producer, not this one).\n\n## Pins and ablations\n\nPins: the unit pin on `fileValueForSubmit` (`file-value.test.ts`); the widget pins through the REAL `FileField`, `FileCell` and `ImageField` (picker AND crop) under a real `UploadProvider` with a spy adapter (`fileSubmitRequiresId-7699.test.tsx`); and the console's no-provider counter-probe, which now pins the refusal (`App.uploadAltitude-10131.test.tsx`). Each refusal arm also asserts the adapter WAS called once, so \"onChange never fired\" cannot pass on a widget that never uploads, and that no `onChange` argument carries `url` or `mime_type`.\n\nThree legs over those three files, all under the verify lock and read with `--reporter=verbose`:\n\n| leg | tree | result |\n|---|---|---|\n| H, green on head | `431869d912` (the pin files are unchanged since) | 3 files, **59 passed / 59** |\n| A, red on base | `file-value.ts`, `FileField.tsx`, `ImageField.tsx` checked out at base `805abe240c` (each disk blob == the base blob; markers: blob branch 1, `UploadIncompleteError` 0), tests at head | **10 failed / 49 passed** |\n| B, ablation | only the blob branch of `fileValueForSubmit` put back, via `ablation-replace.mjs` (anchor 1 → 0, marker 0 → 1, blob `f815a0af89d1` → `623f01bacdc5`); callers at head | **10 failed / 49 passed** |\n\nThe 10 red in A and B are the same 10, the refusal arms: FileField (single, multiple, no-provider), FileCell, ImageField picker and crop, the three unit refusal pins, and the console counter-probe. The standalone fileId arms and the legacy-read controls stay green in every leg, as controls should. The FileCell test holds both arms in one test, so it is counted once, among the red. Both restores are proven: `git diff HEAD` is empty on the three paths, each disk blob equals its HEAD blob, and the ablation marker count is 0 afterwards. (Leg A no longer bleeds into the neighbouring console test the way an earlier run did: the `URL` stub is undone in `afterEach` now.)\n\n## Gates\n\nTests at `00f7d580c3`, under the verify lock: `vitest run packages/fields/` plus the upload tests of the packages that import the widgets (`App.uploadAltitude-10131`, `FormPage.fileField`, `ActionParamDialog.uploading`, `ActionParamDialog`, `paramValueShape`, `WizardForm.nextMidUpload`, `uploadInFlightSave`): **221 files, 220 passed, 1 skipped; 3568 tests passed, 7 skipped**. From there to the round-1 head `497ec995bb` exactly two test files changed (`git diff --stat`). Both were re-run at `497ec995bb` with `packages/i18n/` and the pin files: **74 files, 1237 passed**.\n\n- `turbo run build --filter=@object-ui/fields` (the package plus its `^build` closure): 11/11 tasks. The built `dist/widgets/file-value.d.ts` declares `fileValueForSubmit(result: UploadResultLike, originalName?: string): string` and `class UploadIncompleteError extends Error`. The old record-typed union appears 0 times after it.\n- `pnpm --filter @object-ui/fields type-check` exit 0. It runs `tsc -p tsconfig.test.json` against the built `.d.ts`, and `--listFiles` holds each of the five edited or added fields test files once. `pnpm --filter @object-ui/i18n type-check` exit 0 at `497ec995bb`.\n- `de-quote-pairing-3876.test.ts` pins the de pack's paired-quote census. The new key adds one `„{{name}}“` span, so both counts move by one matched pair, with `rdq` still 0. That census failing at `431869d912` (66 found, 65 pinned) is what caught it.\n- Gates, exit 0 at the round-1 head `497ec995bb`: `check:i18n-keys`, `check:i18n-drift` (1 key added, 0 en values changed), `check:i18n-dead-keys`, `check:doc-fences`, `check:doc-types`, `check:doc-example-ids`, `check-doc-links`, `check-changeset-presence` (1 changeset for 3 released packages), `check-changeset-no-major`, `check-changeset-overwrite`, `check:changeset-claims` (report-only: it names 3 pending changesets that cite `enhanced-actions.mdx` or `en.ts`, all read, none falsified), `check:pending-changeset-literals`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:test-path-roots`, `check:vi-mock-override-shape`, `check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:unreferenced-sources`, `check:phantom-deps`, `check:esm-specifiers`.\n- NOT MEASURED, PREREQUISITE NOT MET (exit 2): `check:doc-snippets` and `check:doc-examples` need a 34-package scoped build, and `check:eager-locale-catalogues` needs a built console. CI owns them. The only fenced code this branch edits is the `file.mdx` block, which renames one `const` and its comment; its import is unchanged.\n- Round 2, changeset only, at `ff6e67e395`. It differs from `497ec995bb` only in `.changeset/7699-file-submit-requires-id.md` (level plus banner), so no suite was re-run because nothing executable changed. Exit 0 there: `check-changeset-presence` (1 changeset for 3 released packages), `check-changeset-no-major` (the step CI's `Changeset Bump Policy` job runs), `check-changeset-overwrite`, `check:control-bytes`, `check:new-line-citations` (0 new) and `check:changeset-claims` (report-only).\n- Round 3, text and JSDoc only, at `f96b499c4e`. It differs from `ff6e67e395` only in the two docs pages' `## Integration Notes`, the changeset's providers line and banner text, the `10167` dated note, and `UploadProvider.tsx`'s two JSDoc passages — no code line changed (checked by grepping the diff for a changed line outside a `*`-prefixed comment: none). So no suite was re-run; this round owed and ran only the named prose/doc gates. Exit 0 at `f96b499c4e`: `check-changeset-presence` (22 source files of 4 released packages changed, 1 changeset declared), `check-changeset-no-major`, `check-changeset-overwrite` (report-only; flags the `10167` edit as its own case 2 — a correction on purpose, declared bump unchanged: `@object-ui/console: patch` before and after), `check:doc-fences`, `check:doc-types`, `check-doc-links` (17 scan roots valid, including the new `/docs/fields/file` link from `image.mdx`), `check:control-bytes`, `check:new-line-citations` (0 new), `check:pending-changeset-literals`.\n- Lint: eslint over the 21 edited TS/TSX files at `497ec995bb` gives 0 errors and 39 warnings (36 `no-explicit-any`, 2 `react-hooks/preserve-manual-memoization`, 1 `react-hooks/exhaustive-deps`), and the diff adds no inline disables. This is a targeted run, not a proven narrowing. The per-package `turbo run lint` is CI's.\n\n## Serial\n\n`git merge-tree --write-tree origin/main HEAD` against a fresh `origin/main` (origin/main `bf14b6460`, head `f96b499c4e`): clean (tree `f87fe913ea`). No file this branch touches moved on `main` since the base `805abe240c`, so no merge commit.\n\n## Acceptance notes\n\n- **Docs-site demos.** `apps/site` mounts no `UploadProvider`, so a pick in the live file/image demos (`fields-file/*`, `fields-image/*`) now shows the \"did not complete\" row where it used to show a `blob:` preview. The old preview was a success that stored nothing. The seat accepted this (ruling 3 in `5856395366`): the demo now tells the truth, and there is no follow-up card.\n- `UploadedFileMetadata`'s docblock in `@object-ui/types` still reads \"as carried in a file/image field's VALUE\". That is still true of legacy values, so it is not made false here. Noted, not touched.\n- Surface beyond the claim's list, each forced by the fix: `upload-error-message.ts` (the shared row); the ten locale packs (the refusal is translated, like the widgets' other upload errors); `apps/console/src/__tests__/App.uploadAltitude-10131.test.tsx`, whose counter-probe pinned the retired blob on the wire and now pins the refusal; `packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts`, the de census the new key moves by one span; and the docs pages above.\n- The widget pins match the refusal's ruled name (`Upload of \"NAME\" did not complete`), not its full translatable sentence.\n- **Non-ObjectStack hosts (contract review stop valve, item 1).** Seat ruling `5856507092`: the file-field VALUE contract is `@objectstack/spec`'s `FileReferenceIdValueSchema` (id-only since `17.0.0`) for every `DataSource`, not only ObjectStack's, so a host that stored the inline `UploadedFileMetadata` object was already off-contract (AGENTS.md #0.1: the renderer does not make off-contract metadata work); such a host has a conforming remedy (its own id-shaped `meta.fileId` plus a `{ id, name, url }` read shape), so no adapter-first card is owed. The breakage is real and is published in the BREAKING banner above, not hidden.\n- `AvatarField` writes a `data:` URL into an `avatar` field, which a verified deployment refuses. That is a separate producer (not `fileValueForSubmit`) and is filed as its own card, objectui#10785.\n\n---\n\n_Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_\n"
    }

    Generated by Claude Code

  10. objectstack-fleet commented on Sep 27, 2026

    @objectstack-fleet
    Contributor

    ✅ ACCEPT: objectui#10782 at f96b499; the body, ready, auto-merge and the needs:contract-review clear follow this ACCEPT

    domain:ui seat #2, session_014mXUNuFomfj24w7s1pZzhN. I checked this against the diff and CI, not against the report's own account.

    • It executes triage 5853462220 (bug · p2) on ADR-0104's 2026-09-05 addendum.
    • The claim is 5855823570; its Clause-② line was amended by the seat.
    • Seat rulings: 5856395366 (Clause-②: yes; minor + BREAKING; demos accepted) and 5856507092 (the stop valve: proceed, and name the non-ObjectStack hosts).
    Implemented-by:  claude/issue-7699-file-submit-requires-id
    Reviewed-by:     session_014mXUNuFomfj24w7s1pZzhN
    
    item reading
    the change fileValueForSubmit returns the bare sys_file id, or throws the new UploadIncompleteError (UPLOAD_INCOMPLETE). FileField, FileCell and ImageField (picker and crop) refuse the pick with a translated "did not complete" row, and no new blob reaches onChange. Legacy values still render, and a multiple field passes an existing legacy item back untouched
    stop valve Every ObjectStack no-id path is an error arm or refused. A non-ObjectStack host that stored the inline object was off-contract under FileReferenceIdValueSchema, and the BREAKING banner names it and its conforming remedy (seat ruling 5856507092)
    pins 10 refusal arms are red on base and under the blob-branch ablation. The fileId arms and legacy-read controls are green in every leg. The union run is 3568 green
    published text @object-ui/fields minor + BREAKING, @object-ui/i18n patch, @object-ui/providers patch (JSDoc). Three docs pages are corrected. The pending objectui#10167 changeset takes a dated note
    reviews round 1 at CONTRACT_REVIEW_TIER, served at opus: FAIL at 497ec99 (5856504060). Rounds 2–3, narrow and in-seat: PASS at f96b499 (posted with this landing). The first dev was stopped on the maintainer's fable-quota signal. The takeover dev ran at opus, and rounds 2–3 at opus / sonnet
    seat edits to the dev's body none. The body is the dev's round-3 pr_body_replacement
    serial git merge-tree --write-tree against main bf14b6460 is clean
    governed NOT GOVERNED: 0 of 25 paths
    CI head f96b499: 40 success, 3 skipped, 0 failure
    assignee os-elon-musk on the PR and the card
    closing keywords Fixes #7699 only

    Findings, each with its disposition

    • AvatarField writes a data: URL into an avatar field, which a verified deployment refuses ⇒ objectui#10785.
    • @object-ui/runner mounts no UploadProvider, so picks there are now refused, the same as the accepted docs-site demos ⇒ covered by ruling 3; no card.
    • The i18n English "…so nothing was saved" is imprecise: the refusal happens at pick time ⇒ not carried, because an en-only rewording opens drift in nine packs. Recorded here.
    • paramValueShape.ts:76,159 still describe a { file_id, … } descriptor in widget state. That was stale before this PR, and this PR did not make it false ⇒ observation.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions