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
17 changes: 17 additions & 0 deletions .changeset/29442-flow-start-input-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/platform-objects': patch
---

feat(spec): a flow start input can declare that it is the id of a record of a named object (`objectName`)

Clause-②: yes (widening)
Metadata-protocol: added

`FlowVariableSchema` declares one optional key, `objectName`: on an input (`isInput: true`), it says the input's value is the id of one record of the named object, e.g. `{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' }`. The key is **planned**: no door reads it yet, and `os validate` warns on every flow that writes it (`liveness-planned-property`). The contract it states, for the start doors to enforce: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP `run_action`, a declared endpoint of type flow) loads a declared input from that object in the caller's own scope before the run exists; an id the caller cannot read there is refused `404 RECORD_NOT_FOUND`, and a value that is not an id is refused `400 VALIDATION_FAILED` naming the input. An input without the declaration keeps the visible-read rule: it is judged as a record only where the flow's own definition reads it as a record id.

- **Spec.** `objectName` is an object name (snake_case) and is refused on a variable that is not an input, since no caller's value reaches one. `type` stays the data type: the variable holds the id. A `reference` or `object` key on a flow variable is still refused, and the refusal now names `objectName`. The flow form's variables repeater gains the column, labelled in the metadata-form bundles (en, zh-CN, ja-JP, es-ES).
- **Lint.** `flow-input-unknown-object`: a declared object the stack does not define is a warning (it may come from another installed package), and an error at the runtime publish gate when the write's package closure does not reach it, the verdict `flow-trigger-unknown-object` gives a trigger object. A `sys_` name is not judged.

This is the contract half: the start doors read the declaration in a later change, and until then no door loads a declared input, so a start that hands the input an id the caller cannot read still runs. Nothing to do for existing flows.
51 changes: 51 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2029,6 +2029,7 @@ variables: [
| `isInput` | `boolean` | Available as input parameter |
| `isOutput` | `boolean` | Available as output parameter |
| `defaultValue` | `unknown` | Bound at run start when no parameter supplies one — this is what makes the declaration a guarantee. Optional |
| `objectName` | `string` | **Planned.** On an input only (refused at parse on any other variable): the input is the id of a record of this object. No start door reads it yet. See [a start input that is a record id](#a-start-input-that-is-a-record-id). Optional |

### `defaultValue` — declaring a variable is not the same as binding it

Expand Down Expand Up @@ -2098,6 +2099,56 @@ The value is **not** checked against the declared `type` — `type` is an open
string, so there is no vocabulary to check against. It behaves like every other
`defaultValue` on the authoring surface.

### A start input that is a record id

An input may declare that its value is the id of a record of a named object,
with `objectName` — the word the CRUD nodes use for the object they read:

{/* os:check */}
```typescript
import { defineFlow } from '@objectstack/spec/automation';

export const CloseAccount = defineFlow({
name: 'close_account',
label: 'Close Account',
type: 'autolaunched',
variables: [
{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' },
],
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
```

<Callout type="warn">
**Planned — no door applies this yet.** The declaration parses, is refused on a
variable that is not an input, and is linted, but no start door loads a
declared input today: a start that hands it an id the caller cannot read still
runs. `os validate` warns on every flow that writes it
(`liveness-planned-property`). The contract below is what the start doors will
enforce.
</Callout>

The contract: every door that starts the flow by name (the trigger route, an
action of `type: 'flow'` over REST and MCP `run_action`, a declared endpoint of
`type: 'flow'`) loads a declared input from the named object in the caller's
own scope before the run exists. An id the caller cannot read there is refused
`404` `RECORD_NOT_FOUND`; a value that is not an id is refused `400`
`VALIDATION_FAILED` naming the input. An input without the declaration is
judged as a record only where the flow's own definition reads it as a record id
(see the `404` row under [running a flow via the API](#run-a-flow-via-api)).

- **Inputs only.** A variable that is not `isInput: true` receives nothing from
a caller, so `objectName` there is refused at parse.
- **`type` stays the data type.** The variable holds the id the caller sent, so
it is `'text'`; `type: 'record'` already means a variable that holds a whole
record (a `get_record` node's `outputVariable`), and is not the declaration.
- **The name is checked.** `os validate` warns when the stack defines no object
of that name (`flow-input-unknown-object`); a `sys_` name is not judged.

<Callout type="warn">
**Reading a variable that might be unbound: only the `vars.`-scoped guard
works.** Conditions are strict CEL, and `has()` is not the escape hatch it looks
Expand Down
3 changes: 2 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,8 @@ no package provenance (#9612); a provenance it cannot read narrows nothing at al
That changes what a rule can **resolve**, never which rules run, and nothing at
this door walks `packages[]`. Two rules are told the closure is in force. A flow
whose trigger object is outside it is refused with `flow-trigger-unknown-object`
instead of advised (#24669), and a list
instead of advised (#24669), as is a flow with a start input declared a record of
an object outside it, with `flow-input-unknown-object` (#29442), and a list
view bound to an object outside it is refused with
`list-view-object-outside-closure` instead of having its field references
skipped (#24563). With no closure in force — a write that names no package —
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ const result = AutomationApiErrorCode.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |

### Nested Shape: `CreateFlowRequest.nodes[number]`

Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/automation/flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const result = FlowSchema.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |

### Nested Shape: `Flow.nodes[number]`

Expand Down Expand Up @@ -230,6 +231,7 @@ const result = FlowSchema.parse(data);
| **isInput** | `boolean` | optional (default: `false`) | Is input parameter |
| **isOutput** | `boolean` | optional (default: `false`) | Is output parameter |
| **defaultValue** | `any` | optional | Value bound at run start when no parameter supplies one — this is what makes a declared variable always bound. An explicitly supplied param wins, including `false` and `null`; the boundary is `params[name] !== undefined`. |
| **objectName** | `string` | optional | [planned] Declares this input a record reference: its value is the id of one record of the named object (the object's snake_case name, e.g. `crm_account`). Only on an input (`isInput: true`). The contract: every door that starts the flow by name (the trigger route, an action of type flow over REST and MCP run_action, a declared endpoint of type flow) loads a declared input from this object in the caller's own scope before the run exists; an id the caller cannot read there is refused 404 RECORD_NOT_FOUND, and a value that is not an id is refused 400 VALIDATION_FAILED naming the input. No door applies it yet: today the key is parsed and checked, and a start with an id the caller cannot read still runs. An input without this declaration is judged as a record only where the flow's own definition reads it as a record id. `type` stays the data type: the variable holds the id the caller sent. |


---
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export {
validateFlowTriggerReadiness,
validateFlowApiTriggerSecret,
FLOW_TRIGGER_UNKNOWN_OBJECT,
FLOW_INPUT_UNKNOWN_OBJECT,
FLOW_DRAFT_STATUS_AMBIGUOUS,
FLOW_TRIGGER_UNKNOWN_EVENT,
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
Expand Down
29 changes: 29 additions & 0 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,35 @@ describe('lintLivenessProperties', () => {
expect(ruleOf(findings, 'nodes.outputSchema')).toBeUndefined();
});

// #29442 — `flow.variables[].objectName` is declared before any start door
// reads it, so its row is `planned` + `authorWarn`. Pinned through the SHIPPED
// ledger and an array of variables, so the drill (`variables.objectName`)
// and the array fan-out both have to hold for the author to be told.
it('warns on a flow input declared a record reference — planned, nothing reads it yet', () => {
const findings = lintLivenessProperties({
flows: [{
name: 'close_account',
variables: [
{ name: 'note', type: 'text', isInput: true },
{ name: 'accountId', type: 'text', isInput: true, objectName: 'crm_account' },
],
nodes: [{ id: 'start', type: 'start' }],
}],
});
const warned = findings.filter((f) => f.message.includes('sets `variables.objectName`'));
expect(warned).toHaveLength(1);
expect(warned[0].rule).toBe('liveness-planned-property');
expect(warned[0].hint).toContain('nothing reads it yet');
// The live siblings the drill graded stay silent.
for (const key of ['name', 'type', 'isInput', 'isOutput', 'defaultValue']) {
expect(ruleOf(findings, `variables.${key}`), key).toBeUndefined();
}
// And a flow that writes no `objectName` draws nothing about it.
expect(lintLivenessProperties({
flows: [{ name: 'f2', variables: [{ name: 'note', type: 'text', isInput: true }], nodes: [] }],
}).filter((f) => f.message.includes('objectName'))).toEqual([]);
});

it('warns on an experimental prop with no authorWarn of its own (tool.outputSchema)', () => {
// `experimental` warns implicitly — shouldWarn() treats a declared-but-
// unenforced guarantee like an opted-in dead prop. Repointed from
Expand Down
28 changes: 28 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2072,6 +2072,33 @@ const FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = {
],
};

// `flow-input-unknown-object` (#29442) shares the rule file, the hedge and the
// closure split with `flow-trigger-unknown-object` above; what differs is the
// position it reads and what a wrong name costs.

const FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = {
rule: 'flow-input-unknown-object',
covers: 'why an unknown start-input object warns, and when it refuses',
paragraphs: [
'A flow input may declare that its value is the id of a record of a named object, with `objectName` on ' +
'the variable. The start doors are to load such an input from exactly that name in the caller\'s own ' +
'scope before the run exists, so a name no object answers to would refuse every start that supplies ' +
'the input. Names match exactly — `app_candidate`, not `candidate`. The declaration is planned: no ' +
'door reads it yet, and the property lint says so separately.',
'The rule asks whether this stack defines the object. One it does not define may be declared by ' +
'another installed package, which a build of this stack cannot see, so the finding is a warning and ' +
'can be ignored when such a package declares the object; a `sys_` name is a platform object and is ' +
'not judged. A start that omits the input is not affected, so this is not a verdict that the flow ' +
'never runs.',
'A write into a package is judged against that package\'s closure instead: its own objects, the objects ' +
'of the packages its manifest declares in `dependencies`, and platform objects. An object outside it ' +
'is one the package never declared it may reference, so there the finding is an error and the publish ' +
'is refused, the verdict `flow-trigger-unknown-object` gives a trigger object. Declare a dependency on ' +
'the package that defines the object, or name an object inside the closure. A `sys_` name is not ' +
'judged there either.',
],
};

// ── Validation rules that compile at publish time ───────────────────────────
// `validate-rule-compilability.ts` and `validate-rule-schema-formats.ts`: the
// write path's fail-open skip and the runtime's ajv environment are shared,
Expand Down Expand Up @@ -5568,6 +5595,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION,
[FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION,
[FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION,
[FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION,
[VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION,
[VALIDATION_RULE_JSON_SCHEMA_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_JSON_SCHEMA_UNCOMPILABLE_EXPLANATION,
[VALIDATION_RULE_JSON_SCHEMA_UNKNOWN_FORMAT_EXPLANATION.rule]: VALIDATION_RULE_JSON_SCHEMA_UNKNOWN_FORMAT_EXPLANATION,
Expand Down
Loading
Loading