feat(core): ambient build context (experiment, for evaluation) - #399
Draft
laazyj wants to merge 10 commits into
Draft
feat(core): ambient build context (experiment, for evaluation)#399laazyj wants to merge 10 commits into
laazyj wants to merge 10 commits into
Conversation
`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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
The mechanism
composeandtaggedBuilderpush the context they are building with onto a stack for the duration of thatbuild()call.resolvefalls back to the top of that stack when handed no context of its own. A sub-builder built inside an enclosingbuild()inherits the enclosing context whether or not anyone remembered to pass it down. An explicitcontextargument always wins — the ambient value is a fallback for theundefinedcase, 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:
@composurecdk/eslint-pluginisprivate: true, so it never reaches a consumer writing their own builder.s3andcloudfrontalready 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)
ec2ands3have 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()fromresolveand re-run — both fail with the originalRef 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:contextby 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.build(scope, id, context?)is the publicLifecyclecontract and callers pass it, so a conduit builder keeps an unused parameter (see theno-unused-varsdisable onVpcBuilder.build). I tried removing it outright first — it narrows the public signature and brokeec2's own test. The ceremony is reduced, not eliminated.globalThisunder aSymbol.forkey — module scope would give the ESM and CommonJS copies separate stacks, so an ESM push would be invisible to a CommonJSresolve, failing exactly the way this mechanism exists to prevent and only in a dual-loaded process. Same hazardREF_BRANDand ADR-0007 address. A test pins it.Lifecycle.buildreturnsTand never a promise. Ifbuildever became async this needsAsyncLocalStorage, 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 amodule-compattest proving cross-realm behaviour against genuinely dual-loaded copies rather than the single-process proxy used here.Checklist
npm run verifypasses locally — with one exception, see belowOn
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) exceptactionlint, 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