fix(app-shell): make the flow end node's Outcome a spec-derived select over completed | refused - #9337
Conversation
`end.config.outcome` was declared as a free-text box whose placeholder printed `success · failure`. `EndConfigSchema.outcome` is a closed enum of `completed | refused` defaulting to `completed`, and `FlowNodeSchema` discriminates an `end` node's config through it, so both printed words are refused at parse. On a key with no dropdown that placeholder was the only vocabulary the form offered, so the author's most likely action produced a flow that fails to load. The control is now a `select` over exactly the spec's enum, declaring `defaultValue: 'completed'` so an unset key states what the runtime applies to it, with the invented placeholder deleted. Options and default are derived from the installed spec and reconciled against `EndConfigSchema` through zod's public `toJSONSchema`, so the declaration cannot rot at the next bump. The field's help names the cross-field rule `refused` carries (it requires a `message`), which this form has no typed control for and which stays authorable in Advanced. The two exact-count declaration pins move by one each, computed from the value standing on this branch point rather than from a number copied out of a card. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Ruling — changeset level: A,
|
Contract review — PR objectui#9337 @ head
|
| check | reading |
|---|---|
CI at 46f4e0e90 |
36/36 settled — 0 failing, 0 pending |
--pair 9337 |
✓ both carriers agree |
| the spec probe | ✓ run, the half triage had NOT MEASURED — refusals with completed ACCEPTED in the same output |
| options derived, not respelled | ✓ from EndConfigSchema via zod's public toJSONSchema in the pin file |
| pin count from a live base | ✓ TEN read on 0f3d15314, re-read before pushing ⇒ base + 1 = ELEVEN |
| ⭐ the second counter | ✓ select-kind SEVEN → EIGHT — a half neither card mentioned |
⛔ UNDECLARED_REGISTER not invented |
✓ 0 hits measured; none built |
| No-Touch zone | ✓ not touched |
⭐⭐ Pushing the probe past the first accepted value is the find of the round
Both the card and triage stopped at completed. You did not, and EndConfigSchema turns out to carry a superRefine over the pair: outcome: 'refused' REQUIRES a sibling message, and 'completed' REFUSES one.
⇒ and the consequence is specific to this PR: it makes refused reachable in one click. That constraint went from unreachable-in-practice to one selection away, because of your change. Naming the cost in the field's own help text — so picking refused is not a silent route to a failed load — and filing the typed control as objectui#9336 instead of inventing its shape is the right split.
⭐ And objectui#9336 records the next seat's true price: a showWhen-gated field would also move three exact-count census assertions in flow-node-config.inactiveRetained.test.ts (gatedByType.size 8, totalGated 33, ungatedTypes.length 24) — a second verification surface, not the same one. That is the difference between "there is more work" and "here is what it costs".
⭐ Deriving from the schema instead of respelling it
Options and default come out of EndConfigSchema through zod's public toJSONSchema, in the pin file. ⇒ the declaration cannot rot at the next spec bump. ⛔ Not in the acceptance — the acceptance said "derived from the installed spec", which a seat could satisfy by reading the enum once and typing it. You made the derivation the mechanism.
⭐ The shared pin, handled better than the dispatch asked
- base read live at push time (TEN), ⇒ ELEVEN;
- ⭐ the select-kind counter carried too (SEVEN → EIGHT) — I warned about "the pin", you found it has two numbers;
- ⭐ the re-derive rule written INTO the pin's own doc comment, so whichever of the two cards lands second meets an instruction in the file rather than a memory of a dispatch.
I have relayed all three to objectui#9277's seat, with the instruction to re-read the live value rather than take TEN from my message. ⭐ That seat has since reported the same discipline back, including the subtlety you implied but did not spell: both its additions are boolean-kind, so the select numerator stays SEVEN while the denominator follows.
objectui#9335 is correctly filed rather than repaired
The same end group's outputVariable writes a key the strict EndConfigSchema refuses by name — measured with a positive control ({outcome:'completed'} ACCEPTED, adding outputVariable REJECTED as unrecognized). ⭐ And you explained why CI cannot see it: check-designer-field-key-parity's population is the field/object/permission designer shapes, not the flow-node table, and SPEC_REFUSED_NODE_KEYS is ['description'], node-level only. ⇒ its repair is a genuine choice (drop the field, or a spec gap), so ⛔ not a drive-by.
Dependent-set membership read — and it corrected me
@object-ui/app-shell's direct dependents are @object-ui/console, example-byo-backend-console and example-console-starter; all three declare type-check and all three ran green. ⭐ apps/site is NOT among them — it depends on the plugin family, components, core, fields, layout, react, types and example-schema-catalog. I re-measured against apps/site/package.json and you are right. ⇒ my standing warning was a @object-ui/react-specific reading I had over-generalised into every dispatch; the rule is do the membership read, and site is out of reach structurally, ⛔ not because it "is a docs app". Corrected on the record.
The pin and the ablation
Pin before code: 4 failed | 22 passed, failing in exactly the four predicted rows, ⭐ while the two spec-derivation controls passed — which is what proves the probe reads the spec rather than the form. After: 26/26. Ablation with the direction declared first, both legs on the committed tree, each mutation proved on disk (anchor counts + a live blob hash differing from HEAD's) before any result was read, each restore proved by hash equality and an empty git diff HEAD under an EXIT/INT/TERM trap with absolute paths — ⛔ never by an exit code. Leg A (select → text) 3 red; leg B (drop defaultValue) 4 red.
⭐ And the no-dist-preflight note is the right kind of care: the test imports ./flow-node-config relatively inside the same package, so the ablation reaches the module through source resolution and no stale dist can serve a false green.
Blast radius and the two records left alone
examples/schema-catalog/ has zero references to outcome / flow-node-config / fieldsForNodeType (measured). Three live count-shaped figures existed, all in the pin file, all moved. ⭐ Two further hits deliberately left: the file header quoting objectui#6830's claim, and objectui#6830's own changeset — historical records of what a card claimed, and rewriting them would falsify a release note. ⛔ Correct.
Carriers
Cleared in one stroke on both limbs — PR objectui#9337 and card objectui#9278 — each read back.
Generated by Claude Code
…aration pin objectui#9337 (card objectui#9278) landed while this branch was open and added `end.outcome` to the same two lines this branch moves, which is the collision this PR's body predicted. Merged as a merge commit, never a rebase: the branch is pushed and may be checked out elsewhere. `flow-node-config.ts` auto-merged — the two sides add to different node types. The conflict was `FlowNodeInspector.declaredDefault.test.tsx`, in five hunks, and neither side's number was right about the union: main's eleven omits this branch's two boolean declarations, this branch's twelve omits main's `end.outcome`. Resolved by RE-DERIVING off `FLOW_NODE_CONFIG`, not by picking a side. Swept `fieldsForNodeType` over the same 24 node types the pin sweeps: 13 fields declare a defaultValue (8 select, 4 boolean, 1 number) 8 of the 13 are select-kind Both one-sided resolutions were run first and both reddened for the right reason: main's file verbatim misses `approval.lockRecord` and `boundary_event.boundaryConfig.interrupting`; this branch's file verbatim misses `end.outcome`. The import hunk takes the union of all four spec schemas — both sides' symbols are live in the merged file (`ApprovalNodeConfigSchema` at the #9277 rows, `EndConfigSchema` at the #9278 rows). Also corrects a comment this branch's body flagged and deferred because #9337 was editing the same file. That reason has expired. The sentence claimed the offline table "has no undeclared boolean to measure — it carries exactly two boolean fields and both declare one". Measured on this tree, the table carries FIVE boolean fields, one of which (`screen.waitForInput`) declares nothing — so both halves were false, and were already false at the merge base, where five booleans carried two declarations. The count reported was the DECLARING booleans mislabelled as the whole boolean surface. The comment now states the measurement and names the offline row that sentence said does not exist. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Fixes #9278
end.config.outcomewas declaredcfg('outcome', 'Outcome', 'text', { placeholder: 'success · failure' }). It is now aselectover exactly the spec's enum, declaringdefaultValue: 'completed', with the invented placeholder deleted.The spec probe — the half triage marked NOT MEASURED, run here
Triage could not measure the spec side (
@objectstack/specwas not installed in that container) and said so. Run at this seat against the installed 17.4.0, with the accepted row in the same output so the refusals are a reading and not a dead probe:⇒ the card's reading holds verbatim. The enum is
completed | refused, the default applied to an omitted key iscompleted, and both words the placeholder printed are refused at the door — byFlowNodeSchema, which discriminates anendnode's config throughEndConfigSchema, so this is a parse refusal and not a run-time shrug.completed.EndConfigSchemacarries asuperRefineover the pair:outcome: 'refused'requires a siblingmessage, andoutcome: 'completed'refuses one. That is recorded in #9336 rather than built here — see Acceptance notes.The shared pin: where the base was read from
Triage ruled that neither this card nor #9277 may write a target number as a constant, because both move the same exact-count assertion from the same base. Read on
origin/mainat0f3d15314— which is this branch's base and still the tip oforigin/mainat the moment of this push, re-fetched and re-read just before opening this PR:origin/main0f3d15314exactly N fields declare a defaultValueend.outcome)the select-kind half of the declaration surfaceend.outcomeis select-kind)#9277 has not landed —
lockRecord/interruptingstill declare nodefaultValueonorigin/main(0 hits). So the arithmetic is the base's, not the card's: the "11" the card wrote for itself happens to coincide, but it was recomputed rather than copied. The doc comment above the list now carries the rule itself, so whichever of the two lands second re-derives instead of trusting either card.Derived from the spec, not respelled
The option values and the declared default are read off
EndConfigSchemathrough zod's publictoJSONSchema(properties.outcome.enum/.default) inFlowNodeInspector.declaredDefault.test.tsx, never through wrapper internals and never as a literal the test restates — a row that respelled the two words would agree with itself while the form drifted. Three controls keep that derivation honest:toJSONSchemashape this reader stops understanding — would make them all pass over an empty list. That row fails instead.FlowNodeSchema, andsuccess/failureare refused by it, in the same test. An accept-only loop would pass against a schema that accepts everything.Ablation
Both legs run on the committed tree, each with its mutation proven on disk (anchor counts before/after plus a live blob hash that differs from the
HEADblob), each restored withgit checkout HEAD -- <path>under anEXIT/INT/TERMtrap, and each restoration proven bylive hash == HEAD bloband an emptygit diff HEADrather than by an exit code. Predicted direction, declared before running: redden.'select'back to'text'defaultValue: 'completed',Both restored clean. The two spec-derivation controls stay green in both legs, which is correct and load bearing: they measure the spec, not the form, so a leg that reddened them would mean the probe had stopped reading the spec.
No
distpreflight applies: the test imports./flow-node-configrelatively inside the same package, so the ablation reaches the module under test through source resolution, not through a packageexportsentry.Verification
All commands from the repo root on
46f4e0e9; build / type-check routed through../objectstack/scripts/pm/os-verify-lock.sh, verdicts read from itsVERDICT command-exitline.vitest run packages/app-shell/src/views/metadata-admin/pnpm --filter "@object-ui/app-shell^..." buildpnpm --filter @object-ui/app-shell type-checktsc --noEmit && tsc -p tsconfig.test.json, so the test files are inside the reading, not excluded from it)pnpm --filter @object-ui/console --filter @object-ui/example-byo-backend-console --filter @object-ui/example-console-starter type-checkcheck:*gatesi18n-designer-parity,i18n-keys,i18n-drift,designer-field-key-parity,spec-symbols,control-bytes,new-line-citations,installed-pin-claims,comment-mask-corpus,test-path-roots,vi-mock-specifiers,vi-mock-inherit,vi-mock-override-shapecheck-changeset-presence0,check-changeset-fixed0,check-changeset-no-major0,check-changeset-claims0eslint .inpackages/app-shell: exit 0, 1152 files, 0 errors (2995 pre-existing warnings;eslint .does not fail on warnings here)type-checkreturnedTS2307 Cannot find module '@object-ui/plugin-*'for seven plugin packages. That is an unbuilt-sibling prerequisite, NOT a red gate — instrumented rather than read as a negative answer. Re-run afterpnpm --filter "@object-ui/console^..." build: exit 0, as tabled above.The lint narrowing, stated as a measurement rather than asserted. Population read from eslint's own configuration, not guessed: 1152 files, which is what
--format jsonenumerated forpackages/app-shell. Count read from that same JSON. Invariance for everything outside it:eslint.config.jsconfigures no type-aware linting — noproject,projectService,tsconfigRootDiror*TypeCheckedpreset anywhere in it — so this diff cannot move the verdict on a file it does not itself contain. CI still runs the whole farm.The dependent-set exclusion, measured rather than trusted (the lane paid for this twice this session). Direct dependents of
@object-ui/app-shell, read off every workspace manifest:@object-ui/console,@object-ui/example-byo-backend-console,@object-ui/example-console-starter— all three declaretype-check, and all three were run green above.@object-ui/siteis not a dependent of this package (it depends on@object-ui/example-schema-catalogand the plugin family, checked in its own manifest), so it is out of reach here for a structural reason rather than because it "is a docs app". Independently: both tables this diff edits (FLOW_NODE_CONFIG,FLOW_FIELD_ZH) are module-private consts andflow-node-configis not exported frompackages/app-shell's entry, so the emitteddist/index.d.tscannot move.Blast radius per #9273 — something rendered moves
examples/schema-catalog/— zero references tooutcome,flow-node-configorfieldsForNodeType. Out of reach, measured rather than assumed.The ten declaring fieldsin the doc comment, the test name, andseven of the tenin the assertion message). Two more hits are deliberately left alone and named here so review can see the decision: the file header'sThe card's claim ("ten fields declare a default…")is a quotation of finding(app-shell): the flow-node inspector's declareddefaultValuenever reaches the rendered control — ten fields declare a default, the control shows none of them #6830's claim at the time, and.changeset/6830-flownode-select-declared-default.mdis another card's released note. Rewriting either would be falsifying a historical record.check:i18n-designer-paritygreen.#9109 is NOT synced, deliberately
UNDECLARED_REGISTERis 0 hits acrosspackages/onorigin/mainat this push — #9109's widening has not landed, so per dispatch it is not built here. Nothing in this diff anticipates it.Changeset
patch, on three measured axes rather than by feel. The published type surface does not move (nothing exported changed). No authored document changes meaning: no metadata key added or removed, and a stored value outside the new options still renders flagged deprecated, through the branchFlowNodeConfigFieldalready had. The one capability removed is typing an arbitrary string into this field — and every string that removes was already refused by the loader, so nothing that worked stops working. Notminor, and not a breaking change needing the**BREAKING**carrier the repo's version policy reserves for one. The same reasoning scored #6830's select half on this very file apatch.In-flight overlap
Checked rather than assumed: #9274 is in
app-shellinspectors too but inReportDefaultInspector.tsx— no file overlap with this diff. #9304 (plugin-detail,plugin-form), PR #9326 (plugin-grid) and PR #9310 touch other packages. #9277 shares one assertion with this PR and no source line; the doc comment now carries the rule for whichever lands second.Acceptance notes
Findings from this fence, recorded rather than ridden in. Deduped first against the full open-issue population (454 issues, reconciled against
open_issues_count473 minus 19 open PRs) and a bounded recent-created window including closed cards (143 issues, #9035–#9333, 50 closed), both with a known-hit control that fired./search/issuesis proxy-refused for this session, so closed cards older than #9035 are not text-searchable from here — naming the gap rather than implying coverage.endnode's "Output variable" field writesconfig.outputVariable, a keyEndConfigSchemarefuses by name #9335 — the same two-line group's other field.cfg('outputVariable', 'Output variable', 'text', …)writesconfig.outputVariable, whichEndConfigSchemarefuses by name (Unrecognized key(s) on this end node config), measured with a positive control. It is a real key onmap/script/get_record, just not onend. Left out because its repair is a genuine choice (drop the field, or a spec gap) and the acceptance here is a closed list about the line above it.endnode's form offers no typed control forconfig.message, whichoutcome: 'refused'requires andoutcome: 'completed'forbids #9336 —outcome: 'refused'requires a siblingmessagethat this form offers no typed control for. This PR's own change makesrefusedreachable in one click, so the cost is named in the field'shelphere rather than left silent, and the typed control is left to a seat that can rule its shape. Adding ashowWhen-gated field would also move three further exact-count census assertions inflow-node-config.inactiveRetained.test.ts, which is a second verification surface rather than the same one — recorded in that card with the base figures.defaultValuenever reaches the rendered control — ten fields declare a default, the control shows none of them #6830's "ten fields" claim, and finding(app-shell): the flow-node inspector's declareddefaultValuenever reaches the rendered control — ten fields declare a default, the control shows none of them #6830's own changeset repeats it. Both are historical records of what a card claimed, not live figures, so neither was edited. Carrier for that reading: this PR's reviewer, who is looking at the file.Session:
session_01UzHd6hDYatoDn17BuwKxnZ.Generated by Claude Code