Uh oh!
There was an error while loading. Please reload this page.
fix(runtime-config): resolve OS_PRODUCT_STAGE into branding.stage so the documented preview-badge switch works (#9252) - #9658
Conversation
) The Console's PreviewBadge reads `branding.stage` off `/api/v1/runtime/config`, and objectui's app-shell README documents `OS_PRODUCT_STAGE` / `new RuntimeConfigPlugin({ stage })` as the operator switch that hides the badge. Neither half was implemented — measured with a control, the producer exists in neither the framework nor the cloud repo — so `OS_PRODUCT_STAGE=ga objectstack dev` silently did nothing. Resolved in the plugin constructor beside every sibling branding key, not threaded through the CLI: the card's own repro (examples/app-showcase) constructs its own RuntimeConfigPlugin, which wins over the CLI's by plugin name, so a `Serve.RUNTIME_CONFIG_OPTIONS` route would have left it broken. The cloud distribution's subclass inherits it, keeping one mechanism. Closed value space ('preview' | 'beta' | 'ga'); an unrecognised value is refused with a mount-time warn naming the accepted set rather than forwarded. Unset stays absent — no key at all, so the Console keeps its own documented 'preview' default. Co-Authored-By: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1a60085e617612b928615a711ac9f57112b7877a && git checkout 1a60085e617612b928615a711ac9f57112b7877a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07026cf2b7718e4dd1543dd5b1ecad16eb9d0e5b 16b3fd1efc588069582134aa25b60ba03b4fddd2 && git checkout -B drift-repro 07026cf2b7718e4dd1543dd5b1ecad16eb9d0e5b && git merge --no-ff 16b3fd1efc588069582134aa25b60ba03b4fddd2
node scripts/docs-audit/affected-docs.mjs --json 07026cf2b7718e4dd1543dd5b1ecad16eb9d0e5b |
os-zhuang
commented
Aug 18, 2026
PM review — accepted, ready + enqueued. ⭐ And this PR falsified my dispatch brief, not the other way round.
The Zone 2 falsification is correct, and it was mine to get wrongMy brief asserted a two-site change with a value threaded in from The card's own repro constructs its own plugin, which beats the CLI's by plugin name. So a value threaded through Also verified independently: the sibling branding keys (
|
Fixes#9252
OS_PRODUCT_STAGE=ga objectstack devleft the Console's "Preview" chip onscreen.
RuntimeConfigPluginnever emittedbranding.stage, so objectui'sPreviewBadge— which reads exactly that key — never saw the value.The premise, re-verified with controls
Triage asked for a re-check against a moved
main, and one guess in the cardturned out to be wrong in the operator's favour.
OS_PRODUCT_STAGE, framework repo-widebranding.stage/productStage/PreviewBadge,packages/OS_CLOUD_URL/OS_ALLOW,packages/OS_PRODUCT_STAGE/branding.stage/PlatformStage, cloud repo-wideOS_PRODUCT_NAME, cloud repobranding, cloud repoThe card supposed the knob was "honored only by the cloud distribution."
It is honored by nobody — the producer side does not exist in either
repository. The controls are what make those zeros a reading rather than a
broken search. So this is the declared-but-unenforced trap in its purest form:
objectui's app-shell README documents the operator interface in as many words
("Operators set it with
OS_PRODUCT_STAGEornew RuntimeConfigPlugin({ stage })")and no code anywhere answered it. Emitting the key restores an already-declared
contract; no request accepted today becomes rejected, or vice versa.
Falsified: this is a one-file change, not the two-site change the card predicted
The dispatch expected a value threaded in from
Serve.RUNTIME_CONFIG_OPTIONSinpackages/cli/src/commands/serve.ts.packages/cliis not touched by this PR,for a reason that is checkable rather than stylistic:
productName,logoUrl,faviconUrl,brandColor,pwaDescription,pwaThemeColor) already resolvesconfig.X ?? OS_Xin this same constructor, host option winning.stagejoins that rule instead of inventing a second one.
examples/app-showcase, constructs itsown
RuntimeConfigPlugininobjectstack.config.ts:163, and ahost-wired plugin wins over the CLI's by plugin name (the subject of
serve-marketplace-cloud-host-precedence.test.ts). A value threadedthrough
Serve.RUNTIME_CONFIG_OPTIONSwould have left the reported reprostill broken, and would have made every other host responsible for
remembering one more passthrough — the every-host-must-remember failure
features.installLocalwas demoted to a ceiling for.The cloud distribution inherits this for free: its
RuntimeConfigPluginextends the open one and spreads its config into
super(), so one mechanismanswers this question in both distributions rather than two that can drift.
Value space: closed, and refusals are loud
'preview' | 'beta' | 'ga'— exported asPlatformStage, mirroring the unionthe Console branches on. Not free text, because the consumer branches on the
value. An unrecognised value (
GA,general-availability) is refused andnamed in a mount-time
warnlisting the accepted spellings, never forwarded:the SPA discards off-contract values anyway, so a passthrough would recreate
this bug's exact shape — an operator sets the knob, nothing happens, nothing is
said.
warnand noterrorper the repo's log-level rule: this is a functionaldegradation, with nothing claimed-persisted going missing behind it.
No
packages/specchange is involved — the runtime-config response has no specschema (
runtime/configinpackages/spec/src/: 0 hits), andpwaThemeColorproves the branding shape has exactly one declaration site in this repo.
Unset stays absent
No
stagekey at all when nothing sets it — not an empty string, not aserver-invented default. The Console owns the documented
'preview'default,so nothing that works today changes. The response builds the key by conditional
spread, and the pin asserts that direction on key presence
(
hasOwnProperty), nottoBeUndefined():{ stage: undefined }satisfies thelatter while being a present property that survives
structuredCloneand showsup in
Object.keys.Ablation: predicted, NOT executed — declared rather than implied
The two ablation legs were scripted and predicted in advance, then could not be
run: the shared heavy-verify lock (
/tmp/os-heavy-verify.lock) starved thissession for ~77 minutes across 10 acquisition attempts, every one returning
the queue-timeout code. The cause is measurable rather than mysterious — other
sessions in this container queue with
-w 2400/-w 3000(40-50 minutes),while a wait that must fit inside one foreground call caps out around 8
minutes, so the long waiters win every race. Load average was 6.16 with 5
vitest/tsc processes live. I stopped and reported instead of running unlocked.
Recorded so the PM (or a follow-up) can execute and compare rather than take my
word:
origin/main, keep the pin. Predicted9 failed / 10 passed of 19. The interesting half of that prediction is
which tests survive: every absent-direction test passes vacuously
pre-fix, because the key was already missing — that is the bug's own
baseline, and it is exactly why direction 1 is the load-bearing half.
stage: this.stage.Predicted 8 failed / 11 passed. Every
hasOwnPropertyassertion fails onpresent-and-undefined, while the JSON-round-trip test still passes
(
JSON.stringifydrops undefined) — which is the precise reason the pinasserts on the raw object rather than only after a round trip.
The script is kept at
scratchpad/issue-9252/ablate.sh— it reverts, runs,restores, and verifies byte-identity at each step. It expects a worktree at
/home/user/objectstack-issue-9252, which this session tore down per thecontainer cleanup rule, so recreate one from the pushed branch first:
git worktree add ../objectstack-issue-9252 claude/issue-9252-product-stage-runtime-config.What was executed in this direction: the type reverse-verification below,
which is a real red-then-green against the rebuilt
.d.ts.Evidence
All at
16b3fd1ef.pnpm --filter @objectstack/cloud-connection test— 24 files, 210 tests passed.reads as a pass): 1 file, 19 tests passed.
.d.ts:stage: 'GA'andstage: 'general-availability'are rejected (TS2820 / TS2322 — tsc evensuggests
"ga"), while all three valid members compile. Exactly 2 errors,both on the invalid lines.
pnpm check:nul-bytes— OK, 6169 files; self-scan of my four files: 0control-byte lines, control query 745 lines.
pnpm check:route-envelope— OK, and it names this file: exempt, closed atunenveloped 1 (pre-auth discovery, 2026-08-17 ruling). This PR adds a key to
that body, not a new body, so the ratchet is unmoved.
check-empty-changeset/check-adr-0087-registration/check-changeset-no-major,each
--base origin/main— OK.pnpm check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coverage— OK, none newlyratcheted (these are convention-triggered by adding a test file; derived from
my real changed paths via
scripts/pm/dispatch-gates.mjs, not recalled).CI owns the rest of the farm.
Generated by Claude Code