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
39 changes: 39 additions & 0 deletions .changeset/17493-node-door-refusal-residues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/spec": patch
---

docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493)

Two places in `packages/spec` still described the world as it was before the
blank structural condition became a defect. Neither changes behaviour: this is
the notification half of a refusal that has already shipped.

**The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required`
named only the edge key.** Its `surface` and `acceptanceCriteria` told a
consumer replaying the chain to sweep `edges[].condition` and nothing else —
so a deployment carrying a blank `config.condition` on a flow node was never
told to look, even though `AutomationEngine.registerFlow` refuses it since
#17322 and `objectstack validate` since #17495. Both fields now name both
structural slots, the node key's own locator
(the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is
stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the
warning that removing a `condition` from a `start` node opens the trigger gate
rather than preserving it. The entry's `id`, `replacement` and `reason` are
untouched, and no new entry is added: this is one decision reaching its second
slot, not a second decision.

**`structuralConditionRefusal`'s docblock stated a ruling that had become
false.** It admitted a whitespace-only string on the ground that such a
condition "is consistent on both sides and is ruled correct, not a defect" —
the ground #15807 removed at the edge door and #17322 ruled on. The admission
itself is unchanged and still correct, because this function answers the SHAPE
question only and the blank is refused beside it by the imported
evaluated-slot rule; what the docblock now records is which card removed the
ground, which door each refusal lives at, and why the two refusals are kept
distinct.

It also records, without answering, the question one slot over: the ledger
`predicate` slots (`config.conditions[].expression`,
`screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as
correct by #15572 on the same ground. Narrowing them re-judges that pin and
moves a published accept-set, so it is a ruling and stays open on #17493.
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,13 @@ describe('every pre-#14149 entry resolves byte-identically (the ratchet\'s fixtu
it('admits every string — what it SAYS is validateExpression\'s business', () => {
expect(structuralConditionRefusal('record.rating >= 4')).toBeUndefined();
expect(structuralConditionRefusal('{record.rating} >= 4')).toBeUndefined();
// Ruled correct, not a defect: a whitespace-only STRING means "not
// authored" on both sides and stays so.
// Still admitted — but on the SHAPE question only, and no longer because
// the blank is correct. #15662 admitted it as "not authored on both
// sides"; #15807 refused it at the edge door and #17322 rebound the node
// door at `registerFlow`, so a blank structural condition IS a defect
// today. It is refused there by the imported evaluated-slot rule sitting
// BESIDE this one, never by this function — which is exactly what these
// two assertions pin. See the docblock of `structuralConditionRefusal`.
expect(structuralConditionRefusal(' ')).toBeUndefined();
expect(structuralConditionRefusal('')).toBeUndefined();
});
Expand Down
45 changes: 41 additions & 4 deletions packages/spec/src/automation/flow-node-expression-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,10 +421,30 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL =
*
* `undefined` — admitted — for:
*
* - every **string**, including a whitespace-only one. What a non-empty string
* *says* stays `validateExpression('predicate', …)`'s verdict, and a
* whitespace-only condition meaning `false` is consistent on both sides and
* is ruled correct, not a defect.
* - every **string**, including a whitespace-only one — on the SHAPE question,
* which is the only question this function answers. What a non-empty string
* *says* stays `validateExpression('predicate', …)`'s verdict.
*
* ⚠️ The whitespace-only string is still admitted here, but NOT for the
* reason #15662 gave. That reason was that such a condition, meaning `false`,
* "is consistent on both sides and is ruled correct, not a defect" — and
* #15807 removed the ground under it, by making `FlowEdgeSchema.condition`
* compose `EvaluatedExpressionInputSchema`, which refuses a blank `source` at
* `FlowSchema.parse`. #17322 then ruled on the disagreement that left
* (一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑) and rebound the node
* door at `AutomationEngine.registerFlow`; #17495 followed at
* `objectstack validate`. A blank structural condition is a defect today,
* refused at all three doors.
*
* It is refused there by the EVALUATED-SLOT rule, not by this one. Both
* consumers ask `EvaluatedExpressionInputSchema` — the edge door's own
* schema, imported rather than restated — in a second gate sitting behind
* this shape refusal and in front of the CEL pass, answering
* `EVALUATED_EXPRESSION_SOURCE_REQUIRED` and not
* {@link STRUCTURAL_CONDITION_SHAPE_REFUSAL}. Keeping the two distinct is
* deliberate: a string IS a well-shaped structural condition, and a second
* hand-written notion of "blank" per door is exactly the drift #15662 built
* this one shared refusal to prevent. ⛔ Do not move the blank rule in here.
* - absent / `null`. "Not authored" is not a malformed predicate; both callers
* already return early on it, and this agrees rather than disagreeing.
* - an **expression envelope the engine can evaluate**: an object carrying a
Expand All @@ -451,6 +471,23 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL =
* `EvaluatedExpressionSchema` is the one place to relax, and this clause
* follows it.
*
* ## The sibling `predicate` slots — an OPEN question, not answered here
*
* The blank rule reached the two STRUCTURAL slots only. The ledger `predicate`
* slots — `config.conditions[].expression`, a `decision` node's branch list, and
* `screen.fields[].visibleWhen` — are judged by {@link predicateSlotRefusal},
* not by this function, and they still ADMIT a whitespace-only string:
* registration takes it and `evaluateCondition` answers `false`, the same silent
* dead branch #17322 closed one slot over. Recorded here rather than fixed,
* because it is a RULING and not a refactor: #15572 pinned that admission as
* correct on the very ground #15807 removed — that the blank is treated the same
* way on both sides — so narrowing those slots re-judges a pin and moves a
* published accept-set. #17493 carries the question (does the ledger predicate
* slot follow the structural one?) and it is open at the time of writing. ⛔ Do
* not answer it by widening this refusal: those slots do not pass through this
* door, and a second notion of "blank" is what the shared refusal exists to
* prevent.
*
* ## What it refuses, and what that was doing before
*
* A number, a boolean, an array, or an object carrying no string `source` —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import type { SemanticMigration } from '../../types.js';
export const entry: SemanticMigration = {
id: 'flow-edge-condition-evaluated-slot-source-required',
surface:
'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate '
+ 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an '
+ 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or '
+ 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', '
+ 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable '
+ 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack '
+ 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in '
+ 'sys_metadata',
'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch '
+ 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition '
+ 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — '
+ 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } '
+ 'with no source), or with a source that is blank after trimming, through the envelope key '
+ '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it '
+ '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound '
+ 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather '
+ 'than deriving a second one; it is the same decision reaching the second slot, which is why '
+ 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored '
+ 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, '
+ 'a POST /flows body, and a flow row already sitting in sys_metadata',
replacement:
'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare '
+ 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the '
Expand Down Expand Up @@ -54,23 +58,30 @@ export const entry: SemanticMigration = {
+ 'repo reading, which is why the notification is registered here rather than skipped. '
+ 'ADR-0087, ADR-0032.',
acceptanceCriteria:
'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, '
+ '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no '
+ '`source` key and for a `source` (or bare string) that is empty after trimming. For each '
+ 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the '
+ '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, '
'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s '
+ '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger '
+ 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and '
+ 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` '
+ '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the '
+ 'node key unswept, and the node key is the one with no schema in front of it. For each '
+ 'hit decide, per the `replacement` note, whether the condition was meant to branch (author '
+ 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a '
+ '`start` node removal opens the trigger gate rather than preserving it. Two proofs, '
+ 'and the second is the one that matters for stored rows. (1) For a stack authored in config '
+ 'files, `objectstack validate` is clean: it locates each offender at '
+ '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and '
+ 'files, `objectstack validate` is clean: it locates each offender with the '
+ '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at '
+ '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, '
+ "e.g. `node 'gate' (start) condition` — and "
+ 'an `ast`-only envelope is also reported by the lint path as '
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back '
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that '
+ 'spelling as well. There is no CLI verb that lowers a stored row back '
+ 'into a config file, so this proof does not reach a flow that exists only in '
+ '`sys_metadata`. (2) Boot the stack and '
+ 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot '
+ 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed '
+ 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and '
+ 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` '
+ 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is '
+ 'unaffected; every edge '
+ 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its '
+ '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same '
+ "slot phrase. A flow that boots without that warn is unaffected; every structural "
+ 'condition carrying a non-blank `source` parses byte-identically to before.',
};
49 changes: 30 additions & 19 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7840,14 +7840,18 @@ const step18: MigrationStep = {
{
id: 'flow-edge-condition-evaluated-slot-source-required',
surface:
'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate '
+ 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an '
+ 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or '
+ 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', '
+ 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable '
+ 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack '
+ 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in '
+ 'sys_metadata',
'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch '
+ 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition '
+ 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — '
+ 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } '
+ 'with no source), or with a source that is blank after trimming, through the envelope key '
+ '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it '
+ '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound '
+ 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather '
+ 'than deriving a second one; it is the same decision reaching the second slot, which is why '
+ 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored '
+ 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, '
+ 'a POST /flows body, and a flow row already sitting in sys_metadata',
replacement:
'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare '
+ 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the '
Expand Down Expand Up @@ -7887,24 +7891,31 @@ const step18: MigrationStep = {
+ 'repo reading, which is why the notification is registered here rather than skipped. '
+ 'ADR-0087, ADR-0032.',
acceptanceCriteria:
'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, '
+ '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no '
+ '`source` key and for a `source` (or bare string) that is empty after trimming. For each '
+ 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the '
+ '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, '
'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s '
+ '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger '
+ 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and '
+ 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` '
+ '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the '
+ 'node key unswept, and the node key is the one with no schema in front of it. For each '
+ 'hit decide, per the `replacement` note, whether the condition was meant to branch (author '
+ 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a '
+ '`start` node removal opens the trigger gate rather than preserving it. Two proofs, '
+ 'and the second is the one that matters for stored rows. (1) For a stack authored in config '
+ 'files, `objectstack validate` is clean: it locates each offender at '
+ '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and '
+ 'files, `objectstack validate` is clean: it locates each offender with the '
+ '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at '
+ '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, '
+ "e.g. `node 'gate' (start) condition` — and "
+ 'an `ast`-only envelope is also reported by the lint path as '
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back '
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that '
+ 'spelling as well. There is no CLI verb that lowers a stored row back '
+ 'into a config file, so this proof does not reach a flow that exists only in '
+ '`sys_metadata`. (2) Boot the stack and '
+ 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot '
+ 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed '
+ 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and '
+ 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` '
+ 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is '
+ 'unaffected; every edge '
+ 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its '
+ '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same '
+ "slot phrase. A flow that boots without that warn is unaffected; every structural "
+ 'condition carrying a non-blank `source` parses byte-identically to before.',
},
{
Expand Down
Loading