Skip to content

spec: importing one function from a @objectstack/spec subpath pulls its whole module graph — measured +237 KB minified, nothing tree-shakes #10031

Description

@os-support-ai

Measured while implementing objectstack-ai/objectui#5287 (console top bar); filed unassigned, out of scope for that PR.

What was measured

The console needed exactly one three-value predicate — postureEnforcesWall from @objectstack/spec/security, a pure function over a string union. Bundling it costs a quarter of a megabyte, and it costs that even when the consumer's graph already holds the entire spec root entry.

Measured on @objectstack/spec@17.0.0 as resolved in objectui, with that repo's own esbuild (--bundle --format=esm --minify --tree-shaking=true), an entry importing nothing else:

entry minified
import * as spec from '@objectstack/spec' 1271.8 KB
the same plus import { postureEnforcesWall } from '@objectstack/spec/security' 1509.1 KB (+237.3 KB)
@objectstack/spec/ui alone 630.1 KB
/ui plus /security 867.4 KB (+237.3 KB)

The delta is identical in both directions, so the subpath entries do not share their bundled schema modules with each other or with the root entry — each import materializes its own copy of what it reaches.

Why nothing shakes it out

@objectstack/spec's package.json declares no sideEffects field. Both esbuild and rollup then have to assume every module in the package may have side effects on evaluation, and the schema entries do evaluate at module scope. So the retained bytes are not "what you imported" but "every module the entry file reaches", regardless of which export the consumer named.

Why this matters beyond one call site

Downstream consumers pay it on page load. objectui already runs a dedicated build-time guard for exactly this class — apps/console/vite.config.ts's assertLazyLinterStaysLazy, added over 89 KiB gzipped of @objectstack/lint leaking into the eager closure (objectui#5266). A protocol package whose predicates cost 237 KB each pushes consumers toward re-deriving protocol rules locally instead of importing them, which is precisely the drift ADR-0105 put the tenancy posture into the protocol to prevent. objectui#5287 took that trade deliberately and pinned the local copy with a parity test, but the trade should not have to exist.

Directions worth triaging (not a decided fix)

  • Declare sideEffects: false (or a narrow allowlist) if the schema modules are in fact evaluation-pure — the standard fix, and the one that needs a maintainer to confirm the claim rather than an agent to assume it.
  • Annotate top-level schema construction as /*#__PURE__*/ so bundlers may drop unreached schemas.
  • Split the pure predicate/vocabulary helpers (postureEnforcesWall, postureStampsOrganization, postureUsesUnionScope, normalizeTenancyPosture, TENANCY_POSTURES) into a schema-free subpath consumers can import for a few hundred bytes.

Reproduction is two esbuild invocations against an installed @objectstack/spec; no objectui checkout is required.

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 19, 2026
  2. self-assigned this
    on Aug 19, 2026
  3. added theissue type on Aug 19, 2026
  4. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    Contributor

    Triage (triage seat, session session_014qTKTqjme5Fp5BH9iRmy6t): routed to the decision inbox — needs-user-decision + domain:spec, type Task, assigned to the inbox queue per the #9976 protocol.

    Rationale: lands in the packages/spec build/publish pipeline (tsup config + package.json sideEffects/exports). Not a duplicate of #8133 — that card is cross-entry IDENTITY and duplication; this one is intra-entry tree-shaking — but both are the same publish-pipeline design decision, and #8133's hold condition ("a consumer is measured breaking on … bundle size") is at least grazed by this measurement. Recommend deciding the two together; whichever seat implements must treat them as one worksite (serial, one dispatch entry).

    <!-- os-decision-facets -->

    • platform long-term coherence: a protocol package whose predicates cost 237 KB each pushes consumers to re-derive protocol rules locally — objectui#5287 already did, with a parity pin. That is exactly the drift ADR-0105 moved the tenancy posture into spec to prevent. Fixing the package shrinks a special case; leaving it grows parity-pin scar tissue in every consumer.
    • measured business pull: yes, today — console top bar pays +237.3 KB minified for one pure function; measurement is reproducible with two esbuild invocations against the published artifact.
    • AI-agent error-resistance: local re-derivation of protocol predicates is the highest-risk pattern for agent-written code (silent divergence). A cheap schema-free import is structurally safer than N pinned copies. Caveat the other way: declaring sideEffects: false on modules that DO register at module scope would make bundlers silently drop live code — the purity claim must be measured, not assumed.
    • startup scope discipline: option A is a one-line build-metadata change (smallest); option C adds a new public subpath = a permanent exports-map obligation. Prefer the smallest change that removes the trade.

    Options:

    Recommendation: A, escalating to B only if the purity measurement already forces touching the tsup config. Confidence gap: this analysis has not verified whether any spec module has real module-scope side effects (registry writes, global mutation) — that measurement decides whether A is safe at all, and it should be the implementing dev's first step with a hard stop if impure modules surface.


    Generated by Claude Code

  5. removed their assignment
    on Aug 19, 2026
  6. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    Contributor

    Self-correction (same triage session): assignee cleared — the #9976 assign-to-inbox routing was retired by skill PR #9994 (merged 13:00Z today; maintainer ruling: decision cards are not pushed, the inbox is digested periodically in discussion). The needs-user-decision label and the facet block above are the operative record; nothing else changes.


    Generated by Claude Code

  7. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded (2026-08-20, live decision-inbox session with the triage seat, session session_01PjAP6vbcsg2yMtvySPv1Qo)

    Ruled: approved as one family with #10096 — full ruling text, the minted standing principle (「浏览器可达的 spec 导出面必须 schema-free」, maintainer verbatim 「同意」), and the four implementation clauses are recorded on #10096 to keep one authoritative copy. This card's slice: sideEffects declaration (purity proven by measurement, the maintainer-confirmation the body asked for is hereby given at the policy level — the per-module purity claim remains the dev's to prove), /*#__PURE__*/ where warranted, and the schema-free predicate/vocabulary subpath direction for postureEnforcesWall and siblings.

    State transition in the same stroke: needs-user-decision → pm:queue. Fold-or-serial with #10096 is the domain:spec seat's call at dispatch (five-gate test reads foldable).


    Generated by Claude Code

  8. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 20, 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

    domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions