Skip to content

fix(app-shell): give the flow end node a typed control for config.message - #9444

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9336-end-message-control
Sep 14, 2026
Merged

fix(app-shell): give the flow end node a typed control for config.message#9444
os-tesla merged 1 commit into
mainfrom
claude/issue-9336-end-message-control

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9336

EndConfigSchema cross-validates outcome and message in both directions through a
superRefine. The inspector's end group offered a typed control for outcome and none
for message — so an author who picked refused in the UI could not produce a valid flow,
because the one key that outcome requires was authorable only through the Advanced (JSON) block.
A blocked authoring path, not a missing nicety.

The probe, re-run at this branch's head

Measured at 1d1ca96 (base e2feb13) against the installed @objectstack/spec 17.4.0
not inherited from the card. Accepted rows are printed beside the refusals, so the refusals are
a reading rather than a dead probe.

spec version: 17.4.0

{}                                    ACCEPTED  {"outcome":"completed"}
{"outcome":"completed"}               ACCEPTED  {"outcome":"completed"}
{"outcome":"refused"}                 REJECTED: [config.message] `outcome: 'refused'` requires a `message` …
{"outcome":"refused","message":"why"} ACCEPTED  {"outcome":"refused","message":"why"}
{"outcome":"completed","message":"x"} REJECTED: [config.message] `message` is only rendered when `outcome` is 'refused' …
{"message":"x"}                       REJECTED: [config.message] `message` is only rendered when `outcome` is 'refused' …
{"outcome":"refused","message":""}    REJECTED: [config.message] Too small: expected string to have >=1 characters
{"outcome":"completed","message":""}  REJECTED: [config.message] Too small … | `message` is only rendered when …
{"outcome":"success"}                 REJECTED: [config.outcome] Invalid option: expected one of "completed"|"refused"

--- EndConfigSchema shape ---
keys: [ 'outcome', 'message' ]

Three rows the card did not have, each of which changed the repair:

  1. {message:'x'} — a message with no outcome at all is refused too. The completed-side
    rule bites on the effective outcome, so the declared completed default carries it. This is
    why the new field is gated on the controller rather than shown always: an always-on box would
    trade this defect for the contract's other direction.
  2. {outcome:'refused', message:''} and {outcome:'completed', message:''} — the empty
    string is refused under both outcomes (min(1) on one side, the no-op rule on the other).
    So a control that committed '' on clear would ship an unloadable flow either way. It does not:
    setAtPath deletes the leaf on an empty commit, and that is now pinned.
  3. {outcome:'refused', outputVariable:'result'} reports only unrecognized_keys — the
    superRefine never runs while an unrecognized key is present. See the separability note below.

The repair

One descriptor in the end group:

cfg('message', 'Why the run was refused', 'textarea', {
  placeholder: 'Refused: {record.name} is a confirmed duplicate',
  help: 'Required when the outcome is "Refused", and refused on a completed end — …',
  showWhen: { field: 'outcome', equals: ['refused'] },
}),

Every choice is derived from the installed spec, not picked:

  • textarea — the spec's own message description says it is "interpolated at run time
    exactly like a screen description", and that field is a textarea in this same table; so is
    the sibling message key on notify. Two independent derivations agreeing.
  • label / placeholder — the spec's .describe() opens "Why the run was refused", and both
    the describe and the refusal message give that exact example string.
  • no defaultValuemessage is z.string().min(1).optional() with no .default(),
    and a declaration there is read as a claim about the installed spec.

The gate keeps both directions authorable. An unset outcome resolves through the declared
completed default, so the field stays off screen until the author actually picks refused; a
stored message re-shows it regardless (objectui#6499's stored-value rule), which is the only
way an author can clear a stale message after switching back to completed.

The Outcome field's help (English and the zh-CN overlay) no longer says the message is set
in Advanced — this PR makes that sentence false, so it goes.

Census, re-measured at landing

flow-node-config.inactiveRetained.test.ts carries three exact counts. end gains its first
showWhen field and so becomes a new bucket. Re-measured here rather than copied, as triage ruled
for the counts objectui#9277 / objectui#9278 share:

assertion before after
gatedByType.size 8 9
totalGated / checked 33 34
ungatedTypes.length 24 23

Ablation — prediction written before the run, then the run

Predicted, in writing, before running (recorded in the working notes): direction turn-red
(not diagnostics-grow, not inversion); 9 failed tests across 2 files — 7 of the new pin's 10,
plus the 2 census tests. The 3 predicted to stay green were named in advance: the two rows that
read only the installed spec, and the outputVariable separability row.

Mutation: delete only the four-line message descriptor. Proven on disk before the run —
HEAD blob 4ea15bf4f6cd51087ef442e63da6ea4a2acfc3c1, mutated blob
2f832f385ae62ba5575a7f689d637c8347ebb8b0, marker count 1 → 0.

Observed: Tests 9 failed | 15 passed (24) — the predicted 7 + 2, and the three named
survivors survived. Prediction matched exactly.

Restore proven by state, never by re-running the test: git checkout HEAD -- PATH (never a
bare git checkout --, which would restore the mutation out of the index), then blob hash
4ea15bf4f6cd51087ef442e63da6ea4a2acfc3c1 == HEAD blob and git diff HEAD empty, working tree
clean. The mutation script carried trap … EXIT INT TERM throughout.

Separability from objectui#9335 — the answer is YES, they are separable

objectui#9335 is a live, unclaimed defect in this very group: the same form writes
config.outputVariable, a key EndConfigSchema refuses by name. This PR does not touch it
and claims no repair of it; that card stays open. Evidence that message got its typed control
without touching or breaking that row:

  • Structurally independent. The change is a pure insertion between two descriptors. The
    outputVariable line is byte-identical to its state on origin/maingit diff shows it only
    as unchanged context.
  • No shared mechanism. message is gated by showWhen on outcome; outputVariable is
    ungated and reads no controller. They share no descriptor, no controller and no code path.
  • Independent at the schema, too — but with one asymmetry worth recording.
    {outcome:'refused', message:'why', outputVariable:'result'} is still refused, for
    outputVariable alone. Note the direction: an unrecognized key masks the superRefine (it
    never runs), so objectui#9335 hides this card's defect, never the reverse. Repairing message
    therefore cannot make objectui#9335 harder to see or harder to repair; on a node where an author
    used the Output variable box, objectui#9335's refusal simply arrives first.
  • Pinned, so a later reader sees it was left deliberately. The new suite asserts the
    outputVariable row is still present, still text, still ungated, and that the three-key config
    is still refused — so a future repair of objectui#9335 has to come past that assertion on purpose.

objectui#9335 remains open and deliberately untouched by this PR.

Clause-②: no — re-declared against the actual diff

The seat predicted no; the diff is the fact, and the diff agrees. flow-node-config.ts is not
exported from packages/app-shell/src/index.ts (its only occurrence there is inside a comment —
a reading, not a dead grep: that file is 470 lines with 92 export occurrences and 84 relative
imports). The package publishes one subpath. The diff adds no exported symbol and moves no
signature: one element appended to an internal descriptor array, one key added to an internal zh
overlay, plus tests and a changeset. The changeset gate confirms mechanically: "0 of them a
manifest whose published contract moved"
. And message is a key the published contract already
declares and requires — teaching the form to author it is pulling back to the declared
contract. No needs:contract-review.

Verification

check result
new pin + census (vitest, repo root) 24 passed (2 files)
whole metadata-admin tree 259 files, 2733 passed, 1 skipped
pnpm --filter @object-ui/app-shell type-check pass (tsc --noEmit + tsc -p tsconfig.test.json; --listFiles confirms the new suite is in the checked set — 1 hit, so the green covers it)
pnpm --filter @object-ui/app-shell lint pass — 0 errors (2999 pre-existing warnings, exit 0). Whole package, not a narrowed run.
check-changeset-presence / check-changeset-no-major pass
check-control-bytes pass (7605 tracked text files)
check-governed-queue-guard --test NOT GOVERNED — 5 paths, none matched

Dependency closure built first (pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build);
the first type-check attempt reported unbuilt siblings, which is NOT MEASURED, not a red gate.
Repo-wide pnpm lint / full pnpm test are CI's runs, not claimed here.


Generated by Claude Code

…message`

`EndConfigSchema` cross-validates `outcome` and `message` in BOTH directions
through a `superRefine`: `outcome: 'refused'` REQUIRES a `message`, and
`outcome: 'completed'` — including an omitted `outcome`, since the declared
default resolves to `completed` — REFUSES one.

The inspector's `end` group offered a typed control for `outcome` and none for
`message`. Since the Outcome field became a closed two-option dropdown,
`refused` is one click away, and picking it with nothing else done produced a
flow that fails to load: the one key that outcome requires was authorable only
through the Advanced (JSON) block. A blocked authoring path, not a missing
nicety.

The group now offers "Why the run was refused" — a `textarea` gated on
`showWhen: { field: 'outcome', equals: ['refused'] }`. Kind, label, placeholder
and help are derived from the installed spec: it describes `message` as a
`{token}` template "interpolated at run time exactly like a screen
`description`", and that field is a `textarea` in this same table, as is the
sibling `message` key on `notify`. No `defaultValue` — the key is `.optional()`
with no `.default()`.

The gate keeps BOTH directions authorable. An unset `outcome` resolves through
the declared `completed` default, so the field stays off screen until the author
picks `refused`; a STORED `message` re-shows it regardless, which is the only
way to clear a stale message after switching back. Clearing deletes the key
rather than storing `''`, which this schema refuses under both outcomes.

The Outcome help (English and the zh-CN overlay) no longer says the message is
set in Advanced, since it no longer is. The `inactiveRetained` census was
re-measured at landing rather than copied: `end` becomes a new gated bucket,
8 -> 9 buckets, 33 -> 34 gated fields, 24 -> 23 ungated types.

The same group's `outputVariable` row is deliberately untouched.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3061.2 KB 3104.5 KB
Main entry chunk (gzip) 145.6 KB 350 KB
Entry file index-BRxJPin5.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 544.84KB 130.47KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 213.54KB 59.33KB
fields (index.js) 247.89KB 62.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.33KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 252.27KB 65.55KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.55KB 57.83KB
plugin-kanban (index.js) 46.63KB 14.53KB
plugin-list (index.js) 112.68KB 27.68KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 84.36KB 20.78KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 99.04KB 32.62KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-tesla
os-tesla marked this pull request as ready for review September 13, 2026 23:52
@os-tesla
os-tesla added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit d27dcf2 Sep 14, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9336-end-message-control branch September 14, 2026 00:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants