Skip to content

Commit 0a88a80

Browse files
os-billclaude
andauthored
docs(spec): name the node slot in the structural-condition ruling and its ADR-0087 entry (#17761)
Part of #17493 Items ① and ② of the card. ⛔ Item ③ is a **ruling, not a refactor** — it is measured and reported here, and deliberately **not implemented**; the card stays open on it, and this PR therefore carries no closing keyword. Nothing in this diff changes behaviour. The refusal already shipped (#17322 at `registerFlow`, #17495 at `objectstack validate`); what shipped with it was the notification, and this is that half. - **Clause-②: no** — this PR puts no new key on any published payload. --- ## ① The on-site ruling that had become false `packages/spec/src/automation/flow-node-expression-paths.ts` — the docblock of `structuralConditionRefusal`. It admitted a whitespace-only string on this ground, verbatim as it stood: > 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.** #15807 removed that ground (`FlowEdgeSchema.condition` composes `EvaluatedExpressionInputSchema`), #17322 ruled on the disagreement that left, and #17495 finished it at the third door. ⛔ Not deleted — it records a real decision, so the edit says **what changed it**: - The admission itself is **unchanged and still correct**. This function answers the SHAPE question; a string is a well-shaped structural condition. The blank is refused **beside** it, by the imported evaluated-slot rule, answering `EVALUATED_EXPRESSION_SOURCE_REQUIRED` and not `STRUCTURAL_CONDITION_SHAPE_REFUSAL`. - A new section records **why item ③'s question is open**, without answering it. - The same stale ground sat three lines from the symbol, in `flow-node-expression-paths.test.ts`'s own case comment ("not authored on both sides and stays so"). Same defect class, same file face, mechanical: the comment is corrected, both assertions untouched. ⭐ Declared as a bounded on-site fix beyond the dispatched file face. ## ② The ADR-0087 entry named only the edge slot `packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts` — `surface` and `acceptanceCriteria`. Both named only `edges[].condition`, so a consumer replaying the chain was told to sweep the edge key alone and a deployment carrying a blank `config.condition` on a node was **never told to look**. Both now name both structural slots, plus the node key's own locator phrase (the one the structural pass builds, e.g. `node 'gate' (start) condition`) beside the edge's `flows.N.edges.N.condition`, and the sweep carries the warning that deleting a `condition` from a `start` node **opens the trigger gate** rather than preserving it. - `id`, `replacement` and `reason` are untouched, per the card. - No backticks in `surface` — measured, 0 (`build-upgrade-guide.ts` renders it inside a code span). - **No new entry.** This is one decision reaching its second slot, which is what both follow-up changesets (`blank-node-condition-refused-at-registration.md`, `validate-refuses-blank-structural-condition.md`) already declared by taking the `not-required (already-registered …)` disposition against this very entry. - `packages/spec/src/migrations/registry.ts` is **regenerated by the repo's own generator**, `pnpm --filter @objectstack/spec gen:migration-registry` — ⛔ never hand-edited. `check:migration-registry` is green. ## ⛔ ③ Measured, reported, NOT implemented `registerFlow` still **ACCEPTS** a whitespace-only string at both sibling ledger `predicate` slots, on this head, against controls that prove the probe reaches them: | slot | valid CEL (positive control) | envelope with a blank source (refusal control) | `' '` | `''` | |:--|:--|:--|:--|:--| | `config.conditions[].expression` | ACCEPTED | REFUSED — `PREDICATE_SLOT_STRING_REFUSAL` at `config.conditions[0].expression` | **ACCEPTED** | **ACCEPTED** | | `screen.fields[].visibleWhen` | ACCEPTED | REFUSED — `PREDICATE_SLOT_STRING_REFUSAL` at `config.fields[0].visibleWhen` | **ACCEPTED** | **ACCEPTED** | | `config.condition` (lit control — the slot #17322 DID narrow) | ACCEPTED | REFUSED | **REFUSED** | **REFUSED** | `evaluateCondition(' ')` and `evaluateCondition('')` both answer `false` — the same silent dead branch. The current behaviour is pinned as correct by #15572 at `packages/services/service-automation/src/decision-predicate-envelope.test.ts` (the `it` block at `:110`–`:114` on this head; the card cited `:113`–`:117` from `aefbb07b2`, a 3-line drift, the pin itself intact). ⛔ Narrowing those slots re-judges that pin and moves a published accept-set: **a ruling, not a refactor**. `packages/services/**` is untouched by this diff — 0 files. ## 验收备注 - `packages/lint`'s `predicateSlotRefusal`-side prose and `flow-node-expression-paths.test.ts:189` also rest on "consistent on both sides" — but for the **ledger predicate slots** that statement is still **true** (resolver skips the blank, evaluator answers `false`, and neither has been rebound). Noted, not filed: nothing to correct until item ③ is ruled on. - `packages/spec/src/migrations/registry.ts` is a hot shared file: PR #17638 and PR #17635 both regenerate it from different entry files. No conflict at this head; `origin/main` merged at `5a77c75b29` and the registry regenerated and re-verified after. ## Verification Every run below is on the final head `5a77c75b29` (`origin/main` merged at that commit), with the gate's own verdict line, never a bare exit status read through a pipe. - `pnpm --filter @objectstack/spec build && check:generated && typecheck && test` — VERDICT `command-exit 0`; **473 test files / 13436 tests passed**. - The one test-layer file this diff touches, re-run alone: `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation/flow-node-expression-paths.test.ts` — 1 file / **26 tests passed**. - `node scripts/pm/dispatch-gates.mjs --commands` derived **84** gate families for this change set; **84 run, 82 green, 0 unrun** — reconciled with `--ran`, exit codes captured before any pipe. The two non-green exited **3 = PREREQUISITE NOT MET, i.e. NOT MEASURED and not a finding**: `check:dual-build-cjs-loads` and `check:type-check-debt` both refuse without a repo-wide build closure, and both are declared to CI, which builds one. - Item ②'s own gate, `check:adr-0087-registration` — exit 0, verdict: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". `check:migration-registry` exit 0, verdict: "src/migrations/registry.ts is current (202 semantic, 167 retired-key, 178 retired-def)". `check:spec-changes` and `check:upgrade-guide` both report their artifact up to date; `check:docs` reports 222 generated files in sync. - `eslint . --no-inline-config --format json` over the **whole repo**, eslint's own population read from its JSON output: **6638 files, 0 errors, 0 warnings**. No narrowing claimed and none needed. - `check:nul-bytes` exit 0 (8441 text files), plus a control-character self-scan over every changed file: 0 hits. Changeset: `.changeset/17493-node-door-refusal-residues.md`, `@objectstack/spec: patch`, card-scoped filename in the tree's prevailing spelling. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 305e7fc commit 0a88a80

5 files changed

Lines changed: 147 additions & 44 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493)
6+
7+
Two places in `packages/spec` still described the world as it was before the
8+
blank structural condition became a defect. Neither changes behaviour: this is
9+
the notification half of a refusal that has already shipped.
10+
11+
**The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required`
12+
named only the edge key.** Its `surface` and `acceptanceCriteria` told a
13+
consumer replaying the chain to sweep `edges[].condition` and nothing else —
14+
so a deployment carrying a blank `config.condition` on a flow node was never
15+
told to look, even though `AutomationEngine.registerFlow` refuses it since
16+
#17322 and `objectstack validate` since #17495. Both fields now name both
17+
structural slots, the node key's own locator
18+
(the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is
19+
stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the
20+
warning that removing a `condition` from a `start` node opens the trigger gate
21+
rather than preserving it. The entry's `id`, `replacement` and `reason` are
22+
untouched, and no new entry is added: this is one decision reaching its second
23+
slot, not a second decision.
24+
25+
**`structuralConditionRefusal`'s docblock stated a ruling that had become
26+
false.** It admitted a whitespace-only string on the ground that such a
27+
condition "is consistent on both sides and is ruled correct, not a defect" —
28+
the ground #15807 removed at the edge door and #17322 ruled on. The admission
29+
itself is unchanged and still correct, because this function answers the SHAPE
30+
question only and the blank is refused beside it by the imported
31+
evaluated-slot rule; what the docblock now records is which card removed the
32+
ground, which door each refusal lives at, and why the two refusals are kept
33+
distinct.
34+
35+
It also records, without answering, the question one slot over: the ledger
36+
`predicate` slots (`config.conditions[].expression`,
37+
`screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as
38+
correct by #15572 on the same ground. Narrowing them re-judges that pin and
39+
moves a published accept-set, so it is a ruling and stays open on #17493.

packages/spec/src/automation/flow-node-expression-paths.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,13 @@ describe('every pre-#14149 entry resolves byte-identically (the ratchet\'s fixtu
267267
it('admits every string — what it SAYS is validateExpression\'s business', () => {
268268
expect(structuralConditionRefusal('record.rating >= 4')).toBeUndefined();
269269
expect(structuralConditionRefusal('{record.rating} >= 4')).toBeUndefined();
270-
// Ruled correct, not a defect: a whitespace-only STRING means "not
271-
// authored" on both sides and stays so.
270+
// Still admitted — but on the SHAPE question only, and no longer because
271+
// the blank is correct. #15662 admitted it as "not authored on both
272+
// sides"; #15807 refused it at the edge door and #17322 rebound the node
273+
// door at `registerFlow`, so a blank structural condition IS a defect
274+
// today. It is refused there by the imported evaluated-slot rule sitting
275+
// BESIDE this one, never by this function — which is exactly what these
276+
// two assertions pin. See the docblock of `structuralConditionRefusal`.
272277
expect(structuralConditionRefusal(' ')).toBeUndefined();
273278
expect(structuralConditionRefusal('')).toBeUndefined();
274279
});

packages/spec/src/automation/flow-node-expression-paths.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,10 +421,30 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL =
421421
*
422422
* `undefined` — admitted — for:
423423
*
424-
* - every **string**, including a whitespace-only one. What a non-empty string
425-
* *says* stays `validateExpression('predicate', …)`'s verdict, and a
426-
* whitespace-only condition meaning `false` is consistent on both sides and
427-
* is ruled correct, not a defect.
424+
* - every **string**, including a whitespace-only one — on the SHAPE question,
425+
* which is the only question this function answers. What a non-empty string
426+
* *says* stays `validateExpression('predicate', …)`'s verdict.
427+
*
428+
* ⚠️ The whitespace-only string is still admitted here, but NOT for the
429+
* reason #15662 gave. That reason was that such a condition, meaning `false`,
430+
* "is consistent on both sides and is ruled correct, not a defect" — and
431+
* #15807 removed the ground under it, by making `FlowEdgeSchema.condition`
432+
* compose `EvaluatedExpressionInputSchema`, which refuses a blank `source` at
433+
* `FlowSchema.parse`. #17322 then ruled on the disagreement that left
434+
* (一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑) and rebound the node
435+
* door at `AutomationEngine.registerFlow`; #17495 followed at
436+
* `objectstack validate`. A blank structural condition is a defect today,
437+
* refused at all three doors.
438+
*
439+
* It is refused there by the EVALUATED-SLOT rule, not by this one. Both
440+
* consumers ask `EvaluatedExpressionInputSchema` — the edge door's own
441+
* schema, imported rather than restated — in a second gate sitting behind
442+
* this shape refusal and in front of the CEL pass, answering
443+
* `EVALUATED_EXPRESSION_SOURCE_REQUIRED` and not
444+
* {@link STRUCTURAL_CONDITION_SHAPE_REFUSAL}. Keeping the two distinct is
445+
* deliberate: a string IS a well-shaped structural condition, and a second
446+
* hand-written notion of "blank" per door is exactly the drift #15662 built
447+
* this one shared refusal to prevent. ⛔ Do not move the blank rule in here.
428448
* - absent / `null`. "Not authored" is not a malformed predicate; both callers
429449
* already return early on it, and this agrees rather than disagreeing.
430450
* - an **expression envelope the engine can evaluate**: an object carrying a
@@ -451,6 +471,23 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL =
451471
* `EvaluatedExpressionSchema` is the one place to relax, and this clause
452472
* follows it.
453473
*
474+
* ## The sibling `predicate` slots — an OPEN question, not answered here
475+
*
476+
* The blank rule reached the two STRUCTURAL slots only. The ledger `predicate`
477+
* slots — `config.conditions[].expression`, a `decision` node's branch list, and
478+
* `screen.fields[].visibleWhen` — are judged by {@link predicateSlotRefusal},
479+
* not by this function, and they still ADMIT a whitespace-only string:
480+
* registration takes it and `evaluateCondition` answers `false`, the same silent
481+
* dead branch #17322 closed one slot over. Recorded here rather than fixed,
482+
* because it is a RULING and not a refactor: #15572 pinned that admission as
483+
* correct on the very ground #15807 removed — that the blank is treated the same
484+
* way on both sides — so narrowing those slots re-judges a pin and moves a
485+
* published accept-set. #17493 carries the question (does the ledger predicate
486+
* slot follow the structural one?) and it is open at the time of writing. ⛔ Do
487+
* not answer it by widening this refusal: those slots do not pass through this
488+
* door, and a second notion of "blank" is what the shared refusal exists to
489+
* prevent.
490+
*
454491
* ## What it refuses, and what that was doing before
455492
*
456493
* A number, a boolean, an array, or an object carrying no string `source` —

packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import type { SemanticMigration } from '../../types.js';
77
export const entry: SemanticMigration = {
88
id: 'flow-edge-condition-evaluated-slot-source-required',
99
surface:
10-
'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate '
11-
+ 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an '
12-
+ 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or '
13-
+ 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', '
14-
+ 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable '
15-
+ 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack '
16-
+ 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in '
17-
+ 'sys_metadata',
10+
'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch '
11+
+ 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition '
12+
+ 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — '
13+
+ 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } '
14+
+ 'with no source), or with a source that is blank after trimming, through the envelope key '
15+
+ '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it '
16+
+ '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound '
17+
+ 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather '
18+
+ 'than deriving a second one; it is the same decision reaching the second slot, which is why '
19+
+ 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored '
20+
+ 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, '
21+
+ 'a POST /flows body, and a flow row already sitting in sys_metadata',
1822
replacement:
1923
'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare '
2024
+ 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the '
@@ -54,23 +58,30 @@ export const entry: SemanticMigration = {
5458
+ 'repo reading, which is why the notification is registered here rather than skipped. '
5559
+ 'ADR-0087, ADR-0032.',
5660
acceptanceCriteria:
57-
'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, '
58-
+ '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no '
59-
+ '`source` key and for a `source` (or bare string) that is empty after trimming. For each '
60-
+ 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the '
61-
+ '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, '
61+
'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s '
62+
+ '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger '
63+
+ 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and '
64+
+ 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` '
65+
+ '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the '
66+
+ 'node key unswept, and the node key is the one with no schema in front of it. For each '
67+
+ 'hit decide, per the `replacement` note, whether the condition was meant to branch (author '
68+
+ 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a '
69+
+ '`start` node removal opens the trigger gate rather than preserving it. Two proofs, '
6270
+ 'and the second is the one that matters for stored rows. (1) For a stack authored in config '
63-
+ 'files, `objectstack validate` is clean: it locates each offender at '
64-
+ '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and '
71+
+ 'files, `objectstack validate` is clean: it locates each offender with the '
72+
+ '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at '
73+
+ '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, '
74+
+ "e.g. `node 'gate' (start) condition` — and "
6575
+ 'an `ast`-only envelope is also reported by the lint path as '
66-
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back '
76+
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that '
77+
+ 'spelling as well. There is no CLI verb that lowers a stored row back '
6778
+ 'into a config file, so this proof does not reach a flow that exists only in '
6879
+ '`sys_metadata`. (2) Boot the stack and '
6980
+ 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot '
7081
+ 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed '
7182
+ 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and '
72-
+ 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` '
73-
+ 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is '
74-
+ 'unaffected; every edge '
83+
+ 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its '
84+
+ '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same '
85+
+ "slot phrase. A flow that boots without that warn is unaffected; every structural "
7586
+ 'condition carrying a non-blank `source` parses byte-identically to before.',
7687
};

packages/spec/src/migrations/registry.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7842,14 +7842,18 @@ const step18: MigrationStep = {
78427842
{
78437843
id: 'flow-edge-condition-evaluated-slot-source-required',
78447844
surface:
7845-
'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate '
7846-
+ 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an '
7847-
+ 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or '
7848-
+ 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', '
7849-
+ 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable '
7850-
+ 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack '
7851-
+ 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in '
7852-
+ 'sys_metadata',
7845+
'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch '
7846+
+ 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition '
7847+
+ 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — '
7848+
+ 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } '
7849+
+ 'with no source), or with a source that is blank after trimming, through the envelope key '
7850+
+ '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it '
7851+
+ '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound '
7852+
+ 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather '
7853+
+ 'than deriving a second one; it is the same decision reaching the second slot, which is why '
7854+
+ 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored '
7855+
+ 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, '
7856+
+ 'a POST /flows body, and a flow row already sitting in sys_metadata',
78537857
replacement:
78547858
'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare '
78557859
+ 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the '
@@ -7889,24 +7893,31 @@ const step18: MigrationStep = {
78897893
+ 'repo reading, which is why the notification is registered here rather than skipped. '
78907894
+ 'ADR-0087, ADR-0032.',
78917895
acceptanceCriteria:
7892-
'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, '
7893-
+ '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no '
7894-
+ '`source` key and for a `source` (or bare string) that is empty after trimming. For each '
7895-
+ 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the '
7896-
+ '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, '
7896+
'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s '
7897+
+ '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger '
7898+
+ 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and '
7899+
+ 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` '
7900+
+ '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the '
7901+
+ 'node key unswept, and the node key is the one with no schema in front of it. For each '
7902+
+ 'hit decide, per the `replacement` note, whether the condition was meant to branch (author '
7903+
+ 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a '
7904+
+ '`start` node removal opens the trigger gate rather than preserving it. Two proofs, '
78977905
+ 'and the second is the one that matters for stored rows. (1) For a stack authored in config '
7898-
+ 'files, `objectstack validate` is clean: it locates each offender at '
7899-
+ '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and '
7906+
+ 'files, `objectstack validate` is clean: it locates each offender with the '
7907+
+ '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at '
7908+
+ '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, '
7909+
+ "e.g. `node 'gate' (start) condition` — and "
79007910
+ 'an `ast`-only envelope is also reported by the lint path as '
7901-
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back '
7911+
+ '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that '
7912+
+ 'spelling as well. There is no CLI verb that lowers a stored row back '
79027913
+ 'into a config file, so this proof does not reach a flow that exists only in '
79037914
+ '`sys_metadata`. (2) Boot the stack and '
79047915
+ 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot '
79057916
+ 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed '
79067917
+ 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and '
7907-
+ 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` '
7908-
+ 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is '
7909-
+ 'unaffected; every edge '
7918+
+ 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its '
7919+
+ '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same '
7920+
+ "slot phrase. A flow that boots without that warn is unaffected; every structural "
79107921
+ 'condition carrying a non-blank `source` parses byte-identically to before.',
79117922
},
79127923
{

0 commit comments

Comments
 (0)