Skip to content

Commit 6d9d3ef

Browse files
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)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7z92P2z3yYaoSGUvsM82Eb)_ Part of #25801 Clause-②: yes (narrowing) Metadata-protocol: semantic ## Summary This PR does the card's first half: a flat `screen` can 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: ```ts { id: 'ask', type: 'screen', config: { fields: [ { name: 'contract_name', label: 'Name', type: 'text', required: true }, { name: 'contract_file', label: 'Signed copy', type: 'file', required: true }, ] } }, { id: 'write', type: 'create_record', config: { objectName: 'contract', fields: { name: { dialect: 'cel', source: 'contract_name' }, doc: { dialect: 'cel', source: 'contract_file' } } } }, ``` - **Console.** The flow screen dialog renders the object form's own `FileField` for the field, reached through `getLazyFieldWidget('file')` as `ActionParamDialog`'s file params reach it. It uploads through the console's `UploadProvider`, which is the file service. The upload lands as a `sys_file` that 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 posts `screenResumeValues(...)`, which reduces each file field's value to its bare id with `fileIdOf` (a prefilled `defaultValue` may arrive in the expanded `{ id, name, url, … }` form). - **Engine.** `validateScreenInputs` reads `'file'` as the second closed value domain beside `'number'`. A value that is not a file id is refused with `400 INVALID_SCREEN_INPUT` and the field code `invalid_type` (`Screen field "contract_file" must be the id of an uploaded file`). The id is judged by the spec's `isFileIdToken`, 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. - **Spec and docs.** `ScreenFieldConfigSchema.type` and the `ScreenFieldSpec` contract 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. - **Ownership.** The `create_record` after the screen writes the id into the record's `file` field, and that write claims the file for the record (ADR-0104 D3: one file, one owner, met by unattached-then-claimed). Under the default `runAs: 'user'` the claim first makes the download door's read check as the run's user (#25692). This PR does not touch `service-storage`: the claim is the lifecycle #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 with `isFileIdToken` on every shape, absence left to `required`, a hidden field not judged (with a positive control), and `image`/`avatar`/`video`/`audio` left open. `builtin/screen-file-field-resume.test.ts` (new) runs the engine: the pause carries `type: 'file'`, a URL resume is refused with nothing inserted and the pause kept for the corrected resume, and an id resume reaches `create_record` unchanged. - `app-shell`: `FlowRunner.fileField-25801.test.tsx` (new, 7 tests) drives the real `FileField` under a held `UploadProvider` adapter. 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; and `screenResumeValues` on its own. With its neighbours `FlowRunner.bounds-9248` and `ActionParamDialog.uploading`: Test Files 3 passed (3), Tests 16 passed (16). - Dogfood: `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 carries `type: 'file'`; an upload lands as a `sys_file` with no owner; a resume carrying the download URL or the expanded form is refused `400 INVALID_SCREEN_INPUT` with 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 its `file` field 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 at `1e9a122b`: 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. - Type-check: `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --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.mjs` with 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 blob `753e36a8`. - `FlowRunner.tsx`: `disabled={submitting || uploads.anyUploading}` to `disabled={submitting}` turned 1 test red, the held-upload test, on `toBeDisabled()`. - `FlowRunner.tsx`: `resumeWith(screenResumeValues(screen, values))` to `resumeWith(values)` turned 1 test red, the prefilled expanded value test. - `ScreenView.tsx`: `if (kind === 'file') {` to `if (false) {` turned 4 tests red, every dialog test that waits for the widget's file input. Each file was restored with `git diff HEAD` empty. A first attempt at the `disabled` mutation 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/objectstack` at `9c17572c` (117 commands). Each ran with its exit code captured before any pipe, and `--ran` reconciled them: "117 derived famil(ies) accounted for — 116 run, 1 NOT-MEASURED". The 116 that ran exited 0. They include `check-adr-0087-registration`, `check-changeset-no-major`, `check:api-surface`, `check:authorable-surface`, `check:generated`, `check:docs`, `check:liveness`, `check:nul-bytes` and `check: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 workspace `pnpm build`. It stopped on that prerequisite with exit 3 before reading anything. This diff does not change any package's build shape. ## Acceptance notes - **The second half of the card is a product decision, not built here.** The cancel door (`POST /automation/:name/runs/:runId/cancel`) is an operator verb (#13953: "a run belongs to the environment rather than to a user"), so a run's starter who is not an operator gets 403 `PERMISSION_DENIED` there. 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. - **BREAKING, a resume-acceptance narrowing.** The resume now refuses values it used to accept for a field already declared `type: 'file'` (a URL, the expanded form, any non-id string), and that `type` value changes meaning from a text input to an upload. No producer of a `type: 'file'` screen field was found in the repo's examples or packages. The seat amended the claim's declaration lines to match (#25801, comment 6417676103). The changeset carries the FROM/TO fix for a caller that resumes the run itself, and the ADR-0087 disposition `not-required (no-migration-prescription)`: no spec key, stored row or export moves, so there is nothing for `objectstack migrate meta` to rewrite. - A `runAs: 'system'` screen flow writes the user-supplied id without a read check, because no system write is checked (the posture #25951 decided). This is not new: a system flow could already write a `type: 'text'` screen value into a `file` field. The spec docblock and the changeset say so. - The console lowercases a screen field's `type` before choosing a widget, while the engine matches the value domain exactly. A field declared `type: 'File'` gets the upload widget but no server check. This asymmetry already exists for `'number'`. Noted, not filed. - One file per field. A screen field has no `multiple`, `accept` or `maxSize` key, so a list is refused at the resume and the widget applies no type or size limit. The target object's own `file` field 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`, `video` and `audio` screen hints still render a text input and stay open widget hints. The value-domain set is closed and grows by ruling. - This PR does not touch `ui/packages/app-shell/src/utils/flowResponse.ts` (PR #26011) or `service-storage` (sibling #25779). --- _Generated by [Claude Code](https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0121LS2jqNju6opaDpdcn7ht --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfaad4d commit 6d9d3ef

14 files changed

Lines changed: 959 additions & 48 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@object-ui/app-shell': minor
5+
---
6+
7+
feat(spec,automation,app-shell): a flow screen field typed `file` collects an upload, and the run resumes with the uploaded file's id
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: semantic
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped. `ScreenFieldConfigSchema.type` stays an open string, so every flow that parsed before still parses, and nothing exported is removed or narrowed. What changes is what a screen field already declared `type: 'file'` means: the console renders the upload widget for it, and the resume door refuses a value that is not a file id. The remedy is a change in what a caller posts to `resume` (the uploaded file's id), never a rewrite of a package source or a stored row, so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: the three packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no runtime interface, type or export is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
13+
14+
A wizard can now ask for a file before the record that will own it exists, and write everything after its last screen.
15+
16+
- **`@objectstack/spec`**: `ScreenFieldConfigSchema.type` (and the `ScreenFieldSpec` contract) documents `'file'` as the second value domain beside `'number'`. A `type: 'file'` field renders the platform upload widget. The upload goes through the file service and lands as a `sys_file` that no record owns yet. The resume carries that file's id, which is ADR-0104 D3's stored form, and nothing else. A later `create_record` or `update_record` node writes the id into an object's `file` field, and that write claims the file for the record. Under the default `runAs: 'user'`, the claim first makes the download door's read check as the run's user; a `runAs: 'system'` run's write is not checked, as no system write is.
17+
- **`@objectstack/service-automation`**: the resume re-checks a `type: 'file'` field as a value domain. A value that is not a file id, judged by the spec's `isFileIdToken` (the same predicate the claim path reads), is refused with `400 INVALID_SCREEN_INPUT` and the field code `invalid_type` ("must be the id of an uploaded file"). That covers a URL, the expanded `{ id, name, url, … }` read form, a list and a number. The value is never reduced to an id. 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. `image`, `avatar`, `video` and `audio` stay open widget hints.
18+
- **`@object-ui/app-shell`**: the flow screen dialog renders the object form's `FileField` for a `type: 'file'` field and uploads through the console's `UploadProvider`. While an upload is in flight, Submit is disabled and reads "Uploading…". The resume posts each file field as its bare id, and a prefilled expanded value is reduced to its id first.
19+
20+
**BREAKING** (a resume-acceptance narrowing) for a flow whose `screen` node already declares a field `type: 'file'`. None was found in this repository's examples or packages.
21+
22+
- **Before.** `type: 'file'` was an open widget hint the console did not know, so the field rendered a plain text input, and `POST /automation/:name/runs/:runId/resume` accepted any value for it.
23+
- **Now.** The field renders the upload widget, and the resume refuses any value that is not a file id with `400 INVALID_SCREEN_INPUT` (`invalid_type`). The refusal keeps the pause.
24+
- **Fix.** A caller that resumes the run itself, without the console, uploads the file through the file service first and posts the minted `sys_file` id for the field: FROM `{ "contract_file": "https://…/nda.pdf" }` TO `{ "contract_file": "FILE_ID" }`. A field that was never meant to collect an upload takes another `type`, such as `text`.

‎content/docs/automation/flows.mdx‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,39 @@ and `placeholder`, and the console's flow runner overlays it in the active
785785
locale — see the flows row in
786786
[Translations](/docs/ui/translations#what-you-can-translate).
787787

788+
#### A file field
789+
790+
`type: 'file'` asks for a file before the record that will own it exists, so a
791+
wizard can collect everything first and write once, after its last screen:
792+
793+
```typescript
794+
{ id: 'ask', type: 'screen', config: { fields: [
795+
{ name: 'contract_name', label: 'Name', type: 'text', required: true },
796+
{ name: 'contract_file', label: 'Signed copy', type: 'file', required: true },
797+
] } },
798+
{ id: 'write', type: 'create_record', config: { objectName: 'contract',
799+
fields: { name: { dialect: 'cel', source: 'contract_name' }, doc: { dialect: 'cel', source: 'contract_file' } } } },
800+
```
801+
802+
- **The console uploads, the run receives the file's id.** The flow dialog
803+
renders the platform upload widget and uploads through the file service. The
804+
file lands as a `sys_file` that no record owns yet, and Submit stays disabled
805+
until the upload settles. The resume carries the file's **id**, the stored
806+
form of a `file` field.
807+
- **The write after the screen claims the file.** Writing the id into an
808+
object's `file` field makes that record the file's one owner. Under the
809+
default `runAs: 'user'` the write first checks that the run's user may read
810+
the file, as the download door does. A `runAs: 'system'` run's write is not
811+
checked, as no system write is.
812+
- **Anything but an id is refused on resume.** Like `type: 'number'`, `file` is a
813+
shape `validateScreenInputs` checks. A URL, the expanded
814+
`{ id, name, url, … }` read form, a list or a number is refused with
815+
`invalid_type` inside the run's `INVALID_SCREEN_INPUT`, never reduced to an id.
816+
The run stays parked, and the corrected submission still lands.
817+
- **One file per field.** A screen field has no `multiple`, `accept` or `maxSize`.
818+
The target `file` field's own `accept` / `maxSize` still apply at the write
819+
that claims the file.
820+
788821
**Screen (object form):**
789822

790823
Set `config.objectName`

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
278278
| :--- | :--- | :--- | :--- |
279279
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
280280
| **label** | `string` | optional | Display label |
281-
| **type** | `string` | optional | Input type |
281+
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field |
282282
| **required** | `boolean` | optional | Whether a value is required to submit |
283283
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
284284
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
@@ -300,7 +300,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
300300
| :--- | :--- | :--- | :--- |
301301
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
302302
| **label** | `string` | optional | Display label |
303-
| **type** | `string` | optional | Input type |
303+
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field |
304304
| **required** | `boolean` | optional | Whether a value is required to submit |
305305
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
306306
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Flow screen file-intake fixture (#25801).
4+
//
5+
// The shape the card asks for: a wizard that collects everything first —
6+
// including a file — and writes at the end. The record that will own the file
7+
// does not exist while the screen is open, so the upload lands as a `sys_file`
8+
// no record owns yet (ADR-0104 D3), the run resumes with that file's id, and
9+
// the `create_record` after the screen writes the id into the record's `file`
10+
// field — the write that claims the file for the record.
11+
//
12+
// The flow runs as the user (`runAs: 'user'`), so the claiming write is the
13+
// caller's own and the claim is read-judged as #25692 asks; a member, not the
14+
// seeded admin, drives it, so that judgement is a real one.
15+
16+
import { defineStack } from '@objectstack/spec';
17+
import { ObjectSchema, Field } from '@objectstack/spec/data';
18+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
19+
import type { Flow } from '@objectstack/spec/automation';
20+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
21+
22+
/** The record the end of the wizard writes, owning the file the screen collected. */
23+
export const SfiContract = ObjectSchema.create({
24+
name: 'sfi_contract',
25+
// [ADR-0090 D1] grandfather stamp: the gate under test is the screen's file
26+
// intake and the claim, not owner-sharing.
27+
sharingModel: 'public_read_write',
28+
label: 'Contract',
29+
pluralLabel: 'Contracts',
30+
fields: {
31+
name: Field.text({ label: 'Name', required: true }),
32+
doc: Field.file({ label: 'Signed copy' }),
33+
},
34+
});
35+
36+
/**
37+
* `sfi_contract_intake` — start → screen (name, file) → create_record → end.
38+
*
39+
* Nothing is written before the screen is answered: the record and the claim
40+
* both come from the one write after it.
41+
*/
42+
export const flowScreenFileIntake: Flow = {
43+
name: 'sfi_contract_intake',
44+
label: 'Contract intake',
45+
type: 'screen',
46+
status: 'active',
47+
runAs: 'user',
48+
nodes: [
49+
{ id: 'start', type: 'start', label: 'Start' },
50+
{
51+
id: 'ask',
52+
type: 'screen',
53+
label: 'New contract',
54+
config: {
55+
title: 'New contract',
56+
fields: [
57+
{ name: 'contract_name', label: 'Name', type: 'text', required: true },
58+
{ name: 'contract_file', label: 'Signed copy', type: 'file', required: true },
59+
],
60+
},
61+
},
62+
{
63+
id: 'write',
64+
type: 'create_record',
65+
label: 'Create contract',
66+
config: {
67+
objectName: 'sfi_contract',
68+
fields: {
69+
name: { dialect: 'cel', source: 'contract_name' },
70+
doc: { dialect: 'cel', source: 'contract_file' },
71+
},
72+
},
73+
},
74+
{ id: 'end', type: 'end', label: 'End' },
75+
],
76+
edges: [
77+
{ id: 'e1', source: 'start', target: 'ask' },
78+
{ id: 'e2', source: 'ask', target: 'write' },
79+
{ id: 'e3', source: 'write', target: 'end' },
80+
],
81+
};
82+
83+
/** What a fresh, grant-less member resolves to: create and read on the contract. */
84+
export const sfiMemberSet: PermissionSet = PermissionSetSchema.parse({
85+
name: 'sfi_fixture_member',
86+
label: 'Screen file intake fixture member',
87+
objects: {
88+
sfi_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
89+
},
90+
});
91+
92+
/** The platform defaults (the seeded admin keeps full access) plus the member fallback above. */
93+
export function screenFileFixtureSecurity(): SecurityPlugin {
94+
return new SecurityPlugin({
95+
defaultPermissionSets: [...securityDefaultPermissionSets, sfiMemberSet],
96+
fallbackPermissionSet: sfiMemberSet.name,
97+
});
98+
}
99+
100+
export const screenFileStack = defineStack({
101+
manifest: {
102+
id: 'com.dogfood.flow-screen-file',
103+
namespace: 'sfi',
104+
version: '0.0.0',
105+
type: 'app',
106+
name: 'Flow Screen File Intake Fixture',
107+
description: 'A screen flow that collects a file before the record that owns it exists, then writes and claims it.',
108+
},
109+
objects: [SfiContract],
110+
flows: [flowScreenFileIntake],
111+
});

0 commit comments

Comments
 (0)