Skip to content

feat!: ship tree-shakeable dual ESM + CJS build (#2344)#2355

Merged
andybevan-scope3 merged 5 commits into
mainfrom
issue-2344
Jul 13, 2026
Merged

feat!: ship tree-shakeable dual ESM + CJS build (#2344)#2355
andybevan-scope3 merged 5 commits into
mainfrom
issue-2344

Conversation

@andybevan-scope3

@andybevan-scope3 andybevan-scope3 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What this does (#2344)

Makes @adcp/sdk tree-shakeable: ships a dual ESM + CJS build and marks the package sideEffects-aware, so importing one symbol no longer drags in the whole library. require('@adcp/sdk') and the CLI keep working. Major version (the exports map changes for every consumer).

The win (measured): import { EventTypeValues } from '@adcp/sdk/types'~0.6 KB, zod absent (was ~1.9 MB with zod).

Status — all green ✅

check result
Fast test gate 12,519 pass / 0 fail
Slow / CLI tests 50 pass / 0 fail
typecheck, build, ESM+CJS runtime, CLI pass

How it works

  • tsup with bundle: false → one output file per source module (mirrors the old tsc layout, so tree-shaking works across the preserved graph and the CLI/tests that deep-import build internals still resolve).
  • esbuild-fix-imports-plugin adds ESM import extensions + directory /index resolution; a small companion plugin does the same for dynamic import() (the standard plugin misses those, which is what caused lazy await import() to fail — now fixed).
  • An ESM banner supplies per-file __dirname / require from import.meta.url (per-file, not a shared shim chunk).
  • exports: import.mjs, require.js, types.d.ts. type stays commonjs. Declarations via tsc --emitDeclarationOnly.
  • Dual-package safety: the request-signing and response-capture AsyncLocalStorage stores are anchored on the global symbol registry (a process that loads both builds shares one instance — no silently-unsigned requests); the verified-signature brand uses Symbol.for.
  • Default invariants register via an explicit registerDefaultInvariants() (plus a load-time self-call) instead of a bare side-effect import, so bundlers can't drop them.

Notes for review

  • Build tool is tsup (the standard choice). rollup preserveModules was evaluated as an alternative and also works; tsup wins on config simplicity.
  • default-invariants.ts shows a large diff — it's a mechanical wrap of the existing registrations into registerDefaultInvariants() (review with git diff -w).

Comment thread src/lib/canonical-references/index.ts Outdated
@andybevan-scope3
andybevan-scope3 force-pushed the issue-2344 branch 3 times, most recently from 96b0637 to 1a87431 Compare July 11, 2026 15:10
benminer
benminer previously approved these changes Jul 13, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. One line in an otherwise sound dual-package build turns a fail-closed provenance brand into a fail-open one on the signed-requests trust boundary — the wrong direction for a security control (fail-closed beats fail-open).

The build machinery is right. bundle: false + one-file-per-module preserves the graph for tree-shaking and keeps the CLI's deep require paths and __dirname schema lookups working; the per-file ESM banner (tsup.config.ts:88-99) and the companion dynamic-import() extension fixer (tsup.config.ts:14-46) close the two gaps esbuild-fix-imports-plugin leaves. The AsyncLocalStorage global-registry anchoring is the correct fix for silently-unsigned requests across a mixed ESM/CJS load. All of that ships as-is. The one blocking issue is buyer-agent.ts.

MUST FIX

Forgeable verified-signature brand — auth bypass on signingOnly/mixed (src/lib/server/decisioning/buyer-agent.ts:403). The change flips the provenance brand from a module-private Symbol() to Symbol.for('@adcp/sdk.verifiedHttpSig'). That symbol is const and unexported (used only at :403/:406/:424/:441), so under the old private Symbol() no code outside this module could construct it — which is exactly what the retained docstring at :382-388 still asserts: "NOT registered via Symbol.for(...) — code outside this module cannot synthesize the symbol value, which is what makes it a usable provenance check." The code now contradicts its own security invariant.

The brand is the sole gate. isVerifiedHttpSig (:441) is the only thing separating a verifier-produced http_sig credential from a literal-shape impostor — isVerifiedHttpSigPayload (:377) checks nothing but that agent_url is a non-empty string. So any in-process code (a compromised dependency, or the custom authenticate callback the docstring at :461-465 names as the exact forgery vector the brand exists to close) can now do:

const b = Symbol.for('@adcp/sdk.verifiedHttpSig');
Object.defineProperty(cred, b, { value: true, enumerable: false });

and a signingOnly seller — documented as "traffic that doesn't sign is automatically refused" — routes an unsigned, never-cryptographically-verified credential to resolveByAgentUrl as if it signed. That is MUST FIX #2 (auth bypass on a trust boundary).

The dual-package justification doesn't hold for this path the way it does for the ALS stores. markVerifiedHttpSig (auth-signature.ts) and isVerifiedHttpSig (buyer-agent.ts) live in the same server subpath graph and are instantiated from the same copy on any normal @adcp/sdk/server import; cross-copy mismatch requires wiring the verifier and the registry from two different copies, which no ordinary adopter does. Even if it did, the old failure was fail-closed (reject a valid credential → surfaced error), and the new one is fail-open (accept a forged one → silent bypass). Trade the wrong way and the constitution's fail-closed rule loses.

Fix: revert :403 to a private Symbol('@adcp/sdk.verifiedHttpSig'). If cross-copy matching for this brand is a real requirement (I don't think it is), anchor a private symbol behind a globalThis rendezvous the way globalAsyncLocalStorage does — but note that rendezvous is itself discoverable, so revert is the cleaner, fail-closed answer. Either way the :382-388 docstring must match the code.

Things I checked

  • ALS global anchoring (src/lib/utils/global-async-local-storage.ts) — Symbol.for rendezvous on globalThis, returns the shared instance if present, one unique key per store. The four call sites (signing/agent-context.ts, rawResponseCapture.ts, responseSizeLimit.ts, transportDiagnostics.ts) swap new AsyncLocalStorage()globalAsyncLocalStorage('<key>') with distinct keys. Correct and fail-closed.
  • a2a.ts transportrequire('@a2a-js/sdk/client')import { A2AClient as A2AClientImpl } (a2a.ts:1), still the official @a2a-js/sdk client, no custom HTTP/SSE. callContextStorage (a2a.ts:46) is left un-anchored — correct: it's per-call transient state whose .run() and .getStore() always execute in the same copy, so it has no cross-boundary handoff.
  • Changeset-vs-wire audit — two changesets present. tree-shakeable-esm-cjs.md is major and correctly names the exports-map rework as the breaking reason (MUST FIX #7 satisfied). package-artifact-verification.md is patch for verification tooling + peer-floor corrections. package.json version stays 11.2.0 on both base and head — no manual bump (MUST FIX #6 clear).
  • default-invariants.ts (2737 net lines, largest non-generated file) — mechanical wrap into registerDefaultInvariants() (:72), guarded by registerOnce/REGISTERED (:56-60) so the load-time self-call and the explicit calls from comply.ts and storyboard/index.ts are idempotent against registerAssertion's duplicate-throw. git diff -w confirms the body is unchanged.
  • package.json (381 net lines) — every subpath now emits per-condition types (import.d.mts/.mjs, require.d.ts/.js); enums, testing/personas, server/legacy/v5 gained their missing typesVersions entries; sideEffects scoped to **/default-invariants.*.

Follow-ups (non-blocking — file as issues)

  • Assertion registry isn't globally anchored the way the ALS stores are. REGISTERED (default-invariants.ts:54) and the underlying registerAssertion registry are per-copy. In a genuine dual-load, testing-harness registrations from one copy are invisible to resolveAssertions in the other. Failure mode is fail-closed (throws on unresolved id) and the path is test-only, so it ships — but if the dual-package concern was worth anchoring the signing ALS, it's worth a note here for symmetry.
  • create-adcp-server.ts moved require('../schemas') to a top-level import. Relies on ESM live bindings to populate TOOL_INPUT_SHAPES before getToolInputShapes() runs (it does — accessed lazily inside the fn, not at load). Worth a comment noting the circular-import reason the require originally existed, so a future edit doesn't hoist the access to load time.

Minor nits (non-blocking)

  1. Peer-floor raises split across bump categories. The @a2a-js/sdk floor (^0.3.4^0.3.13) rides the major changeset; the @modelcontextprotocol/sdk floor (^1.17.5^1.24.0) rides the patch one. Raising a peer floor is breaking-shaped; both are only correct here because the aggregate release is major. Fine to ship, but a one-line note in the patch changeset that the floor bump is safe only under the co-released major would age better.

Fix the brand and the docstring at buyer-agent.ts:403/:382-388 and this is a clean, well-argued major.

andybevan-scope3 and others added 4 commits July 13, 2026 13:31
Build the library with tsup (`bundle: false`, one output file per source
module) so it ships ESM (`.mjs`, via the `import` condition) alongside CJS
(`.js`, via `require`), and declare `sideEffects` for tree-shaking. Importing
one symbol no longer pulls the whole barrel: `import { EventTypeValues } from
'@adcp/sdk/types'` bundles to under 1 KB with zod absent (was ~1.9 MB).

`require('@adcp/sdk')` and the CLI keep working. Dual-package safety: the
signing and response-capture AsyncLocalStorage stores are anchored on the
global symbol registry, and the verified-signature brand uses `Symbol.for`.

Default invariants register via an explicit `registerDefaultInvariants()`
(with a load-time self-call) instead of a bare side-effect import, so ESM
extension rewriting and tree-shaking cannot silently drop them.

BREAKING CHANGE: the package `exports` map now resolves `import` to ESM
(`.mjs`) and `require` to CJS (`.js`); every consumer's module resolution goes
through the reworked map. Major version bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard the publish artifact so packaging regressions fail CI instead of
reaching consumers:

- check:package (publint --strict + attw --pack .) on every PR
- verify:package: clean-room npm-install of the packed tarball with required
  peers at their range floors, loaded via real ESM + CJS (gated CI job)
- emit ESM-format .d.mts declarations alongside .d.ts and give each subpath
  per-condition types, fixing attw "masquerading as CJS"; enums import now
  points at enums.mjs; add missing typesVersions entries
- raise @modelcontextprotocol/sdk peer floor ^1.17.5 -> ^1.24.0 (main entry
  eagerly loads server/tasks -> experimental/tasks/*, absent before 1.24.0)

Verification tooling is devDependencies only; no new runtime deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… npm pack --json

npm runs the `prepare` lifecycle during `npm pack` (even with --ignore-scripts
on some npm versions), and its banner pollutes stdout — breaking JSON.parse of
the --json payload in CI. Pack into the temp dir with --pack-destination and
read the emitted .tgz from disk, sidestepping stdout parsing entirely. Quiet
the pack file-listing notice with --loglevel=error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exports map moved from flat { types, import, require } to per-condition
{ import: { types, default }, require: { types, default } } to give the ESM
`import` condition its own .d.mts. Update the four tests that pinned the old
flat shape (net, brand, canonical-references public exports, and the
./types/v2-5 bundle) to assert the nested shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andybevan-scope3
andybevan-scope3 marked this pull request as ready for review July 13, 2026 17:32
The dual-build work switched the http_sig provenance brand from a
module-private `Symbol()` to `Symbol.for('@adcp/sdk.verifiedHttpSig')`. That
made the brand forgeable: any in-process code (a compromised dependency, or a
custom `authenticate` callback) could obtain the same global-registry symbol
and stamp an unverified `{ kind: 'http_sig' }` credential as verifier-produced,
so `signingOnly`/`mixed` registries would route an unsigned agent_url as if it
had passed the RFC 9421 check — a fail-open auth bypass on the signed-requests
trust boundary.

Revert to a private `Symbol()`. The verifier (`markVerifiedHttpSig` in
auth-signature.ts) and the checker (`isVerifiedHttpSig` here) live in the same
`server` subpath graph and load from the same copy on a normal
`@adcp/sdk/server` import, so the dual-copy mismatch the change tried to fix
does not arise in practice; even if it did, a mismatch fails closed (a valid
credential is rejected with a surfaced error) rather than open. The docstring
at the brand definition already describes this private-Symbol behavior.

Also note in the changeset that the peer-floor raise is safe only under the
co-released major.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@andybevan-scope3

Copy link
Copy Markdown
Collaborator Author

Thanks @aao-secretariat — addressed the MUST FIX and the changeset nit in 6728ce2f.

MUST FIX — forgeable verified-signature brand (buyer-agent.ts:403): reverted the brand from Symbol.for('@adcp/sdk.verifiedHttpSig') back to a module-private Symbol('@adcp/sdk.verifiedHttpSig'), and removed the Symbol.for justification comment so the definition matches the :382-388 docstring again. Agreed on the reasoning: markVerifiedHttpSig (auth-signature.ts) and isVerifiedHttpSig (here) resolve from the same @adcp/sdk/server copy on any ordinary import, so the cross-copy mismatch the change was guarding against doesn't arise in practice — and even if it did, a private-Symbol mismatch fails closed (a valid credential is rejected with a surfaced error), not open. Revert is the fail-closed answer, no globalThis rendezvous needed. Verified: tsc --noEmit clean, buyer-agent credential-synthesis + registry suites green (45/45).

Minor nit #1 — peer-floor bump across changeset categories: added a line to package-artifact-verification.md noting the @modelcontextprotocol/sdk floor raise is safe only under the co-released major.

Non-blocking follow-ups (assertion registry not globally anchored; the create-adcp-server.ts requireimport circular-import note): agreed these are worth tracking; leaving them out of this PR and will file as issues for symmetry rather than expand the diff here.

The build machinery, ALS anchoring, and changeset audit you confirmed are unchanged.

@bokelley

Copy link
Copy Markdown
Contributor

Thanks for the rundown. The Symbol.for revert is the right call — fail-closed on cross-copy mismatch is exactly the correct default, and the auth-signature/buyer-agent pair resolving from the same copy in normal usage means there's no practical upside to the global registry for this brand. The changeset note on the peer-floor bump is a clean addition. Deferring the assertion-registry and circular-import threads to follow-up issues keeps this diff focused.

Ready for merge when CI is green.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

Acknowledged — awaiting CI green before merge. No action needed from this routine.


Generated by Claude Code

@andybevan-scope3
andybevan-scope3 dismissed aao-secretariat[bot]’s stale review July 13, 2026 20:13

Addressed in 6728ce2: the http_sig provenance brand was reverted from Symbol.for(...) back to a private Symbol() (fail-closed), and the docstring now matches. The reviewer auto-re-ran on the fix commit (run 29280360665) with no new findings, but the Argus action only posts when it has blocking findings, so it couldn't self-clear this review. Dismissing to reflect the resolved state.

@andybevan-scope3
andybevan-scope3 merged commit 1053705 into main Jul 13, 2026
32 checks passed
@andybevan-scope3
andybevan-scope3 deleted the issue-2344 branch July 13, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants