Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/25801-flow-screen-file-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@object-ui/app-shell': minor
---

feat(spec,automation,app-shell): a flow screen field typed `file` collects an upload, and the run resumes with the uploaded file's id

Clause-②: yes (narrowing)
Metadata-protocol: semantic

<!-- 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). -->

A wizard can now ask for a file before the record that will own it exists, and write everything after its last screen.

- **`@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.
- **`@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.
- **`@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.

**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.

- **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.
- **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.
- **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`.
33 changes: 33 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,39 @@ and `placeholder`, and the console's flow runner overlays it in the active
locale — see the flows row in
[Translations](/docs/ui/translations#what-you-can-translate).

#### A file field

`type: 'file'` asks for a file before the record that will own it exists, so a
wizard can collect everything first and write once, after its last screen:

```typescript
{ 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' } } } },
```

- **The console uploads, the run receives the file's id.** The flow dialog
renders the platform upload widget and uploads through the file service. The
file lands as a `sys_file` that no record owns yet, and Submit stays disabled
until the upload settles. The resume carries the file's **id**, the stored
form of a `file` field.
- **The write after the screen claims the file.** Writing the id into an
object's `file` field makes that record the file's one owner. Under the
default `runAs: 'user'` the write first checks that the run's user may read
the file, as the download door does. A `runAs: 'system'` run's write is not
checked, as no system write is.
- **Anything but an id is refused on resume.** Like `type: 'number'`, `file` is a
shape `validateScreenInputs` checks. A URL, the expanded
`{ id, name, url, … }` read form, a list or a number is refused with
`invalid_type` inside the run's `INVALID_SCREEN_INPUT`, never reduced to an id.
The run stays parked, and the corrected submission still lands.
- **One file per field.** A screen field has no `multiple`, `accept` or `maxSize`.
The target `file` field's own `accept` / `maxSize` still apply at the write
that claims the file.

**Screen (object form):**

Set `config.objectName`
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **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 }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **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 |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **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 |
Expand All @@ -300,7 +300,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **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 }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type |
| **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 |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **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 |
Expand Down
111 changes: 111 additions & 0 deletions packages/qa/dogfood/test/fixtures/flow-screen-file-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Flow screen file-intake fixture (#25801).
//
// The shape the card asks for: a wizard that collects everything first —
// including a file — and writes at the end. The record that will own the file
// does not exist while the screen is open, so the upload lands as a `sys_file`
// no record owns yet (ADR-0104 D3), the run resumes with that file's id, and
// the `create_record` after the screen writes the id into the record's `file`
// field — the write that claims the file for the record.
//
// The flow runs as the user (`runAs: 'user'`), so the claiming write is the
// caller's own and the claim is read-judged as #25692 asks; a member, not the
// seeded admin, drives it, so that judgement is a real one.

import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
import type { Flow } from '@objectstack/spec/automation';
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';

/** The record the end of the wizard writes, owning the file the screen collected. */
export const SfiContract = ObjectSchema.create({
name: 'sfi_contract',
// [ADR-0090 D1] grandfather stamp: the gate under test is the screen's file
// intake and the claim, not owner-sharing.
sharingModel: 'public_read_write',
label: 'Contract',
pluralLabel: 'Contracts',
fields: {
name: Field.text({ label: 'Name', required: true }),
doc: Field.file({ label: 'Signed copy' }),
},
});

/**
* `sfi_contract_intake` — start → screen (name, file) → create_record → end.
*
* Nothing is written before the screen is answered: the record and the claim
* both come from the one write after it.
*/
export const flowScreenFileIntake: Flow = {
name: 'sfi_contract_intake',
label: 'Contract intake',
type: 'screen',
status: 'active',
runAs: 'user',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'ask',
type: 'screen',
label: 'New contract',
config: {
title: 'New contract',
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',
label: 'Create contract',
config: {
objectName: 'sfi_contract',
fields: {
name: { dialect: 'cel', source: 'contract_name' },
doc: { dialect: 'cel', source: 'contract_file' },
},
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask' },
{ id: 'e2', source: 'ask', target: 'write' },
{ id: 'e3', source: 'write', target: 'end' },
],
};

/** What a fresh, grant-less member resolves to: create and read on the contract. */
export const sfiMemberSet: PermissionSet = PermissionSetSchema.parse({
name: 'sfi_fixture_member',
label: 'Screen file intake fixture member',
objects: {
sfi_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
},
});

/** The platform defaults (the seeded admin keeps full access) plus the member fallback above. */
export function screenFileFixtureSecurity(): SecurityPlugin {
return new SecurityPlugin({
defaultPermissionSets: [...securityDefaultPermissionSets, sfiMemberSet],
fallbackPermissionSet: sfiMemberSet.name,
});
}

export const screenFileStack = defineStack({
manifest: {
id: 'com.dogfood.flow-screen-file',
namespace: 'sfi',
version: '0.0.0',
type: 'app',
name: 'Flow Screen File Intake Fixture',
description: 'A screen flow that collects a file before the record that owns it exists, then writes and claims it.',
},
objects: [SfiContract],
flows: [flowScreenFileIntake],
});
Loading
Loading