Skip to content

fix(runtime-config): resolve OS_PRODUCT_STAGE into branding.stage so the documented preview-badge switch works (#9252) - #9658

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9252-product-stage-runtime-config
Aug 18, 2026
Merged

fix(runtime-config): resolve OS_PRODUCT_STAGE into branding.stage so the documented preview-badge switch works (#9252)#9658
os-zhuang merged 1 commit into
mainfrom
claude/issue-9252-product-stage-runtime-config

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes#9252

OS_PRODUCT_STAGE=ga objectstack dev left the Console's "Preview" chip on
screen. RuntimeConfigPlugin never emitted branding.stage, so objectui's
PreviewBadge — 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 card
turned out to be wrong in the operator's favour.

proberesult
OS_PRODUCT_STAGE, framework repo-wide0 hits
branding.stage / productStage / PreviewBadge, packages/0 hits
control: OS_CLOUD_URL/OS_ALLOW, packages/143 files
OS_PRODUCT_STAGE / branding.stage / PlatformStage, cloud repo-wide0 hits
control: OS_PRODUCT_NAME, cloud repo9 hits
control: files mentioning branding, cloud repo18 files

The 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_STAGE or new 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_OPTIONS in
packages/cli/src/commands/serve.ts. packages/cli is not touched by this PR,
for a reason that is checkable rather than stylistic:

  • Both halves of the documented interface name this plugin, not the CLI.
  • Every sibling branding key (productName, logoUrl, faviconUrl,
    brandColor, pwaDescription, pwaThemeColor) already resolves
    config.X ?? OS_X in this same constructor, host option winning. stage
    joins that rule instead of inventing a second one.
  • Decisively: the card's own repro, examples/app-showcase, constructs its
    ownRuntimeConfigPlugin in objectstack.config.ts:163, and a
    host-wired plugin wins over the CLI's by plugin name (the subject of
    serve-marketplace-cloud-host-precedence.test.ts). A value threaded
    through Serve.RUNTIME_CONFIG_OPTIONS would have left the reported repro
    still broken
    , and would have made every other host responsible for
    remembering one more passthrough — the every-host-must-remember failure
    features.installLocal was demoted to a ceiling for.

The cloud distribution inherits this for free: its RuntimeConfigPlugin
extends the open one and spreads its config into super(), so one mechanism
answers this question in both distributions rather than two that can drift.

Value space: closed, and refusals are loud

'preview' | 'beta' | 'ga' — exported as PlatformStage, mirroring the union
the Console branches on. Not free text, because the consumer branches on the
value. An unrecognised value (GA, general-availability) is refused and
named in a mount-time warn listing 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. warn and not error per the repo's log-level rule: this is a functional
degradation, with nothing claimed-persisted going missing behind it.

No packages/spec change is involved — the runtime-config response has no spec
schema (runtime/config in packages/spec/src/: 0 hits), and pwaThemeColor
proves the branding shape has exactly one declaration site in this repo.

Unset stays absent

No stage key at all when nothing sets it — not an empty string, not a
server-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), not toBeUndefined(): { stage: undefined } satisfies the
latter while being a present property that survives structuredClone and shows
up 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 this
session 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:

  • Leg A — revert the producer to origin/main, keep the pin. Predicted
    9 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.
  • Leg B — keep the fix, swap the conditional spread for stage: this.stage.
    Predicted 8 failed / 11 passed. Every hasOwnProperty assertion fails on
    present-and-undefined, while the JSON-round-trip test still passes
    (JSON.stringify drops undefined) — which is the precise reason the pin
    asserts 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 the
container 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 test24 files, 210 tests passed.
  • The new pin alone, confirming it really executed (a zero-match run exits 0 and
    reads as a pass): 1 file, 19 tests passed.
  • Type reverse-verification against the rebuilt.d.ts: stage: 'GA' and
    stage: 'general-availability' are rejected (TS2820 / TS2322 — tsc even
    suggests "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: 0
    control-byte lines, control query 745 lines.
  • pnpm check:route-envelope — OK, and it names this file: exempt, closed at
    unenveloped 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 newly
    ratcheted (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

)
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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cloud-connection/src/index.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 3 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 07026cf2b7718e4dd1543dd5b1ecad16eb9d0e5bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1a60085e617612b928615a711ac9f57112b7877a — the merge of head 16b3fd1efc588069582134aa25b60ba03b4fddd2 into base 07026cf2b7718e4dd1543dd5b1ecad16eb9d0e5b, 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 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

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

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — accepted, ready + enqueued. ⭐ And this PR falsified my dispatch brief, not the other way round.

domain:cli seat, session session_012WKSnqAaoqtW3QX7SSf1Vk. Dev reported status: done; I flipped ready at 14:43:24Z and armed auto-merge after (⚠️ arming does not survive a draft conversion, so the order matters). Enqueued confirmed by the timeline event, ⛔ not by the auto_merge field.

The Zone 2 falsification is correct, and it was mine to get wrong

My brief asserted a two-site change with a value threaded in from Serve.RUNTIME_CONFIG_OPTIONS in packages/cli. The dev refused that and shipped one file. I re-derived the decisive fact myself rather than accepting the report:

examples/app-showcase/objectstack.config.ts:163
new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, installLocal: true })

The card's own repro constructs its own plugin, which beats the CLI's by plugin name. So a value threaded through Serve.RUNTIME_CONFIG_OPTIONS would have left the reported repro still broken — a fix that passes its own tests and does not fix the bug that was filed. The smaller diff is not the cheaper option here; it is the only correct one.

Also verified independently: the sibling branding keys (productNameOS_PRODUCT_NAME, logoUrlOS_LOGO_URL, faviconUrlOS_FAVICON_URL, brandColorOS_BRAND_COLOR, …) already resolve config.X ?? OS_X in that same constructor, so stage joins an existing rule rather than minting a second mechanism.

⚠️ The ablation was NOT run — accepted, with the reason stated rather than waved through

The dev declared this instead of implying it, which is the right call. What it did run is a real reverse verification in the type direction against the rebuilt .d.ts (stage:'GA' and stage:'general-availability' → exactly 2 errors, TS2820/TS2322, all three valid members compile). What is missing is the behavioural leg.

I accept it because the load-bearing half of the pin cannot be vacuous, and that is checkable by construction rather than by execution. Seven present-direction assertions read body.branding.stage and compare it to a concrete value:

:82 expect(body.branding.stage).toBe('ga') ← the reported repro
:86 it.each(['preview','beta','ga']) → toBe(stage)
:95 toBe('ga') :101 toBe('ga') :106 toBe('beta')

Pre-fix the key did not exist, so every one of those resolves undefined and fails. Reverting the producer cannot leave them green. The dev's own Leg A prediction (9 of 19 red) is consistent with that, and — more to the point — the dev correctly identified which half is weak: the absent-direction cases (:115, :119, :126) pass vacuously pre-fix because the key was already missing. Naming that itself is worth more than the ablation would have been.

⇒ The unrun leg would have confirmed a fact derivable from the diff. It is not a reason to hold a green release-path fix.

⚠️ The gate the dev warned about passed

check:type-check-debt was unrun, and the dev flagged the real risk: @objectstack/cloud-connection sits in the DEBT ledger at errors:13 while not being in TEST_DEBT, so its new test file lands inside the measured program. TypeScript Type Check is one of the six required checks and came back completed: success. All six green:

TypeScript Type Check · Lint & Repo Gates · Test Core (+3 shards) · Dogfood Regression Gate (+3 shards) · Build Core · Temporal Conformance (live PG + MySQL)

Landing probe, registered before the merge

PlatformStage0 occurrences on origin/main at 5f99739cb, against a control of 18 files matching RuntimeConfigPlugin. A symbol this diff introduces, measured against its own pre-merge baseline of zero. ⛔ Not an absence test, ⛔ not a gate's output count.

Third time today this lock has cost a real measurement

77 minutes across 10 attempts, every one exiting 99. That is the second dev in this session to lose its verification to /tmp/os-heavy-verify.lock and the mechanism is now filed as #9661 — compliant waiters are starved by non-compliant ones because flock grants to whoever is currently blocked, and a rule-abiding ~9-minute waiter is absent from the queue for part of every cycle while a 40–50 minute waiter never is. ⛔ Not this PR's problem to fix.

Out-of-scope finding, filed correctly

objectui#5206 — the app-shell's 'Server-side: see' pointer names a path that does not exist (packages/runtime/src/cloud/runtime-config-plugin.ts; the plugin lives in packages/cloud-connection/src/), with a control proving the miss is a reading and not a typo'd search. That stale pointer is the only cross-repo link from this contract's consumer to its producer — the exact trip this card had to make. Filed unassigned in the destination repo, no pm:queue, duplicate-checked first. Correct on every count.


Generated by Claude Code

Merged via the queue into main with commit 990a893Aug 18, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9252-product-stage-runtime-config branch August 18, 2026 15:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dev server: OS_PRODUCT_STAGE / branding.stage never reaches /api/v1/runtime/config — the documented PreviewBadge ops switch is a no-op on OSS

2 participants

@os-zhuang@claude