Skip to content

Commit 46774c3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-1r3slx
2 parents 6fb2d7e + 7111e14 commit 46774c3

30 files changed

Lines changed: 1781 additions & 193 deletions
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`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(ui): the Studio permission-set form offers the field permission `when` (row condition on the edit vote)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Part of #25866 — the Studio clause of ruling 6408558941 (part ①, item 4), after #25980 landed the key itself.
11+
12+
- **Before.** The permission matrix's field rows carried two checkboxes, `readable` and `editable`: no way to offer or see a `when`. The Read-off cascade could leave `readable: false` beside an authored `when` (the shape the spec refuses at publish), and the Read all / Write all shortcuts dropped the condition.
13+
- **Now.** Each field row carries **When (edit)**: an authored condition shows as a chip, and the row opens the same CEL editor the row-level-security clauses use, linted the same way; a finding the publish gate would refuse holds the save. Read off clears the condition, Read all / Write all keep it, Clear drops the entry, blank text removes the key. `FieldPerm` gains `when`, carried verbatim by the package-door slice merge.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a bulk action's `confirmText` fills its `{{ count }}` hole with the number of records the run covers
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A bulk action's `confirmText` is a `template` slot, and its declared scope names `count`, the number of records the run will cover. The bulk dialog printed the sentence as written, so a person confirming `Archive {{ count }} account(s)?` read the braces instead of the number. The confirm step now fills `{{ count }}` through the same template engine the flow notifications and the single-record `confirmText` render with, so the whitelisted formatters apply (`{{ count | number }}`) and a hole holds no logic. A sentence without a `{{ }}` hole is shown as written, the default sentence is unchanged, and a source the engine refuses is shown as written.
11+
12+
A hole naming one of the action's params prints empty for now; filling the params with their display values is the next change.
13+
14+
What to do: nothing.

‎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/permissions/field-level-security.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ fields: {
6464
RLS predicate (`rls-predicate-*` findings on `permissions[].fields[].when`):
6565
the fields it names must exist on the entry's object, no cross-object
6666
reach, no unresolvable `current_user.*` variable.
67+
- **In Studio.** The permission-set form offers the condition on each field
68+
row under **When (edit)**, in the same CEL editor as the row-level security
69+
clauses, linted the same way; a condition the publish gate would refuse
70+
holds the save. Switching **Read** off clears the condition, and the
71+
**Read all** / **Write all** shortcuts keep it.
6772

6873
## Hidden vs. Read-Only
6974

‎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 |

‎docs/adr/0066-unified-authorization-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Captured for the record; **out of scope for Phases 1–4 above**. Each is anchor
131131
- **⑦ Permission-set groups + subtractive *muting*.** Pure union does not scale governance ("permission-set explosion"); Salesforce added permission-set-group *muting* precisely to allow taking access away. Roles→permission-sets already bundle; a subtractive/deny layer (precedence step 4) is the missing piece for large-org administration. Pairs with delegated admin (#9).
132132
- **⑧ FLS posture: unlisted fields are visible, and field grants union without deny** *(recorded 2026-07 pre-launch architecture assessment)*. Runtime FLS is block-list-shaped: `field-masker.ts` hides a field only when an explicit rule marks it `readable:false`, so an **undeclared** sensitive field is exposed by default, and `getFieldPermissions` merges most-permissively, so one set's `readable:true` permanently out-votes another's `false`. Consequences: protecting a salary/ID-number field relies on discipline (grant it only where needed), not mechanism. The object-level `private` posture (D2/④) mitigates the unlisted-default-visible half for objects that adopt it; the union half is the field-level face of ⑦ — a muting/deny layer must cover **field** grants, not just object grants, when it lands. Until then, treat "sensitive field on a `public` object" as a smell in review.
133133

134-
> **Note (2026-10-26) — a field permission entry may carry a row condition (#25866, ruling of record 6408558941).** `FieldPermissionSchema.when` is a row predicate in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (`when` beside `readable: false` is refused at parse). The condition **narrows where an entry votes**: on a row that matches, the entry votes as declared; on a row that does not, the entry is **absent**, as if the set had not declared the field. It does **not** change the posture recorded here — field grants still **union without deny**: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, an entry without `when` votes on every row, and only declaring entries vote. The write gate judges the stored row before an update and the incoming row of an insert, in process (`RLSCompiler.compileRowPredicate` + `storedFormCheckJudge`); a row that cannot be judged (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) and every row-blind reader (`/auth/me/permissions`, the field projections, `os explain`) apply the entry as its lock, so no reading is wider than the gate's. The condition is validated like an RLS predicate at authoring (`rls-predicate-*` findings on `permissions[].fields[].when`). The muting/deny layer ⑦ still owes remains the mechanism for taking an edit away that another set grants.
134+
> **Note (2026-10-26) — a field permission entry may carry a row condition (#25866, ruling of record 6408558941).** `FieldPermissionSchema.when` is a row predicate in the same canonical CEL as `rowLevelSecurity[].using`, on the **edit** vote only (`when` beside `readable: false` is refused at parse). The condition **narrows where an entry votes**: on a row that matches, the entry votes as declared; on a row that does not, the entry is **absent**, as if the set had not declared the field. It does **not** change the posture recorded here — field grants still **union without deny**: a matched conditioned lock is out-voted by a sibling's unconditioned `editable: true` exactly as before, an entry without `when` votes on every row, and only declaring entries vote. The write gate judges the stored row before an update and the incoming row of an insert, in process (`RLSCompiler.compileRowPredicate` + `storedFormCheckJudge`); a row that cannot be judged (a predicate update, a stored row the caller cannot read, a condition that does not resolve for the request) and every row-blind reader (`/auth/me/permissions`, the field projections, `os explain`) apply the entry as its lock, so no reading is wider than the gate's. The condition is validated like an RLS predicate at authoring (`rls-predicate-*` findings on `permissions[].fields[].when`). Taking an edit away that another set grants remains the job of the muting/deny layer ⑦ still owes.
135135
- **⑨ Authoring-time validation for capability references** *(recorded 2026-07 pre-launch architecture assessment)*. `systemPermissions` / `requiredPermissions` are free strings today; a typo (`mange_users`) fails closed at runtime — the safe direction — but is undiscoverable: nothing reports that the referenced capability exists nowhere. When D1 lands the `sys_permission` registry, add the authoring/publish-gate lint alongside it: referencing an unregistered capability warns at author time (consistent with ADR-0049 honesty and the contract-first Prime Directive — reject at the producer, don't tolerate at the consumer). **Landed (2026-07):** `validateCapabilityReferences` in `@objectstack/lint` warns (never errors — cross-package capabilities and the fail-closed runtime make a hard gate wrong) when a `requiredPermissions` reference resolves against neither the built-in set (`@objectstack/spec` `PLATFORM_CAPABILITY_NAMES`), nor a capability the stack grants via a permission set's `systemPermissions`, nor a `sys_capability` seed row. Wired into `os validate` and `os lint`. `systemPermissions` (the declaration side) is deliberately not flagged.
136136

137137
> **Note (2026-10-14) — ⑨'s seed-row source is gone.** With `sys_capability` retired by #15204

0 commit comments

Comments
 (0)