@@ -9058,6 +9058,80 @@ const step18: MigrationStep = {
90589058 + "slot phrase. A flow that boots without that warn is unaffected; every structural "
90599059 + 'condition carrying a non-blank `source` parses byte-identically to before.',
90609060 },
9061+ // The ledger `predicate` slots' half of the blank-predicate rule. A SEPARATE
9062+ // entry from `flow-edge-condition-evaluated-slot-source-required` on purpose:
9063+ // that one carries the structural slots (`edges[].condition`,
9064+ // `config.condition`), refused by the evaluated-slot rule under
9065+ // EVALUATED_EXPRESSION_SOURCE_REQUIRED, where removing a blank condition
9066+ // INVERTS the edge. These slots are declared `z.string()`, are refused under
9067+ // PREDICATE_SLOT_STRING_REFUSAL, and removing the blank is behaviour-
9068+ // preserving — a different prescription, which one entry cannot carry for both.
9069+ //
9070+ // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
9071+ // span already, and a nested backtick would close it.
9072+ {
9073+ id: 'flow-predicate-slot-blank-string-refused',
9074+ surface:
9075+ 'the two ledger predicate slots on a flow node — config.conditions[].expression on a decision '
9076+ + 'node (a branch predicate) and config.fields[].visibleWhen on a screen node (a field visibility '
9077+ + 'predicate) — authored as a string that is blank after trimming (\'\', \' \', a tab or a '
9078+ + 'newline), at any depth including an ADR-0031 region body. Reachable wherever a flow is '
9079+ + 'authored or stored: defineStack({ flows }) sources, defineFlow(), an exported stack passed to '
9080+ + 'objectstack validate, a POST /flows body, and a flow row already sitting in sys_metadata',
9081+ replacement:
9082+ 'the predicate the branch or field was meant to test, as non-blank bare CEL text '
9083+ + '(`expression: \'record.amount > 10\'`, `visibleWhen: \'amount > 0\'`); or REMOVE the blank. '
9084+ + 'On a screen field, drop the `visibleWhen` key: an absent `visibleWhen` shows the field '
9085+ + 'unconditionally, which is what a blank one already did at run time (the resume contract '
9086+ + 'treated it as absent, and the renderer fell back to showing the field). On a decision '
9087+ + 'branch, drop the whole `conditions[i]` element: `expression` is required by '
9088+ + '`DecisionConditionSchema`, so a branch cannot keep its label without it, and a branch whose '
9089+ + 'predicate was blank was never taken, so dropping it changes no run. ⚠️ Removal is '
9090+ + 'behaviour-preserving HERE, unlike on a structural condition, where dropping a blank '
9091+ + '`condition` turns a never-firing edge into an always-firing one '
9092+ + '(`flow-edge-condition-evaluated-slot-source-required`)',
9093+ reason:
9094+ 'Card #17493, ruling A (5651023407). Both slots are declared bare CEL text (`z.string()`) '
9095+ + 'and both admitted a blank string at every door: the expression ledger resolver skipped it '
9096+ + 'as "not authored", and `AutomationEngine.evaluateCondition` answered it `false` — so a '
9097+ + 'decision branch carrying it was never taken, with nothing said at any layer, and a screen '
9098+ + 'field carrying it was shown with its predicate ignored. #15572 had pinned that '
9099+ + 'admission as correct because the two sides agreed. The ruling is that self-consistency '
9100+ + 'between parser and evaluator is not a defence when the author\'s intent is silently '
9101+ + 'dropped — the third instance of one rule, after #17322 (the structural `config.condition`) '
9102+ + 'and #15811 (a blank evaluated `source`). The blank is now refused at `FlowSchema.parse`, '
9103+ + 'at `AutomationEngine.registerFlow` (which parses first) and at `objectstack validate`, all '
9104+ + 'three through `predicateSlotRefusal`, leading with `PREDICATE_SLOT_STRING_REFUSAL`. '
9105+ + '⚠️ No D2 conversion, and the reason is the judgment this entry delegates: the blank is '
9106+ + 'where an author meant to write a rule, and the platform cannot tell a predicate somebody '
9107+ + 'forgot from one they meant to delete. Removing it preserves what ran; writing it is what '
9108+ + 'the author intended; only the author knows which. '
9109+ + '⚠️ A flow ALREADY STORED with such a blank no longer registers at all, not just that '
9110+ + 'branch or field: `registerFlow` parses through `canonicalizeStoredFlow` → '
9111+ + '`FlowSchema.parse`, and each boot path in `service-automation/src/plugin.ts` logs one '
9112+ + '`warn` naming the flow and continues — its trigger is never armed. '
9113+ + 'ADR-0087, ADR-0032.',
9114+ acceptanceCriteria:
9115+ 'Grep every flow node in `defineStack({ flows })` sources, exported stacks, `POST /flows` '
9116+ + 'bodies and every flow row in `sys_metadata` — including nodes inside a `loop` / '
9117+ + '`parallel` / `try_catch` region body — for a `decision` node whose '
9118+ + '`config.conditions[i].expression`, or a `screen` node whose `config.fields[i].visibleWhen`, '
9119+ + 'is a string that is empty after trimming. Each refusal names the node and the branch or '
9120+ + 'field, which is the TODO\'s locator: `FlowSchema.parse` anchors a `custom` issue at '
9121+ + '`nodes.N.config.conditions.I.expression` (or `…config.fields.I.visibleWhen`, or the region '
9122+ + 'path `nodes.N.config.body.nodes.M.config…`), and `objectstack validate` prints the same '
9123+ + 'path; `validateStackExpressions` phrases it as '
9124+ + '`node \'check\' (decision) decision branch expression at config.conditions[0].expression`. '
9125+ + 'For each hit decide, per the `replacement` note, whether to write the predicate or to '
9126+ + 'remove it — and on a decision branch removing means the whole branch. Two proofs. (1) For '
9127+ + 'a stack authored in config files, `objectstack validate` is clean. (2) Boot the stack and '
9128+ + 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot '
9129+ + 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed '
9130+ + 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`) — that '
9131+ + 'warn line is the locator for a row that exists only in `sys_metadata`. A non-blank '
9132+ + 'predicate parses and registers byte-identically to before, and a non-string in these '
9133+ + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).',
9134+ },
90619135 {
90629136 id: 'hook-register-undispatched-lifecycle-event-refused',
90639137 surface:
0 commit comments