Skip to content

feat(core): ambient build context (experiment, for evaluation) - #399

Draft
laazyj wants to merge 10 commits into
mainfrom
claude/sub-builder-call-site-bugs-62z7ro-ambient-context
Draft

feat(core): ambient build context (experiment, for evaluation)#399
laazyj wants to merge 10 commits into
mainfrom
claude/sub-builder-call-site-bugs-62z7ro-ambient-context

Conversation

@laazyj

@laazyj laazyj commented Aug 15, 2026

Copy link
Copy Markdown
Owner

What & why

Refs #386. This is a demonstration for evaluation, not a merge candidate — opened as a draft deliberately. It exists so the trade-off can be read as code rather than argued in the abstract. The recommendation at the bottom of this description is still to ship the lint rule (#398) and hold this.

Stacked on the five fixes (#390, #393, #394, #395, #396), because the demonstration works by removing the threading those PRs add and showing their regression tests still pass.

The mechanism

compose and taggedBuilder push the context they are building with onto a stack for the duration of that build() call. resolve falls back to the top of that stack when handed no context of its own. A sub-builder built inside an enclosing build() inherits the enclosing context whether or not anyone remembered to pass it down. An explicit context argument always wins — the ambient value is a fallback for the undefined case, never an override.

What it buys that neither listed option does

Refs work through a sub-builder with no action from the builder's author:

  • The lint rule catches the mistake at author time, but only inside this repo — @composurecdk/eslint-plugin is private: true, so it never reaches a consumer writing their own builder.
  • The uniform three-parameter signature would not have prevented two of the four live bugs; s3 and cloudfront already had the parameter and still dropped the context.

This is the only one of the three that fixes the failure for code this repo does not lint.

The demonstration (subset, as requested)

ec2 and s3 have had their explicit threading removed, and their regression tests from #393/#394 pass unchanged. To confirm the tests exercise the new mechanism rather than leftover plumbing, drop ?? currentAmbientContext() from resolve and re-run — both fail with the original Ref to "…" cannot be resolved: component not found in context. I ran exactly that check; it fails as expected.

The other three fixed packages keep their explicit threading, so the branch also shows the two styles coexisting — what any real migration would look like.

What it costs — please read this part

Documented in full in docs/proposals/ambient-build-context.md:

  • Data flow becomes implicit. Thirty builders thread context by hand today, and that explicitness is a deliberate property of the codebase. Reading a builder no longer tells you where its sub-builder's refs resolve from. This is the real objection.
  • The parameter does not actually go away. build(scope, id, context?) is the public Lifecycle contract and callers pass it, so a conduit builder keeps an unused parameter (see the no-unused-vars disable on VpcBuilder.build). I tried removing it outright first — it narrows the public signature and broke ec2's own test. The ceremony is reduced, not eliminated.
  • It weakens a real error. A ref that should fail loudly because a dependency was never declared can instead find a same-named component in an enclosing context and resolve to the wrong thing. Narrow, but it trades a loud failure for a silent mis-wire.
  • A dual-package trap. The stack must live on globalThis under a Symbol.for key — module scope would give the ESM and CommonJS copies separate stacks, so an ESM push would be invisible to a CommonJS resolve, failing exactly the way this mechanism exists to prevent and only in a dual-loaded process. Same hazard REF_BRAND and ADR-0007 address. A test pins it.
  • Synchrony is load-bearing. A plain stack is safe only because Lifecycle.build returns T and never a promise. If build ever became async this needs AsyncLocalStorage, which is Node-only.

Scope if adopted

ADR-scale — it changes resolve's contract for every package. It would need an ADR, a migration removing the now-redundant threading from the other 28 builders, and a module-compat test proving cross-realm behaviour against genuinely dual-loaded copies rather than the single-process proxy used here.

Checklist

  • Linked to an issue (or it's a small, obvious fix)
  • npm run verify passes locally — with one exception, see below
  • Tests added/updated for the change — 12 cases covering nesting, throw-safety, explicit-wins precedence, the no-push-on-undefined case, and the global-registry invariant
  • If it adds an example stack: registered, listed in the examples README, and covered by a smoke test that exercises its runtime behaviour — n/a, no example stack

On npm run verify: every gate passes (format:check, build, catalogue:check, check:exports, lint, cdk-floors:check, validate, test — the full suite across 24 projects) except actionlint, which could not run: its shellcheck binary download returns 403 through this environment's proxy. No file under .github/workflows/ is touched.


Generated by Claude Code

claude added 10 commits August 15, 2026 13:26
`resolveQueryLogging` built the hosted zone's auto-managed query-log group
with `subBuilder.build(scope, id)`, dropping the build context. #375 widened
`LogGroupBuilderProps.encryptionKey` to a `Resolvable`, so a caller reaching
for a composed KMS key through the documented `configure` escape hatch:

    createHostedZoneBuilder()
      .zoneName("example.com")
      .queryLogging({ configure: (lg) => lg.encryptionKey(ref("logsKey", ...)) });

got `resolve(ref, undefined)` → resolved against `{}` → `Ref to "logsKey"
cannot be resolved: component not found in context`. The one thing the
widened prop exists to allow was the thing that failed.

`HostedZoneBuilder.build` now takes the optional `context` third parameter
`Lifecycle` already declares, and threads it through `resolveQueryLogging`
to the sub-builder — the same shape as the sibling delegation-provider log
group in this package. Adding the parameter is source-compatible for
standalone callers, and `compose` already passes context to every component.

Note this class of bug is not covered by the `lifecycle-build-context-required`
lint rule: it keys on `Resolvable<` appearing in the builder's own class body,
and `HostedZoneBuilder` has none — the resolvable lives one delegation away in
the sub-builder's props. Extending the rule is issue 386's open question and
is deliberately left out of this change.

Refs #386
…lders

`resolveFlowLogs` built the VPC's auto-managed flow-log group with
`subBuilder.build(scope, id)`, dropping the build context. #375 widened
`LogGroupBuilderProps.encryptionKey` to a `Resolvable`, so a caller reaching
for a composed KMS key through the documented `configure` escape hatch:

    createVpcBuilder()
      .flowLogs({ configure: (lg) => lg.encryptionKey(ref("flowLogsKey", ...)) });

got `resolve(ref, undefined)` → resolved against `{}` → `Ref to "flowLogsKey"
cannot be resolved: component not found in context`.

`VpcBuilder.build` now takes the optional `context` third parameter
`Lifecycle` already declares, and threads it through `resolveFlowLogs` to the
sub-builder. Adding the parameter is source-compatible for standalone callers,
and `compose` already passes context to every component.

`InterfaceEndpointBuilder` now forwards its context to the managed
`SecurityGroupBuilder` too. That one is not a reachable bug today — the
builder is constructed internally from an already-resolved VPC, and the
`allowDefaultPortFrom` peers are resolved by the parent against its own
context — so there is no behaviour change and nothing to regression-test. It
is forwarded so the call site does not become a bug the moment the security
group gains a `configure` hook or a ref'd prop.

Note this class of bug is not covered by the `lifecycle-build-context-required`
lint rule: it keys on `Resolvable<` appearing in the builder's own class body,
and `VpcBuilder` has none — the resolvable lives one delegation away in the
sub-builder's props. Extending the rule is issue 386's open question and is
deliberately left out of this change.

Refs #386
`resolveAccessLogs` built the bucket's auto-managed server-access-log bucket
with `subBuilder.build(scope, id)`, dropping the build context. #375 widened
`BucketBuilderProps.encryptionKey` to a `Resolvable`, so a caller reaching for
a composed KMS key through the documented `configure` escape hatch:

    createBucketBuilder()
      .serverAccessLogs({ configure: (sub) => sub.encryptionKey(ref("logsKey", ...)) });

got `resolve(ref, undefined)` → resolved against `{}` → `Ref to "logsKey"
cannot be resolved: component not found in context`. The bucket's own
`encryptionKey` resolved correctly — `build` already had the context and
passed it to `encryptionKeyProps` — so only the logging sub-builder was
affected, which is what made this one easy to miss.

`BucketBuilder.build` already declared the `context` parameter; it is now
threaded through `resolveAccessLogs` to the sub-builder. No signature change,
so this is a pure bug fix with no surface impact.

Note this call site is not listed in issue 386's table, which surveyed
`route53`, `ec2`, `cloudfront` and `apigateway`. It is the same class of bug
and reachable the same way.

Refs #386
`resolveAccessLogs` built the distribution's auto-managed standard-logging
bucket with `subBuilder.build(scope, id)`, dropping the build context. #375
widened `BucketBuilderProps.encryptionKey` to a `Resolvable`, so a caller
reaching for a composed KMS key through the documented `configure` escape
hatch:

    createDistributionBuilder()
      .accessLogs({ configure: (lb) => lb.encryptionKey(ref("logsKey", ...)) });

got `resolve(ref, undefined)` → resolved against `{}` → `Ref to "logsKey"
cannot be resolved: component not found in context`.

`DistributionBuilder.build` already declared the `context` parameter and used
it for its own `origin`; it is now threaded through `resolveAccessLogs` to the
sub-builder. No signature change, so this is a pure bug fix with no surface
impact.

Note this class of bug is not covered by the `lifecycle-build-context-required`
lint rule: it keys on `Resolvable<` appearing in the builder's own class body,
and the resolvable here lives one delegation away in the sub-builder's props.
Extending the rule is issue 386's open question and is deliberately left out
of this change.

Refs #386
`resolveDeployOptions` built the API's auto-managed access-log group with
`createLogGroupBuilder().build(scope, id)`, dropping the build context that
both `RestApiBuilder.build` and `SpecRestApiBuilder.build` already receive.

Unlike the sibling fixes for route53, ec2, s3 and cloudfront, this one is not
a reachable bug today and has no regression test: `accessLogging` is a plain
boolean, so there is no `configure` hook through which a caller could hand the
sub-builder a `ref()`. The builder is always constructed fresh with no user
input, leaving nothing to strand.

It is forwarded so the call site does not silently become the "component not
found in context" bug the moment `accessLogging` grows a `configure` hook —
the shape every other logging config in the library already has. A comment at
the call site records why the argument is there.

Both `resolveDeployOptions` callers pass their context; the parameter is
optional and trailing, so there is no surface change.

Refs #386
…de/sub-builder-call-site-bugs-62z7ro-ambient-context
…e/sub-builder-call-site-bugs-62z7ro-ambient-context
…to claude/sub-builder-call-site-bugs-62z7ro-ambient-context
…to claude/sub-builder-call-site-bugs-62z7ro-ambient-context
Demonstrates issue 386's third option: instead of every builder threading
`context` to its sub-builders by hand, `compose` and `taggedBuilder` push the
context they are building with onto a stack for the duration of that `build()`
call, and `resolve` falls back to the top of that stack when handed no context
of its own. A sub-builder built inside an enclosing `build()` inherits the
enclosing context whether or not anyone remembered to pass it down.

An explicit `context` argument always wins — the ambient value is a fallback
for the `undefined` case, never an override.

This is a demonstration branch, not a merge candidate. It is deliberately
scoped to a subset so the trade-off can be read as code.

What it buys: refs work through a sub-builder with no action from the author.
Neither option in the issue delivers that. The lint rule catches the mistake
at author time but only inside this repo, since the plugin is private and
never reaches a consumer writing their own builder; the uniform three-parameter
signature would not have prevented two of the four live bugs, because s3 and
cloudfront already had the parameter and still dropped the context.

Demonstration: `ec2` and `s3` have had their explicit threading removed, and
their regression tests pass unchanged. Disabling the fallback in `resolve`
makes both fail with the original "component not found in context", confirming
the tests exercise the new mechanism rather than leftover plumbing. The other
three fixed packages keep their explicit threading, so the two styles are
shown coexisting.

The stack lives on `globalThis` under a `Symbol.for` key rather than in module
scope. Both dual-package copies must share one stack, or an ESM push would be
invisible to a CommonJS resolve — the same hazard `REF_BRAND` and ADR-0007
address. A test pins this. A plain synchronous stack is safe only because
`Lifecycle.build` returns `T` and never a promise; every push is paired with a
`finally` pop so a throwing build cannot leak a frame.

Known costs, documented in full in the proposal doc: data flow becomes
implicit in a codebase that is deliberately explicit about it; the parameter
does not actually disappear, since it is the public contract, so a conduit
builder keeps an unused one; and a ref that should fail loudly for an
undeclared dependency could instead resolve against an enclosing context.

See docs/proposals/ambient-build-context.md for the full evaluation, including
why the recommendation is still to ship the lint rule and hold this.

Refs #386
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Blocked on another issue/PR before it can proceed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants