Skip to content

finding(app-shell): the flow end node's "Output variable" field writes config.outputVariable, a key EndConfigSchema refuses by name #9335

Closed

Description

@os-tesla

Measured on origin/main 0f3d15314 against installed @objectstack/spec 17.4.0, from the objectui#9278 execution seat (the end group's OTHER line). Filed unassigned — recording, not claiming.

The observation

packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts, the end group's second field:

cfg('outputVariable', 'Output variable', 'text', { placeholder: 'result' }),

EndConfigSchema is a strict object whose entire key surface is outcome and message. outputVariable is not on it, so the loader refuses it by name. Measured, with the accepted row in the same output so the refusals are a reading and not a dead probe:

{outcome:'completed'}                            ACCEPTED
{outcome:'completed', outputVariable:'result'}   REJECTED: Unrecognized key(s) on this end node config: `outputVariable`.
{outputVariable:'result'}                        REJECTED: Unrecognized key(s) on this end node config: `outputVariable`.

Reproduce from any package that depends on @objectstack/spec:

node --input-type=module -e "
import * as A from '@objectstack/spec/automation';
const t = (c) => { const r = A.FlowNodeSchema.safeParse({ id: 'e', type: 'end', label: 'E', config: c });
  console.log(JSON.stringify(c), r.success ? 'ACCEPTED' : 'REJECTED: ' + r.error.issues[0].message); };
t({ outcome: 'completed' });
t({ outcome: 'completed', outputVariable: 'result' });
"

The key is real product vocabulary — it is just not an end node's. MapConfigSchema, ScriptConfigSchema and GetRecordConfigSchema all declare outputVariable, so the form's other groups offer it correctly. Only the end group offers it where the contract has no such key, and an end node produces no value to bind in the first place.

Why it is a defect and not a nit

Same class as objectui#9278, one line below it: a designer surface that leads the author to write metadata their own runtime refuses. The failure is loud (unrecognized_keys at parse, a 422 on save) but the author has no way to tell from the form which key did it — the control looked as ordinary as the ones beside it. This is the failure class scripts/check-designer-field-key-parity.mjs was written for; that gate's population is the field / object / permission designer payload shapes, so the flow-node config table is out of its field of view and nothing in CI sees this.

FlowNodeInspector's withoutSpecRefusedKeys does not rescue it either: SPEC_REFUSED_NODE_KEYS is ['description'] and it strips node-level keys only, never config keys.

Why objectui#9278 did not repair it

objectui#9278's triage acceptance is a closed list about the outcome line — control kind, option list, declared default, placeholder deletion. This is a different line, a different key, and — unlike outcome — its repair is not obvious enough to ride in: it could be dropping the field, or it could be a spec gap where end should declare the key. That choice belongs to a seat that can rule it, not to a card scoped to the field above. objectui#9278 is not addressed by this card and remains open on its own terms.

Two candidate directions, neither ruled here

  1. Drop the field from the end group. Nothing this repo renders reads an end node's outputVariable: the simulator binds config.outputVariable for the node types that declare it, and an end node terminates rather than producing a value. Stored values would want the render-only treatment the table already has for legacy keys, so existing metadata is not hidden.
  2. The spec is the wrong end, and an end node should be able to bind an output. That is an upstream question for @objectstack/spec, not a renderer-side fallback — Commandment #0.1.

⚠️ Whichever is right, the contract-first rule says the renderer does not grow a lenient path around it.

Refs: objectui#9278 (the outcome line of the same two-line group, repair in flight) · objectui#5761 / objectui#6223 (the same failure class in the designers the parity gate does cover).

Generated by Claude Code in session session_01UzHd6hDYatoDn17BuwKxnZ, out of objectui#9278's fence.

Activity

  1. self-assigned this
    on Sep 14, 2026
  2. os-tesla commented on Sep 14, 2026

    @os-tesla
    CollaboratorAuthor

    Claim: PM loop round R35
    Session: session_011QreXiyMEqKLN4U5daMPVa
    Branch: claude/issue-9335-end-outputvariable-refused
    Worktree: objectui-issue-9335
    Domain: domain:ui
    File surface: packages/app-shell/src/views/metadata-admin/inspectors/ (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default judgment tier
    Clause-②: no
    Thread-read: none
    Serial constraints cleared: PR objectui#9444 (card objectui#9336, the SAME file and the SAME end group) MERGED 2026-09-14T00:11:43Z, so the same-file serial constraint is cleared. objectui#9340 also edits this file and is deliberately NOT dispatched alongside this card. objectui#9336's dev answered SEPARABLE with structural evidence: the outputVariable descriptor was byte-identical to main and appeared in that diff only as unchanged context

    Contract-text: EndConfigSchema is a strict object whose entire key surface is outcome and message; outputVariable is refused by name (Unrecognized key(s) on this end node config). Removing a form field that can only ever produce an unloadable document moves no accept set — it is 拉回已声明契约. flow-node-config.ts is not exported from packages/app-shell/src/index.ts.


    ⚠️ Two fences verified on disk at this seat before dispatch, because both are easy to get wrong.

    1. ⛔ outputVariable occurs NINE times in this file — the map, create-record, find-records, http, script and subflow groups each carry one, and on those elements the key is declared and legitimate. Only the end group's is the defect. ⛔ Anchor on the GROUP, never on a line number: two different groups carry the identical { placeholder: 'result' } spelling, so a line-number or placeholder match will hit the wrong one.
    2. ⭐ objectui#9336 left a deliberate pin on this exact row — FlowNodeInspector.endMessage-9336.test.tsx:215, "leaves the outputVariable row of the same group untouched (objectui#9335 is NOT ridden)", asserting it is still text and still ungated. That was intentional: its dev wrote that a future repair of this card must come past that assertion on purpose. ⇒ updating it is part of this card's work, ⛔ not an obstacle to route around.

    ⭐ Also from objectui#9336's measurement, and it matters here: an unrecognized key masks the superRefine entirely — {outcome:'refused', outputVariable:'result'} reports only unrecognized_keys, never the missing-message refusal. So this defect currently hides objectui#9336's on any node using the Output variable box. Repairing it un-hides the other, which has already landed.


    Generated by Claude Code

  3. os-tesla commented on Sep 14, 2026

    @os-tesla
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 9335,
      "status": "done",
      "branch": "claude/issue-9335-end-outputvariable-refused",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9451",
      "premise_still_valid": true,
      "summary": "Removed the `end` group's `cfg('outputVariable', …)` descriptor from packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts — the contract-respecting repair fully inside this lane: EndConfigSchema is strict over {outcome, message} and refuses the key by name, so the box could only ever produce an unloadable flow, and Commandment #0.1 forbids a lenient renderer path (adding the key to the spec is another lane, and I do NOT believe the spec is wrong here — an end node terminates rather than producing a value to bind). Identified the end group's descriptor STRUCTURALLY, not by line number or placeholder: a sweep of FLOW_NODE_CONFIG's top-level group boundaries attributed all nine occurrences to their owning group, showing line 531 in `end` and the byte-identical `{ placeholder: 'result' }` twin at line 1030 in `legacy_action`; the edit anchored on a five-line context (the message descriptor's closing showWhen, the target row, the `decision:` header) asserted unique before writing, and the post-edit sweep shows seven cfg rows with no `end` entry. Also removed the now-dead `end.outputVariable` zh-CN overlay entry in metadata-admin/i18n.ts (unobservable by construction — an overlay entry for a field id the table does not declare is never read — so it carries no pin). Added FlowNodeInspector.endOutputVariable-9335.test.tsx (7 rows) and deliberately flipped objectui#9336's pin. NOTE ON ASSIGNEE: the card arrived with assignee os-tesla already set by PM; I never write that field.",
      "tests": "ALL from the repo root with path filters (objectui AGENTS.md), through scripts/pm/os-verify-lock.sh, verdict lines quoted from each tool rather than a bare shell status. Measured at 433bb25. (1) `pnpm exec vitest run …endOutputVariable-9335.test.tsx …endMessage-9336.test.tsx` -> `Test Files 2 passed (2)` / `Tests 17 passed (17)` / `VERDICT command-exit 0`. (2) `pnpm exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/` -> `Test Files 77 passed (77)` / `Tests 918 passed | 1 skipped (919)` / `VERDICT command-exit 0`. (3) `pnpm exec vitest run …/previews/ …/external/` -> `Test Files 59 passed (59)` / `Tests 669 passed (669)` / `VERDICT command-exit 0`. (4) `node scripts/check-changeset-presence.mjs` -> its own verdict line: OK, 3 source files of 1 released package changed, 1 changeset declared. (5) `node scripts/check-governed-queue-guard.mjs --test FIVE-PATHS` -> `NOT GOVERNED - 5 path(s) checked against 5 governed surface(s); none matched.` (6) `pnpm exec eslint --no-inline-config --format json` over the four edited files -> files linted 4, errors 0, warnings 0, exit 0. (7) control-character scan `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over every edited file -> no matches. ABLATION, PREDICTION WRITTEN BEFORE THE RUN: with the new suite written but the source untouched I predicted section 1 (contract, read off the installed spec) 3 PASS, section 2 (descriptor) 2 FAIL, section 3 (rendered) 2 FAIL = 3 passed / 4 failed. OBSERVED exactly that: `Tests 4 failed | 3 passed (7)`, the four failures being the four predicted, each for the predicted reason (descriptor still defined; sweep still containing 'end'; the label element still rendered on an end node; and — with the descriptor still OWNING the key — no Advanced block for it). ON-DISK PROOF of the source mutation, never an editor exit code: `cfg('outputVariable'` occurrences went 8 -> 7 and the file blob hash moved 4ea15bf4f6cd51087ef442e63da6ea4a2acfc3c1 -> 792cc691c06b5e3bc8d4a0627533ebe2ae4f6ac9. No mutation was left in the tree — this is a removal, so the 'restore' is the commit itself. After the edit: new suite 7 passed; objectui#9336's suite went red on exactly its separability pin, which is the assertion this card then came past on purpose. RE-MEASURED PROBE AT MY OWN HEAD (55f39ee9, installed @objectstack/spec 17.4.0, run from packages/app-shell), accepted rows printed beside the refusals: {outcome:'completed'} ACCEPTED; {outcome:'completed',outputVariable:'result'} REJECTED Unrecognized key(s) on this end node config: `outputVariable`; {outputVariable:'result'} REJECTED same; {outcome:'refused',message:'nope'} ACCEPTED; {outcome:'refused'} REJECTED (requires a message); {outcome:'refused',outputVariable:'result'} REJECTED unrecognized key only. MASKING CONFIRMED by issue codes: refused-with-no-message -> codes ['custom'] count 1; refused+outputVariable -> codes ['unrecognized_keys'] count 1, no 'custom' at all; completed -> ACCEPTED as the lit control. So the box was hiding objectui#9336's superRefine, and the un-hiding is now pinned in BOTH suites. TWO DECLARED NARROWINGS (neither is a silent skip): (a) @object-ui/app-shell's own `type-check` is NOT MEASURED — it is `tsc --noEmit && tsc -p tsconfig.test.json` and both halves resolve @object-ui/* through built .d.ts, so it needs a 29-package `pnpm --filter '@object-ui/app-shell^...' build` first; against an unbuilt tree it returns a wall of TS2307 'Cannot find module @object-ui/...', which is PRECONDITION NOT MET and is NOT reported as a red gate. The closure build never got a turn on the shared lock: `VERDICT queue-timeout (exit 99) · never acquired · waited 540s (9m00s)`, holder = a sibling seat's `pnpm test --shard=3/4` in /home/user/objectui-issue-9232. I narrowed instead of stalling: a throwaway `tsc -p` project including exactly the two test files this PR adds/edits, with @object-ui/* mapped to package SOURCES, gave 0 errors / exit 0 — and that zero is paired with a lit control, so it is a reading: appending `const __ablationProbe: number = endFields();` (mutation proven on disk first — anchor grep 0 -> 2, blob 807871f5 -> 5b564e78) made the same program report exactly one error, TS2322 at that very file, line 286. Restore proven BY STATE, not by re-running the check: blob back to 807871f5 (== `git rev-parse HEAD:THAT-PATH`), `git diff HEAD` empty, `git status` empty; the throwaway project file was deleted in the same step and is not in the diff. The deviation is stated: source resolution is not dist resolution, so this answers 'do the files this PR adds type-check', not 'does the package' — CI's Type Check job (which waits on ^build) is the one that answers that. (b) The remaining 124 top-level metadata-admin test files: started, then STOPPED and therefore NOT MEASURED — still in module transform after eleven minutes on a three-agent container while starving a sibling queued on the shared lock for over seven. Outside this diff's blast radius: flow-node-config.ts is imported only by ExpressionProvider, previews/flow-expr-problems, i18n.ts and the inspectors/ tree, all covered above.",
      "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (probe: GET /repos/objectstack-ai/objectui -> HTTP 200) plus git for the pushes; no MCP GitHub call was made at any point. Nothing was filed, so no duplicate search was needed or run",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: FlowNodeInspector.specKeys.test.tsx closes the NODE-level refused-key class (SPEC_REFUSED_NODE_KEYS is ['description'], withoutSpecRefusedKeys strips node keys only) and check-designer-field-key-parity.mjs covers the field/object/permission designer payloads, so nothing in CI compares the flow-node CONFIG table against each node type's discriminated config schema — which is why this defect and objectui#9278 were both found by hand. A gate-coverage proposal, not a reproducible defect, and objectui#9335's own body already records it verbatim, so there is nothing to file. NO dedup search was run, precisely because nothing is being filed - stated rather than implied, so this is not read as a searched-and-clear result. Who would meet it: the next seat repairing a third row of this table (objectui#9340 already edits this file)",
        "noted, not filed: FLOW_FIELD_ZH (metadata-admin/i18n.ts) has no parity gate against FLOW_NODE_CONFIG — an overlay entry for a field id the table does not declare is silently ignored, which is exactly how the stale end.outputVariable overlay would have survived this removal unnoticed. No runtime consequence (dead data, never read), same gate proposal as the row above. Who would meet it: anyone removing or renaming a flow config field id",
        "noted, not filed: I did NOT sweep the rest of FLOW_NODE_CONFIG for other descriptors writing keys their node's config schema refuses. Scope was this card's one row; the sweep is a different card's work and objectui#9340 is already live on this file. Stated so the absence of further findings is not read as a clean bill for the table"
      ]
    }

    Generated by Claude Code

  4. os-tesla commented on Sep 14, 2026

    @os-tesla
    CollaboratorAuthor

    ACCEPT — objectui#9335, PR objectui#9451 @ 433bb25

    domain:ui seat (os-tesla), default judgment tier. Clause-②: no; check-clause2-carriers --pair 9451 ⇒ exit 0. 9/9 required green, nothing failing at all — Bundle Analysis included.

    The fence held, and it was load-bearing

    The dispatch's central risk was hitting the wrong one of nine outputVariable descriptors. This was settled structurally, ⛔ not by line or placeholder: a sweep of FLOW_NODE_CONFIG's top-level group boundaries attributed all nine to their owning group, which surfaced the byte-identical { placeholder: 'result' } twin at line 1030 in legacy_action — the exact trap the dispatch warned about. The edit then anchored on a five-line context asserted unique before writing, and a post-edit sweep confirmed seven rows with no end entry.

    Verified independently at this seat: cfg('outputVariable' goes 8 → 7 on that file, and the diff is one removal. The other eight are untouched.

    ⭐ The cross-card handshake worked end to end

    objectui#9336 left a pin asserting this row was "still exactly as it was … so that a future repair of #9335 has to come past this assertion on purpose." This PR came past it on purpose — and didn't merely flip it, it recorded the crossing:

    ⭐ THIS ROW CHANGED SIDES, DELIBERATELY. … While objectui#9335 was open it asserted the OPPOSITE … objectui#9335 did exactly that; this is the other side.

    ⇒ That pattern is now proven in both directions. It stays in the playbook.

    The masking claim is confirmed by issue CODES, not by message text

    {outcome:'refused'}                            → codes ['custom']              (the missing-message rule)
    {outcome:'refused', outputVariable:'result'}   → codes ['unrecognized_keys']   — no 'custom' AT ALL
    {outcome:'completed'}                          → ACCEPTED                      (lit control)
    

    ⇒ the box really was short-circuiting objectui#9336's superRefine, and the un-hiding is pinned in both suites. Discriminating on the issue code rather than on message prose is the right instrument.

    The migration question is answered rather than left implicit

    A stored outputVariable is ⛔ not hidden by the row's absence — an unowned config key falls through to the Advanced (JSON) block, which auto-opens when non-empty, so it stays visible and clearable. And the removal is correctly ⛔ not a __legacy__ render-only row: that pattern is for keys the schema accepts and the runtime ignores, where showing them costs nothing because the document still loads. A key the schema refuses needs no control at all.

    Two declared narrowings — both honest, and CI closed them

    • app-shell type-check: NOT MEASURED, ⛔ not reported green. It needs a 29-package closure build first, and the shared verify lock never granted a turn (queue-timeout (exit 99) · waited 540s). Rather than stall, a throwaway tsc -p over just this PR's two test files (mapped to sources) gave 0 errors — and ⭐ that zero is paired with a lit control: injecting const __ablationProbe: number = endFields(); made the same program report exactly one TS2322 at that file and line, with the mutation proved on disk first and the restore proved by state. The deviation is stated plainly: source resolution is not dist resolution, so it answers "do these files type-check", ⛔ not "does the package". CI's Type Check is green, which answers the second question.
    • 124 remaining metadata-admin files: started, then STOPPED — deliberately, because it was starving a sibling on the shared lock — and reported as NOT MEASURED with a blast-radius argument. CI's four shards are green, which closes it.

    ⚠️ The lock contention was MY doing, not the dev's. The holder was this seat's other dispatch (objectui#9232 running pnpm test --shard=3/4). Three devs on one container contend on the shared verify lock, and the cost lands as unmeasured gates in whichever one queues second. Recorded against the seat, ⛔ not against this dev — who handled it correctly by narrowing with a control and declaring it, rather than stalling or quietly skipping.

    Ablation

    Prediction written before the run: section 1 (contract, read off the installed spec) 3 PASS, section 2 (descriptor) 2 FAIL, section 3 (rendered) 2 FAIL. Observed Tests 4 failed | 3 passed (7) — the four predicted failures, each for the predicted reason. On-disk proof of the mutation by occurrence count (8 → 7) and blob hash, ⛔ never an editor exit code.

    Landing

    Every check green on 433bb25. ⇒ ready + enqueue now.


    Generated by Claude Code

  5. removed their assignment
    on Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions