Skip to content

fix(devx): bundle THIS tree's @objectstack/spec into the vendored Console SPA - #9660

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-8134-console-spec-dist-injection
Aug 18, 2026
Merged

fix(devx): bundle THIS tree's @objectstack/spec into the vendored Console SPA#9660
os-steve merged 1 commit into
mainfrom
claude/issue-8134-console-spec-dist-injection

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#8134

Gives scripts/build-console.sh an OBJECTSTACK_SPEC_DIST injection mirroring the existing OBJECTSTACK_CLIENT_DIST one, so the vendored Console SPA bundles this tree's@objectstack/spec instead of the last published tarball. This is the direction ruled by the maintainer (comment 5307570335), and it closes the class, not the #7804 instance — that instance belongs to #8893, which is untouched here and stays open.

The ordering constraint the earlier blocked round measured is discharged: objectstack-ai/objectui#4854 merged, and the pinned SHA 82a94170c405 carries the hook.

Verification: before / after, on real console builds

Two full scripts/build-console.sh runs in this container at pin 82a94170c405, the second differing only by this PR's change.

The card's own probe no longer discriminates, and that had to be re-derived.#8134 measured the defect with #7804's describe() text, which was absent from the vendored spec at filing time. Since the v17 GA cut, the pinned objectui lockfile resolves @objectstack/spec@17.0.0 — which carries that key. Grepping for it now returns a hit in both builds and proves nothing.

So the probe was re-derived against the two specs actually on disk: 102 schema description strings declared in this tree's packages/spec are absent from the vendored 17.0.0. The class is live today, not hypothetical.

⚠️ A one-sided probe passes with no injection at all. Measured, and it changed the design. Asserting only "text unique to this tree's spec appears in the bundle" returned green on the unmodified build, because the console bundle already carried a second, transitive copy of this tree's spec — dragged in by the injected @objectstack/client, landing in framework-*.js, a different chunk from the console's own @objectstack/spec imports in vendor-objectstack-*.js. The designer runs on the latter. A one-sided canary reads the wrong copy and reports success.

The instrument is therefore two-sided: a fresh witness (injected-only text, must be present) and a stale detector (vendored-only text, must be absent). The stale detector is the half that actually catches this defect.

measurementbeforeafter
stale detector "Additional custom metadata types" (vendored-only)presentabsent
workspace-only probe "Field providing the marker longitude ..."in framework-*.js onlyin vendor-objectstack-*.js
chunks carrying spec content2 (duplicate copies)1
vendored-only descriptions leaking into the bundle, all 18 export entries8 of 80 of 8
assert-console-spec-injection.mjsEXIT=1EXIT=0
built dist size53036 KB51736 KB
modules transformed86458644

The probe migrating from framework-*.js into vendor-objectstack-*.js is the load-bearing row: it is the console's own spec imports changing resolution. The zero-leak sweep across all 18 export entries answers whether the collapse is partial — it is not; no entry point still resolves to the published spec.

⚠️ One published string, "Explicit columns for the inline grid (derived from the child object when omitted)", still matches after the change. It is not residual stale content: the workspace reworded that description by appending a clause, so the old text is a strict prefix of the new one, and the match sits in the same chunk as the workspace-only probe. Three of the first candidate detectors were exactly this shape, which is why probes are validated against the entire other spec's built output rather than against a string set.

What the hook actually consumes (it is not symmetric with the client)

Read at the pinned SHA, apps/console/vite.config.ts plus scripts/vite-objectstack-spec-dist.ts:

  • it accepts the package directory, its dist/, or an entry file inside it, walking up to the nearest package.json and requiring it to be@objectstack/spec;
  • it derives one alias per exports entry from the override's own map, because a Vite string alias is prefix replacement and never consults an exports map — a literal copy of the client's one-line alias would rewrite @objectstack/spec/ui to a path that does not exist;
  • it therefore requires the spec to be built: every export target must exist on disk or it throws, naming the value.

That last point drives the build guard here, which is keyed on two sentinels rather than the client's one. dist/index.mjs is tsup's output; json-schema/openapi.json is gen:openapi's — the single export entry that does not live under dist/, is not committed, and is wiped by a later gen:schema run. A guard keyed on dist/ alone sails past a tree where that happened and the hook then throws mid-build. Conversely the guard deliberately does not key on a declaration file the way the client's does: the hook resolves the import condition only, so a spec whose DTS pass never ran is still complete for the injection.

turbo.json at the pinned SHA already allowlists OBJECTSTACK_SPEC_DIST, so it survives the turbo deps build as well as the direct console build.

The assertion is derived, not frozen

scripts/assert-console-spec-injection.mjs is new. It is deliberately not a fixed literal like the client's BUNDLE_CANARY: "does the bundle carry the surface the framework declares now" is a moving target, and any string pinned today is carried by the published spec within one release — after which it passes forever while proving nothing, which is the same silent pass this PR removes. Both probes are re-derived on every run from the two package trees on disk, and the check degrades honestly (reports "no skew" and exits 0) in the state right after a spec publish, where there is nothing to detect.

It is self-tested in both directions: pointed at the vendored spec as haystack it exits 1, pointed at this tree's spec it exits 0.

Preflight, per the ruling

A hook-presence guard mirroring build-console.sh's client one refuses the build and names the pin when the pinned objectui predates the hook. An unguarded injection would silently produce a console built against the published tarball again — the exact silent-wrong-answer this card is about.

Scope

Out-of-scope finding

Filed as #9659 (unassigned, finding): 4 of 6@objectstack/* packages in the console build tree remain resolved from objectui's lockfile after this PR — formula, lint, core, sdui-parser. lint is confirmed to reach the bundle. No user-visible break is measured for any of them, so the card asks for a per-package judgement rather than a reflexive fourfold injection.

Gates

Union re-run after the final commit, at b841493ed:

check:changeset-gate-self-tests, check:objectui-changeset, check:adr-0087-registration, check:empty-changeset, check:changeset-no-major, check:nul-bytes, check:console-sha — all green. The five changeset-family gates are what node scripts/pm/dispatch-gates.mjs derives for the actual changed paths.

⚠️check:objectui-pin-fresh reports STALE, and it is pre-existing: git diff origin/main...HEAD -- .objectui-sha is empty, so the gate's input is byte-identical to origin/main. That lag is #8893's pin bump, which remains open.


Generated by Claude Code

…sole SPA (#8134)
build-console.sh injected only OBJECTSTACK_CLIENT_DIST, so the console's
@objectstack/spec always came from objectui's lockfile under
--frozen-lockfile — the last published spec, never this workspace. An
authorable key added to packages/spec after that publish was accepted and
round-tripped by the server while the Studio designer rejected it as
unrecognized, with the framework-side card closing green throughout.
Mirrors the client injection including its preflight: a hook-presence guard
naming the pin, a build guard keyed on both generators that produce the
spec's export targets, and a bundle assertion.
The assertion derives its probes each run instead of freezing a literal, and
tests both directions — the bundle also carries a transitive copy of this
tree's spec via the injected client, which makes a one-sided probe pass with
no injection at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #8134 / PR #9660 · Q1 ruled B (as a separate card), Q2 no action

Verified independently: 3 files +332/-5, zero governed-surface hits.

⭐ The most important thing in this report is that you falsified the card's own probe

I sent you H1 with the card's measurement to reproduce, and warned about the optionsFrom.object false positive. I did not anticipate the actual trap: #7804's describe() text is now carried by the published 17.0.0, so grepping for it hits before AND after and proves nothing. The probe the card built its entire case on has gone stale in the direction that makes it silently useless — it does not fail, it succeeds meaninglessly.

If you had run the card's probe as written, you would have measured "the key is present" before your change and concluded the defect was already fixed. Re-deriving the probe instead of executing the card's is what makes the rest of this report worth reading.

And the class premise survives, which is the right outcome: 102 schema description strings declared in this tree's packages/spec are absent from the vendored @objectstack/spec@17.0.0. The card was right about the mechanism and wrong about its instrument.

The before/after is a real measurement, twice

Two full build-console.sh runs at the same pin, differing only by the change:

  • before: assert-console-spec-injection.mjs → exit 1, "Built console still carries the PUBLISHED @objectstack/spec", naming a vendored-only string; the workspace-only probe appears in framework-*.jsonly, not the vendor chunk.
  • after: "Console bundle carries THIS tree's @objectstack/spec, and only it"; the workspace-only probe has migrated into vendor-objectstack-*.js — i.e. the console's own spec imports changed resolution, which is the thing under test.

H3's leak sweep makes it a collapse rather than a partial fix: 8 substring-safe vendored-only descriptions across all 18 export entries, 0 still present after. Not "the one string I checked is gone."

And the instrument was shown able to both hit and miss: self-test red against the vendored spec, green against this tree's; positive control — the card's original probe matches 24 files in the published tarball while the re-derived probe matches 0. Plus you caught a false-positive class mid-build: 3 candidate stale-detectors were prefixes of reworded workspace strings, so probes are now validated against the entire other spec's built output rather than a hand-picked string set. That is the difference between a canary and a coincidence.

The two-copies finding, folded in correctly

The bundle carried two copies of this tree's spec before the change (one via the injected client landing in framework-*.js, one published copy in vendor-objectstack-*.js), collapsed to one here (-1300 KB, 8645 → 8644 modules). Correctly not filed as a separate defect: it is the same resolution split — and it is what made a one-sided canary pass with no injection at all. That sentence explains why this defect survived a green check for so long.


⭐ Ruling on Q1 (the cache-key residual): B — and it ships as its own card, not here.

I verified your finding myself:

ci.yml:1439 key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}
ci.yml:1485 (identical)
release.yml:491 (identical)

packages/spec is not in the key, and your assertion runs insidebuild-console.sh, so a cache hit skips it entirely. This PR busts the key once by editing build-console.sh, and the gap reopens on the next spec-only change, silently — which is the same shape as the defect you just fixed, one layer out. Finding that while wiring CI, and not fixing it because it changes cost characteristics, is the right call.

B for the reason you gave: the split restore/save in ci.yml was deliberately engineered so a failed build never poisons the cache, and A pays a full cold console rebuild (~20 min, measured) for every spec edit on a repo doing ~18 merges/day. That is a large recurring cost to close a window. B keeps the economics and removes the silent half for one node process.

Two constraints for whoever takes it, because B has a trap of its own: a gate that reddens a PR because a cached artifact is stale will red on PRs that did nothing wrong. So (i) its failure message must name the remedy (bust/rebuild) rather than just reporting the mismatch, and (ii) whoever builds it should consider whether the right response is to rebuild rather than to fail. Filing it now.

C rejected: scoping the concern to release.yml leaves PR-time verification blind, and a defect first observed at release time is the expensive kind. D rejected outright.

Ruling on Q2: no action, recorded exactly as you asked.

The two injections read as one mechanism at the build-console.sh layer (guard + export + assert) but not at the alias layer, for measured reasons: the client is one prefix alias to a package directory; spec needs one alias per exports entry, derived from the override's own map, because a Vite string alias never consults an exports map. And the guard asymmetry follows: the client's guard keys on dist/index.d.ts because tsup writes bundles before the DTS pass, while the spec hook resolves the import condition only — so the spec guard keys on dist/index.mjsandjson-schema/openapi.json, the one export entry outside dist/, produced by a different generator, uncommitted, and wiped by a later gen:schema run.

That last detail is precisely the kind of thing a future reader would "harmonize" away. It is now on the record with its reason.

On the pre-existing red

check:objectui-pin-fresh exit 1 (STALE) is pre-existing and not yoursgit diff origin/main...HEAD -- .objectui-sha is empty, so the gate's input is byte-identical to origin/main. That lag belongs to #8893. Proving it by diffing the gate's input rather than arguing from the diff's contents is the right way to disown a red.

#9659

4 of 6 @objectstack/* packages in the console build tree still resolve from objectui's lockfile after this PR — formula (2 declarers), lint (1, confirmed reaching the bundle via vite's browser-externalization warnings), core and sdui-parser (transitive, 0 direct import sites). Correctly asks for a per-package judgement rather than a reflexive fourfold injection, since no user-visible break was measured for any of them. That restraint is right: you fixed the one with a measured symptom and enumerated the rest. Queued.

Verdict: ACCEPT. Arming once the three running gates converge.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 18, 2026 14:50
@os-steve
os-steve added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit fc89098Aug 18, 2026
21 checks passed
@os-steve
os-steve deleted the claude/issue-8134-console-spec-dist-injection branch August 18, 2026 15:17
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mtooling

Projects

None yet

2 participants

@os-steve@claude