Skip to content

fix(spec)!: refuse a blank string in a flow node's predicate slot — decision branch expression, screen field visibleWhen (#17493) - #19960

Merged
os-justin merged 7 commits into
mainfrom
claude/issue-17493-node-door-residues
Sep 24, 2026
Merged

os-justin merged 7 commits into
mainfrom
claude/issue-17493-node-door-residues

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #17493
Clause-②: no (narrowing)

Executes ruling A (5651023407). A string that is blank after trimming is refused in two flow-node predicate slots: decision config.conditions[].expression and screen config.fields[].visibleWhen. The refusal fires at FlowSchema.parse, AutomationEngine.registerFlow and objectstack validate, with a message led by PREDICATE_SLOT_STRING_REFUSAL.

  • Census first (ruling item 1): at base 3b5607019f, the dev found no flow in the tree or in the example stacks carrying either blank. The method is in report 5811268231.
  • Code:
    • flow.zod.ts: a FlowSchema refinement over the ledger predicate slots.
    • flow-node-expression-paths.ts: the resolver emits a blank predicate string, and predicateSlotRefusal refuses it.
    • engine.ts and validate-expressions.ts: comments only.
  • Card item 1: the structuralConditionRefusal docblock now records that ruling A answered its open question, and its doors line is corrected for the node slot.
  • Card item 2: a new ADR-0087 entry, flow-predicate-slot-blank-string-refused; registry.ts is regenerated.
  • Pins: one file per door, plus re-judged existing pins. The dev ablated each refusal red (report 5811268231). predicate-slot-blank.test.ts also pins, on the real decision executor, that 'false' runs what the blank ran, and that dropping the only branch runs the out-edge it labelled. Both were ablated red (report 5812924875).
  • Seat rulings (5811310916, corrected by 5811904464 and 5812959979): the parse door stays although config.condition has none. On a decision branch, the prescription that keeps the run is expression: 'false', the value the blank evaluated to. Dropping a decision's only branch is named as the thing not to do.

Changeset: @objectstack/spec, @objectstack/service-automation and @objectstack/lint at minor, plus BREAKING (the launch window refuses major), with an ADR-0087 registered marker.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 8 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via FlowSchema (symbol, a top-level const))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via validateStackExpressions (symbol, a top-level function))
  • content/docs/releases/v17/17-0.mdx (via FlowSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-4.mdx (via FlowSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3b5607019f6b1f84b14716c9c5e3359a986de08e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 32c93b68e703145a1ce1cda39fb503a114b5c4d6 — the merge of head 58a65d12819cc4bc3a690a9207e3074e805303c7 into base 3b5607019f6b1f84b14716c9c5e3359a986de08e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 32c93b68e703145a1ce1cda39fb503a114b5c4d6 && git checkout 32c93b68e703145a1ce1cda39fb503a114b5c4d6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3b5607019f6b1f84b14716c9c5e3359a986de08e 58a65d12819cc4bc3a690a9207e3074e805303c7 && git checkout -B drift-repro 3b5607019f6b1f84b14716c9c5e3359a986de08e && git merge --no-ff 58a65d12819cc4bc3a690a9207e3074e805303c7

node scripts/docs-audit/affected-docs.mjs --json 3b5607019f6b1f84b14716c9c5e3359a986de08e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3b5607019f6b1f84b14716c9c5e3359a986de08e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dd251cb112f71d2d41d0f075453463fd2bf99f86

Reviewed and posted 2026-09-24T09:53Z by the at-tier review subagent the domain:spec#5 seat spawned — card #17493 (all 12 comments), rulings 5651023407 / 5808326582 / 5811310916, report 5811268231, #17322 / #15572 / #15807 / PR #17761, ADR-0087, AGENTS.md, the two references; the diff, body, 4 commits and 46 check-runs at this head (read). Built spec / service-automation / lint and their deps in a detached worktree at this head; ran the three pin suites, re-ran ablations A1–A5, and drove the parse, registerFlow, boot (LiteKernel + AutomationServicePlugin), REST-mapping and run-time routing probes on the built dist (ran). Base-tree accept set, the metadata saveMetaItem 422 door, the artifact-file boot path and the CLI's printed output are read, not run (NOT MEASURED where so marked).

① Derived judgments

Closure — every door a flow reaches the engine through, and what the blank meets there (all measured on the built head unless marked read).

  • FlowSchema.parse / defineFlow: refused, code: custom, path nodes.N.config.conditions.I.expression / …fields.I.visibleWhen, message leads with PREDICATE_SLOT_STRING_REFUSAL; for '', ' ', '\t\n '; inside a loop body, a parallel branch (…config.branches.0.nodes.0.config…) and a try_catch catch region. collectFlowGraphs walks exactly the FLOW_REGION_SLOTS table (loop.body, parallel.branches[], try_catch.try/.catch) — the same walk the engine pass and lint use.
  • Scope is exactly the two ledger predicate slots (the ledger carries 2 predicate, 2 flow-template, 1 value entry). Untouched at parse, measured: absent / null key, the { dialect, source } envelope, 42, a flow-template blank (loop.collection: ' '), conditions not an array, a custom node type carrying conditions[].expression, and a node's blank config.condition (parses success: true — the docblock's corrected "not at FlowSchema.parse on a node" is true).
  • AutomationEngine.registerFlow: throws the parse's ZodError; getFlow → null; with a recording record_change trigger registered the blank flow is never bound while the non-blank control is (getActiveTriggerBindings = control only). Reached by POST /automation, PUT /automation/:name, POST /automation/:name/clone and any IAutomationService caller (read). REST mapping measured through fieldsFromZodIssues: field: nodes.1.config.fields.0.visibleWhen, code: invalid_value; the 400 VALIDATION_FAILED envelope is read from flowDefinitionRefusal, not run.
  • objectstack validate: ObjectStackDefinitionSchema issue at flows.0.nodes.1.config.conditions.0.expression (measured); validateStackExpressions errors with the pinned where locator; the CLI prints path.join('.') (cli/src/utils/format.ts formatZodErrors, read).
  • defineStack({ flows }) (strict by default): throws StackSchemaInvalidError at module evaluation — the whole stack, not one flow (measured). The artifact-file boot path (metadata/src/plugin.ts:915, ObjectStackDefinitionSchema.parse with no catch around it) fails the whole artifact (read). See ③.
  • Boot, stored / registry-pulled flow: LiteKernel + AutomationServicePlugin with a fake objectql registry and a fake protocol both serving a blank flow and a good sibling: bootstrap completes; two warns, [Automation] failed to register flow and [Automation] cold-boot flow bind: failed to register flow, each with flow: "stored_blank" in meta; the sibling registers and binds; the blank is neither stored nor armed. The third spelling (re-sync) has the same catch shape (read). objectstack migrate meta --stored goes through canonicalizeStoredFlow and reports the row failed (read).
  • Metadata save door (protocol.saveMetaItem → getMetadataTypeSchema('flow') = FlowSchema): refuses at save — read, NOT MEASURED. Not named by the entry.
  • objectui at the pin 62597c5880: clientValidation.ts LOADERS.flow live-validates the flow draft against FlowSchema, so the Studio now reds a pasted blank before save with this message; the designer's own row writer (FlowObjectListField rowsToList) drops a blank cell, so it never writes the blank (it writes the absent key — [finding] A decision branch with no expression key registers and validates clean, although DecisionConditionSchema declares it required and the executor throws on the source-less envelope #19961). No bypass. rollbackFlow's this.flows.set reads an in-memory history filled only by registerFlow in the same process (read); BPMN import returns constructs that go through FlowSchema (read); packages/mcp has no flow-authoring tool (grep). evaluateCondition direct callers still get false (pinned, measured) — by design.

Regression. Object.keys(FlowSchema.shape) = Flow.json properties (23 = 23); .meta() null; FlowSchema carries one custom check before and after (the block sits inside the existing superRefine); the diff touches no json-schema/**, api-surface, export-origins file; z.toJSONSchema cannot project a superRefine walk (refinement-projection header), so the published Flow.json stays wider than the parse on these two slots — the same shape #17322 shipped. PREDICATE_SLOT_STRING_REFUSAL text: 0 copies of the old or new wording in objectstack (every matcher binds the constant, startsWith/toThrow) and 0 in objectui at the pin. Consumers of FlowSchema (read): automation-api.zod.ts request/response, metadata-type-schemas.ts, stack.zod.ts, engine canonicalizeStoredFlow, objectui clientValidation.

Census (own numbers). Static, tight regex over every tracked file: 0 blank visibleWhen literals outside tests (the one hit is a lint comment) and 0 blank expression literals outside tests/CHANGELOG; positive control: the same regex hits 3 blanks in test files. Dynamic, deep-walked: app-crm 1 flow / 8 nodes, app-todo 4 / 26 (6 regions), app-showcase 30 / 139 (14 regions, via its allFlows barrel — the config's dependency closure does not load without a full build), app-multi-package 0 flows: 0 decision branches, 5 screen fields, 0 authored visibleWhen, 0 blanks; lit control finds a planted blank of each kind, one nested in a loop body. ⚠️ The stacks carry no instance of either slot, so the dynamic census is a null over the narrowed set; the static grep is the evidence.

Stored flows at boot. True as measured for a sys_metadata / registry-pulled flow (above). ⚠️ False by omission for the two other stored forms: a defineStack source throws whole at import, an artifact file fails whole at _parseAndRegisterArtifact. Nothing shipped says so. The boot warn spells the path nodes[1].config.conditions[0].expression (bracket form), not the nodes.N.config… the entry names — cosmetic.

Pins re-measured (one anchor per ablation, one hunk proved, spec dist rebuilt for A1–A3, restored, tree clean after each). A1 parse call: spec 8 red / sa 7 red / lint green. A2 blank arm: 9 / 8 / 8. A3 resolver: 9 / 9 / 8. A4 lint call site (whole call disabled): lint 11 red = the 8 blank pins + 3 #15572 envelope pins. A5 engine call site (whole call disabled): sa 5 red, all five the #15572 envelope pins; every blank pin stays green — the engine pass is the second line for the blank and the only line for the envelope, exactly what the engine comment and the "Unchanged" paragraph claim. Head re-run after restore: 41 / 34 / 319 green. Uncovered by pins: the saveMetaItem door, defineStack/artifact whole-stack refusal, the REST 400 (declared out of surface), the re-sync warn spelling, and the prescription's run-time equivalence — which is where the blocking finding lives.

The seat's departure (Q2) — measured, and it is NOT behaviour-preserving. Real decision executor (registerLogicNodes) on the built engine; the "before" flow registered with a placeholder and its stored parsed definition mutated to expression: ' ' (the evaluator is untouched — ' ', '' and the blank envelope all answer false, measured). Node d with out-edges e1 labelled b0 (unconditional) → x and e2 isDefault → y:

  • conditions: [{ label: 'b0', expression: ' ' }] → nodes run: start, d, y. Control expression: 'false' → identical.
  • prescription applied (conditions: [], and again with no conditions key) → nodes run: start, d, y, x — x now runs on every pass. The node flips from declared branching (all failed → default → the isDefault edge only) to a plain gateway (conditions.length === 0 → no branch label → every out-edge, traverseNext).
  • Controls: without a default edge before = after (x, y both, with the A decision node has three declared ways to route a branch and two of them do nothing — app-crm's convert-lead guard runs both branches #4414 unclaimed-label warn); a blank that is NOT the last branch ([b0 blank, b1 true]) before = after (y only).
  • The lint adds flow-decision-unconditional-branch on every measured after-shape (none before).
    This is the canonical "branch on the node" example content/docs/automation/flows.mdx documents (one branch + isDefault). The sentences "a branch whose predicate was blank was never taken, so dropping it changes no run. ⚠️ Removal is behaviour-preserving HERE" (entry replacement), "Removing is behaviour-preserving on these two slots" (changeset) and "Dropping the branch is the behaviour-preserving reading" (seat ruling, PR body) are false for a node whose only branch is blank; the refusal message's "drop that branch from conditions" walks an author into that flip without a word. The visibleWhen half holds: the resume contract trims (blank ≡ absent), judgeHeadlessScreen reads no visibleWhen, and the pinned renderer shows the field for '' and, via the blank-predicate fallback, for whitespace.

Other shipped sentences. Verified: the parse-door custom issue and paths; "leads with the published sentence"; "registerFlow and objectstack validate through that same parse" (A5); "engine.ts and validate-expressions.ts: comments only" (diff: 8 + 5 comment lines); the entry is verbatim in the regenerated registry (whitespace-normalised compare, id present once); the "Unchanged" paragraph (envelope still parses; edge and config.condition keep EVALUATED_EXPRESSION_SOURCE_REQUIRED; evaluator false); the corrected structural docblock; the ledger names exactly the two slots. "Used to be accepted … by FlowSchema.parse, registerFlow and objectstack validate" at base: NOT MEASURED at base (no base build); the three re-judged pins' prior assertions are base evidence for the last two, and no parse-time refusal existed to remove for the first. False: surface and acceptanceCriteria name POST /flows — no such route exists; the write door is POST /automation (route ledger automation.create; automation-api.zod.ts), and surface is rendered into the upgrade guide.

② Semver level

@objectstack/spec: minor + **BREAKING** + Clause-②: no (narrowing) + the adr-0087 marker registered flow-predicate-slot-blank-string-refused — the level, banner and arm are what AGENTS.md prescribes for a narrowing under the launch window (check-changeset-no-major refuses major; Check Changeset green), the FROM → TO table and one-line fix are present, and the disposition is registered with an id new in the diff. The disposition is right in form and wrong in content: the entry's prescription changes a run (①, blocking). Packages: registerFlow (@objectstack/service-automation) and validateStackExpressions (@objectstack/lint) both moved from accept to refuse at this head (their own pins were re-judged), and the changeset names spec only; the precedent for this exact resolver-plus-predicateSlotRefusal change, .changeset/decision-predicate-envelope-refused.md (#15572), listed all three packages. Their CHANGELOGs will carry no sentence for the moved verdict — non-blocking.

③ Boundary flags

Blocking:

  • B1 — the decision-branch prescription is not behaviour-preserving when the blank branch is the node's only branch (measured, ①). Fix before landing: rewrite the entry replacement / acceptanceCriteria, the changeset's "Removing is behaviour-preserving" paragraph and the refusal message so that on a decision branch the prescription is "write the predicate" first, and removal of the last branch is named for what it is (the node becomes a plain gateway; every ungated out-edge runs — also remove or gate the out-edge that branch labelled), and add a pin for the equivalence the prescription claims. The seat ruling on Q2 is recorded as departing from the ruling's letter; this is the measurement the director needs to ratify or overrule it.

Non-blocking:

CI at this head, 46 runs → 35 names after de-dup by latest started_at: 30 success, 5 skipped, 0 failed, 0 running (the four Test Core shards running at spawn all finished green). Skips, with reason: Build Docs and Console Pin Gate (path filter needs.filter.outputs.docs/console), Packed-tarball smoke (opt-in label absent), Auto Label and Check PR Size (their latest runs fired on edited/labeled, which the workflow skips because a body or label edit moves no file; their opened runs succeeded). Swept for every model-identifier spelling: none in this record.

Implemented-by: claude/issue-17493-node-door-residues
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: FAIL


Generated by Claude Code

…s routing, scope the stored-flow and route sentences

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 58a65d12819cc4bc3a690a9207e3074e805303c7

Reviewed and posted 2026-09-24T11:52Z by the at-tier review subagent the domain:spec#5 seat spawned — round 2 after the FAIL 5811874589. Card #17493 (all 15 comments), rulings 5651023407 / 5808326582 / 5811310916 / 5811904464 / 5812959979, reports 5811268231 / 5812924875, #17322 / #15572 / #15807 / PR #17761, #19961 / #19966, ADR-0087, AGENTS.md, the two references; the diff (13 files), body, 7 commits and 46 check-runs at this head (read). Detached worktree at this head, dependencies installed, the spec / service-automation / lint / metadata / cli closures built for real (57 tasks) with a dist preflight proving the head's refusal text present and the base wording absent; ran the parse, registerFlow, trigger-binding, run-time routing, boot (LiteKernel + AutomationServicePlugin), saveMetaItem, artifact-file, defineStack, validateStackExpressions + lintFlowPatterns and built-CLI validate probes; the six pin files; ablations A1–A5, R1, R2 (ran). Base-tree accept set, the REST 400 mapping, the re-sync warn, migrate meta --stored, duplicatePackage, BPMN import and objectui's renderer are read, not run (NOT MEASURED where so marked).

① Derived judgments

Round-1 items. B1 fixed (below, measured). N1: POST /flows gone from the entry (0 hits in the card's files; the 2 hits left in registry.ts are the sibling entry, #19966). N2: changeset now names spec, service-automation, lint at minor — the #15572 set. N3: the stored-flow sentence is scoped to three measured forms (below). N5: measured clean (below). N4 and N6 carried (③).

Closure — every door, what the blank meets there (measured on the built head unless marked read).

  • FlowSchema.parse / defineFlow: refused, code: custom, path nodes.1.config.conditions.0.expression / …fields.0.visibleWhen, message leads with PREDICATE_SLOT_STRING_REFUSAL; for '', ' ', '\t\n ', NBSP and '\r'; inside a loop body, both parallel branches, try_catch try and catch, and a loop inside a loop (nodes.1.config.body.nodes.0.config.body.nodes.0.config…). Ledger: exactly 2 predicate entries (plus 2 flow-template, 1 value); a screen's fields sit only at config.fields[] (no sections). Untouched, 0 issues each: absent / null key, the { dialect, source } envelope, 42, loop.collection: ' ', conditions: 'nope', conditions: [], { label } with no expression ([finding] A decision branch with no expression key registers and validates clean, although DecisionConditionSchema declares it required and the executor throws on the source-less envelope #19961), a custom node type carrying conditions[].expression: ' ', a node's blank config.condition, 'false'. An edge blank keeps EVALUATED_EXPRESSION_SOURCE_REQUIRED.
  • defineStack({ flows }): StackSchemaInvalidError, whole stack, issue at flows.1.nodes.1.config.conditions.0.expression. Built CLI os validate on the blank: exit 1, defineStack validation failed, the new refusal text at flows.0.nodes.1.config.conditions.0.expression.
  • AutomationEngine.registerFlow: throws the parse's ZodError (custom, exact path, sentence lead); getFlow → null; with a record_change trigger registered, the blank record-change flow is never bound while the sibling is (getActiveTriggerBindings = tb_good only). Reached by POST /automation, PUT /automation/:name, POST /automation/:name/clone (domains/automation.ts, read; 400 mapping NOT MEASURED this round).
  • Boot, registry / sys_metadata: LiteKernel + AutomationServicePlugin, fake objectql registry and fake protocol both serving stored_blank beside stored_good: bootstrap completes; two warns — [Automation] failed to register flow and [Automation] cold-boot flow bind: failed to register flow — each with flow: stored_blank, code: custom, the sentence, path spelled nodes[1].config.conditions[0].expression; stored_good named in neither, registers; the blank is null. The re-sync spelling has the same catch (read).
  • Studio / /meta save, saveMetaItem: 422 INVALID_METADATA at nodes.1.config.conditions.0.expression in draft and publish mode, with no automation service and with a canonicalizer that throws (the raw-body fallback lands on the schema gate); the 'false' control passes the gate and reaches persistence. Round 1 had this NOT MEASURED; measured now.
  • Artifact file, MetadataPlugin._parseAndRegisterArtifact: ZodError at flows.1.nodes.1.config.conditions.0.expression, nothing registered (the good sibling included); 'false' control loads both.
  • migrate meta --stored reports the row failed; duplicatePackage fails the row unparseable metadata; rollbackFlow reads an in-memory history only registerFlow fills; BPMN import validates against FlowSchema; packages/mcp / AI tools have no flow-authoring path (grep) — all read. objectui at the pin 62597c5880: clientValidation.ts LOADERS.flow = FlowSchema (read), 0 copies of the old or new refusal text.

Regression. Object.keys(FlowSchema.shape) = Flow.json properties (23 = 23), .meta() null, one check (the block sits inside the existing superRefine); no json-schema/**, api-surface, export-origins file in the diff — the published Flow.json stays wider than the parse on these slots (unprojectable superRefine, as #17322). PREDICATE_SLOT_STRING_REFUSAL text: 0 copies in objectstack outside its declaration and 0 in objectui at the pin; 6 test files / 30 references bind the constant (23 startsWith / toThrow / toContain sites). The hand-written content/docs/automation/flows.mdx names both slots with non-blank predicates only. The refusal string carries no tracker number.

Census (own, static). git grep over every tracked file at this head: 0 blank visibleWhen / expression literals outside tests, CHANGELOG.md and this changeset (denominators 2078 visibleWhen keys, 42 conditions arrays); positive control: 7 test files carry the blank form. Dynamic walk of the example stacks NOT re-run (the grep covers examples/).

Pins re-measured (head: spec 41 / sa 38 / lint 319 green; spec-side ablations each with a real spec rebuild, a dist preflight proving the ablated behaviour, restore to 0 diff lines, rebuild, preflight restored):

The seat's departure (Q2) — expression: 'false', measured on the real decision executor, 7 cases (before = registered with 'true', stored branch rewritten to ' '; columns = successful steps, #4414 unclaimed-label warns): C1 only branch + isDefault edge: blank start,d,y w0 = 'false'; drop start,d,y,x. C2 not last: y = y; drop (all branches) z,x,y. C3 only branch, no default: x,y w1 = w1; drop w0. C4 last-not-only: z = z. C5 only branch, a single labelled out-edge: x w1 = w1; drop x w0. C6 two blanks + default: z = z. C7 only branch + an edge labelled default: y = y; drop x,y. 'false' equals the blank in routing and warn count in all 7; dropping the only branch differs in all 7 (in C5 only by the lost warn — the blank already ran x through the unclaimed-default fallback, so "no longer held back" is the mechanism, not that case's before-state). evaluateCondition('false'), the envelope, '' and ' ' all answer false; validateExpression('predicate', 'false') ok. The visibleWhen half: screen-input-contract.ts guards visibleWhen with trim() !== '' at three sites (blank ≡ absent, read); the client renderer is round 1's reading at the pin, NOT re-run. Every shipped sentence now says 'false' / drop visibleWhen; "behaviour-preserving", "drop that branch", "drop the whole", "removing means", "each boot path": 0 hits in the card's files.

N5. Built CLI os validate on the prescribed shapes (C1 with 'false' + isDefault; a screen field with no visibleWhen): ✓ Validation passed, 46 author-time rules, --json valid: true, only the three stack-level notices the probe stack draws; validateStackExpressions and lintFlowPatterns report nothing at the node for C1, C2, C3, C5; the drop shapes draw flow-decision-unconditional-branch on every case. Acceptance proof (1) holds as written.

Other shipped sentences. Verified: the entry is verbatim in the regenerated registry (whitespace-normalised, id once); "registerFlow (which parses first)" (A5); "engine.ts and validate-expressions.ts: comments only" (diff); the three boot-warn spellings match plugin.ts (two fired, one read); the corrected structural docblock ("not at FlowSchema.parse on a node" — a blank config.condition parses with 0 issues); the flow.zod block's three scopes (strings only, predicate role only, no key-set closure — all measured); the PR body's census, pin and seat-ruling lines. NOT MEASURED at base (no base build): "used to be accepted … by FlowSchema.parse, registerFlow and objectstack validate" and "parses and registers byte-identically to before" — evidence is the re-judged pins' prior assertions, no parse refusal existing to remove, and an addIssue-only refinement.

② Semver level

@objectstack/spec, @objectstack/service-automation, @objectstack/lint at minor + **BREAKING** + Clause-②: no (narrowing) + <!-- adr-0087: registered flow-predicate-slot-blank-string-refused --> with the id new in the diff and resolving once — the level, banner, arm and disposition AGENTS.md and the ADR-0087 level amendment prescribe under check-changeset-no-major; FROM → TO table and one-line fix present; Check Changeset green. The disposition is now right in content too: the entry's prescription keeps the run in every measured case. The three packages are the ones whose own verdict moved (the #15572 set); metadata, metadata-protocol, runtime and cli move only as consumers of the spec's parse and carry no code change — no line owed by precedent.

③ Boundary flags

Blocking: none.

Non-blocking:

CI at this head, 46 runs → 35 names after de-dup by latest started_at: 30 success, 5 skipped, 0 failed, 0 running; every run had completed when read, the last being Check Changeset's re-fire on the body edit (started 11:15Z, success). The seven required contexts are all success. Skips, with reason from the workflow files: Build Docs and Console Pin Gate (!cancelled() && needs.filter.outputs.<name> != 'false', the filter job's path outputs); Packed-tarball smoke (needs:pack-smoke label absent); Auto Label and Check PR Size (latest runs fired on edited / labeled at 11:14–11:15Z, which their if: excludes; their opened runs at 10:53Z succeeded). Swept for every model-identifier spelling: none in this record.

Implemented-by: claude/issue-17493-node-door-residues
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: PASS


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 24, 2026 11:55
@os-justin
os-justin added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 2c1011b Sep 24, 2026
50 of 51 checks passed
@os-justin
os-justin deleted the claude/issue-17493-node-door-residues branch September 24, 2026 12:15
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ull — at all three doors (objectstack-ai#19961) (objectstack-ai#20315)

Fixes objectstack-ai#19961

Clause-②: no (narrowing)

A `decision` branch with no `expression` (the key absent, or
`expression: null`) is now refused at all three doors:
`FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack
validate`. It goes through the same walk, the same function and the same
lead sentence that already refuse a blank branch predicate (objectstack-ai#17493 / PR
objectstack-ai#19960).

## What was wrong, measured on `origin/main` `a9fb83ef`

`DecisionConditionSchema` declares a branch `{ label, expression }` with
`expression` a required `z.string()`. Nothing parses a decision node's
open `config` against that schema. The expression ledger's resolver also
skipped an absent value as "not authored". So the build accepted a
branch that the run refuses.

| branch | `FlowSchema.parse` | `registerFlow` | `objectstack validate
--json` |
|:--|:--|:--|:--|
| `{ label: 'y' }` (the card's shape) | accepted | registered | `valid:
true`, exit 0 |
| `{ label: 'y', expression: null }` | accepted | registered | `valid:
true`, exit 0 |
| `{ label: 'y', condition: 'true' }` (the edge's spelling) | accepted |
registered | `valid: true`, exit 0 |
| `{ label: 'y', expression: ' ' }` (control, objectstack-ai#19960) | refused,
`custom` at `nodes.1.config.conditions.0.expression` | refused, same
issue | `valid: false`, exit 1, same path |
| `{ label: 'y', expression: 'true' }` (control) | accepted | registered
| `valid: true`, exit 0 |

What the run did with it: `evaluateCondition({ dialect: 'cel', source:
undefined })` and `source: null` both throw `condition evaluation error:
A structural condition …`. On the real decision executor, a run that
reaches such a branch ends `success: false` at the branch (pinned
below).

## The fix: one walk, one judge

- `packages/spec/src/automation/flow-node-expression-paths.ts`
- `FlowNodeExpressionPath` gains `required?: true`. It is set on
`decision` `conditions[].expression` and on nothing else.
- For a `required` predicate slot, `resolveFlowNodeExpressions` now
emits the absent or `null` value on a branch that exists. It still skips
a decision with no `conditions`, an empty list, and an absent screen
`visibleWhen`.
- `predicateSlotRefusal(undefined | null)` now has its own detail
sentence and prescription, under the unchanged
`PREDICATE_SLOT_STRING_REFUSAL` lead.
- `packages/spec/src/automation/flow.zod.ts`: the `FlowSchema`
predicate-slot refinement now admits the absent or `null` value of a
`required` slot, next to strings. Every other non-string keeps its
objectstack-ai#15572 scope, so it is still not refused at this door.
- `packages/lint/src/validate-expressions.ts`: `checkDeclaredPredicate`
dropped its `raw == null` early return. Whether an absent value is a
finding is the resolver's call. The early return answered "valid" for
the exact value `FlowSchema.parse` refuses, for any caller of
`validateStackExpressions` that does not parse first. This site is
outside the claim's file surface. The measurement put the third door's
refusal there (ablation B below).
- `engine.ts`: no change. Its ledger pass already calls
`predicateSlotRefusal` on everything the resolver emits, and
`registerFlow` parses first, so the parse answers first. That is the
same two-layer shape the blank has.

**The route choice (Zone 2 item 3).** I chose (a), the predicate-slot
walk treating an absent `expression` as a refused slot. I did not choose
(b), parsing each branch against `DecisionConditionSchema`. Reasons, per
axis:

- Business need, measured: the only named producer is objectui's
`rowsToList`, which writes `{ label }`. The absent key is the whole
defect.
- Long-term design: (a) keeps one judge (`predicateSlotRefusal`) and one
walk for the three doors. (b) would be a second judge with Zod's own
messages, a different prescription at each door, and a key-set closure
on branches that nobody ruled.
- Guarding AI authors: both refuse loudly. (a) also names the
`condition` alias mistake in the same prescription.
- No scope growth: (a) touches one ledger entry. (b) would narrow a much
wider accept set, including unknown branch keys and the whole branch
shape.

`DecisionConditionSchema` and the fenced `DecisionConfigSchema` / `mode`
region are untouched. objectstack-ai#20168's PR objectstack-ai#20279 landed while this was in
flight, and this branch is merged over it (`7534fd7e`). Its refusal
lives in `DecisionConfigSchema`, which no door parses a node's config
against, so it and this walk do not meet. Its suite is green here (in
the `src/automation` run below).

**Prescription wording.** Triage (`5811370954`) says PR objectstack-ai#19960's
decision-branch prescription is "删掉这个分支" (delete the branch). The landed
objectstack-ai#19960 text says something else: write the predicate, or `expression:
'false'` to keep what the blank ran, and ⚠️ **not** by dropping a
decision's only branch. That clause is pinned by
`predicate-slot-blank.test.ts`. This PR follows the landed wording. It
drops the "keep what ran" half, because an absent predicate never ran:
it failed the run at the branch. So `'false'` is offered as "keep the
branch and its label, never take it", and nothing is claimed to be
preserved.

### The refusal text, quoted (`predicateSlotRefusal(undefined)`, byte
for byte what all three doors print)

```text
A predicate slot holds BARE CEL TEXT that states a rule — it is declared `z.string()` — so an expression envelope, any other non-string, or a string that is blank after trimming is not authorable there. Found nothing — the key is absent where the slot is required: a decision branch is `{ label, expression }` and its `expression` is not optional, so a branch without one states no rule. Write the predicate the branch was meant to test (e.g. `record.rating >= 4`); a predicate written under another key — `condition` is the edge's spelling — belongs in `expression`. There is no run to keep: the executor evaluates every branch it reaches, and a branch with no `expression` failed the run there. To keep the branch and its label but never take it, write `expression: 'false'`. Not by dropping a decision's only branch: the node then routes by its out-edges alone, and the out-edge that branch labelled is no longer held back.
```

For `null`, `Found nothing — the key is absent` reads `Found` followed
by the code-spelled `null`. The lead sentence
(`PREDICATE_SLOT_STRING_REFUSAL`) is unchanged, byte for byte.

**After, measured on `e702ebd4` (the real CLI door, spec rebuilt; no
file of this diff changed after that).** `{ label: 'y' }`, `expression:
null` and `condition: 'true'` all give `objectstack validate --json`
`valid: false`, exit 1, one `custom` error at
`flows.0.nodes.1.config.conditions.0.expression`. The absent and alias
messages are byte-identical. `registerFlow` refuses the same three with
a `custom` issue at `nodes.1.config.conditions.0.expression`.
`expression: 'true'` still validates and registers. The blank keeps its
own message.

## Pins: one table per door, the same five rows

Every refused row asserts the issue `code`, the `path`, and the full
message equal to the spec's own `predicateSlotRefusal(value).message`.

-
`packages/spec/src/automation/flow-decision-branch-expression-absent.test.ts`:
`FlowSchema.parse`.
- The five rows: absent, `null`, `condition` alias, blank control, real
accept control.
  - Branch index 1 is anchored. The ADR-0031 region body is anchored.
- Controls: a decision with no `conditions` or `[]` still parses; an
absent screen `visibleWhen` still parses.
-
`packages/services/service-automation/src/decision-branch-expression-absent.test.ts`:
`registerFlow`.
  - The same table. `getFlow` is `null` after each refusal.
  - Region body.
- On the real decision executor: the absent branch failed the run
(`success: false`, `condition evaluation error`, ran `['start']`);
`expression: 'false'` routes to the fallback.
- `packages/lint/src/validate-expressions.test.ts` `describe('a decision
branch with no expression (objectstack-ai#19961)')`: `validateStackExpressions`, with
the same table, the exact `where` string, branch index 1, and controls.
- `packages/spec/src/automation/flow-node-expression-paths.test.ts`:
- The resolver emits `undefined` or `null` for the decision slot and
skips everything else.
- `predicateSlotRefusal(undefined | null)` prescription clauses are
pinned by name.
- The `required` set is pinned to exactly
`decision.conditions[].expression (predicate)`, because the absent arm's
wording is decision-specific.
-
`packages/services/service-automation/src/builtin/config-expression-ledger.test.ts`:
the reconciliation ratchet now reads each channel's JSON-Schema
`required` list. It asserts that the ledger's `required` flags equal the
channel's, in both directions, over the `predicate` role. It derives,
not assumes, that `visibleWhen` is optional. It asserts that `required`
is never set on another role. The channels do require `loop.collection`
/ `map.collection`, but no door refuses their absence (reported to the
seat as an out-of-scope finding).

**Pin sweep.** One published pin flipped:
`decision-predicate-envelope.test.ts` asserted
`decisionFlow('str_absent', undefined)` registers. It was re-judged in
place, and the reason is written beside it. It now asserts the throw
carries `PREDICATE_SLOT_STRING_REFUSAL` and `Found nothing — the key is
absent where the slot is required`.

Repo sweep for other branches without an `expression`: a
bracket-balanced scan of every `.ts` / `.json` / `.yaml` file that
mentions both `decision` and `conditions` found only this PR's own
fixtures. A grep of helper-built branches (`{ label: …, expression }`
shorthand) found 4 sites, all in suites run below. No other package's
test builds a `decision` with `conditions`.

## Ablation: the pins can fail

Both ablations were run on committed state through
`scripts/ablation-replace.mjs`, which wraps the change, verifies it on
disk and restores it with a trap. Both proved restore by blob hash equal
to HEAD and an empty `git diff HEAD`.

- **A: the `required` flag neutralised.** `required: true,` was replaced
by a spread that is `{}` unless a `globalThis` flag named
`ABLATION_19961` is set.
- `ablation-dist-preflight.mjs @objectstack/spec ABLATION_19961` found
the marker present in 20 built files.
  - Red, in the expected direction:
- spec: 7 failed (the absent, `null` and alias rows, index 1, region,
the `required`-set pin, the resolver pin);
- service-automation: 6 failed (the three rows, region, the re-judged
envelope pin, the ratchet);
    - lint: 4 failed.
  - The blank and real controls stayed green at every door.
- Restore leg: rebuild, `--absent` marker gone from all 222 built files,
whole-tree `git status` clean. spec 52/52, service-automation 34/34 and
lint 344/344 green.
- The first attempt was a no-op and its reading was discarded: my
replacement was not valid TypeScript, so the transform failed and the
build never ran.
- **B: the lint early return put back** (`if (raw == null) return {
refused: false };`): lint showed 4 failed (absent, `null`, alias, index
1), and the blank and real rows stayed green. Restored by blob hash. The
first attempt was refused by the tool before running anything, because
the anchor matched its own replacement.

## Producer census (Zone 2 item 4): authored count 0

- `examples/**` at `e702ebd4`: 3 flows carry `decision` nodes (app-crm
`convert-lead`, app-showcase `needs_exec` / `triage`, app-todo
`check_recurring`). All of them branch on out-edges and declare no
`conditions`, so 0 branches lack an `expression`.
- `packages/**` non-test: no default flow carries a `decision` node. The
`content/docs/automation/flows.mdx` examples: 3 `conditions` lists, all
with `expression`.
- cloud `origin/main` `96eb092f`: 0 `decision` nodes.
`service-ai-studio`'s authoring whitelist names `decision` as an
authorable node type, so AI-authored flows now meet this refusal.
- objectui at the pin `f8a9d0fb` (`.objectui-sha`):
`FlowObjectListField` `rowsToList` still drops a blank cell, so a branch
row with an empty expression cell is written as `{ label }`. That is the
known writer. Triage accepted that its save now fails loudly, so it is
not fixed here.

## ADR-0087 and changeset

- New semantic entry `flow-decision-branch-expression-absent-refused`
(major 18) and a regenerated `registry.ts`.
- There is no D2 conversion: the platform cannot know the rule the
author left out, and `'false'` would change behaviour rather than keep
it.
- The changeset
`.changeset/19961-decision-branch-expression-absent-refused.md`:
`@objectstack/spec` and `@objectstack/lint` `minor`, BREAKING, with the
FROM → TO table.
- `service-automation` gets no changeset: its diff is test files only,
and those are not in `files[]`.

## Verification (final head `7534fd7e`, which is `origin/main`
`6a6a17b6` merged, objectstack-ai#20279 included)

- Tests (`os-verify-lock`, spec rebuilt on this head):
- spec `src/automation` + `src/migrations`: 1005/1005, including
objectstack-ai#20279's `schemaless-node-config.test.ts`.
  - service-automation: the 4 predicate-slot / ledger files, 50/50.
  - lint `validate-expressions.test.ts`: 344/344.
- Full suites, run on the first merge head `266cd043`: spec 16855 passed
(583 files), service-automation 1767/1767, lint 4269/4269.
- Typecheck, including the test layers, on `266cd043`: spec, lint and
service-automation all exit 0. No file of this diff changed after that.
- Gates: `dispatch-gates.mjs --commands` re-derived on `7534fd7e` gives
90 families. 89 ran with exit 0. `dispatch-gates --ran` answers "90
derived famil(ies) accounted for — 89 run, 1 NOT-MEASURED".
- NOT MEASURED: `check:type-check-debt`. Its `--re-measure` runs a
whole-tree `turbo run build --filter=./packages/*` outside
`os-verify-lock`. This diff touches no DEBT-ledger package.
- On earlier heads, two gates needed their prerequisites built first,
and both then exited 0. `check:dual-build-cjs-loads` answered
PREREQUISITE NOT MET because 12 unrelated packages were unbuilt.
`check:dts-closure` went red on local state: 6 packages lost their
`.d.ts` to my own interrupted `--re-measure` build. That is not this
diff.
- `eslint --no-inline-config --format json` over the 12 changed `.ts`
files on `7534fd7e`: 12 files, 0 errors, 0 warnings.
- Population: `eslint.config.mjs` `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`, and no file here is ignored.
- Invariance: the config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot move a verdict on an
untouched file.
- Declared narrowing: after the second and third `origin/main` merges, I
re-ran the targeted suites above and every gate, not the full package
suites. The incoming commits touch other surfaces (rls, date comparands,
report charts, cli generate, pm scripts, and objectstack-ai#20279's
`DecisionConfigSchema` `mode`), not the flow predicate walk.

## Acceptance notes (observed, not filed)

- `service-automation` `engine.ts` `evaluateCondition` still has an
inline comment saying the empty-source arm is where "a `decision` node
whose `conditions[]` entry has no `expression`" lands and answers
`false`. Since objectstack-ai#16038 the shape gate throws first, and since this PR the
shape cannot register. The comment is stale; no behaviour follows from
it. Carrier: whoever next edits `evaluateCondition`, else none.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants