Repository navigation
feat(spec,automation,app-shell)!: a flow screen field typed file collects an upload, and the run resumes with the uploaded file's id - #26134
Conversation
…aded file's id A flat screen field declaring `type: 'file'` is a value domain on resume, beside `number`: a present value must be a `sys_file` id (the spec's `isFileIdToken`, the predicate the record write's claim path reads), or the resume is refused with `invalid_type` under `INVALID_SCREEN_INPUT`. The spec describes the member on `ScreenFieldConfigSchema.type` and `ScreenFieldSpec`. Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
…en field type Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
…et and resumes with its id
A `type: 'file'` flat-screen field now renders the object form's FileField
(through getLazyFieldWidget('file')), which uploads through the ambient
UploadProvider and hands back the minted sys_file id. The runner renders the
screen body inside an uploading scope and holds Submit, labelled "Uploading…",
until every upload settles, then posts screenResumeValues, which reduces a
file field's value to its bare id with fileIdOf.
Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht
Co-authored-by: Claude <noreply@anthropic.com>
…ialog Drives the real FileField through a held UploadProvider adapter: Submit is disabled and reads "Uploading…" while the upload is in flight, and the resume posts the minted id; a required empty file field is refused locally; a prefilled expanded value goes out as its bare id. screenResumeValues is pinned on its own. Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
…laims the file A real boot with the local storage adapter, driven as a plain member: the paused screen carries `type: 'file'`, an upload lands with no owner, a resume carrying the download URL or the expanded read form is refused 400 INVALID_SCREEN_INPUT with nothing written and the pause kept, and a resume with the id writes the record and claims the file for it. The spec docblock now says the claim's read check is asked under the default `runAs: 'user'`, and that a system run's write is not checked. The changeset covers spec, service-automation and app-shell. Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a5bdd4e245183be5559ab70e9ba25b8b251430ba && git checkout a5bdd4e245183be5559ab70e9ba25b8b251430ba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a335f1defbea57ffe552d2e9efc5ad8d56a2e76a 9c17572c2da999a01edf567e4a95bab87b771d1d && git checkout -B drift-repro a335f1defbea57ffe552d2e9efc5ad8d56a2e76a && git merge --no-ff 9c17572c2da999a01edf567e4a95bab87b771d1d
node scripts/docs-audit/affected-docs.mjs --json a335f1defbea57ffe552d2e9efc5ad8d56a2e76a
|
A short "A file field" section under the flat screen: the console uploads and the run resumes with the file's id, the write after the screen claims the file (read-checked under runAs 'user'), anything but an id is refused on resume with invalid_type, and a field takes one file. Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
…ptance narrowing The seat amended the claim to `Clause-②: yes (narrowing)` and `Metadata-protocol: semantic`, as measured: a screen field already declared `type: 'file'` now renders the upload widget and its resume refuses any value that is not a file id. The changeset carries both lines, a BREAKING note with the FROM/TO fix for a caller that resumes the run itself, and the ADR-0087 disposition `not-required (no-migration-prescription)`: no metadata key, stored row or export moves, so there is nothing for `objectstack migrate meta` to rewrite. Claude-Session: https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht Co-authored-by: Claude <noreply@anthropic.com>
file collects an upload, and the run resumes with the uploaded file's idfile collects an upload, and the run resumes with the uploaded file's id
Contract reviewServed-tier: Inputs: card #25801, its body and all eight comments (claim 6415695572, amendment 6417676103, os-dev-reports 6417535261 and 6418524489; 6417697490 is a declared duplicate); PR #26134's body and file list as live at this read, its net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered 2026-10-26T14:36Z from the card, the PR and the head's check-runs; nothing built, run or re-run. |
Seat ACCEPT — PR #26134 at
|
Requested by Steedos · project thread
Part of #25801
Clause-②: yes (narrowing)
Metadata-protocol: semantic
Summary
This PR does the card's first half: a flat
screencan ask for a file, so a wizard collects the file before the record that will own it exists and writes everything after its last screen. The card's second half (closing a paused run's dialog cancels the run, or offers to) is left open as a product decision. It is not built here, and the section at the end says why.A screen field declared
type: 'file'now works like this:FileFieldfor the field, reached throughgetLazyFieldWidget('file')asActionParamDialog's file params reach it. It uploads through the console'sUploadProvider, which is the file service. The upload lands as asys_filethat no record owns yet, and the widget's value is that file's id. The screen body renders inside an uploading scope (useUploadingScope), so while an upload is in flight Submit is disabled and reads "Uploading…". The resume postsscreenResumeValues(...), which reduces each file field's value to its bare id withfileIdOf(a prefilleddefaultValuemay arrive in the expanded{ id, name, url, … }form).validateScreenInputsreads'file'as the second closed value domain beside'number'. A value that is not a file id is refused with400 INVALID_SCREEN_INPUTand the field codeinvalid_type(Screen field "contract_file" must be the id of an uploaded file). The id is judged by the spec'sisFileIdToken, the same predicate the claim path reads, so whatever the door admits is something a later write can claim. A URL, the expanded read form, a list or a number is refused and never reduced. The refusal writes nothing and keeps the pause, so a corrected resume still lands. An empty optional field and a hidden field are not judged.ScreenFieldConfigSchema.typeand theScreenFieldSpeccontract document the new member, the reference docs are regenerated, and the flows guide gains an "A file field" section. The key stays an open string, so nothing an author already wrote is refused at publish.create_recordafter the screen writes the id into the record'sfilefield, and that write claims the file for the record (ADR-0104 D3: one file, one owner, met by unattached-then-claimed). Under the defaultrunAs: 'user'the claim first makes the download door's read check as the run's user (files: an authorization gap in the file-reference lifecycle, within one organization (detail withheld) #25692). This PR does not touchservice-storage: the claim is the lifecycle fix(storage)!: a record write claims or copies only a file its caller may read #25951 landed, unchanged.Tests
Measured on this branch.
service-automation:screen-input-file-field.test.ts(new) covers minted ids accepted, six wrong shapes refused with the exact message, agreement withisFileIdTokenon every shape, absence left torequired, a hidden field not judged (with a positive control), andimage/avatar/video/audioleft open.builtin/screen-file-field-resume.test.ts(new) runs the engine: the pause carriestype: 'file', a URL resume is refused with nothing inserted and the pause kept for the corrected resume, and an id resume reachescreate_recordunchanged.app-shell:FlowRunner.fileField-25801.test.tsx(new, 7 tests) drives the realFileFieldunder a heldUploadProvideradapter. It covers the widget rendering instead of a text input; Submit disabled and reading "Uploading…" mid-upload, a click on it resuming nothing, and the resume after the upload settles posting{ contract_file: MINTED_ID }; a required empty file field refused locally; a prefilled expanded value posted as its id; andscreenResumeValueson its own. With its neighboursFlowRunner.bounds-9248andActionParamDialog.uploading: Test Files 3 passed (3), Tests 16 passed (16).flow-screen-file-intake.dogfood.test.ts(new) boots a real stack with the local storage adapter and drives it as a plain member. The paused screen carriestype: 'file'; an upload lands as asys_filewith no owner; a resume carrying the download URL or the expanded form is refused400 INVALID_SCREEN_INPUTwith no record written and no claim; the corrected resume lands on the same run; and a resume with the id writes the record with the id in itsfilefield and claims the file (ref_object,ref_id,ref_field), read back through the data door.pnpm --filter @objectstack/dogfood exec vitest run test/flow-screen-file-intake.dogfood.test.ts: Test Files 1 passed (1), Tests 4 passed (4). The full battery at1e9a122b: Test Files 297 passed, 3 skipped (300); Tests 2527 passed, 18 skipped (2545). The commits after it change only the changeset and the flows guide.pnpm --filter @object-ui/app-shell run type-checkandpnpm --filter @objectstack/service-automation run typecheck, exit 0 each.pnpm --filter @objectstack/dogfood run typecheck, exit 0.Ablation
Each mutation went through
scripts/ablation-replace.mjswith its restore-to-HEAD check:service-automation: deleting the'file'row of the value-domain map turned 4 tests red. The file was restored to its HEAD blob753e36a8.FlowRunner.tsx:disabled={submitting || uploads.anyUploading}todisabled={submitting}turned 1 test red, the held-upload test, ontoBeDisabled().FlowRunner.tsx:resumeWith(screenResumeValues(screen, values))toresumeWith(values)turned 1 test red, the prefilled expanded value test.ScreenView.tsx:if (kind === 'file') {toif (false) {turned 4 tests red, every dialog test that waits for the widget's file input.Each file was restored with
git diff HEADempty. A first attempt at thedisabledmutation split its anchor on the||and wrote a file that did not parse ("no tests"). That reading was void and was re-run with the full anchor; the line above is the re-run.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat9c17572c(117 commands). Each ran with its exit code captured before any pipe, and--ranreconciled them: "117 derived famil(ies) accounted for — 116 run, 1 NOT-MEASURED". The 116 that ran exited 0. They includecheck-adr-0087-registration,check-changeset-no-major,check:api-surface,check:authorable-surface,check:generated,check:docs,check:liveness,check:nul-bytesandcheck:type-check-debt, which ran under the verify lock and reported "26 raw tsc error(s) total, none above its recorded number".Not measured locally, left to CI:
check:dual-build-cjs-loads, which needs a full workspacepnpm build. It stopped on that prerequisite with exit 3 before reading anything. This diff does not change any package's build shape.Acceptance notes
POST /automation/:name/runs/:runId/cancel) is an operator verb (service-automation: the two operator run-lifecycle verbs (cancelRun, restoreConsumedSuspension) have no door — no REST route, no CLI command, and not on IAutomationService #13953: "a run belongs to the environment rather than to a user"), so a run's starter who is not an operator gets 403PERMISSION_DENIEDthere. A cancel also does not undo writes made before the screen. Closing the dialog therefore cannot cancel the run without a new starter-scoped verb or a wider cancel gate, and "cancel silently" against "offer to cancel" is a real fork. It is reported to the seat with options; this PR keeps today's behaviour (closing leaves the run paused). With the first half in, a wizard can write after its last screen, so a closed dialog leaves no half-written record.type: 'file'(a URL, the expanded form, any non-id string), and thattypevalue changes meaning from a text input to an upload. No producer of atype: 'file'screen field was found in the repo's examples or packages. The seat amended the claim's declaration lines to match (Flow screens: a flatscreenhas nofilefield type, so a multi-step wizard must create the record before its last step to collect a file — and closing the screen dialog leaves the run paused and the half-written record behind #25801, comment 6417676103). The changeset carries the FROM/TO fix for a caller that resumes the run itself, and the ADR-0087 dispositionnot-required (no-migration-prescription): no spec key, stored row or export moves, so there is nothing forobjectstack migrate metato rewrite.runAs: 'system'screen flow writes the user-supplied id without a read check, because no system write is checked (the posture fix(storage)!: a record write claims or copies only a file its caller may read #25951 decided). This is not new: a system flow could already write atype: 'text'screen value into afilefield. The spec docblock and the changeset say so.typebefore choosing a widget, while the engine matches the value domain exactly. A field declaredtype: 'File'gets the upload widget but no server check. This asymmetry already exists for'number'. Noted, not filed.multiple,acceptormaxSizekey, so a list is refused at the resume and the widget applies no type or size limit. The target object's ownfilefield constraints still apply at the write after the screen, which fails the run there if the file breaks them. Adding those keys is a widening for its own card. Noted, not filed.image,avatar,videoandaudioscreen hints still render a text input and stay open widget hints. The value-domain set is closed and grows by ruling.ui/packages/app-shell/src/utils/flowResponse.ts(PR feat(spec,console): atype: 'flow'action may declareonSuccess, and the console navigates after a run that completes #26011) orservice-storage(sibling Seed datasets cannot carry a file: aField.filecolumn needs asys_fileid only an upload can mint, so a declarative seed either drops the row, breaks the ADR-0104 attestation, or inserts a dangling token silently #25779).Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht